Compare commits

...
258 Commits
Author SHA1 Message Date
Tobias GesellchenandClaude Opus 4.7 ce3b0e582a fix(health): drop InsecureSkipVerify from cert-chain probe
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>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 b149580c19 fix(ding): clamp sample rate against int -> uint32 truncation
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>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 4f6f4c497a fix(health): self-signed AfterTouch chain is INFO, not WARN
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>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 8571595aef feat(health): add CA cert expiry check
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>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 29d611f9c1 feat(health): aggregate device-summary panel on Devices tab
Audit item #1 (11+ recurrences in issues / discussions): pull
speaker /info + /sources + /presets, plus service-side state
and pairing inference, into one view per device.

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 cb81be3143 fix(health): translate wildcard/empty DNS bind into a dialable target
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>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 a59f71a6e1 docs(ding): document supported knobs + caching on HandleDing
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>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 c90fdf234f fix(health): classify self-signed leaves via real CA signature check
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>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 147a69d1c3 refactor(ding): synthesise on demand instead of vendoring the WAV
Move the ding renderer into pkg/service/ding so it can run both
at request time (from the new HandleDing handler) and offline
(from the existing scripts/gen-aftertouch-ding CLI, now a thin
wrapper around the same package).

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 6356ca588b chore: gitignore SERVICE-HEALTH.md alongside NEXT/DONE
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>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 56efb4fcdb feat(health): add per-device "refresh sources" affordance
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>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 bb11b9d48c feat(health): add DNS interception sanity check
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>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 7d46ae2280 feat(health): compare speaker /presets count with service Presets.xml
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>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 77188418a7 feat(health): detect dead Bose orion URLs in service Presets.xml
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>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 41f21f3761 feat(health): add per-device "play ding" affordance
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>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 b18272480a feat(health): probe HTTPS endpoint cert chain against system roots
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>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 dee5a0146c feat(health): check speaker <margeURL> against configured hosts
For each device, probe /info and extract the <margeURL> the
speaker is configured to talk to. Compare the hostname against
the service's expected-hosts list (serverURL host +
httpsServerURL host + --tls-extra-host values).

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 255dd655b5 feat(health): compare speaker /sources with service Sources.xml
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>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 195403f42a feat(health): add speaker /info reachability check
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>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 90a30f9fce feat(health): add ProbeGet helper and ManualCommands on findings
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>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 c89a66b08a feat(media): add AfterTouch "ding" signature audio
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>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 c3723dc0e6 feat(service): add Logs tab streaming the live stderr trace
Cloud-deploy operators on Discussion #295 needed to leave the
admin UI for docker logs / journalctl to see what the service was
doing. Mirror log.Default() output into an in-memory ring buffer
and expose it under /setup/logs so the admin UI can show a live
trace alongside the existing tabs.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:00:16 +02:00
Tobias GesellchenandClaude Opus 4.7 882d0633fb fix(bmx): emit BMX-relative playback hrefs in TuneIn nav/search results
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>
2026-05-19 08:24:34 +02:00
Tobias Gesellchen 68760a8977 feat(service): add --tls-extra-host for additional TLS cert SAN entries
The leaf cert generator already routes IP-shaped entries into the
IPAddresses SAN, and getDomains already feeds it the hostnames parsed
from --server-url and --https-server-url. Add an explicit
--tls-extra-host flag (repeatable, env TLS_EXTRA_HOST) for the
remaining cases: multi-homed hosts, reverse-proxy frontends, or
browsing the admin UI via a LAN IP that isn't part of the configured
server URLs.

Resolves the ERR_CERT_COMMON_NAME_INVALID Chrome refuses when the URL
bar hostname (e.g. the host's LAN IP) isn't in any cert SAN, even
when the local CA is trusted.
2026-05-18 23:09:28 +02:00
dependabot[bot] e1d009de04 ci(deps): bump codecov/codecov-action in the security-actions group
Bumps the security-actions group with 1 update: [codecov/codecov-action](https://github.com/codecov/codecov-action).


Updates `codecov/codecov-action` from 6.0.0 to 6.0.1
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/57e3a136b779b570ffcdbf80b3bdc90e7fab3de2...e79a6962e0d4c0c17b229090214935d2e33f8354)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 6.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 22:40:55 +02:00
dependabot[bot] cee11b4799 ci(deps): bump github/codeql-action from 4.35.4 to 4.35.5
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.35.4 to 4.35.5.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/68bde559dea0fdcac2102bfdf6230c5f70eb485e...9e0d7b8d25671d64c341c19c0152d693099fb5ba)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.35.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 22:40:36 +02:00
github-actions[bot] 4057e4b1a1 chore: sync static dependencies with package.json 2026-05-18 22:38:40 +02:00
dependabot[bot] 3331b1e93d deps(deps): bump preact from 10.26.1 to 10.29.2
Bumps [preact](https://github.com/preactjs/preact) from 10.26.1 to 10.29.2.
- [Release notes](https://github.com/preactjs/preact/releases)
- [Commits](https://github.com/preactjs/preact/compare/10.26.1...10.29.2)

---
updated-dependencies:
- dependency-name: preact
  dependency-version: 10.29.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 22:38:40 +02:00
Tobias Gesellchen 7f2e6abfc3 build: Add automated dependency management for JavaScript libraries
- Sets up Dependabot for JS dependency updates
- Adds GitHub workflow for automated static dependency updates
- Creates update script for Preact and other static JS libraries
- Updates Preact to latest version via new automation
2026-05-18 22:34:26 +02:00
Tobias Gesellchen b95bdae751 feat: Add RadioBrowser integration alongside TuneIn support
- Refactors BMX service to support multiple radio providers
- Adds RadioBrowser.com API integration with search and browse
- Splits TuneIn logic into separate module for better organization
- Adds new web UI components for radio station discovery
- Includes new SVG icons for RadioBrowser branding
2026-05-18 22:34:26 +02:00
Tobias Gesellchen 8881adbede docs: Update development timeline dates to reflect 2026 project timeline
- Updates feature history phases from 2024 to 2026 dates
- Corrects service announcement timeline references
- Aligns API coverage documentation with current project schedule
2026-05-18 22:34:26 +02:00
Tobias Gesellchen 1729eb9616 assets: Add AfterTouch braille logo and update README branding
- Adds new favicon-braille.svg logo file for AfterTouch branding
- Updates README.md to reference the new braille-style logo
- Establishes visual identity for the project
2026-05-18 22:34:26 +02:00
Tobias Gesellchen 3932e9b2b7 docs: Update CLAUDE.md with current project structure and binaries
- Documents soundtouch-web and soundtouch-backup binaries
- Updates build targets and Go version requirements
- Improves session pickup documentation clarity
- Reorganizes project structure documentation
2026-05-18 22:34:26 +02:00
Tobias Gesellchen d8fe03111e update screenshots 2026-05-18 22:34:26 +02:00
Tobias GesellchenandClaude Opus 4.7 75118d9a92 fix(soundtouch-web): keep device WebSocket alive across disconnects
ConnectDeviceWebSocket was a one-shot: connect, wait for disconnect,
log, return. Once the device-side WebSocket died (idle timeout, blip,
speaker reboot), the goroutine ended and conn.WebSocket stayed
pointing at the (now-dead) client — which made the duplicate-spawn
guard `if device.WebSocket == nil` at the five callsites in
handler.go correctly skip spawning, but with nothing else trying to
reconnect, the speaker's status flow froze for the rest of the
process's lifetime. The browser kept receiving status_update
messages on the 5 s ticker (HandleWebSocket), but every payload
carried the same stale data the service last knew.

Symptom: load the page, NowPlaying shows fresh state; some minutes
later, the speaker switches presets or tracks but NowPlaying never
updates — even though playback itself works because those are
one-shot HTTP calls that don't depend on the WebSocket.

Fix: wrap the connect-and-wait in a for-loop with exponential
backoff (1 s → 30 s cap, reset on every successful connect). The
goroutine now lives for the device entry's lifetime; conn.WebSocket
is updated on each successful reconnect and never cleared, so the
existing guards keep working without spawning duplicate loops.

Pre-existing main bug — preserved by the relocation, surfaced when
testing the rebased branch. Fix is contained to the one function;
behaviour is byte-identical for the happy path (one connect, no
disconnect ever).

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:34:26 +02:00
Tobias GesellchenandClaude Opus 4.7 9d2ebb2edd fix(soundtouch-web): unify TuneIn play affordance across item types
Stations still showed a dim ▶ inside the .tunein-item-arrow span
while programs (with the new pill button from 34d4692) showed a
circled play button. Two different play affordances side by side
looked accidental.

Now every item with a playback link renders the same pill button,
and the arrow span carries only the drill-in chevron. Per item type:

  Stations  (play only)            pill ▶
  Programs  (navigate + play)      pill ▶ + chevron ›
  Genres    (navigate only)        chevron ›

The pill stops event propagation, so clicking it triggers play
without bubbling to the row's navigate handler — that lets row
clicks keep drilling into programs while the button cuts straight
to "play latest episode."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:34:26 +02:00
Tobias GesellchenandClaude Opus 4.7 cd387888aa fix(soundtouch-web): surface play button on TuneIn program rows
The Preact TuneInBrowser hid the play affordance whenever an item
also had a navigate link. TuneIn programs have BOTH (drill into
episodes + play latest episode, after backend PR #317), so the
button never appeared on program rows — only the chevron.

Old vanilla UI showed both. Restored:

- navigate(item) keeps its current behaviour (path wins for row
  clicks, falls through to play if there's no path) — that lets
  pure-leaf items (stations) still play on whole-row click.
- New explicit .tunein-play-btn rendered conditionally when an item
  has BOTH a navigate link and a playback link. Stops event
  propagation so clicking it triggers play (device picker overlay)
  instead of bubbling to the row's navigate handler.
- CSS: pill-shaped 32px button using the same --accent / --text-dim
  tokens the rest of the UI uses; hover state swaps to --accent /
  --accent-fg to avoid same-on-same contrast in either theme.

The chevron stays as the row's "drill in" indicator for any
navigable item, including programs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:34:26 +02:00
Tobias GesellchenandClaude Opus 4.7 22f999edaf feat(soundtouch-web): add multi-room zone management
Ports app's commit b040c8a. Five new handlers + five new routes for
master/slave stereo-pair and multi-room management; the Zone.js
frontend was already shipped in the Preact swap.

  HandleGetZone        GET  /api/zone/{id}
    Returns zone info enriched with member names and role flags
    (isMaster / isSlave / isStandalone) computed from the perspective
    of the queried device. Each member carries IP, hwID, and friendly
    name so the frontend can render readable rows.

  HandleZoneAdd        POST /api/zone/{id}/add/{slaveId}
    Adds a slave to the zone where {id} is or becomes the master.
    Standalone master gets a fresh ZoneRequest; existing zone is
    extended via ToZoneRequest + AddMember.

  HandleZoneRemove     POST /api/zone/{id}/remove/{slaveId}
    Removes a named slave from the master's existing zone.

  HandleZoneDissolve   POST /api/zone/{id}/dissolve
    Issues a single-member ZoneRequest so the master goes standalone.

  HandleZoneLeave      POST /api/zone/{id}/leave
    Slave-side leave: looks up the master via findIPByHwID using the
    slave's current zone info, then dispatches RemoveMember against
    the master's client (the speaker protocol requires the master to
    own the SetZone call).

Translation notes:

- All handlers go through app.GetDevice(id) instead of direct
  app.Devices[id] access — matches main's encapsulated-registry
  refactor (post-base on main, see registry_test.go).
- findIPByHwID iterates via app.DeviceSnapshot() instead of ranging
  over the raw map.
- pkg/client (GetZone/SetZone) and pkg/models (ZoneInfo/ZoneRequest/
  Member/NewZoneRequest/AddMember/RemoveMember/IsStandalone/
  ToZoneRequest) API surface confirmed unchanged from app's base —
  verbatim function calls.

Risk recap (per the earlier audit): this was flagged medium-risk
because of pkg/client zone-API drift. Verified clean — all symbols
exist with the expected signatures on current main. The #252 stereo-
pair work that landed on main was in cmd/soundtouch-cli/cmd_group.go
(parallel POST to LEFT and RIGHT), which doesn't intersect with the
single-master SetZone pattern these handlers use.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:34:26 +02:00
Tobias GesellchenandClaude Opus 4.7 b5ed1745ff feat(soundtouch-web): add recents panel + generic content-item player
Ports app's commit 3122c4e to the package layout. Two new handlers
and route registrations; the frontend was already shipped in the
Preact swap.

  HandleDeviceRecents   GET  /api/device-recents/{id}
    Returns the speaker's /recents list as APIResponse{Success,Data}.
    Backs the Recents.js component (lazy-loaded list under the
    device-detail view; hides itself when the device returns no
    recents).

  HandleDevicePlay      POST /api/device-play/{id}
    Generic content-item player. Decodes a {source,type,location,
    sourceAccount,itemName,containerArt,isPresetable} JSON body into
    a *models.ContentItem and runs Client.SelectContentItem. Used by
    Recents.js to replay items the speaker reports, regardless of
    source — TuneIn, Spotify, AUX, etc. Different from HandlePlayTuneIn
    which is TuneIn-specific.

Translation note: app's bodies used app.Devices[id] directly; main's
registry is encapsulated behind GetDevice/AddDevice/TouchDevice (see
the post-base refactor that introduced registry_test.go), so this
commit uses app.GetDevice(id) instead. Same lookup, just through the
maintained API.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:34:26 +02:00
Tobias GesellchenandClaude Opus 4.7 7e696ea000 refactor(soundtouch-web): package owns discovery + route registration
Replays app's commit-1 architectural restructure onto current main —
mechanical move only, behaviour preserved verbatim. main.go shrinks
from 333 to ~190 lines; the binary now orchestrates lifecycle and
flag handling, the package owns the WebApp's responsibilities.

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

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

New helper:

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

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

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

Behaviour parity checklist:

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

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

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

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

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

Backend wiring:

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

Path rename vs. app branch:

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

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

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

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

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

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

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

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

Adjustments:

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

Not changed:

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:34:26 +02:00
Tobias Gesellchen b3b2d9d262 fix(web): parallelize independent startup calls again 2026-05-18 22:21:28 +02:00
Tobias Gesellchen 92917e4375 fix(web): clean stale hash when migration device is unknown 2026-05-18 22:21:28 +02:00
Tobias Gesellchen 48e8ff8352 fix(web): guard pushState in showSummary to break popstate loop
skip history.pushState when the hash already matches, so popstate -> selectMigrationDevice -> showSummary no longer pushes a duplicate entry that traps browser-Back in an oscillation between identical `#tab-migration?<id>` entries.
2026-05-18 22:21:28 +02:00
Marcin Mennemann fbbc0de55c fix(web): added proper fallbacks for missing hash and device_id 2026-05-18 22:21:28 +02:00
Marcin Mennemann ff279a150f fix(web): persist selected migration device in URL hash 2026-05-18 22:21:28 +02:00
Marcin Mennemann b26c5627ee feat(web): add hash-based tab navigation for back-button and reload support 2026-05-18 22:21:28 +02:00
Marcin Mennemann f1821d5995 doc: remove mirroring and parity with Bose cloud 2026-05-18 20:45:18 +02:00
Tobias GesellchenandClaude Opus 4.7 e9565983f8 ci(link-check): accept HTTP 202 as a live response
The Check documentation links job on PR #320 flagged a link in
README.md to eur-lex.europa.eu as dead because the EU legal-content
portal responds with HTTP 202 (Accepted) to HEAD requests. 202 is a
2xx success class — the server responded and the link is valid; it
just means "the request was accepted and is being processed".

Adding 202 alongside 200 / 206 in aliveStatusCodes fixes the false
positive broadly, not just for this one URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:05:13 +02:00
Tobias GesellchenandClaude Opus 4.7 2b48d25e5f chore: scrub 192.168.123.x example IPs to RFC-5737 doc range
Three files carried 192.168.123.x as placeholder IPs in examples and
fixtures. RFC-1918 private space — same reader-confusion concern as
the broader 192.168.1.* sweep in 136d24a. Switched to 192.0.2.x
preserving the last octet so the reader-side intent ("CLI host arg
example", "test fixture URL") stays clear.

- docs/analysis/FACTORY-RESET-PROTOCOL.md       — 14 CLI --host examples + 1 log-fragment
- docs/analysis/TELNET-COMMAND-REFERENCE.md     — 1 docker-run env example
- pkg/service/marge/recents_sourceproviderid_regression_test.go
                                                — 2 XML location URLs (matched-pair within file)

docs/analysis/BOSE-LAB-RUNBOOK.md keeps its 192.168.10/24 subnet
unchanged — that's the documented Pi-as-AP network for the runbook,
not a placeholder.

go test ./pkg/service/marge/... clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:05:13 +02:00
Tobias GesellchenandClaude Opus 4.7 50f1c5980a docs(mac-mapping): scrub dash-form of the real test-speaker MAC
The earlier MAC sweep in 04f9c31 only matched the colon form
(A8:1B:6A:53:6A:98). MAC-ADDRESS-MAPPING.md documents the
normalisation behaviour with separator variants, so it also carried
the dash form (A8-1B-6A-53-6A-98) — 2 hits both replaced with the
canonical AA-BB-CC-DD-EE-FF placeholder.

Surfaced by the post-cleanup re-scan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:05:13 +02:00
Tobias GesellchenandClaude Opus 4.7 a76a112d92 chore(integration): add testdata rotation target + document workflow
Surfaced via the rfc-5737-cleanup sweep: after the anonymisation pass
updated test-suite assertions to RFC-5737 IPs, the next
`make test-http-client` run failed against the stale local
tests/integration/testdata/ left over from a previous build (which
still carried the old 192.168.1.x state via the compose volume).

Two changes, in one commit so the doc references the target it
documents:

1. Makefile: new `test-http-client-rotate` target that renames any
   existing tests/integration/testdata/ to
   tests/integration/testdata_<timestamp>/. Non-destructive (mv, not
   rm), opt-in (no other target invokes it). Archives stay around
   for retrospective debugging — that directory is debug evidence,
   not disposable scratch.

2. CLAUDE.md: new "Integration tests" section under Build/test/run.
   Explains the docker-compose stack, the testdata mount, the
   per-machine-only nature (via tests/.gitignore), and the
   rotate-then-run pattern when fixtures or schemas have changed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:05:13 +02:00
Tobias GesellchenandClaude Opus 4.7 02a1663336 scripts(mitm): parametrise account-id/device-id redaction
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>
2026-05-17 22:05:13 +02:00
Tobias GesellchenandClaude Opus 4.7 702092d772 chore: sweep example LAN IPs to RFC-5737 in source and config files
Completes the docs-tier RFC-5737 rollout by sweeping the remaining
192.168.1.x references that lived outside .md / .txt / test files:

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

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

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

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

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

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

Two semantic fixes alongside the bulk swap:

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:05:13 +02:00
Tobias GesellchenandClaude Opus 4.7 ffd5974ddb docs(anon): rewrite as canonical placeholder mapping table
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>
2026-05-17 22:05:13 +02:00
Tobias GesellchenandClaude Opus 4.7 29f3fc6f96 docs: replace real Bose account IDs in examples with placeholders
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>
2026-05-17 22:05:13 +02:00
Tobias GesellchenandClaude Opus 4.7 fa51a6f610 docs: replace real MAC addresses in examples with placeholders
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>
2026-05-17 22:05:13 +02:00
Tobias GesellchenandClaude Opus 4.7 51d196dd03 docs: replace personal LAN IPs and device names with placeholders
Public-repo hygiene: docs and READMEs carried the maintainer's home
LAN range (192.168.178.x) and personal speaker names ("Sound
Machinechen", "A Sound Machine"). Swapped to RFC-5737 documentation
IPs (192.0.2.x — reserved for examples, won't collide with anyone's
real network) and generic names ("Living Room SoundTouch",
"Kitchen SoundTouch").

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

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

Renamed symbols (no behaviour change):

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:53:33 +02:00
Tobias GesellchenandClaude Opus 4.7 1da654c9b9 refactor(handlers): rename proxy-era leftovers to match public names
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>
2026-05-17 21:53:33 +02:00
Tobias GesellchenandClaude Opus 4.7 018e9fd7cb chore(web): remove obsolete jsdiff dependency
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>
2026-05-17 21:53:33 +02:00
Marcin Mennemann 2747d95a8f remove: proxy forwarding to Bose upstream 2026-05-17 21:53:33 +02:00
Marcin Mennemann 0f0a96c0ce remove: mirror middleware and parity comparison with Bose cloud 2026-05-17 21:53:33 +02:00
Tobias GesellchenandClaude Opus 4.7 88a2185985 chore: ignore .junie/ workspace dir
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>
2026-05-17 19:14:05 +02:00
Tobias GesellchenandClaude Opus 4.7 27b5090ce5 docs(CLAUDE.md): inline communication principles; drop .junie/ pointer
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>
2026-05-17 19:14:05 +02:00
Tobias GesellchenandClaude Opus 4.7 0925ece3b2 docs: track CLAUDE.md as the repo onboarding contract
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>
2026-05-17 19:14:05 +02:00
Tobias GesellchenandClaude Opus 4.7 e3cd5a3459 feat(service): expose build version on GET / mirroring /health
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>
2026-05-17 19:14:05 +02:00
Tobias GesellchenandClaude Opus 4.7 776e0cfe44 chore: ignore .claude/ workspace dir
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>
2026-05-17 19:14:05 +02:00
Tobias GesellchenandClaude Opus 4.7 888f6b096e chore: ignore local NEXT.md / DONE.md working notes
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>
2026-05-17 18:44:04 +02:00
Tobias GesellchenandClaude Opus 4.7 cc7675a07c feat(tunein): play stations/episodes/programs via cli source tunein (#226)
Add a `soundtouch-cli source tunein` subcommand that takes a TuneIn
guide ID and routes it through the right SelectContentItem shape —
`--station`, `--episode`, `--program`, or `--id` with prefix
auto-detect. The flag picks the ContentItem Type (`stationurl` for
stations/episodes, `tracklisturl` for programs) and the location
template, then enriches the now-playing metadata from TuneIn's describe
endpoint unless `--no-lookup` is set.

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:29:48 +02:00
Tobias GesellchenandClaude Opus 4.7 4507d82b4c fix(security): address CodeQL findings on Stockholm + SiriusXM stubs
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>
2026-05-17 15:05:39 +02:00
Tobias GesellchenandClaude Opus 4.7 a7f90f4151 test(http-client): tunein_playback_station now expects 200 without auth
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>
2026-05-17 15:05:39 +02:00
Tobias GesellchenandClaude Opus 4.7 0038db35d3 test(service): regenerate router_routes golden after SiriusXM routes
The new HandleSiriusXMLiveAdapter and HandleSiriusXMLiveAdapterSubpath
routes were registered via r.HandleFunc (every HTTP method) at the top
level in main.go. The router-shape golden file gets one entry per
(method, path) pair, so SiriusXM adds 14 lines across CONNECT / DELETE
/ GET / HEAD / OPTIONS / PATCH / POST / PUT / TRACE.

Pure regeneration — no behaviour change.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:05:39 +02:00
Tobias GesellchenandClaude Opus 4.7 b9c1cdad29 fix(bmx): relax TuneIn + Orion Authorization gate, log instead
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>
2026-05-17 15:05:39 +02:00
Tobias GesellchenandClaude Opus 4.7 d7bbc09ce6 refactor(handlers): split handlers_bmx.go per BMX service
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>
2026-05-17 15:05:39 +02:00
Tobias GesellchenandClaude Opus 4.7 9f260a60ea fix(service): /favicon.ico now serves from the embedded web bundle
The /favicon.ico route was redirecting r.URL.Path to
"/media/favicon-braille.svg" and calling HandleMedia. HandleMedia
strips "/media" and serves from the embedded static/media/ subtree —
which does not contain a favicon. The actual asset lives under the
embedded web/img/ subtree (see the `web/img/favicon-braille*` embed
directive in handlers_media.go).

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:05:39 +02:00
Tobias GesellchenandClaude Opus 4.7 0c4a12670b fix(stockholm): patch browser_http_proxy.js so the proxy URL respects basePath
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>
2026-05-17 15:05:39 +02:00
Tobias GesellchenandClaude Opus 4.7 ae5a1d5a4f docs(stockholm): mention dev-service-stockholm in the user guide
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>
2026-05-17 15:05:39 +02:00
Tobias GesellchenandClaude Opus 4.7 548c815c4d chore(stockholm): add dev-service-stockholm make target
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>
2026-05-17 15:05:39 +02:00
Tobias GesellchenandClaude Opus 4.7 a2fe793cb5 docs: add disclaimer, contributing summary, and sponsorship
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>
2026-05-17 15:05:39 +02:00
Tobias GesellchenandClaude Opus 4.7 6a8ad57e23 docs(stockholm): reflect v3/v4 patches and dynamic scanning
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>
2026-05-17 15:05:39 +02:00
Tobias GesellchenandClaude Opus 4.7 1f61a81841 refactor(stockholm): extract kiloDefaultValue with provenance comment
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>
2026-05-17 15:05:39 +02:00
Tobias GesellchenandClaude Opus 4.7 c9eefc7e84 fix(stockholm): match setupRouter signature in router_test
setupRouter gained a *stockholm.Handler parameter on this branch, but
the test left over from the previous signature still called it with
one argument, breaking `go vet ./...`. Pass nil — Stockholm is opt-in
and not exercised in this test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:05:39 +02:00
Tobias GesellchenandClaude Sonnet 4.6 6fb999435a feat(stockholm): add Go backend integration for Stockholm frontend
Implements pkg/service/stockholm with bridge (appSend/runQueue), HTTP
proxy, static serving, config URL rewriting, native state persistence,
and device discovery. Mounts under a configurable base path (/stockholm
by default) with correct http.StripPrefix routing and apiBase-prefixed
bridge API routes matching the patched JS window.__stockholmBase calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 15:05:39 +02:00
Tobias GesellchenandClaude Sonnet 4.6 c64e601df6 feat(stockholm): add Dockerfile.stockholm and Makefile targets for frontend prep
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>
2026-05-17 15:05:39 +02:00
Tim Vahlbrock 55ae4d06ba Add missing "don't" in README.md regarding On-Device Installer 2026-05-17 13:02:23 +02:00
Tobias GesellchenandClaude Opus 4.7 c668c732df fix(#308): handle placeholder presets without panicking
The ST10's /presets response after a factory reset emits self-closing
<preset/> entries with no ContentItem child. cmd/soundtouch-cli's
getPresets() handled the missing ContentItem in GetDisplayName() but
then dereferenced preset.ContentItem.Source on the next line, panicking
with "invalid memory address or nil pointer dereference" the moment the
loop reached the first empty entry.

A second placeholder shape was observed on healthy devices that were
never reset: <preset id="0"><ContentItem source="INVALID_SOURCE"
isPresetable="true"/></preset>. ContentItem is non-nil here, so the
previous "ContentItem != nil" guard at other call sites still let
these placeholders through into listings and into the AfterTouch
datastore.

Fix shape:

  pkg/models/presets.go - extend Preset.IsEmpty() to recognise both
  shapes (ContentItem == nil, OR Source == "" / "INVALID_SOURCE").
  HasPresets, GetEmptyPresetSlots and GetUsedPresetSlots become honest
  about which slots actually carry playable content.

  cmd/soundtouch-cli/cmd_info.go (the crash site) - filter the slice
  via IsEmpty before the print loop, and switch the still-printed
  fields to the existing nil-safe Get* helpers.

  pkg/service/setup/setup.go - upgrade syncPresets's "ContentItem ==
  nil" continue-guard to IsEmpty so Shape B placeholders don't get
  persisted in the AfterTouch datastore and then surface as junk
  rows in the admin web UI.

  cmd/soundtouch-cli/cmd_events.go, cmd/websocket-demo/main.go - same
  nil-guard upgrade. These already nil-checked so were crash-safe;
  the change is for consistency and to stop printing
  "Preset 0:  (INVALID_SOURCE)" demo lines.

  examples/preset-management/main.go - had the same latent crash as
  cmd_info.go; same fix shape.

Regression tests in pkg/models/presets_test.go cover both shapes using
the exact XML observed in the wild: the reporter's three <preset/>
placeholders plus the three INVALID_SOURCE entries from a live device.
The reporter XML test walks every preset through the same accessor
path the CLI used and asserts no panic.

The soundtouch-web Go code does not deref preset.ContentItem.X
anywhere - presets flow through as JSON - so no separate crash trap
exists there. The web frontend will pick up the cleaner data once
syncPresets stops persisting placeholders.

Closes #308

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:36:55 +02:00
Tobias GesellchenandClaude Opus 4.7 4e7a20f7ec refactor(soundtouch-web): make DeviceConnection.Status atomically swappable
Status was a value-typed DeviceStatus field on DeviceConnection,
written from the periodic poller (UpdateDeviceStatus) and from four
WebSocket event handlers (OnNowPlaying, OnVolumeUpdated,
OnConnectionState, OnPresetUpdated) while being read from every HTTP
handler and the WebSocket broadcaster. The struct was 8+ words wide
with time.Time and string members, so concurrent readers could
observe torn fields or mixed-update snapshots. The map-level race was
fixed in the previous commit; this one closes the per-connection
struct race.

Hide the field behind atomic.Pointer[DeviceStatus]:

  Status()                                 // returns current snapshot
  SetStatus(*DeviceStatus)                 // wholesale replace
  UpdateStatus(func(*DeviceStatus))        // CAS retry loop

NewDeviceConnection constructs a connection with the atomic pointer
pre-initialised, so Status() never returns nil for callers that go
through the constructor (the old struct-literal pattern is no longer
possible because the status field is now private).

UpdateDeviceStatus runs network fetches into local vars first, then
batches them into a single UpdateStatus call so the CAS loop only
retries the merge — not the slow IO. WebSocket event handlers and
the connect/disconnect transitions each use UpdateStatus, so any
ordering of poller + event delivery converges to a consistent
status.

The UpdateStatus docstring is explicit about the shallow-copy
contract: nested pointer fields (NowPlaying, Volume, Bass, Presets,
Sources) MUST be replaced, not mutated through, because the copy
mut receives shares those pointers with the prior snapshot. All
production callers already follow this pattern (every value comes
fresh from the device API).

Tests:
  - types_test.go: migrated literal struct to NewDeviceConnection +
    SetStatus, switched reads to Status().
  - status_test.go (new): six tests covering constructor init,
    SetStatus replacement semantics, UpdateStatus mutator
    application, field preservation across UpdateStatus, snapshot
    isolation (old snapshot stable under later writes), and a
    concurrent stress test (16 writers + 32 readers x 200 ops) that
    runs under -race.
  - handlers_test.go, registry_test.go, spa_test.go: migrated to
    constructor.

Not addressed by this commit:
  - DeviceConnection.WebSocket (set once in ConnectDeviceWebSocket,
    read elsewhere). Word-sized pointer, atomic at the hardware
    level on amd64/arm64; race detector may still flag.
  - DeviceConnection.LastSeen (written under devicesMu by the
    registry, read outside that lock via DeviceSnapshot consumers).
    time.Time is non-atomic but the read is cosmetic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:14:17 +02:00
Tobias GesellchenandClaude Opus 4.7 e7d1b44587 refactor(soundtouch-web): encapsulate WebApp device registry behind methods
The Devices map on WebApp was written from the startup goroutine, the
/api/discover POST handler, and addDevice, while being read from every
HTTP handler and the WebSocket periodic-update loop — all without any
mutex. The Go runtime panics with "fatal error: concurrent map writes"
or "concurrent map read and map write" on any actual collision, so this
was a latent crash, not a tearing issue.

Hide the map behind a sync.RWMutex and a small API:

  GetDevice(id) (*DeviceConnection, bool)
  DeviceSnapshot() []DeviceEntry
  DeviceCount() int
  AddDevice(id, conn) bool        // atomic insert-or-touch
  TouchDevice(id) bool            // fast-path LastSeen bump

Update every caller — handlers, websocket, main, tests — to go through
the API. addDevice's existing-host fast path uses TouchDevice; the
final insert uses AddDevice so a race with another writer is rejected
cleanly instead of silently overwriting.

Add a TestRegistryConcurrent stress test that runs 64 goroutines doing
12,800 operations across writers, touchers, and two reader patterns.
It exists to give `-race` (already on in CI) a concrete shape to catch
if the encapsulation ever leaks back out.

Struct-field races on conn.Status.* are not addressed by this change;
they need their own follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:14:17 +02:00
Tobias GesellchenandClaude Opus 4.7 2c50ce3ee8 refactor(soundtouch-web): unify manual and discovered device registration
`addManualDevice` and the per-device branch of `discoverDevices` were
~40 lines of near-identical client setup, info fetch, connection
build, and map write — differing only in log wording. Extract a
shared `addDevice(app, host, port, source)` helper used by both
paths.

Side effects of consolidating:

- Duplicate-host guard (LastSeen bump) now applies to both paths, so
  passing `--devices 1.2.3.4` twice is idempotent and matches how
  discovery treats repeat sightings.
- Map write happens before the UpdateDeviceStatus goroutine launch,
  so a concurrent GET /api/devices sees the device with
  `IsConnected: false` instead of racing the status update.
- Log wording is consistent: "Failed to fetch device info from <host>
  (<source>): <err>" and "Added <source> device <name> (<type>) at
  <host>:<port>".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:14:17 +02:00
Tobias Gesellchen 1269481411 lint 2026-05-17 10:30:25 +02:00
chrizg 712801259e feat(soundtouch-web): rename --host to --devices, support multiple devices via StringSliceFlag 2026-05-17 10:30:25 +02:00
chrizg 46546f5494 feat(soundtouch-web): add --host flag for manual device IP 2026-05-17 10:30:25 +02:00
chris 6d462191d9 docs: add SoundTouch 30 factory reset sequence (#305)
## Description

Add missing factory reset sequence for SoundTouch 30 (non-Series III).
The current table only lists SoundTouch 30 Series III. The SoundTouch 30
uses a different sequence: power on, then hold Preset 1 + Volume − for
10 s. The display counts down from 10 to 1 and shows "Hold to restore
factory settings" before restarting.

## Type of Change

Please check the type of change your PR introduces:

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to not work as expected)
- [x] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
- [ ] Test improvements
- [ ] Build/CI improvements

## Related Issues

## Changes Made

### API Changes
- [ ] Added new endpoints
- [ ] Modified existing endpoints
- [ ] Added new CLI commands
- [ ] Modified existing CLI commands
- [ ] Added new configuration options

### Implementation Details
- Added missing table row for SoundTouch 30 (non-Series III) in the
factory reset sequences table. No new dependencies.

## Testing

### Automated Tests
- [ ] Unit tests added/updated
- [ ] Integration tests added/updated
- [ ] All existing tests pass
- [ ] Test coverage maintained or improved

### Manual Testing
- [x] Tested with real SoundTouch device(s)
- [ ] Tested CLI changes manually
- [ ] Tested in different network environments

**Device(s) tested with:**
- Device model: SoundTouch 30
- Firmware: 27.0.6.46330
- Test results: Factory reset sequence verified on real device

### Test Commands

## Documentation

- [ ] Updated relevant documentation
- [ ] Added code comments for complex logic
- [ ] Updated CLI help text
- [ ] Added usage examples
- [ ] Updated API documentation

**Documentation files updated:**
- [ ] README.md
- [ ] docs/API-Endpoints-Overview.md
- [ ] docs/CLI-REFERENCE.md
- [ ] Code documentation (godoc)

docs/DEVICE-INITIAL-SETUP.md

## Backward Compatibility

- [x] This change is backward compatible
- [ ] This change includes breaking changes (requires major version
bump)
- [ ] This change requires configuration migration

**Breaking changes (if any):**

## Security Considerations

- [x] No security implications
- [ ] Security review required
- [ ] Added input validation
- [ ] Updated authentication/authorization

## Performance Impact

- [x] No performance impact
- [ ] Performance improvement
- [ ] Potential performance regression (justify why)

**Performance notes:**

## Code Quality

- [ ] Code follows project style guidelines
- [ ] No linting errors
- [ ] No security warnings
- [ ] Memory leaks checked (if applicable)

### Pre-submission Checklist

- [ ] `make check` passes (format, lint, vet)
- [ ] `make test` passes
- [ ] No TODO comments left in production code
- [ ] Error handling is comprehensive
- [ ] Logging is appropriate (not too verbose, not too quiet)

## Deployment Notes

## Screenshots (if applicable)

## Additional Notes

## Review Requests
2026-05-17 10:15:10 +02:00
Tobias GesellchenandClaude Opus 4.7 f3c974cbbd docs(troubleshooting): capture three recurring symptoms from issues #224 #235 #253
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>
2026-05-17 00:12:28 +02:00
Tobias GesellchenandClaude Opus 4.7 862c1caca2 docs: render mermaid diagrams on the GitHub Pages site
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>
2026-05-16 23:54:39 +02:00
Tobias GesellchenandClaude Opus 4.7 b0d7e8aae2 feat(spotify): wire preset storage end-to-end via server-centric priming (#302)
storePreset on the speaker was failing with "AddPreset - failed due to
invalid SourceID" because the watchdog priming path only pushed ZeroConf
credentials and never registered a SPOTIFY ConfiguredSource in marge.

PrimeDeviceWithSpotify now:
- resolves the device's paired account via live :8090/info
(margeAccountUUID), falling back to ServiceDeviceInfo.AccountID — same
order as setup.populateDeviceInfo;
- writes a SPOTIFY ConfiguredSource under that account (providerID=15,
BoseSecret as credential), mirroring bridgeSpotifyToMarge;
- POSTs `<updates><sourcesUpdated/></updates>` so the speaker re-fetches
its on-device Sources.xml from marge.

Also introduce zeroconf.ErrAddUserNoOp for the narrow firmware quirk
(404 + empty body on ?action=addUser when activeUser already matches).
Recognised only on that exact pattern; real 4xx/5xx still surface loudly
with full response details. Same treatment applied to Amazon priming.

Docs:
- new docs/concepts/spotify-overview.md anchors the topic (mental model,
streamingoauth.bose.com DNS gotcha, token lifecycle, clientId notes,
troubleshooting table);
- spotify-oauth.md drops the removed install-primer endpoint and the
on-device boot-primer install sections, adds /mgmt/spotify/prime;
- spotify-priming-strategy.md and MUSIC-SERVICES.md link to the
overview.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:47:57 +02:00
Tobias GesellchenandClaude Opus 4.7 e64481f008 docs(setup): record ST10 ≡ ST20 bundle equivalence + curl reproducer
Two doc-only additions to TestValidateRealSpeakerBundle's header
comment:

  - Cross-model note: ST10 and ST20 ship the byte-identical CA
    bundle on firmware 27.0.6.46330.5043500 (md5
    2d150987b312e4280fc576b508e62b43, 165 certs, ~251 KB).
    Verified against firmware/_backup_ST10/_/etc/pki/tls/certs/
    ca-bundle.crt 2026-05-16. The existing
    testdata/ca_bundle_st20_pristine.crt fixture therefore stands
    in for both models on that firmware build, so any expired-root
    hypothesis evaluated against it covers both.
  - Curl reproducer: three one-liners that point curl at the fixture
    and probe the actual TuneIn stream chain a SoundTouch speaker
    would walk (using K-LOVE / s33828 as the canonical example —
    matches the case from #292). Control with the system trust
    store shown alongside. Both bundles handle the chain (Amazon
    Root CA 1 + DigiCert Global Root, valid through 2026+) so the
    expired-root hypothesis is ruled out for firmware 27 — recorded
    in the comment so future-me / reviewers can replay the same
    probe without re-deriving it from chat context.

No code change; test still passes.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:23:23 +02:00
Tobias GesellchenandClaude Opus 4.7 04b3a445ca feat(bmx): make TuneIn formats= configurable via Settings.TuneInStreamFormats
PR #249 added "hls" unconditionally to TuneIn's Tune.ashx formats=
query. That regressed playback on the SoundTouch line: TuneIn returns
an .m3u8 HLS playlist for stations like K-LOVE (s33828), the speaker
can't parse it, blinks amber and falls silent. Verified that
firmware 27 on ST10 and ST20 ships the byte-identical Mozilla CCADB
bundle and validates the actual stream chain cleanly, so it isn't a
cert-expiry issue (#292's hypothesis) — the speaker simply has no
HLS support.

Changes:

  - TuneInStream is now a builder, not a const: takes the station ID
    plus a formats string (empty falls back to the new exported
    DefaultTuneInStreamFormats = "mp3,aac,ogg" — matches the pre-#249
    request shape).
  - TuneInPlayback and TuneInPlaybackPodcast take the formats string.
  - New Settings.TuneInStreamFormats string. Empty by default.
    Operators with HLS-capable speakers can set it to
    "mp3,aac,ogg,hls" — or any other comma-separated list — via
    settings.json. The value is passed through verbatim; AfterTouch
    does not validate the individual format tokens, so this is also
    the right knob for trialling additional formats without code
    changes.
  - Two regression tests pin both the empty-uses-default contract
    and the override-passes-through contract (with the whitespace-
    trim sub-case) so PR #249-style regressions surface at
    compile/test time.

The setting is settings.json-only (matches the existing pattern for
AllowInsecureUpstreamTLS / TrustForwardedHeaders / TrustedProxyCIDRs
which are also edit-the-file settings). UI surface can be a small
follow-up if reporters ask for it.

Example settings.json snippet to re-enable HLS (only if your
speaker can actually play it):

    {
      "server_url": "http://aftertouch.local:8000",
      "tunein_stream_formats": "mp3,aac,ogg,hls"
    }

Restart soundtouch-service after editing.

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

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

Changes:

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:16:53 +02:00
Tobias GesellchenandClaude Opus 4.7 695dd954e7 test(setup): regression for telnet-only migration detection
Pins the ordering invariant fixed in the preceding commit. Builds a
fake-speaker scenario where:

  - SSH is unavailable (every SSH-driven axis stays false)
  - telnet getpdo reports the AfterTouch hostname

Pre-fix, checkIsMigratedFromProbe ran before the telnet channel was
drained, so summary.TelnetVerifiedConfig was empty when
isTelnetMigrated read it — the telnet axis came back false and
summary.IsMigrated followed. The CLI's `setup verify` exited
non-zero, the web UI rendered "Not Migrated". Reproduced by
foob61451 on #293.

The test asserts:

  - summary.TelnetVerifiedConfig is populated (sanity guard — the
    downstream assertions are meaningless if the probe didn't run)
  - summary.TelnetMigrated == true
  - summary.IsMigrated == true

Verified locally: the test PASSES with the ordering fix applied and
FAILS without it. Failure messages name PR #294 by number so a
future regression points at the same code path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:46:04 +02:00
Marcin Mennemann 3bd82f3bf9 adj: comment numbers 2026-05-16 14:46:04 +02:00
Marcin Mennemann 5d2f5d12ec fix: detect telnet-only migrations in summary by waiting for probe result 2026-05-16 14:46:04 +02:00
Tobias GesellchenandClaude Opus 4.7 675288a329 docs(migration): add CLI-driven factory-reset alternative
The web-UI wizard is in-place migration: it preserves the speaker's
existing pairing and synced data. The CLI sequence is a different
shape — full factory-reset → wifi-push → pair against AfterTouch
from scratch — and it's the right tool when you want a clean,
scriptable, reproducible setup (automation, batched onboarding, or
just starting from a reset speaker).

Documents the full 6-step CLI flow (plan / factory-reset / wait-ap
/ wifi-push / wait-online / setup pair --mode=full), the verification
checks, and a side-by-side comparison so users can pick the right
path. Placed after "Repeat for each speaker" so the wizard remains
the recommended default for one-off migrations.

The flow assumes #195 and #269 are fixed in v0.80.2 — without the
AUX/sources filter, the CLI factory-reset path produces a speaker
where AUX won't dispatch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:14:18 +02:00
Tobias GesellchenandClaude Opus 4.7 355328da57 fix(cli): retry wifi-push once when the speaker's first ACK times out
The previous 10s→30s timeout bump didn't help — the first POST to
/addWirelessProfile on the speaker's AP-mode endpoint frequently
hangs until the deadline elapses, then a second POST a few seconds
later succeeds immediately. Empirically the workaround was "just
run wifi-push twice"; this commit folds that into the function.

PushWiFiCredentials now:
  - caps each attempt at 12 s (well above the sub-second healthy
    response time) so a stuck first attempt doesn't burn the whole
    budget
  - waits 2 s between attempts so the speaker's setup endpoint can
    finish whatever the first POST kicked off
  - falls through cleanly if the first attempt succeeds (the second
    never fires)
  - returns the second attempt's error if both fail, with context
    cancellation surfaced explicitly

Total budget is well under the CLI's 30 s --request-timeout, so
the flag still acts as a hard ceiling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:14:18 +02:00
Tobias GesellchenandClaude Opus 4.7 30456d7ff8 test(integration): update http-client assertions for cloud-side AUX exclusion
Two HTTP client tests asserted AUX (id=10001 / sourceproviderid=9) was
present in /streaming/account/{a}/full and /streaming/account/{a}/sources.
After 2b40481 drops AUX from those cloud responses (matching real Bose
behaviour; see pkg/service/marge/marge.go getAccountSources), both
tests fail. Updates them to:

  - Expect 5 sources in /full (down from 6) — INTERNET_RADIO,
    LOCAL_INTERNET_RADIO, TUNEIN, RADIO_BROWSER, Spotify.
  - Expect ids 10002/10003/10004 (not 10001/...) in /sources.
  - Add explicit negative assertions that sourceproviderid=9 / id=10001
    is *not* present, so a regression that re-introduces AUX in cloud
    responses fails loud.

Verified via `make test-http-client`: 49 requests, 0 failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:14:18 +02:00
Tobias GesellchenandClaude Opus 4.7 74007c7cb2 feat(setup): align <PairDeviceWithAccount> with the official Bose app shape
The Stockholm app (stockholm/setup/js/workflow_add_devices.js:23,77)
and Zimbo88's OpenCloudTouch USB-less script
(https://github.com/scheilch/opencloudtouch/discussions/201) both send
<boseServer>, <updateServer>, and <accountEmail> alongside the
<accountId>/<userAuthToken> pair. AfterTouch's setMargeAccount
historically sent only the latter two.

Adds:

  - MargePairingExtras struct on SessionConfig, opt-in via
    BoseServer (UpdateServer + AccountEmail default-derived when
    empty).
  - DefaultMargeAuthToken constant ("Bearer AfterTouch") and
    DefaultMargePairingEmail constant ("local@aftertouch.invalid",
    RFC 2606 reserved .invalid TLD).
  - buildPairDeviceWithAccountXML helper extracted so tests can
    pin both the minimal-payload and extended-payload shapes
    without driving a full WebSocket session.
  - --token flag on `soundtouch-cli setup pair` so we can override
    the placeholder for token-shape experiments.
  - runPairBare threads --service-url through to PairingExtras so
    `--mode=bare --service-url=...` ships the extended payload too;
    runPairFull already used it via applyInitPlanDefaults.

The speaker accepts any non-empty Bearer string (verified during
#195 investigation: "Bearer AfterTouch" passes and the speaker
re-derives its post-pair state from the marge endpoints regardless
of token content). The Stockholm-app payload shape is purely
documentation alignment; it did NOT fix the post-pair AUX/preset
breakage that turned out to be the cloud /full source list (see the
preceding marge commit). Keeping the wiring so the switches are
ready when we want to experiment further.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:14:18 +02:00
Tobias GesellchenandClaude Opus 4.7 332c7b87d0 fix(marge): drop AUX from cloud /full and /sources to unblock dispatch
Closes #195 and #269. Both issues reported the same symptom on
freshly-paired speakers: AUX selection and preset playback failed
post-pair, while /sources at :8090 still reported the sources as
READY. The bug was upstream in AfterTouch's cloud-side responses.

Real Bose's /streaming/account/{a}/full never emitted AUX as a
cloud <source>. Verified across 61 captured upstream /full bodies
covering 4669 source elements: zero match sourceproviderid=9 (AUX),
zero match the literal string "AUX". Captures sample at
scripts/android/captures/var/lib/soundtouch-service/parity_mismatches/.
The captured speakers are SoundTouch 20s which do have physical AUX
inputs — Bose deliberately kept AUX out of /full and let the speaker
enumerate it locally via isLocal=true.

AfterTouch's getAccountSources unconditionally included AUX
(id=10001) with the wrong shape: a displayName="AUX IN" attribute
(real Bose: never), <name>AUX</name> (real Bose: empty), an empty
<credential> (real Bose: empty for INTERNET_RADIO providerid=2 only,
never present for AUX since AUX wasn't there). The speaker's source-
reconciliation logic treated AfterTouch's malformed AUX entry as a
cloud-side inconsistency and refused dispatch to AUX — even though
the local availability check kept reporting it READY.

This was the actual cause behind a long red-herring trail (TPDA
:30034 storm, IoT.xml/AVS bootstrap, userAuthToken shape, SETUP
state machine bracket). All of those are universal across the
firmware family; spotty has the same TPDA storm in logread and AUX
still works there. Only the cloud-source-list shape diverged
between working and broken speakers.

The filter applies in getAccountSources because both AccountFullToXML
and AccountSourcesToXML go through it. AUX stays in
GetDefaultSources for non-cloud consumers (web UI source picker,
default-sources init). Three handler tests updated to assert AUX is
intentionally excluded from cloud responses.

Verified by gesellix on rhino 2026-05-16 via full factory-reset →
wifi-push → setup pair → AUX press → audio plays.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:14:18 +02:00
Tobias GesellchenandClaude Opus 4.7 824ed920ff fix(cli): give wifi-push the time the speaker needs to ACK
The speaker confirms AddWirelessProfile then tears down its AP within
~30 s. The default 10 s --request-timeout races that ACK whenever the
speaker is busy reconciling state — and a hard-coded 10 s on the
internal http.Client capped the user-passed timeout silently, so a
longer --request-timeout had no effect.

The CLI default is now 30 s and the inner http.Client lets the
context govern alone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:14:18 +02:00
Tobias Gesellchen c5938b8e05 gitignore stale testdata 2026-05-15 19:36:57 +02:00
Tobias GesellchenandClaude Opus 4.7 74420a4d02 docs(web): add stereo-pair rendering to soundtouch-web roadmap
Section 4 captures the presentation-only follow-up to #252: collapse
the two halves of a stereo pair into a single device-list entry using
each speaker's GET /getGroup metadata. Pair lifecycle (add/rename/remove)
already works end-to-end via pkg/client + soundtouch-cli, so this is
purely a soundtouch-web UI concern.

Drafted after BirdyBA's stereo-pair confirmation on the closed #252:
https://github.com/gesellix/Bose-SoundTouch/issues/252#issuecomment-4458140305

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:36:43 +02:00
Tobias Gesellchen 9dcde21f39 Bump release version to v0.80.1 in installer scripts 2026-05-15 19:25:01 +02:00
Tobias GesellchenandClaude Opus 4.7 979374b501 test(integration): pin #285 rename PUT behaviour at the HTTP layer
Adds rename_device.http between get_group.http and unregister_device.http
in the make test-http-client sequence. The new test fires the PUT
the speaker emits after a rename and asserts:

  - 200 OK, content type vnd.bose.streaming-v1.2+xml
  - the response carries the renamed value
  - createdOn matches the value captured during register_device.http
    (cross-request global), locking in the "first-paired" semantics
  - ipaddress is preserved from the prior power_on, not reset by the
    rename body's empty IP field
  - a mismatched body deviceid is rejected with 400

register_device.http captures the initial createdOn into a global so
the rename test can assert equality rather than a flakier
updatedOn != createdOn heuristic. The variant POST's stale
updatedOn === createdOn assertion is replaced with an upsert-aware
equality against the same captured global.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:04:41 +02:00
Tobias GesellchenandClaude Opus 4.7 6bc4ee1e73 fix(marge): reject rename PUT mismatch before persisting
HandleMargeUpdateDevice used to call AddDeviceToAccount (an upsert)
and only check body-vs-URL deviceID after the row was already
written. A speaker sending a malformed PUT with the wrong deviceid
attribute would still leave a spurious record before getting 400.

Now we parse just the deviceid attribute, compare against the URL
segment, and only call into the upsert when they match. The
existing regression test gains two GetDeviceInfo assertions to lock
the no-spurious-row guarantee in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:04:41 +02:00
Tobias GesellchenandClaude Opus 4.7 49904635f2 fix(marge): preserve CreatedOn + IPAddress across the device rename PUT
The PUT handler shipped in 5f31616 + the routing fix in 66b83b6 made
the rename PUT reach AfterTouch and return 200. But the response and
the on-disk record both drifted away from real Bose's parity on every
rename: CreatedOn was rewritten to now() (so the "first paired in
2017" semantics evaporated on the second rename) and IPAddress
landed empty (because the speaker's PUT body doesn't carry it and
the marge handler had no preservation path).

Pre-shutdown capture at
data/parity_mismatches/1771797308__streaming_account_3230304_device_A81B6A536A98.json
shows real Bose's 200 OK shape: createdOn pinned to the original
pairing timestamp (2017-02-07), ipaddress populated, only updatedOn
and name change across renames. Aligning with that.

Three small persistence additions:

  - models.ServiceDeviceInfo grows CreatedOn + UpdatedOn (ISO8601
    strings, omitempty so existing JSON consumers don't break).
  - datastore.SaveDeviceInfo persists them inside the DeviceInfo.xml
    payload as <createdOn> / <updatedOn> alongside the other fields.
  - mergeWithExistingDeviceInfo preserves CreatedOn unconditionally
    (it's the "first-paired" timestamp and never re-derived from
    inbound data) and preserves UpdatedOn only if the caller didn't
    set a fresh one.

marge.AddDeviceToAccount becomes precedence-aware:

  - Reads the existing record once at the top.
  - CreatedOn: preserved from existing if present, else now() for
    first registration.
  - IPAddress: preserves what's in the existing record; falls back
    to r.RemoteAddr's host portion only when no prior IP exists.
    Lets first-time PUTs seed an IP from the inbound connection
    without later renames clobbering a known-good value.
  - UpdatedOn: always now().
  - Response XML now re-reads the persisted record so the
    response body matches what's on disk — no parallel hand-built
    XML drifting from the merge result.

Function signature gained a remoteAddr parameter. Both callers
(HandleMargeAddDevice and HandleMargeUpdateDevice) pass r.RemoteAddr.

Test coverage:

  - TestIssue285_RenamePutAcceptedAndPersisted seeds the datastore
    with a 2017 CreatedOn and a known IP, then PUTs the rename;
    asserts both survive on disk AND in the response body, and
    that UpdatedOn refreshes. The same pre-shutdown capture cited
    above is the parity reference.

  - TestIssue285_NewDeviceGetsRemoteAddrAndFreshTimestamps (new)
    covers the no-prior-record path: first-time PUT against an
    unknown device produces CreatedOn = now() and IPAddress
    pulled from the inbound TCP connection. Pins the fallback
    behaviour so it can't quietly stop seeding new devices.

Authorization is still not enforced — the speaker has no Bose token
to send post-shutdown, and we don't (yet) have a token-authority
story of our own. Adding a warn-only auth check is a deferred
follow-up (see NEXT.md). Real Bose returned 401 for this PUT in the
2026-05-15 capture; we knowingly accept anything.

Refs #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:04:41 +02:00
Tobias GesellchenandClaude Opus 4.7 ff96430f53 fix(router): consolidate /device subrouter so PUT and DELETE actually resolve
Issue #285's first fix (5f31616) registered the rename PUT inside a
chi subrouter at `/streaming/account/{account}/device`, alongside the
existing POST handlers. A *second* subrouter was already declared at
`/streaming/account/{account}/device/{device}` for the per-device
sub-resources (presets, recent, group, …). chi's radix tree treats
those two registrations as overlapping prefixes and at request time
prefers the more-specific `/device/{device}` subrouter — which had
no root-level method handlers. A PUT to /device/X fell through to
the [UNHANDLED] catch-all, got proxied to streaming.bose.com, came
back as 401 from CloudFront. Speakers retried in a loop.

The handlers-package regression test passed because the test router
in `pkg/service/handlers/main_test.go` is flatter (one subrouter for
device, no `/device/{device}` nested block). The route snapshot
test passed because `chi.Walk` enumerates each subrouter's
registrations independently — it doesn't simulate how the radix tree
will resolve a runtime request when subrouters overlap.

Reproduced against the actual production setupRouter in
TestPUTRenameRoutesToLocalHandler (new in router_test.go). Before
this commit: 404 / [UNHANDLED] / 401 proxy. After: 200 from
HandleMargeUpdateDevice.

Fix: collapse the two subrouters into one. All `/device` routes —
the POST/PUT/DELETE on the device resource itself plus the GET/POST
sub-resources — share a single `r.Route("/device", ...)` block with
explicit `/{device}/...` paths inside. No radix-tree ambiguity.

Knock-on: the `r.Delete("/device/{device}", server.HandleMargeRemoveDevice)`
that lived at the outer `/account/{account}` level moves into the
unified `/device` subrouter for symmetry. Its prior placement was
also being shadowed by the radix overlap, which is why the route
snapshot's first regeneration after this fix grew by exactly one
DELETE line — that route was never resolvable at runtime under the
old structure either.

Refs #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:08:52 +02:00
Tobias GesellchenandClaude Opus 4.7 596e24595d docs(on-device-install): debugging recipe for the SSH-tunnel + listener trap
Lifts the back-and-forth in issue #250 into the README so the next
user doesn't repeat the same three traps Gustour hit:

  1. The `ssh -L 8000:localhost:8000` command must run on the user's
     own machine, NOT inside the speaker's SSH session. Gustour
     pasted it at the speaker's `root@mojo:~#` prompt; the tunnel
     ended up speaker → speaker (loopback) and did nothing.

  2. SoundTouch firmware offers only ssh-rsa/ssh-dss host-key
     algorithms; modern OpenSSH refuses them by default with
     `Unable to negotiate with <ip> port 22: no matching host key
     type found`. The README's *initial* ssh command already
     uses `-oHostKeyAlgorithms=+ssh-rsa`, but the port-forward
     example didn't — adding it.

  3. If the tunnel is correct and the browser still gets
     ERR_CONNECTION_RESET, the daemon isn't listening. The previous
     README left the user stranded here. Adds the diagnostic ladder
     (`netstat`, `ps`, `logread | grep aftertouch`) that matches
     the syslog-tag pattern shipped in the prior commit, plus the
     `/etc/init.d/aftertouch start` + `status` retry — the new
     status case can now distinguish "PID alive, listener up" from
     "PID alive, listener silently died".

No script changes; pure docs lift.

Refs #250.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:11:02 +02:00
Tobias GesellchenandClaude Opus 4.7 064fe80e18 fix(on-device-install): persistent install path + syslog-based logging
Bundles the install-time hygiene work for issues #268 and #250.

# Install location — #268

Stock SoundTouch rootfs has only a few MB free (~4 MB on the ST20
the reporter captured); the AfterTouch binary is ~12 MB. The previous
flow downloaded into tmpfs (/media/aftertouch) and then `mv`'d the
binary into /opt/aftertouch on rootfs — which fails with
"No space left on device" on any speaker with the standard layout.

install.sh now installs to /mnt/nv/aftertouch by default (the
persistent partition, ~30 MB free on the same captures) and points
/opt/aftertouch at it via a symlink so the init script's hardcoded
DAEMON path keeps working unchanged. Power users can override with
INSTALL_DIR=/some/other/path. The interactive prompt from the
community patch in #268's thread is dropped — STDIN is the curl
pipe under the documented `curl | sh` invocation, so a read prompt
would hang or read garbage.

uninstall.sh is updated to resolve the symlink and remove the
target before unlinking, so the 12 MB binary doesn't get orphaned
on /mnt/nv when users uninstall.

# Logging — #250

Issue #250 surfaced a "running but unreachable" state: the install
script reported AfterTouch as running, the init script's status
agreed, but `curl :8000` returned connection-refused. start-stop-
daemon's --background detaches stdout/stderr, so any panic the
daemon emitted before dying went to /dev/null with no diagnostic
trail.

The fix is to route the daemon's stdout/stderr through `logger -t
aftertouch` so output lands in BusyBox syslog — a bounded in-memory
ring buffer that never grows on disk (writing to a file in /mnt/nv
would have eaten the volume over months). Diagnostic flow is now:

    logread        | grep aftertouch | tail -20
    logread -f     | grep aftertouch     # live tail

Matches the recipe already documented in TROUBLESHOOTING.md for the
speaker's own logs (Curl 7 section).

Tightening on top of the syslog change:

  - The init script's `status` case now also curls localhost:8000
    when the PID is alive — distinguishes "PID alive, listener up"
    from "PID alive, listener silently died" (which is what fooled
    everyone on #250). A bare PID-liveness check returned "running"
    in both cases.

  - install.sh's post-install verification now does its own 10s
    curl probe after the init script returns; on failure it tails
    the aftertouch syslog so the user sees the actual error rather
    than the install script claiming success.

  - `exec` is added inside the start-stop-daemon's shell wrapper so
    --make-pidfile records the daemon's own PID (not the shell's),
    which keeps `stop` semantics correct.

README updated to document the install location, INSTALL_DIR
override, and the syslog tag.

No automated tests — these are shell scripts the install pipeline
runs once on the device. All three scripts pass `bash -n` /
`sh -n` syntax checks. Real validation is end-user retest, gated on
the next release.

Refs #268, refs #250.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:11:02 +02:00
Tobias GesellchenandClaude Opus 4.7 554fa78c0b fix(marge): handle the rename PUT speakers fire at /streaming/account/.../device/{id}
Closes issue #285. When the user renames an ST10 via the Bose App or
via `soundtouch-cli name set`, the speaker fires:

  PUT http://<aftertouch>:8000/streaming/account/{accountID}/device/{deviceID}
  Content-Type: application/xml
  <device deviceid="…"><name>NEW</name><macaddress>…</macaddress></device>

The router only had POST registered for that path; PUT fell through
to chi's default handling and the speaker observed HTTP 502 (captured
verbatim in _/i285/Rename.log:38: "SimpleURLFetcher: retry needed,
Curl 0, http 502, retries remaining 0"). The speaker's SimpleURLFetcher
retried the PUT on a 15-second timer, the Bose App showed the rename
spinning indefinitely, and the device's display name never updated on
the AfterTouch side.

Implementation reuses marge.AddDeviceToAccount, which is already an
upsert via ds.SaveDeviceInfo — there's no semantic difference between
"add" and "update" at the persistence layer. The new handler
HandleMargeUpdateDevice differs from HandleMargeAddDevice only in the
HTTP envelope:

  - 200 OK (not 201 Created — this is an update, not a fresh resource)
  - no Location header (the resource already lives at the URL the
    speaker is PUT-ing to)
  - deviceID in the body must match the URL's {device} segment;
    mismatch is a 400 rather than a silent re-key

Registered as `r.Put("/{device}", server.HandleMargeUpdateDevice)`
inside the existing `/streaming/account/{account}/device/` route
group in both cmd/soundtouch-service/main.go and the handlers-package
test router. Router-routes snapshot regenerated.

Test coverage in pkg/service/handlers/issue285_regression_test.go:

  - TestIssue285_RenamePutAcceptedAndPersisted seeds the datastore
    with a device under its original name, replays the literal log
    payload from _/i285/Rename.log:36 against the real router, and
    asserts 200 OK + new name in response body + new name persisted
    on disk. testdata/issue285/rename_request.xml is the captured
    payload byte-for-byte (accountID 3981561, deviceID 884AEAEEBD27,
    rename to "Wohnzimmer SB" — same as the reporter).

  - TestIssue285_RenamePutRejectsMismatchedDeviceID pins the safety
    check: body deviceid != URL {device} → 400.

Closes #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:48:06 +02:00
Tobias GesellchenandClaude Opus 4.7 6e6e4838e6 fix(setup): fire <sourcesUpdated/> after data sync to recover post-factory-reset sources
Closes the AfterTouch-side half of issue #234. After a factory reset
the speaker's /sources only lists the always-on local entries (AUX,
BLUETOOTH, AIRPLAY, NOTIFICATION, QPLAY, plus a SpotifyConnectUserName
placeholder); TUNEIN, LOCAL_INTERNET_RADIO, DEEZER, and linked
Spotify accounts are absent until the device receives the
<sourcesUpdated/> notification the reporter ran by hand. SyncDeviceData
now POSTs that notification as the final step, so users get the
visible-source-list recovery for free when they click Data Sync.

The other half — re-creating Marge.xml so playback resumes — is
already handled by the wizard's pair-account flow: it detects an
empty <margeAccountUUID/> in /info and prompts the user to pick a
known account or generate a new one. The wizard's pairing UI is
deliberately user-driven (the user picks the ID); the notification
nudge is purely automatic because there's no choice to make.

Implementation routes through the existing client surface rather
than reinventing it. setup.notifySpeakerSourcesUpdated delegates to
pkg/client.Client.NotifySourcesUpdated — the same path
handlers_mgmt.go already uses after music-service account changes
(handlers_mgmt.go:304, :637). The wire shape lives in one place
(pkg/models.NewSourcesUpdatedNotification). Fire-and-forget: a
notification failure logs but doesn't fail the sync.

Adjacent UX changes:

  - docs/guides/TROUBLESHOOTING.md: new section "Presets flash then
    revert to 'Select a preset' after a factory reset". Names the
    symptom, the Marge.xml + reduced-/sources cause, and walks the
    user through re-opening the Migration tab + Data Sync.

  - pkg/service/handlers/web/js/script.js: devices list now renders
    a "⚠ Not paired — re-pair" badge in the account-ID column for
    speakers whose live /info reports an empty margeAccountUUID.
    Clicking it opens the Migration tab pre-filled with that device,
    surfacing the wizard's existing "Not paired (factory-reset or
    never paired)" flow without making users discover it cold.

  - pkg/service/testing/fakespeaker/testdata/info.xml: demo speaker
    now reports margeAccountUUID=1234567 instead of the misleading
    0000000 (which AfterTouch happens to accept as syntactically
    valid but is not a documented sentinel anywhere — the convention
    is empty for factory-reset, a real 7-digit number otherwise,
    matching pkg/client/testdata/info_response_st{10,20}.xml).
    Screenshots regenerated accordingly.

Test scaffolding:

  - fakespeaker grows a POST /notification recorder that captures
    body + Content-Type; tests assert on s.Notifications().
  - TestIssue234_FactoryResetSpeakerSyncsReducedSources now drives
    SyncDeviceData end-to-end (exercises the wiring) and asserts
    the notification fires with the right deviceID and shape.
  - TestFakeSpeakerNotificationRecorder pins the recorder contract
    and the POST-only method gate.

Refs #234.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:25:07 +02:00
Tobias GesellchenandClaude Opus 4.7 61c33d527c fix(setup): atomic CA-bundle install with PEM-frame verification
Hardens TrustCACertFromBytes against the failure mode behind issue
#262 (corrupted /etc/pki/tls/certs/ca-bundle.crt on a SoundTouch 20)
and against silent transport-time corruption of our own writes.
Three-part change.

1. Atomic write path. The previous flow piped bytes straight into the
   live bundle via `cat > <path>`; a dropped SSH session or partial
   write left the device with a half-written trust store and no way
   to roll back. The new path:

     - uploads to <bundlePath>.aftertouch.tmp (sibling on the same
       filesystem, same rw remount),
     - reads the tmp back over SSH,
     - validates the readback at the PEM-frame layer + the AfterTouch
       sentinel bracketing,
     - atomically `mv`s the tmp into place,
     - on any verification failure: `rm -f` the tmp; the live bundle
       is never touched, so there is no rollback semantics to reason
       about.

   The .original backup written on first install stays as
   defense-in-depth (manual recovery for corruption from outside this
   code path), but it is no longer the primary safety net.

2. New validators in pkg/service/setup/ca_validation.go.

     - validateCABundleBytes: BEGIN/END marker counts match, every
       decoded block is a CERTIFICATE with a non-empty body, decoded
       block count equals BEGIN-marker count (catches a block with
       unparseable base64 body), trailing non-PEM/non-comment content
       rejected.
     - validateAfterTouchLabelBracketing: CALabel appears exactly
       twice and brackets exactly one CERTIFICATE block.
     - stripAfterTouchEntries: collapses any number of stale
       AfterTouch entries from the existing bundle. Older releases
       reported to have appended without stripping, so long-lived
       devices can carry several copies; we strip them all and log
       the cleanup count rather than failing validation. Unpaired
       sentinels (truncated prior install) surface as a structured
       anomaly the caller logs and warns about.

   The validators stay at the PEM-frame layer on purpose — an
   earlier iteration called x509.ParseCertificate per block and
   rejected the real ST20 bundle on block 29 (Go 1.23+ disallows
   negative serial numbers, but Mozilla CCADB still ships ancient
   CA roots that have them). Shipping that version would have made
   every legitimate speaker install fail. The corruption mode #262
   surfaces at the PEM-framing layer; x509-level checks aren't what
   we needed.

3. testdata/ca_bundle_st20_pristine.crt is the pristine
   /etc/pki/tls/certs/ca-bundle.crt captured off a real SoundTouch 20
   (firmware 27.0.6.46330.5043500, snapshot 2022-08-04). Mozilla
   CCADB public dataset, 165 certs, ~251 KB. TestValidateRealSpeakerBundle
   locks in the cert count and asserts the strip pass is a no-op
   against a bundle that has never been touched by AfterTouch.

Test infrastructure. mockSSH (both the setup-package and the
handlers-package copies) now mirrors UploadContent into a private
map so a subsequent `cat <path>` on the same path returns what was
written there. Lets the tmp-readback step in TrustCACertFromBytes
work against tests that only scripted the live-bundle path, without
per-test wiring. Two new behavioural tests in setup_test.go:
TestTrustCACert_StripsMultipleStaleEntriesSilently (pins the
multi-entry cleanup contract) and
TestTrustCACert_PostUploadVerificationFailureCleansUpTmp (pins the
rollback-free recovery: live bundle untouched, tmp removed).

Refs #262.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:05:41 +02:00
Tobias Gesellchen 7d3359dfb4 chore(lint) make the linter happy 2026-05-15 14:13:24 +02:00
Tobias GesellchenandClaude Opus 4.7 673be16f4f fix(bmx): restore the Authorization gate on /core02/.../orion/station
f3a4658 dropped the auth check on HandleOrionPlayback while moving
the orion routes to their registry-advertised paths. The rationale at
the time was "data is the speaker's own input, nothing privileged"
and parity with soundcork's reference impl.

On reflection, requiring the Authorization header is the right
default here for two reasons:

  1. Parity with the rest of our BMX playback surface (TuneIn
     variants — see TestBMXUnauthorized's table — all gate on a
     non-empty Authorization header). Orion being the lone unguarded
     exception was a footgun, not a feature.
  2. Real speakers obtain a Bearer token via the orion
     /token endpoint before they follow a LOCAL_INTERNET_RADIO
     preset, so the gate doesn't cost any legitimate caller. A
     callerless GET (curl, scraper, casual probe) gets a clean 401
     instead of a working playback resolver.

The check itself is the same shape as the other BMX handlers:
empty Authorization header → s.writeBMXUnauthorized → 401. Token
contents are not validated, only presence — sufficient for the
parity contract.

Test side:

  - TestOrionPlayback regains its Bearer header (it had one before
    the GET-method switch in f3a4658).
  - TestBMXUnauthorized's table regains a sibling row for the orion
    station endpoint with the GET + query-string shape.
  - TestIssue218_OrionStationResolvesPresetStreamURL sends a Bearer
    header on the loop-closing GET — added with a doc comment
    naming the orion /token bootstrap a real speaker would do.

No route-table changes; the registry advertisement and route paths
from f3a4658 stay as they are.

Refs #218.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:13:24 +02:00
Tobias GesellchenandClaude Opus 4.7 0e10bfcb14 test(setup): wire issue #235 — Spotify Connect /now_playing reports IsPresetable=false
Two-part iteration. First, the fakespeaker grows a `/now_playing`
route with a default STANDBY fixture — issue #235 is the first one in
this series that needs to override /now_playing, and adding the route
on its own would be infrastructure noise; bundled here it has an
immediate consumer.

The regression test then locks in the device-side signal at the heart
of #235: when a SoundTouch is targeted by Spotify Connect (Spotify
app sends audio to the speaker), the speaker's /now_playing reports

  - source = SPOTIFY
  - sourceAccount = SpotifyConnectUserName (the marker)
  - ContentItem.location = /playback/container/<base64 spotify:...>
    — a perfectly resolvable URI
  - **ContentItem.isPresetable = false**

The contradiction (resolvable location + isPresetable=false) is the
reason the CLI's storeCurrentPreset at
cmd/soundtouch-cli/cmd_preset.go:41 refuses to act and emits "current
content cannot be preset" — exactly the reporter's symptom.

The test base64-decodes the location to surface the contradiction
explicitly: it should yield a `spotify:` URI. When AfterTouch grows a
fallback path (CLI --force, or service-side resolution to the
device's own Spotify integration via the SoundTouch Spotify source
provider), the assertion here stays sound — it tests what the device
emits, not what the CLI decides — but a sibling test should assert
the new fallback path produces a successful preset.

Fixture pattern matches the rest of the issue series:
testdata/issue235/ next to the test, fakespeaker driven via
FixtureOverrides, doc-comment naming what would have to change for
the assertion to flip.

Refs #235.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:13:24 +02:00
Tobias GesellchenandClaude Opus 4.7 65a9873545 test(marge): pin the disk→marge half of issue #253 (preset edit propagation)
Issue #253 ("Edits to local Presets.xml don't propagate to
:8090/presets") has a three-hop propagation chain — disk → marge,
marge → device (via notification or power_on), device → :8090. Only
the first hop is in our reach; if it's broken, neither of the others
can recover.

This test writes presets_v1.xml directly to the datastore
(mimicking the reporter's hand-edit), calls PresetsToXML, asserts the
v1 markers (itemName "Initial Station", location s..INITIAL) land in
the rendered bytes. It then overwrites with presets_v2.xml and calls
PresetsToXML again, asserting:

  - v2 markers ("Edited Station", s..EDITED) land,
  - v1 markers are gone.

Current AfterTouch passes both assertions — disk→marge is sound, so
the reporter's symptom must originate downstream (notification
trigger missing, device-side firmware behaviour, or both). That
narrows the investigation surface for whoever picks up #253 next.

If this test ever flips (a caching layer is added without proper
invalidation, an in-memory presets handle is held across edits), the
fix is to invalidate the cache on disk write rather than weaken the
test — that contract is what the reporter relies on.

Pattern mirrors recents_sourceproviderid_regression_test.go: write
XML directly into the temp datastore filesystem and exercise the
marge function the handler calls (PresetsToXML at marge.go:370).
Fakespeaker isn't involved here — the failure surface is server-side,
not in what the device emits.

Refs #253.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:13:24 +02:00
Tobias GesellchenandClaude Opus 4.7 dd535cdb52 test(setup): pin factory-reset behaviour from issue #234
Wires the device-side state the reporter described in
https://github.com/gesellix/Bose-SoundTouch/issues/234 into the
fakespeaker via FixtureOverrides, and exercises GetLiveDeviceInfo +
syncSources against it.

The factory-reset state has two observable signals:

  - `/info` returns an empty `<margeAccountUUID/>` because Marge.xml
    is missing from the persistence partition. AfterTouch's
    "is the device paired?" check at setup.go:632 keys on AccountID,
    so this is the canonical "needs re-pairing" signal.
  - `/sources` lists only AUX, BLUETOOTH, AIRPLAY, the
    SpotifyConnectUserName placeholder, NOTIFICATION, and QPLAY —
    TUNEIN, LOCAL_INTERNET_RADIO, and any post-pairing Spotify
    accounts are gone until the speaker is nudged with a
    `<sourcesUpdated/>` notification or re-pairs.

Today AfterTouch has no auto-recovery for either signal — it just
passes the state through. The test locks in that contract by
asserting:

  - GetLiveDeviceInfo reports an empty MargeAccountUUID,
  - persisted Sources.xml contains AUX/BLUETOOTH/AIRPLAY sourceKeys,
  - persisted Sources.xml does NOT contain TUNEIN/LOCAL_INTERNET_RADIO.

When auto-recovery lands (e.g. an automatic POST of the
sourcesUpdated notification during sync, or marge-side source
replenishment), the absence assertions will flip — at which point
update them to assert the survivors are *present*, and adjust the
doc-comment so the contract stays in sync with the code.

Pattern mirrors pkg/service/setup/issue218_regression_test.go: a
testdata fixture next to the test, fakespeaker driven via
Config.FixtureOverrides, doc-comment naming what would have to
change for the assertion to flip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:13:24 +02:00
Tobias GesellchenandClaude Opus 4.7 13e82bbf85 test(bmx): close the loop on issue #218 — preset URL resolves end-to-end
Pairs with the existing pkg/service/setup/issue218_regression_test.go
"survives sync" assertion. This one takes the exact `location`
attribute the reporter pasted in issue #218 — the cloud URL embedded
in their LOCAL_INTERNET_RADIO preset — parses out the base64 `data`
query payload, sanity-checks it really does encode the documented
http://ais-sa3.cdnstream1.com/2440_128.aac stream URL, then hits the
preset's path-and-query on the real router and asserts the
BmxPlaybackResponse the speaker would receive: audio.streamUrl, name,
streamType, and the streams[] mirror.

Before f3a4658 this test would have 404'd because orion was nested
under the wrong `/bmx/` prefix. With the routing fix in place, the
two issue #218 regressions now bracket the failure end-to-end:

  - setup test (sync side):  the URL is preserved on the way in
  - handlers test (this one): the URL works on the way out

No fix-side code changes; this is purely a regression-protection
addition that documents the contract resolved by f3a4658.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:13:24 +02:00
Tobias GesellchenandClaude Opus 4.7 098b4f59dd fix(bmx): serve orion at the registry-advertised path, drop the /bmx/ prefix
The BMX registry advertises orion at
`{BMX_SERVER}/core02/svc-bmx-adapter-orion/prod/orion` — no `/bmx/`
prefix. That matches the upstream Bose capture in
pkg/service/handlers/static/bmx_services_ustream.json. But our router
nested both orion routes inside the `/bmx/` chi group, so the speaker
asked `/core02/.../prod/orion/token` and our service routed
`/bmx/core02/.../prod/orion/token` — pure path mismatch. The legacy
preset URLs in issue #218 (LOCAL_INTERNET_RADIO presets pointing at
`https://content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion/station?data=...`)
also dead-ended for the same reason.

Three changes:

- Move `POST /core02/svc-bmx-adapter-orion/prod/orion/token` from the
  `/bmx/` group to top level so it matches what the registry hands the
  speaker.
- Add the missing `GET /core02/svc-bmx-adapter-orion/prod/orion/station`
  that takes `data` as a query string. The handler reuses
  bmx.PlayCustomStream — base64-decode the JSON blob (streamUrl/
  imageUrl/name) and rewrap it into the standard BmxPlaybackResponse
  shape, exactly the way soundcork's reference impl handles it
  (soundcork main.py:786, bmx.py:720). No auth check on this endpoint:
  `data` is the speaker's own preset payload, there's nothing
  privileged to gate, and the upstream behaviour treats it the same way.
- Drop the local-invention `POST /bmx/orion/v1/playback/station/{data}`
  route. Nothing advertised it, nothing real-world called it, and
  keeping it as a "convenience alias" would have left a misleading
  duplicate next to the canonical path.

TuneIn's `/bmx/tunein/...` routes stay where they are — TuneIn's
upstream baseUrl genuinely is `{BMX_SERVER}/bmx/tunein`, so the chi
group prefix is correct for that one.

Router snapshot regenerated; TestOrionPlayback flipped from
POST `/bmx/orion/v1/playback/station/{data}` to GET
`/core02/...station?data=...` (no auth header); the orion entry in
TestBMXUnauthorized's table is removed (the endpoint isn't authed
anymore, by design).

Refs #218.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:13:24 +02:00
Tobias GesellchenandClaude Opus 4.7 2fabdece64 test(fakespeaker): wire issue-specific payloads via Config.FixtureOverrides
Introduces a per-route fixture-override hook on fakespeaker.Config so
open issues with concrete device-side payloads can become repeatable
regression tests, then demonstrates the pattern by wiring issue #218.

Foundation. Config grows a single optional field:

  FixtureOverrides map[string][]byte

Routes named in the map (e.g. "/presets", "/sources", "/info") return
the supplied bytes; routes not in the map fall through to the embedded
testdata defaults the screenshot pipeline relies on. Stateful handlers
(/getGroup, /addGroup, /updateGroup, /removeGroup) are unaffected
because they're code-driven, not fixture-driven. The override slice is
snapshotted at construction so later mutations of the caller's slice
don't change the served body. Zero-value Config keeps the existing
behaviour, so cmd/dummy-speaker + scripts/screenshots are untouched.

Iteration zero — issue #218.
pkg/service/setup/issue218_regression_test.go starts a fakespeaker
serving the reporter's LOCAL_INTERNET_RADIO preset XML verbatim (URL:
content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion/station?…),
runs Manager.syncPresets against it, then asserts the persisted
Presets.xml retains the Bose cloud URL prefix. This locks in the
"location preserved through sync" contract; when AfterTouch starts
rewriting the URL to its own base (the eventual fix for #218), the
assertion flips and the fixture stays unchanged — the test is the
carrier for the decision.

Pattern reference for future issue regression tests: this exemplar
mirrors pkg/service/marge/recents_sourceproviderid_regression_test.go's
style (issue link, trigger chain in the doc-comment, locked-in
assertion) but is the first one to drive the device side via fakespeaker
rather than an inline httptest.NewServer. Subsequent issues with
device-side payloads (#234 factory-reset state, #235 Spotify-as-preset,
…) can reuse the FixtureOverrides hook without further infrastructure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:13:24 +02:00
Tony 6196a802e2 add new format in tunein query 2026-05-15 14:04:32 +02:00
Frank W 996faa0578 API uses "playback", not "playbook" 2026-05-15 13:45:54 +02:00
Tobias GesellchenandClaude Opus 4.7 5ba0776787 Bump install scripts to v0.79.0
on-device-install and raspberry-pi installers default to the new
v0.79.0 release binary. Also refreshes two stale comment examples in
the raspberry-pi install script (v0.17.0 → v0.78.0, v0.18.1 → v0.79.0)
so the in-file usage hints reflect the same era as the default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 12:56:43 +02:00
Tobias GesellchenandClaude Opus 4.7 abae685a85 fix(screenshots): widen fakespeaker coverage and stabilize the pipeline
make screenshots was producing artifacts: a ghost Spotify pill on
ui-devices, empty Plan-card URL inputs on ui-migration with cascading
"localhost" warnings, and "Checking configuration…" placeholder text
instead of " Not configured" on ui-settings. Two root causes, fixed
together so the run is deterministic again.

1. Fakespeaker too thin for the post-wizard inspect pipeline. The new
   migration wizard probes /supportedURLs and reads /networkInfo and
   /sources alongside the existing /info, /presets, /recents. Those
   routes now exist with sanitized fixtures (deviceID DEADBEEFCAFE,
   loopback IPs, no real MACs or account IDs). The full group endpoint
   set is also wired: /getGroup and /removeGroup return the empty
   <group/> shape a real un-paired device emits; /addGroup and
   /updateGroup echo the posted body with <status>GROUP_OK</status>
   inserted before </group>, matching the success path documented in
   issue #252. /supportedURLs lists everything the fake now serves so
   any caller that probes capabilities first (e.g. marge_pairing.go)
   sees a coherent picture. Tests cover the GET routes' XML roots, the
   POST echo + GROUP_OK insertion contract, and /removeGroup's
   GET-only contract (405 with Allow: GET on other methods).

2. run.sh seed hit a DNS cliff. The :443 preflight shipped in 3727ae6
   resolves server_url on every /setup/settings call, and the
   populatePlannedNetworkConfig step does it again. With the previous
   seed of http://aftertouch.local:8000 each lookup burned ~5s on DNS
   timeout, which compounded across the wizard calls and pushed
   ui-migration past chromedp's 30s per-shot budget. Switched the seed
   to http://aftertouch.localhost:8000 — RFC 6761 means *.localhost
   resolves to loopback via the system resolver in milliseconds
   (verified ~8ms on macOS / glibc / systemd-resolved) — so the brand-
   friendly hostname survives in the captured PNGs without the
   timeout. Manifest settle times bumped (ui-settings 300→2000ms,
   ui-devices 500→2500ms, ui-sync 300→1000ms) to give fetchSettings +
   fetchSpotifyStatus time to complete in headless Chrome.

While here, softened validateURL's loopback message to acknowledge the
on-device-install case (AfterTouch running on the speaker itself, where
loopback works) instead of unconditionally telling users they're
wrong. The validation still flags 127.0.0.1 / localhost since it's the
wrong answer 99% of the time, but the message now frames the
constraint rather than scolding.

docs/images/ui-*.png regenerated against the new pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 12:56:43 +02:00
Tobias GesellchenandClaude Opus 4.7 9cb8549c79 docs(troubleshooting): add filtered logread recipe + cross-link from Curl 7
Add the loopback-filtered command `logread -f | grep -v '127.0.0.1'` to
DEVICE-LOGGING.md's Pro-Tip section with a one-line rationale (strips
the speaker's in-device localhost chatter so cloud/AfterTouch attempts
are readable). Cross-link from the new Curl 7 entry in TROUBLESHOOTING
so users hitting that symptom find the SSH/logread how-to.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:03:12 +02:00
Tobias GesellchenandClaude Opus 4.7 33c1db5b97 style(preflight): replace if-else chain with switch (gocritic)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:03:12 +02:00
Tobias GesellchenandClaude Opus 4.7 8cc8f28bdd style: gofmt alignment and blank-line tidy
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:03:12 +02:00
Tobias GesellchenandClaude Opus 4.7 3727ae6f0f feat(service): pre-flight :443 reachability check with UI surfacing
Speakers connect to Bose hostnames over implicit HTTPS (:443) while
AfterTouch's listener defaults to :8443. Without iptables / setcap /
reverse-proxy in front, the speaker side sees Curl 7 / connection
refused and AfterTouch's HTTP log stays silent — a recurring source
of confusion (see #214, #269).

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:03:12 +02:00
Tobias GesellchenandClaude Opus 4.7 ef2b775ce0 test(integration): add http-client test for stereo-pair Marge POST
Add an end-to-end IntelliJ HTTP Client test that replays the exact
request shape a SoundTouch 10 master sends to its configured Marge
server during stereo-pair formation (captured live in issue #252):

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

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

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

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

Refs #252

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

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

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

Refs #252

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

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

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

Refs #252

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs #264

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

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

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

Refs #264

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

Signed-off-by: mehmet turac <mehmetturac@gmail.com>
2026-05-14 15:37:56 +02:00
Tobias GesellchenandClaude Opus 4.7 8ee15bb034 test(handlers): use deterministic IP in BMX registry test
soundtouch.local relied on mDNS resolution, which works on developer
macOS but not in CI/Linux. With the new server_url validation, an
unresolvable hostname now correctly causes DNS to refuse to start --
which flips dnsEnabled to false and made the test fail honestly instead
of passing while DNS was silently broken. Switch the fixture to
127.0.0.1 so the test exercises the DNS-enabled path everywhere.

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

Refs #269

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

Introduce a separate DiscoveryInterface knob:

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

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

Refs #264.

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

Refs #264.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

What landed:

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

WebSocket notifications:

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

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

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

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

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

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

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

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

Three independent pieces:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

Three changes, each defended by the new regression test:

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

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

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

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

Two buttons, one per Apply path:

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 ae9b02a42b docs(service): API reference for the *_url option family + telnet-probe
The /setup/migrate/{deviceIP} reference table covered only the legacy
self/proxied/original mode selectors, with a one-line "Custom service
URL" mention of target_url. The wizard has been writing literal
per-field URLs via marge_url / stats_url / sw_update_url / bmx_url
for weeks; external API callers had nothing to read.

Expanded the table into three blocks with precedence rules:

  1. Top-level params — method, target_url, proxy_url with the
     four migration mechanisms (xml / telnet / resolv, hosts marked
     deprecated).
  2. Per-field implementation mode — the legacy self/proxied/original
     family, kept for API back-compat with a note that the UI no
     longer sets them.
  3. Per-field literal URL overrides — marge_url / stats_url /
     sw_update_url / bmx_url with a "literal wins over mode" rule
     and the soundcork-suffix-propagates-to-envswitch note.

Three example curl invocations (canonical XML, soundcork telnet,
resolv with HTTPS) replace the old proxy=original-only snippet up
top.

Also added stub reference entries for POST /setup/telnet-probe and
the internal GET /probe/{token}[/*] catch-all — the SSH-less
reachability check the wizard runs automatically in its pre-flight
panel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 2b8e652b7e docs(web): "Migration Process at a Glance" no longer SSH-only
The landing-tab overview still framed SSH as a hard prerequisite —
"Migration requires SSH access." That was true under the original
design, but the wizard now probes both SSH and Telnet:17000
automatically and uses whichever the device exposes. SSH-less
speakers (USB-unlock-refusing firmware like SA-5, ST520, recent ST
Portables) can migrate over telnet without ever opening a shell.

Updates:

  - Prerequisite box retitled "Speaker shell access" with two
    sub-bullets that match the state card's Transports row:
      * SSH — richest option, required for XML / DNS / CA install,
        same USB-stick procedure as before
      * Telnet:17000 — SSH-less fallback, no setup, HTTP-only
  - Step 1 (Settings) now mentions that Target URL can be edited
    inline on the Migration tab with Save as default, since the
    Settings tab is no longer the only place to set it.
  - Step 4 (Migration) replaces "we recommend the XML Configuration
    method" with a description of the actual wizard: Apply
    Suggested Plan, Customize three-axis form, and the visible
    pre-flight check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 fe58b61c11 refactor(web): pre-flight HTTPS check uses the actual migration target
The pre-flight connection check always hit summary.server_https_url
(the HTTPS health endpoint), regardless of what URL the migration
would actually write to the speaker. That gave a useful baseline
("can the device reach our service over HTTPS at all?") but didn't
test the right thing for HTTP-target migrations — the dominant
configuration when SSH is available and the user goes with the
Suggested Plan's XML+HTTP default.

preflightConnectionTestURL now picks the test URL by intent:

  - methods.includes("resolv") → server_https_url. DNS interception
    leaves the device hitting https://*.bose.com (firmware-hardcoded
    scheme) which DNS redirects to our HTTPS endpoint; testing the
    health URL is the right shape.
  - URL-flip methods (xml / telnet) → derived from the user's
    targetUrl: scheme + host + "/health". HTTP-target migrations get
    an HTTP test, HTTPS-target migrations get an HTTPS test (still
    with use_explicit_ca=true so the trust path is forward-looking
    when CA install is part of the plan).
  - Fallback to server_https_url when targetUrl can't be parsed, so
    older call shapes keep working.

The row label is now dynamic: "HTTPS connection from device" or
"HTTP connection from device" depending on the actual test scheme,
so the panel tells the user which path is being exercised.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 56c3e4f641 docs(guide): user-facing migration guide reflects the wizard
The guide still described the pre-wizard UI: "SSH status, CA trust
status, and connection test results before letting you apply the
redirect" and two methods (XML / DNS). The migration tab now opens
with the state card + Plan card + Customize three-axis form + visible
pre-flight panel, and a third transport (Telnet:17000) lets users
without SSH access migrate too.

Updates:

  - Step 3 retitled "Enable shell access on each speaker" with two
    sub-sections: SSH (the richest option, required for XML / DNS /
    CA install) and Telnet:17000 (the SSH-less fallback, no setup
    required, HTTP-only).
  - Step 5 rewritten to walk through the actual UI:
      * the state card's three rows (Transports, Migration State,
        Preconditions) with the action affordances inline
      * the Plan card — target URL with Save as default, per-field
        Service URLs editor with validation and soundcork-mode,
        account pairing, and Apply Suggested Plan
      * the visible pre-flight checks panel with its three or four
        checks per method and the Proceed Anyway / Cancel branch
      * Customize this migration with three independent axes
  - Step 6 mentions the auto-expand of Customize on Apply success
    and the per-transport reboot picking.
  - Rollback section adds the telnet-only "reboot reverts the
    runtime layer if envswitch isn't written" property, plus the
    rename to "Revert to Defaults" matching the button label.

The image reference (ui-migration.png) stays pointing at the
existing screenshot; a fresh capture is needed once the wizard is
final but the surrounding prose is now accurate either way.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 8a61c43cfa refactor(web): prune deprecated hosts-redirection-test markup and JS
The /etc/hosts migration method has been hidden from the UI since
before the wizard refactor — the Customize three-axis form doesn't
expose it, the suggested-plan engine never picks it, and
onCustomizeChange explicitly force-hides the legacy
#hosts-redirection-test pane. The pane was sitting in the DOM doing
nothing.

Removed:

  - The hosts-redirection-test <div> (button, result pane, header)
  - test-hosts-btn.onclick wiring in showSummary
  - The testHostsRedirection() function (orphaned once the button is
    gone)
  - The show("hosts-redirection-test", false) toggle in
    onCustomizeChange (orphaned once the pane is gone)

Backend untouched:

  - /setup/test-hosts/{deviceId} and HandleTestHostsRedirection still
    exist for API back-compat. Same pattern we used when retiring the
    XML method's self/proxied/original dropdowns — only the UI
    surface moves; the manager-level entry points stay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 441632b642 docs(analysis): post-implementation addendum (§9) for the telnet method
The feasibility analysis (§§1–8) was written before any of the wizard
shipped, and §7 forecast the surface area roughly. The migration tab
grew considerably during implementation — three-axis state model,
Plan card with per-field URL editor and validation, Customize
three-axis form, visible pre-flight panel, account pairing folded
into the wizard, and the SSH-less round-trip probe — none of which
the original §7 captures faithfully.

Added §9 "What actually shipped (post-implementation addendum)" with:

  §9.1 Three-axis state model (per-axis migration booleans, IsPaired,
        the state-card layout)
  §9.2 Plan card per-field URL editor (single source of URL overrides
        for both XML and Telnet, live optimistic preview)
  §9.3 Customize three-axis form (URL flip / DNS / CA radios driving
        applyCustomPlan)
  §9.4 Pre-flight panel (visible check list, decision tree, override
        affordances)
  §9.5 Telnet round-trip probe (the SSH-less reachability check via
        swUpdateUrl flip + :8090/swUpdateCheck trigger + probe-token
        registry)
  §9.6 Backend additions worth knowing (applyURLOverrides, parser,
        option allow-list, telnet timeout bumps)
  §9.7 Future probe candidates (pushCustomerSupportInfoToMarge;
        running the round-trip probe on SSH-capable speakers too)

§§1–8 stay verbatim as the historical feasibility record, with a
forward-pointer at the head of §7 so readers know the as-shipped
state is documented further down.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 6617c22967 style(setup): satisfy govet shadow + thelper lints
Two lint findings flagged by golangci-lint:

  - telnet_probe.go:90 — t.Dial()'s local err shadowed the outer
    url.Parse error (govet shadow). Renamed the inner one to
    dialErr.
  - migration_summary_telnet_test.go:20 — telnetSummaryEnv didn't
    call t.Helper(), so test failures pointed at the helper rather
    than the calling test (thelper). Now mirrors the t.Helper() in
    telnetSummaryEnvWithInfo.

No behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 95e76b52ad docs(web): drop stale pair-account-panel note from Telnet pane
The Telnet method pane still said "After a successful migration a
Pair Account panel will appear below this one" — but pair-account-pane
was removed three commits ago when pairing was folded into the Plan
card as a configured-up-front step that runs as part of Apply. The
note pointed users at a panel that no longer exists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 23b2cd49ed feat(web): wire telnet round-trip probe into pre-flight panel
Replaces the placeholder "skip — telnet round-trip probe not yet
implemented" branch with an actual call to POST /setup/telnet-probe
when SSH is unreachable but Telnet:17000 is. SSH-less speakers now
get real reachability verification before any migration step runs,
instead of being silently ignored by the pre-flight pipeline.

Decision tree for the reachability check:

  - SSH reachable      → HTTPS connection test from device (existing)
  - Telnet:17000 only  → Telnet round-trip probe (new)
  - neither            → skip with "no transport reachable" message

The probe row reports its result inline with the existing pre-flight
panel idiom (🕐 / ⟳ /  / ), surfacing elapsed_ms on success so
users see how long the round-trip took. Failure messages from the
backend (timeout, sys configuration rejected, dial refused) propagate
verbatim so the user knows which step of the orchestration tripped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 09c8b916ae feat(setup,handlers): SSH-less reachability via telnet round-trip probe
Fills the SSH-less gap the curl-from-device test leaves in the
pre-flight panel: instead of skipping connectivity verification on
USB-unlock-refusing speakers, we drive a round-trip from the device
itself using only telnet:17000 and the device's own :8090 API.

Sequence (Manager.RunTelnetRoundTripProbe):

  1. telnet `getpdo CurrentSystemConfiguration` — capture the
     speaker's current swUpdateUrl so we can restore it.
  2. Generate a random hex token; register a one-shot signal
     channel under it via the new probeRegistry on Server.
  3. telnet `sys configuration swUpdateUrl <targetURL>/probe/<token>`
     — runtime layer only, no envswitch boseurls set, so the
     persistence layer keeps the original and a reboot heals the
     device naturally if our restore step fails.
  4. HTTP GET :8090/swUpdateCheck — the cleanest :8090 endpoint
     that triggers exactly one outbound to the configured
     swUpdateUrl. Read-only on the cloud side, doesn't depend on
     margeAccountUUID, doesn't start an actual update.
  5. Wait on the registered channel up to telnetProbeTimeout (6s).
  6. telnet `sys configuration swUpdateUrl <original>` — restore
     in a deferred call so it runs even on the failure path.

New /probe/{token}[/*] catch-all on the root router signals the
matching channel when the speaker's outbound lands; the response is
a minimal `<swUpdateIndex/>` so the device's swUpdateCheck doesn't
choke on a missing structure. The {token}/* sub-path is registered
because some firmware appends a path component to the configured
swUpdateUrl.

POST /setup/telnet-probe/{deviceId}?target_url=… exposes the
orchestrator as a single REST call returning {ok, result: {reached,
restored, original_url, probe_url, elapsed_ms, logs}, error?}.

Tests cover: happy path with channel signalled by the fake registrar
when the :8090 trigger fires, timeout when no inbound arrives,
abort when getpdo doesn't expose swUpdateUrl, abort when the
firmware rejects sys configuration, dial failure, invalid target URL.

Frontend wiring (visible pre-flight panel) lands in the next
commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 102770e301 feat(web): account pairing folded into Plan card and Apply orchestrator
Pairing was previously its own post-telnet pop-up pane —
loadAccountIDSuggestions(deviceId) was called only after a successful
telnet migration, leaving the user to interact with a separate panel
and click a separate "Pair Account" button. XML migrations didn't
surface pairing at all.

The Plan card now has its own Account pairing section between Service
URLs and Suggested plan, with the same affordances (current state,
7-digit input, Generate button, datastore picker) but always
visible. The implicit intent — read by readPlanPairTarget — is:

  - empty input + currently paired      → no pairing step (current ID kept)
  - empty input + currently unpaired    → no pairing step (warning hint visible)
  - input matches summary.account_id    → no pairing step
  - input is exactly 7 digits, differs  → pair step queued at Apply
  - input is non-empty but malformed    → blocks Apply with a clear error

Both Apply orchestrators (applySuggestedPlan, applyCustomPlan) now
queue a `pairAccount(deviceId, accountId)` call when the intent says
to. It runs *after* the URL flip / DNS / CA steps so the user sees
the migration succeed before pairing — pairing is independent of
the migration target so order is purely UX. First-failure-aborts is
preserved: a pair-account error stops the rest of the sequence.

Removed:
  - #pair-account-pane HTML and all its descendants
  - loadAccountIDSuggestions / generateAccountID / pairAccount(deviceId)
    (the old pane-bound functions)
  - the "if method === telnet → loadAccountIDSuggestions" trigger in migrate()

Added:
  - renderPlanPairing(summary, deviceId) — populates the section on
    every showSummary
  - loadPlanAccountSuggestions(deviceId) — fetches /setup/account-id-
    suggestions; gracefully degrades on failure
  - onPlanPairIDChange / onPlanPairPick / generatePlanAccountID — UI
    handlers with implicit-intent status hints
  - readPlanPairTarget — orchestrator-facing intent extractor
  - pairAccount(deviceId, accountId) — POSTs and throws on failure
    (replaces the old pane-bound function with a step-friendly shape)
  - resetPlanCardForDeviceSwitch clears the pairing input on speaker
    change so the previous device's ID can't leak

Backend untouched — all the pairing endpoints (/setup/account-id-
suggestions, /setup/pair-account) and the setup.PairAccount + telnet-
fallback logic stay exactly as-is.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 a7c9bb1eae feat(web): visible pre-flight panel runs the same checks the Test buttons run
Replaces the silent confirm()-dialog pre-flight with an inline panel
that pops up the moment Apply is clicked, walks through each
applicable check live, and surfaces the result before any backend
operation touches the speaker.

Three checks run in order:

  1. Backend summary re-check (always) — the existing
     runPreflightCheck logic, repackaged as the first row in the
     panel. Catches transport/resolve_ip drift since the cached
     summary loaded.
  2. HTTPS connection from the device (when SSH is reachable) —
     reuses /setup/test-connection with use_explicit_ca=true so the
     test exercises the trust path even when CA install is part of
     the plan. Identical to the manual "Test with Explicit CA.crt"
     button under HTTPS Connection Test, but runs without requiring
     the user to click it. SSH-less devices show a "skip" row with
     a note pointing at the future telnet round-trip probe.
  3. DNS redirection from the device (only when resolv is in the
     plan and SSH is reachable) — reuses /setup/test-dns. Same
     parity as #2 with the manual "Test DNS Redirection" button.

UX:

  - Each check renders with 🕐 pending → ⟳ running →  ok / 
    fail / — skipped, so the user sees feedback while the backend
    works.
  - On all green: a 700ms hold lets the success state register, then
    Apply auto-proceeds.
  - On any red: a "Proceed Anyway" / "Cancel" pair appears; default
    is to abort, but the user can override on a known false-positive.

Both Apply paths (applySuggestedPlan and applyCustomPlan) now share
runApplyPreflight and awaitPreflightDecision; the unused
confirmPreflightIssues helper is removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 1d7f8e621e feat(web): authoritative pre-flight check before Apply
The Plan-card preview is now optimistic and renders client-side on
every keystroke (previous commit), so the view can drift from what
the backend would actually do — at least until the next summary
fetch. Runtime state can also drift between the cached summary the
user is looking at and the moment they click Apply (a transport
goes down, DNS hostname stops resolving, etc).

Adds runPreflightCheck which both Apply paths call once before
kicking off any backend operation:

  - applySuggestedPlan calls it with the single chosen method.
  - applyCustomPlan calls it with the full list of operations the
    sequence will run (flip method, optional resolv, optional
    trust-ca) so the SSH/Telnet reachability requirement is checked
    against the actual fresh summary, not the stale cached one.

The check covers four classes of inconsistency:

  - resolve_ip_error from the device's perspective
  - SSH reachable when xml / resolv / trust-ca is queued
  - Telnet:17000 reachable when telnet is queued
  - The backend's planned_config XML contains every per-field URL
    override we're about to send (sanity check that the client's
    optimistic preview agrees with the server's render before we
    write to the speaker)

On any issue, confirmPreflightIssues shows them in a confirm()
dialog so the user can override on a known-false-positive (slow
DNS, etc.) but the default is to abort.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 af6fe78f3f feat(web): live planned-XML preview + reset stale form state on device switch
Two related fixes for the Plan-card → Customize-pane preview flow:

1. Live planned-XML preview. The Customize panel's "Planned Config
   (AfterTouch)" pane previously showed summary.planned_config —
   server-rendered, only updated on the next showSummary fetch. So
   editing a URL field in the Plan card had no visible effect on the
   preview until the user manually refreshed. The new
   renderPlannedXMLPreview composes the same XML client-side from
   plan-target-url + the four override inputs, mirroring exactly what
   migrateViaXML writes (target-derived defaults + applyURLOverrides),
   and is called from validatePlanURLs which already runs on every
   keystroke.

2. Per-device form-state isolation on speaker switch. The Plan card
   inputs preserve manual edits across summary refreshes (force=false)
   so a user's typed URL doesn't get clobbered by a re-fetch. That
   semantic is right within one device but wrong across devices: if
   the user edited a URL on speaker A and then picked speaker B in
   the dropdown, A's value silently appeared in B's preview.

   showSummary now compares the previous summary-device-id to the new
   one and, on change, calls resetPlanCardForDeviceSwitch to clear
   the four URL inputs, the Soundcork checkbox, the "saved" hint
   dataset, the URL-validation banner, and both apply-status lines.
   The downstream fillPlanURLInputs(defaults, force=false) then fills
   the now-empty inputs with the new device's canonical defaults.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 967d516d4d fix(web): pair Current/Planned diffs per axis instead of mixing them
With XML+resolv selected together, the bottom panes rendered as
"Current XML | Planned XML | Planned resolv hook" plus a separate
full-width "Current /etc/resolv.conf" block above — three panes plus
a hanger above, each pair scattered.

Restructured into two side-by-side .diff-container rows that each
pair their own Current/Planned columns:

  - #xml-diff-row    — Current Config (on Speaker)   | Planned Config (AfterTouch)
  - #resolv-diff-row — Current /etc/resolv.conf      | Planned /etc/resolv.conf Hook

current-resolv-pane moved out of its standalone wrapper into the
resolv row. The deprecated #planned-hosts-pane is removed entirely
(hosts is no longer offered as a method, per the earlier UI cleanup).

onCustomizeChange now toggles the row IDs instead of per-pane IDs,
and uses display:"" rather than display:"block" so the .diff-container
flex layout isn't accidentally overridden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 12165ba58a feat(web): Customize panel — three-axis form with Apply Custom Plan
Replaces the migration-method dropdown and its toggleMigrationMethod
visibility logic with a unified three-axis form inside the Customize
details:

  - URL flip transport: XML over SSH / Telnet (Port 17000) / Skip
  - DNS interception:   None / /etc/resolv.conf hook
  - Local CA install:   checkbox (SSH-only)

Each radio/checkbox has a transport-availability hint next to it
(e.g. "(SSH unreachable)" or "(already trusted)") so users see *why*
an option is disabled before they pick. renderCustomizeForm runs on
every summary load to recompute these hints and pick a valid initial
selection when the previous default isn't reachable.

applyCustomPlan orchestrates the chosen combination as a sequence of
existing backend calls:

  - URL flip != none → POST /setup/migrate?method={xml,telnet}
  - DNS = resolv     → POST /setup/migrate?method=resolv
                       (already includes the CA install, so an explicit
                       CA step is skipped in that case)
  - CA install only  → POST /setup/trust-ca

Steps run in order; the first failure aborts the rest. After the
sequence completes, refreshSummary repopulates the state card.

migrate() now takes the method as an explicit parameter instead of
reading it from the dropdown; applySuggestedPlan and applyCustomPlan
both pass it directly. The legacy "Confirm Migration" button is
removed (Apply Custom Plan supersedes it). The reboot-method picker
now reads the URL flip radio rather than the dropdown.

The legacy per-method preview/test panes (xml-diff, planned-xml,
planned-resolv, current-resolv, dns-redirection-test) become
visibility-driven by the radio choices via onCustomizeChange instead
of the dropdown's toggleMigrationMethod (now removed). The hosts-
related panes are forced hidden — hosts is the deprecated method.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 670252b230 refactor(web): remove legacy service-options table and Telnet URL Targets
The Plan card's per-field URL editor now drives both XML and Telnet
migrations via the same marge_url / stats_url / sw_update_url / bmx_url
options, so the two duplicate places that used to set those values are
gone:

  - The XML method's "Service Implementations" table (#service-options)
    with its self/proxied/original dropdowns. The legacy options keys
    (marge / stats / sw_update / bmx) stay accepted by the backend's
    applyProxyOptions for any direct API user, but the UI no longer
    sets them.
  - The "URL Targets" sub-pane inside #telnet-method-pane with its
    parallel set of telnet-marge-url / etc. inputs and its own
    Reset-to-defaults button. The Telnet pane retains its
    explanatory header and limitations note (no CA install, pairing
    panel below) — only the duplicate URL editor is gone.

Stripped the now-dead JS:

  - showSummary's #service-options visibility toggle and
    parsed_current_config-driven population of orig-marge etc.
  - showSummary's reads of opt-marge / opt-stats / opt-sw_update /
    opt-bmx in the summary query string.
  - migrate's reads of those same fields in the migrate query string.
  - fillTelnetURLInputs / readTelnetURLOptions /
    resetTelnetURLsToDefaults / defaultTelnetURLs entirely.
  - renderTelnetPreflight entirely (its writes were all into the
    removed elements; the state card and Plan card now own all the
    surfaces it used to populate).
  - toggleMigrationMethod's serviceOptions branches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 3dd3e3eaef feat(web): per-field URL editor with validation in the Plan card
Adds a Service URLs section to the Plan card with four free-form URL
inputs (margeServerUrl, statsServerUrl, swUpdateUrl, bmxRegistryUrl), a
"Current on Device" column populated from telnet getpdo (falling back
to the SSH-read XML config), a Soundcork-mode checkbox that flips the
/marge suffix on margeServerUrl, and a Reset-to-defaults button.

Validation runs on every keystroke (oninput) and on each summary
render: each URL must parse via the URL constructor, the scheme must
be http or https, the hostname must be non-empty, and "localhost" or
"127.0.0.1" are explicitly rejected (the speaker can't reach this
machine via that name). Invalid inputs get a red border, an inline
error list surfaces under the table, and the Apply Suggested Plan
button is disabled until everything is valid. migrate() also gates on
validatePlanURLs() and surfaces a clear status message rather than
sending typoed URLs that would silently brick the speaker.

The Plan card's per-field URLs feed both XML and Telnet migrations
via the marge_url / stats_url / sw_update_url / bmx_url options the
backend's applyURLOverrides honors. The legacy XML dropdowns
(self/proxied/original) and the duplicate URL Targets table inside
the Telnet pane stay in the markup for now — the next iteration
removes them once we're confident the Plan card flow covers
everything.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 10954c6161 feat(setup): XML migration honors per-field URL overrides
Adds applyURLOverrides — a tiny helper that, given a PrivateCfg and the
migration options map, copies any non-empty marge_url / stats_url /
sw_update_url / bmx_url value into the matching PrivateCfg field. The
helper runs after applyProxyOptions in both the read path
(GetMigrationSummary's planned-config preview) and the write path
(migrateViaXML's actual XML upload), so the planned diff and the file
the migration writes both reflect what the user typed.

Precedence: a literal *_url override wins over the legacy
self/proxied/original mode set on the same field, because the user
picked a URL and the migration honors it verbatim. Empty/missing
overrides leave the field unchanged. The legacy mode handling stays
in place for API back-compat — only the UI is moving away from it.

Tests cover the helper directly, the override-vs-mode precedence rule,
and a full GetMigrationSummary round-trip that verifies the override
shows up in the rendered PlannedConfig XML.

This is the data-layer half of the upcoming unified per-field URL
editor in the Plan card; no UI changes here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 d48aa63b9a feat(telnet,web): relax timeouts and hint at transient probe failures
Two halves of the same flakiness fix:

  - pkg/telnet defaults: dial 2s→4s, read 5s→7s, write 2s→3s,
    idleWindow 400ms→600ms. The diagnostic shell on FW 27.0.6
    occasionally takes >2s to accept a fresh TCP connection (likely
    while servicing other work), and the previous tight budget
    produced flaky preflight results on healthy speakers that
    consistently recovered on a second attempt.

  - state card: when the probe error wraps an i/o timeout / "timed out"
    / "connection reset", the panel now appends a hint pointing the
    user at the ↻ refresh button next to the device dropdown — instead
    of leaving the user to assume telnet is permanently unreachable.
    looksTransient() keeps the substring match conservative so genuine
    "connection refused" / "host unreachable" errors keep the original
    framing.

The 4s dial budget adds at most ~2s to summary loads on devices
where telnet is genuinely down; that's an acceptable trade-off for
removing the false-negative reports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 c5362be11b refactor(web): drop obsolete overview lines, fold actions into state card
The state card now duplicates everything the legacy overview
paragraphs reported, so the redundant block between the card and the
Customize details was visible-but-stale: SSH/Telnet status, the two
Backup status paragraphs, Remote Services line, and the AfterTouch
Local Root CA Trusted line.

Removed wholesale, plus the original-config-pane and toggleOriginalConfig
that the Show Original Config button drove. Kept "Trust CA Now" and
"Download CA cert" (per user request), relocating both into the state
card's CA / TLS cell as inline actions next to the verdict — the
verdict text now writes to a #state-ca-line sub-span so re-renders
don't clobber the buttons.

Also gated the HTTPS Connection Test pane on summary.ssh_success: the
backend's TestConnection uploads a temp CA file and runs curl on the
device via SSH, so the panel makes no sense when SSH isn't reachable.
A telnet-poke + service-side observation alternative is on the roadmap
but not implemented yet.

Stripped the dead JS branches that wrote to ssh-status, ca-trust-status,
remote-services-status/found, original-config-status, no-original-config-status,
original-config-content, original-config-pane, and backup-config-btn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 b7175289c2 fix(web): clear DNS port warning when leaving the resolv method
toggleMigrationMethod()'s XML branch never reset
#dns-port-warning, so switching from resolv back to xml left the
"DNS Discovery is DISABLED" warning visible while the XML method was
selected — where the warning is irrelevant.

Reset the display to "none" in the default (XML) branch alongside
the existing telnet/hosts branches that already do this. The next
iteration's redesign of the Customize panel folds this state into
per-method preconditions and removes the global warning entirely;
this fix keeps the current UI honest until then.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 b035dd3bf9 feat(web): Plan card with capabilities header, suggestion, save-as-default
Step-2 wizard, foundation iteration. Adds a new Plan card below the
state card on the Migration tab with three sections:

  - Target service URL: editable input mirrored bidirectionally with
    the canonical #target-domain field on Settings, plus a "Save as
    default" button that POSTs to /setup/settings (preserving other
    fields and the "***" secret-unchanged convention).
  - Capabilities: which transports the speaker exposes (SSH and
    Telnet:17000), and which migration recipes AfterTouch can offer
    given those transports — the "possible vs supported" surface that
    teaches the user *why* options are available before they pick.
  - Suggested plan: a one-click "Apply Suggested Plan" button driven
    by computeSuggestedPlan. The conservative default picks XML over
    SSH with HTTP (no DNS, no CA install) when SSH works; falls back
    to Telnet:17000 + HTTP when only telnet is reachable; and
    explains the absence of a path otherwise. Already-migrated
    devices show an info message instead of a button.

The legacy Migration Method dropdown, per-method panes, and action
buttons (Confirm/Revert/Reboot/Cancel) are preserved verbatim but
wrapped in a <details>"Customize this migration"</details> that opens
on demand. After a successful migrate(), the customize section is
auto-expanded so the prominent Reboot affordance is reachable from
the suggested-plan flow too.

The Apply button currently delegates to the existing migrate() entry
point by setting the dropdown value programmatically, which keeps the
options-plumbing path identical until the next iteration moves the
per-field URL editor and validation into the Plan card.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 d6e9639d89 fix(web): URL Configuration verdict respects DNS interception
The URL Configuration cell flagged "Original (Bose cloud)" with a red
 even when the DNS hook (or /etc/hosts redirects, deprecated though
it is) was actively intercepting those hostnames and routing them at
AfterTouch — i.e. the expected migrated state for the DNS method.

urlConfigVerdict now factors in resolv_migrated/hosts_migrated:

  - URL flip (xml or telnet) active            →  "AfterTouch URLs"
  - URL flip not active, DNS interception on   →  "Original (Bose
    cloud) — intercepted via DNS, device reaches AfterTouch"
  - URL flip not active, no DNS interception   →  "Original (Bose
    cloud) — not intercepted, device will reach the real Bose cloud"

The third case is the only one that's actually broken; the first two
are valid migrated states for different methods.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 9160d803da feat(web): three-axis state card at top of migration summary
The migration summary now opens with a dedicated state panel that
surfaces, in three tight blocks:

  - Transports — SSH and Telnet:17000 reachability, telnet banner if
    any, and a probe-error sub-line when a TCP dial succeeded but the
    shell rejected getpdo.
  - Migration State — three rows for the orthogonal axes: URL
    Configuration (verdict from xml_migrated/telnet_migrated, with the
    four URL fields shown as on-disk vs live pairs underneath), DNS
    Interception (resolv hook / hosts redirects / none), and CA / TLS
    (local root CA installed yes/no).
  - Preconditions — remote_services persistence, account-pairing
    state (from is_paired / live margeAccountUUID), and the XML
    .original backup presence.

Pure UI restructuring of data the backend already exposes. The
existing dropdown, method-specific panes, diff view, and per-field
service-options table are untouched so step 2 (the wizard refactor)
can replace them in a focused diff. The legacy SSH/Telnet status
paragraphs and the cross-check warnings banner stay below the card
during the transition; the next iteration removes them once the card
is the canonical surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 bcd0970e35 feat(setup): expose per-axis migration booleans on MigrationSummary
Adds XMLMigrated, HostsMigrated, ResolvMigrated, TelnetMigrated, and
IsPaired as explicit fields on the summary so the UI can render
partial-state cells (URLs flipped via telnet but the on-disk XML
hasn't caught up; DNS interception in place but no CA installed; etc.)
and surface pairing as its own precondition. IsMigrated remains
backward-compatible — it is now the OR of the four migration axes.

checkIsMigrated stops short-circuiting and writes each axis verdict
unconditionally so a "partial" state on any axis is always visible to
the UI even when another axis already reports the device migrated.
populateDeviceInfo now derives IsPaired from the live :8090/info
margeAccountUUID (clobbering any stale datastore copy), so a
factory-reset speaker is correctly flagged as unpaired.

Tests cover the per-axis verdicts independently and the IsPaired
derivation in both the populated and empty live-info cases.

This is the data layer for the upcoming three-axis "state view" panel
on the migration tab. No frontend or behavior changes here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 ebbc1209e5 feat(web): refresh button next to migration device dropdown
Adds a circled-arrow (↻) button beside the migration tab's device
dropdown that re-runs the summary fetch for the selected speaker.
Reuses the existing refreshSummary() entry point, which now also
falls back to the dropdown value when no summary has been loaded yet
so the button works on a freshly-selected device too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 ae21552878 fix(setup,web): parse the protobuf-text getpdo reply real devices send
The live SoundTouch firmware (FW 27.0.6.46330.5043500, ST 20) replies
to `getpdo CurrentSystemConfiguration` with a Protobuf-text-like
nested-block format, not the key=value format my parser was written
against:

    margeServerUrl {
      text: "https://streaming.bose.com"
    }
    statsServerUrl {
      text: "https://events.api.bosecm.com"
    }
    ...
    ->OK
    ->

Effect of the bug: the four "Current on Device" cells in the telnet
URL Targets table stayed empty after a summary load, and the
crossCheckPreflights helper silently produced no warnings even when
SSH-XML and telnet-getpdo would have disagreed. Both behaviours were
reported from a real-device summary fetched against the running
service.

Both parsers (Go setup.parseGetpdoConfig and JS
parseTelnetVerifiedConfig) now accept the protobuf-text shape and keep
the legacy key=value path as a tolerance fallback. An isIdentifier
guard prevents protobuf "text: …" lines from being misread as flat
fields and keeps prompt characters (->, ->OK) out of the result map.

A new TestParseGetpdoConfig_ProtobufTextRealDevice test pins the
parser to the verbatim live response so this regression cannot recur
silently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 27dccc779f feat(web): per-field telnet URL inputs, preflight status, warnings
The migration tab gains:

  - Telnet (Port 17000) status line in the summary box, mirroring the
    SSH connection line. Shows /, the device's diagnostic shell
    banner if any, and a probe-error block when a TCP dial succeeded
    but the shell rejected getpdo.
  - Cross-check warnings banner that surfaces summary.warnings (the
    SSH-XML vs telnet-getpdo URL diffs from the parallel preflight) as
    informational notices above the migration controls.
  - URL Targets table inside the telnet method pane with four editable
    inputs (Marge, Stats, Software Update, BMX Registry) pre-filled
    from the canonical defaultTelnetURLs(target_url) derivation. Each
    row shows the device's current value alongside, parsed from
    summary.telnet_verified_config. A "Reset to defaults" button wipes
    user edits in the table.
  - Migrate / Reboot buttons now enable when *either* SSH or telnet is
    reachable, so the SSH-less telnet path can actually be triggered
    from the UI.

The four URL inputs are folded into the migrate query string as the
marge_url / stats_url / sw_update_url / bmx_url options the handler now
recognises. Empty fields are omitted so the service's
telnetURLsFromOptions canonical fallback runs.

JS helpers parseTelnetVerifiedConfig and defaultTelnetURLs mirror the
Go-side parseGetpdoConfig and defaultTelnetURLs — keep them in sync.

I cannot run a browser test from this environment, so this change is
verified only by go build, the Go test suite (setup + handlers, race),
and node --check on the modified script.js. Worth a manual smoke test
of: switching to telnet, observing the inputs pre-fill, editing one
field, kicking off a migration, and reading back the warnings banner
on a freshly-migrated speaker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 909a85883a feat(handlers): allow per-field telnet URL keys in migration options
Extracts the migration-options query-string parsing into a single
parseMigrationOptions helper used by both HandleGetMigrationSummary and
HandleMigrateDevice. The allow-list now covers two families:

  - marge / stats / sw_update / bmx (XML method's per-field
    self|proxied|original implementation selectors, unchanged)
  - marge_url / stats_url / sw_update_url / bmx_url (telnet method's
    per-field URL overrides; empty values fall back to the canonical
    derivation in setup.telnetURLsFromOptions)

Unknown keys are still dropped, so the manager only sees parameters the
handler explicitly opted into. Tests cover the allow-list, the noise
filter, and the empty-query case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 d5f9d16e42 feat(setup): per-field telnet URLs with envswitch derivation rule
Refactors telnetURLConfigCommands into a telnetURLs value type with
explicit per-field URLs (Marge, Stats, SwUpdate, BmxRegistry) and adds
telnetURLsFromOptions to resolve those four URLs from a base targetURL
plus optional per-field overrides via the migration options map
(marge_url, stats_url, sw_update_url, bmx_url).

Envswitch derivation rule: arg1 = u.Marge verbatim, arg2 = u.SwUpdate
verbatim. The soundcork case (Marge has /marge appended) is handled
without any branching — envswitch arg1 carries the same suffix and the
parallel persistence layer stays consistent with the runtime layer on
the next reboot.

The default path is unchanged for users who only enter a base URL: all
four fields share targetURL with the canonical /updates/soundtouch and
/bmx/registry/v1/services suffixes. MigrateSpeaker plumbs the options
map through so the existing handler's option dictionary works for telnet
without UI changes; the UI can layer per-field input on top later.

Existing telnet migration tests updated to call the new signature.
TestMigrateViaTelnet_SoundcorkMargeSuffixPropagatesToEnvswitch is the
load-bearing regression test for the derivation rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 720f12d4d7 feat(setup): cross-check SSH-XML against telnet-getpdo URL fields
When both preflights succeed, GetMigrationSummary now compares the URL
fields in the parsed SoundTouchSdkPrivateCfg.xml (read via SSH) against
the matching keys in `getpdo CurrentSystemConfiguration` (read via
telnet) and appends a Warnings entry for any field whose values differ.

The two sources can briefly disagree because `sys configuration …`
writes the runtime layer while envswitch writes the parallel persistence
layer and the on-device XML file is only re-rendered after a reboot.
The warning text says exactly that, so the UI can surface a non-fatal
hint instead of treating a freshly-migrated-but-not-yet-rebooted device
as broken.

Adds Warnings []string on MigrationSummary, parseGetpdoConfig (a
key=value parser tolerant to banner/prompt noise), and
crossCheckPreflights wired in as step 9 of GetMigrationSummary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 cb7c3f319d feat(setup): detect telnet-only migrated devices via getpdo
Adds Manager.isTelnetMigrated, which substring-matches m.ServerURL's
hostname against TelnetVerifiedConfig — the response captured by the
preflight's `getpdo CurrentSystemConfiguration`. Mirrors the existing
isXMLMigrated semantics so users see consistent migration-state
detection regardless of which transport the device exposes.

checkIsMigrated no longer early-returns on !SSHSuccess. Telnet runs
first and unconditionally; the SSH-based hosts/resolv.conf checks still
run when SSH is reachable, since neither variant shows up in
`getpdo CurrentSystemConfiguration`. This closes the gap where a
USB-unlock-refusing speaker (SA-5, ST520, recent ST Portable) that had
already been migrated via telnet was silently reported as IsMigrated:
false in the UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 91ba28c52e feat(setup): run telnet preflight in parallel with SSH probes
GetMigrationSummary now kicks off telnetPreflight in a goroutine at
entry and merges the four Telnet* fields into the main summary just
before returning. Wall time becomes max(ssh, telnet); the two transports
are queried independently and their results combined — SSH retains
visibility into /etc/hosts, /etc/resolv.conf and the on-device XML
config, while telnet contributes the live URL set readable via
`getpdo CurrentSystemConfiguration` without root.

Race-free by construction: the goroutine writes to its own
MigrationSummary instance and only the four telnet fields are copied
back. Verified with `go test -race`.

Tests cover telnet-only, ssh-only, and both-succeed paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 c84cfeb757 feat(setup): read-only telnet preflight populating MigrationSummary
Adds Manager.telnetPreflight that dials port 17000, captures the banner,
and runs `getpdo CurrentSystemConfiguration` to read back the device's
live URL configuration. Errors are recorded on TelnetProbeError instead
of returned, so the probe is best-effort and never breaks summary
construction.

This is the data-gathering layer that the four already-declared
TelnetReachable / TelnetBanner / TelnetVerifiedConfig / TelnetProbeError
fields on MigrationSummary were waiting for. Subsequent iterations wire
the preflight into GetMigrationSummary (in parallel with SSH) and use
TelnetVerifiedConfig as a SSH-free signal for "already migrated".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 eab1b7a15a fix(security): close go/path-injection alerts via os.Root containment
The previous filepath.IsLocal-up-front pattern in safeJoin/safeJoin-equivalents
turned out not to satisfy CodeQL's go/path-injection rule — the post-validation
filepath.Join still constructs the joined string from tainted input, so the
analyser conservatively assumes the os.* sink that consumes it is tainted
too. Only one of 34 alerts closed on the previous attempt.

Switch to *os.Root (Go 1.24+, available on the project's 1.26.3 toolchain).
The Go runtime guarantees that operations on a Root cannot escape the
anchored directory regardless of what's in the relative path, and CodeQL has
a built-in model that recognises *os.Root.* methods as path-traversal
sanitisers. Result: every os.* sink in the datastore, marge, recorder,
mirror parity-mismatch writer, and docs handler is now reached only via a
*os.Root, which closes the rule-level alerts cleanly.

Changes per file:

* pkg/service/datastore/datastore.go — Adds a `root *os.Root` to DataStore,
  lazily opened at first use (after MkdirAll-ing baseDir) and closed by a
  new `(*DataStore).Close()`. Adds package-private helpers
  (rootStat / rootReadFile / rootWriteFile / rootMkdirAll / rootRemove /
  rootRemoveAll / rootRename / rootReadDir / rootOpen / rootExists) plus
  three exported wrappers (ReadDirUnderBase, MkdirAllUnderBase,
  WriteFileUnderBase) for the cross-package marge / handlers callers.
  Every os.* call that previously consumed safeJoin output now goes through
  these helpers. The post-join belt-and-suspenders prefix check inside
  safeJoin is preserved as a defence-in-depth fallback.

* pkg/service/marge/marge.go — Replaces the five `os.ReadDir(devicesDir)`
  call sites with `ds.ReadDirUnderBase(...)` so the datastore's root
  enforces containment.

* pkg/service/proxy/recorder.go — Mirrors the datastore pattern with its
  own `root *os.Root` anchored at Recorder.BaseDir, lazily opened. New
  helpers convert the eight existing `os.*` sites that consume sessionID
  / relPath / sanitizedSegments inputs. The earlier safeJoin (filepath.IsLocal
  pre-check) stays in place as the same belt-and-suspenders guard.

* pkg/service/handlers/handlers_docs.go — Opens a *os.Root at "docs" via
  sync.Once and reads file content (and SUMMARY.md sidebar) through it.
  Removes the prior filepath.IsLocal pre-check; the runtime now guarantees
  containment.

* pkg/service/handlers/mirror_middleware.go — Routes the parity-mismatch
  JSON write through `s.ds.WriteFileUnderBase` so the datastore's root
  performs the path-traversal sanitiser.

Behavioural fix: *os.File.ReadDir(-1) returns directory entries in
filesystem order, but os.ReadDir is documented to sort by name and at least
one regression test
(handlers.TestMargeAccountFullExcludesEmptyAmazonSource) depends on the
sorted contract. Both rootReadDir helpers explicitly sort by name to match.

All test suites pass for the touched packages; the unrelated
TestDocsConsistency failure about untracked working-tree docs is
pre-existing. golangci-lint reports 0 issues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:18:24 +02:00
Tobias GesellchenandClaude Opus 4.7 f951fc92df feat(handlers): proxy-aware RemoteAddr via opt-in TrustForwardedHeaders
Wire up X-Real-IP / X-Forwarded-For / True-Client-IP support for
deployments fronted by a reverse proxy, while staying safe on flat-LAN
deployments where a malicious speaker could spoof those headers
directly.

Two new fields on `datastore.Settings`:

* TrustForwardedHeaders (bool, default false) — opt-in switch.
* TrustedProxyCIDRs ([]string, default `["127.0.0.0/8", "::1/128"]`)
  — only requests whose immediate TCP peer falls in one of these
  blocks may have their source IP rewritten from forwarded headers.
  Loopback default matches the documented same-host nginx layout in
  docs/guides/HTTPS-SETUP.md.

New middleware in `pkg/service/handlers/middleware_realip.go`:

* TrustedRealIP wraps `chi/middleware.RealIP` with a trusted-peer
  gate. When the immediate TCP peer is in the allowlist, chi's
  parsing handles the actual header → IP rewrite. When it isn't
  (e.g. a speaker sending forwarded headers itself), we ignore the
  headers and r.RemoteAddr stays as-is.
* ParseTrustedProxyCIDRs converts string CIDRs into *net.IPNet,
  applying the loopback default on empty input and erroring loudly
  on invalid entries.

Server.TrustedRealIPMiddleware() returns the middleware (or nil) by
reading the live settings; the router setup in
cmd/soundtouch-service/main.go installs it as the very first
middleware so SnapshotMiddleware and downstream handlers see the
correct r.RemoteAddr.

HandleMargePowerOn now prefers r.RemoteAddr over the body's
self-reported `<IPAddress>` for outbound credential push:

* The body field is treated as a hint only — a malicious LAN speaker
  could set it to any value; using it for outbound HTTP requests is
  the SSRF surface the previous zeroconf hardening was guarding
  against from the sink side. Fixing it at the source as well closes
  the gap entirely.
* When body IP and TCP source disagree, a log line names both and
  the device ID so the discrepancy is investigable.
* RemoteAddr is unparseable → fall back to the body so we don't
  silently drop the priming.

docs/guides/HTTPS-SETUP.md gains a follow-up note next to the existing
nginx snippet explaining the new flag, the loopback-only default, and
the explicit warning against enabling the flag on a flat-LAN
deployment without a real proxy.

Eleven test cases in middleware_realip_test.go lock in the gate
behaviour: trusted peers honoured for X-Real-IP / X-Forwarded-For /
no-headers / IPv6, untrusted peers' headers ignored, garbage values
rejected, ParseTrustedProxyCIDRs covers default / override / invalid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:40:15 +02:00
Tobias GesellchenandClaude Opus 4.7 dc1f811a81 docs(zeroconf): clearer literal-IP error and a Security Considerations note
Building on the strict literal-IP validator from the previous commit,
make the runtime error self-explanatory so anyone tripping on a
hostname URL can fix it in one shot:

* Errors now lead with the offending zeroconf URL and the rejected
  host, so wrapping by GetInfo / PushCredentials / pushSimplifiedToken
  doesn't bury the actual bad value.
* The "host must be a literal IP" error suggests two concrete one-liner
  resolutions (`getent hosts <name>` and `dig +short <name>`) so the
  user has a copy-paste fix.
* The "host is not on a local network" error names the accepted ranges
  (loopback / RFC1918 private / link-local v4+v6) so the user knows
  what they're allowed to pass.

docs/guides/SOUNDTOUCH-SERVICE.md gains a bullet under Security
Considerations explaining the constraint and the rationale (LAN-resident
SSRF surface), so the strict behaviour is documented rather than a
surprise.

The 17 TestValidateZcBaseURL cases still pass — only the message bodies
changed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:30:55 +02:00
Tobias GesellchenandClaude Opus 4.7 fbde4e136f fix(security): tighten zeroconf URL validation to literal local IPs
CodeQL re-fired three new go/request-forgery alerts (#134/135/136) on
the lines my previous validateZcBaseURL refactor introduced. The
previous validator accepted hostname-style hosts unchanged, so even
though the IP-class check ran when applicable, u.String() at the call
sites still emitted the original tainted host into the request URL —
which is exactly what CodeQL traces.

Tighten validateZcBaseURL to:

* require the host to parse as a literal IP — DNS / mDNS hostnames
  are rejected (with a clear error explaining the caller should
  resolve to a private IP first); doing the lookup inside the
  validator would re-introduce the SSRF surface CodeQL is flagging,
  because malicious DNS could point a *.local name at a public host
  between the lookup and the request.
* require that IP to be loopback / RFC1918 private / IPv4-or-IPv6
  link-local. Anything else (global IPs in either family) is refused.
* rebuild the returned *url.URL from validated components — scheme
  (already checked), the validated IP literal joined with the
  original port, and the original path. Pre-existing query/fragment
  are stripped so callers attach their own ?action= cleanly. CodeQL
  recognises this fresh-construction pattern as taint sanitisation.

In practice this matches what SoundTouch speakers actually announce:
IP-based zeroconf URLs at port 8200 against an LAN address. The
existing PushCredentials_FullRoundTrip and FallbackOnGetInfoFailure
tests already exercise the loopback path through httptest.NewServer
and pass unchanged.

Adds TestValidateZcBaseURL covering 17 inputs — 9 accept (loopback,
private 10/172/192, link-local v4, IPv6 loopback, IPv6 link-local,
strips query) and 8 reject (public IPv4, public IPv6, hostname,
plain hostname, ftp/file schemes, empty host, unparseable) — to lock
the new contract in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:30:55 +02:00
Tobias GesellchenandClaude Opus 4.7 339dc80bf1 feat(proxy): add UnsafeLogCredentialHeaders escape hatch for debugging
The previous commit made credential-header redaction unconditional in
proxy log output, which is the right safety floor for production but
inconvenient for local debugging when a developer wants to inspect
Authorization / Cookie / X-Bose-Token values flowing through the
service.

Add an explicit "I-know-what-I-am-doing" toggle:

* New LoggingProxy.UnsafeLogCredentialHeaders bool field.
* Default off — the redaction floor stays in place.
* Reads the LOG_PROXY_CREDENTIALS env var so a developer can flip it
  on without recompiling, mirroring the existing LOG_PROXY_BODY
  pattern.
* When true, formatHeaders skips both the always-sensitive floor and
  the broader Redact policy, so log lines contain raw header values.

CodeQL's go/clear-text-logging rule continues to be satisfied because
the default code path still redacts; only an explicit opt-in via
configuration produces unredacted output, mirroring how
AllowInsecureUpstreamTLS works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:30:55 +02:00
Tobias GesellchenandClaude Opus 4.7 fb75c8b1f3 fix(security): validate zeroconf URLs against local-network allowlist
CodeQL alerts #121, #122, #123 (go/request-forgery) flagged the three
client.Get / client.PostForm sites in pkg/service/zeroconf/zeroconf.go
that build their request URL by string-concatenating the caller-supplied
zcBaseURL with "?action=…". The base URL ultimately originates from a
device-pairing payload that the speaker pushes to us, so unvalidated
input could redirect outbound HTTP requests to arbitrary hosts (server-
side request forgery).

Add validateZcBaseURL which:

* parses zcBaseURL via net/url so the scheme and host are first-class
  values rather than substrings,
* requires the scheme to be http or https,
* rejects literal IP hosts that aren't loopback / RFC1918 private /
  link-local — those are the only places a real SoundTouch speaker
  can live on a local network, and a global IP would be an obvious
  exfiltration target,
* leaves hostname-style hosts (e.g. mDNS *.local) accepted: name
  resolution itself is a separate trust boundary on the local segment.

A small withAction helper builds the per-call URL from the validated
base URL via url.Values rather than string concatenation, which CodeQL
recognises as a non-tainted construction.

GetInfo, PushCredentials and pushSimplifiedToken each call
validateZcBaseURL up-front so all three CodeQL alerts close in a
single pass. PushCredentials also re-validates even though it then
calls GetInfo (which validates again) so the fallback to
pushSimplifiedToken on getInfo failure is also gated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:30:55 +02:00
Tobias GesellchenandClaude Opus 4.7 fbeae8bb11 fix(security): make upstream TLS verification opt-in via settings flag
CodeQL alerts #70 and #71 (go/disabled-certificate-check) flagged the
hard-coded `InsecureSkipVerify: true` in handlers_proxy.go (the
/proxy/{url} reverse proxy) and mirror_middleware.go (the parity-check
mirror). Both target *.bose.com whose certificate chain is becoming
unreliable post end-of-service, but unconditionally disabling
verification is still wrong: a deployment that doesn't actually need
the bypass loses TLS hygiene for free.

Add an `AllowInsecureUpstreamTLS bool` field to datastore.Settings,
default false. Read it in both call sites — they aren't on a hot path
— and pass the value as InsecureSkipVerify. CodeQL accepts the
configurable boolean as a non-flag (vs. the previously hard-coded
`true`), and the runtime behaviour now defaults to verifying
certificates with an explicit opt-in for the broken-chain scenario.

Behaviour change: TLS upstream traffic is verified by default. Anyone
relying on the previous always-skip behaviour can re-enable it by
setting `"allow_insecure_upstream_tls": true` in settings.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:30:55 +02:00
Tobias GesellchenandClaude Opus 4.7 be45b3485d fix(security): always redact credential headers in proxy logs
CodeQL alert #43 (go/clear-text-logging) flagged that headers flow to
log.Printf in pkg/service/proxy/proxy.go. The existing implementation
only redacted when LoggingProxy.Redact was true — an opt-in. CodeQL is
right to flag this: the safety floor for credential-bearing headers
should not depend on caller configuration.

Split the sensitive-header list into two:

* alwaysSensitiveHeaders — Authorization, Proxy-Authorization, Cookie,
  Set-Cookie, X-Api-Key, X-Bose-Token. Redacted unconditionally,
  regardless of LoggingProxy.Redact.
* sensitiveHeaders — kept as a compatibility alias pointing at the same
  list, and still gated on Redact for any future use cases that want
  *additional* opt-in redaction beyond the floor.

Behaviour change is strict tightening: nothing that was previously
hidden becomes visible, and credentials that would have been logged
when Redact was false are now hidden by default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:30:55 +02:00
Tobias GesellchenandClaude Opus 4.7 426232e699 fix(security): close go/reflected-xss alerts via html.EscapeString
CodeQL flagged five reflected-XSS sites where caller-supplied query
parameters or path segments were concatenated into HTML responses
without escaping:

* handlers_mgmt.go:147 — Spotify oauth error landing page
* handlers_mgmt.go:490 — Amazon oauth error landing page
* handlers_docs.go:65 — <title> built from r.URL.Path
* recorder_middleware.go:83, mirror_middleware.go:205 — passthrough
  Write()s carrying tainted bytes from the three sources above

Wrap each user-controlled value in html.EscapeString before it lands
in the HTML body. The escaped output covers the upstream sources so
the middleware passthrough alerts close as well.

For handlers_docs the rendered markdown (`output`) and sidebar are
server-controlled (loaded from on-disk doc files) and intentionally
contain HTML, so only the URL path is escaped — the documentation
content itself still renders normally.

Handler test suite passes; pre-existing TestDocsConsistency failure
about untracked working-tree docs is unrelated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:30:55 +02:00
Tobias GesellchenandClaude Opus 4.7 648eedefde fix(security): close go/path-injection alerts via filepath.IsLocal sanitiser
CodeQL flagged 34 go/path-injection alerts across datastore.go, marge.go,
recorder.go, handlers_docs.go and mirror_middleware.go. The existing
defences (DataStore.safeJoin's post-join prefix check, handlers_docs's
HasPrefix(filepath.Clean(...))) are functionally correct but sit
downstream of the join, so CodeQL's interprocedural taint tracking
treats every os.* sink that consumes them as still tainted.

Move the validation up-front using filepath.IsLocal, which CodeQL
recognises as a path-traversal sanitiser. IsLocal rejects absolute
paths, ".." segments, and (on Windows) reserved device names — the
same set the existing checks intended to block, just expressed in the
shape the analyser understands.

Changes:

* DataStore.safeJoin (datastore.go) — pre-validates each non-empty
  element with filepath.IsLocal before joining. Existing post-join
  prefix check stays as belt-and-suspenders. ~30 of the 34 alerts
  flow through this helper.

* Recorder (recorder.go) — adds a new (*Recorder).safeJoin method
  with the same sanitiser. getRecordingDir, DeleteSession,
  GetInteractionContent and ArchiveSession route through it; their
  signatures already returned error so plumbing it through is local.

* HandleDocs (handlers_docs.go) — replaces the post-join HasPrefix
  check with an up-front filepath.IsLocal gate.

* Mirror parity recorder (mirror_middleware.go) — also strips
  backslash separators (Windows) and gates the resulting filename
  component on filepath.IsLocal, falling back to "invalid" rather
  than letting malformed paths reach os.WriteFile.

No behaviour change for legitimate inputs (account IDs, device IDs,
session IDs, doc paths all satisfy IsLocal). Datastore and proxy
test suites pass; handler suite's pre-existing TestDocsConsistency
failure is unrelated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:30:55 +02:00
Tobias GesellchenandClaude Opus 4.7 9ce42f3965 fix(ui): switch display-into-innerHTML status writes to textContent
Sweeps the remaining instances of the same pattern that triggered CodeQL
alert 132 in PR #240's review: status messages built by string-concatenating
the user-controlled `display` (device name) into `.innerHTML`. None of
these had ever needed HTML formatting; they're all plain status text.

Converts 26 sites across reboot(), revert(), migrate(), showSummary(),
trustCA(), ensureRemoteServices(), removeRemoteServices(), backup(),
plus fetchDevices' error fallback and the loadAccount sync log line.

The one site that genuinely needs intentional <strong> formatting — the
migrate() success message ("Please reboot the device to activate the
changes.") — is rebuilt with replaceChildren + createElement so the
device name still flows through createTextNode rather than HTML parsing.

Out of scope (intentionally left for a separate pass): the dashboard
table rows, account-metadata templates, and the error.message-into-
colored-span / redirectUrl-into-href patterns. Those are different
classes and benefit from a focused refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:14:49 +02:00
Tim Vahlbrock bc8213f0a1 change default discovery interval 2026-05-10 12:58:00 +02:00
Tim Vahlbrock 9ca5b88025 notes on storage limits 2026-05-10 12:58:00 +02:00
Tim Vahlbrock 8c01edaae4 allow usage of custom tmp directory for updates 2026-05-10 12:58:00 +02:00
Tim Vahlbrock 759c6da52a create tmp/aftertouch directory 2026-05-10 12:58:00 +02:00
Tim Vahlbrock 3da023aa78 make curl less verbose 2026-05-10 12:58:00 +02:00
Tim Vahlbrock 7d410eef24 store updates on tmp 2026-05-10 12:58:00 +02:00
Tim Vahlbrock b2dc2cb802 make curl verbose 2026-05-10 12:58:00 +02:00
Tim Vahlbrock bd2e594ba8 download updates to /media to not require additional storage space 2026-05-10 12:58:00 +02:00
Tim Vahlbrock 4257c100ac note on reverting the migration in uninstallation guide 2026-05-10 12:58:00 +02:00
Tim VahlbrockandTobias Gesellchen e008bb6a2b Apply suggestions from code review
Co-authored-by: Tobias Gesellchen <tobias@gesellix.de>
2026-05-10 12:58:00 +02:00
Tim Vahlbrock 1d9264437d add reference to on-device installer to README.md 2026-05-10 12:58:00 +02:00
Tim Vahlbrock ff8bf75982 make default version number the next minor release 2026-05-10 12:58:00 +02:00
Tim Vahlbrock dbe5b90d8d fix typo 2026-05-10 12:58:00 +02:00
Tim Vahlbrock 981ecf6d89 fix typo 2026-05-10 12:58:00 +02:00
Tim Vahlbrock 37d758f7f3 minor fixes 2026-05-10 12:58:00 +02:00
Tim Vahlbrock 370b587fcf feat: Provide scripts and documentation for on-device install 2026-05-10 12:58:00 +02:00
Tobias GesellchenandClaude Opus 4.7 e3dac8b5a6 fix(ui): close CodeQL js/xss-through-dom finding (PR #240 review)
CodeQL alert 132 flagged the reboot status line as a sink that received
user-controlled DOM text (device names from the migration/sync select
options and table rows) without escaping. Six data-flow paths converged
on script.js:1950.

Switch the sink at line 1950 from .innerHTML to .textContent — the
status message has never needed HTML formatting. The pre-existing
display-into-innerHTML pattern still exists elsewhere in this file but
those lines aren't in this PR's scope and are tracked by their own
historical alerts.

Also harden the (newer) `currentP.innerHTML = ... <strong> + data.current
+ </strong> ...` line in loadAccountIDSuggestions: rebuild the paragraph
with replaceChildren + createElement so the account ID never becomes
HTML, even though it's expected to be a 7-digit string.

Coerce known account IDs to String() when populating the existing-account
dropdown so the IDE's type inference stops complaining about
opt.value = id; / opt.textContent = id; on data of unknown[] type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:43:36 +02:00
Tobias GesellchenandClaude Opus 4.7 0b579a7e59 fix(ui): clarify telnet pane wording about deferred Pair Account panel
The previous copy said "see the panel below" while the Pair Account panel
is intentionally hidden until migration succeeds (loadAccountIDSuggestions
makes it visible). Reword so users know the panel will appear after they
click Migrate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:43:36 +02:00
Tobias GesellchenandClaude Opus 4.7 d3b1593953 docs(analysis): add device compatibility matrix for telnet migration
New section §8 records what is currently known about which devices and
firmware our migrateViaTelnet flow handles end-to-end, derived from the
six community sources catalogued in TELNET-COMMAND-REFERENCE.md plus our
issue threads.

* §8.1 — proven to work end-to-end (ST 10, 20, 300, Wave III, Wave IV on
  FW 27.0.6 with multi-reporter agreement).
* §8.2 — proven to need the PairAccount telnet fallback (ST Portable,
  BST20 Portable: /setMargeAccount missing or wedged on those firmware
  builds).
* §8.3 — likely to fail (SA-5 on FW 9.x with the older shell generation;
  newer ST Portable builds with shrunk command set). The preflight +
  abort-on-first-rejection design ensures these fail cleanly, leaving no
  half-configured state.
* §8.4 — unverified targets that are expected to work but lack concrete
  captures (ST 30, ST 520, Wave Music System I/II).
* §8.5 — flags the apparent contradiction between S5's enumerated
  "valid roots" on ST 10 / FW 27.0.6 (which omits envswitch) and #221's
  successful envswitch use on the same firmware. Most plausible reading:
  S5 is a non-exhaustive probe, not a negative claim; preflight catches
  any real absence.
* §8.6 — maps every failure mode to its observable outcome and the unit
  test that exercises it.
* §8.7 — TL;DR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:43:36 +02:00
Tobias GesellchenandClaude Opus 4.7 889470716b docs(analysis): add consolidated Telnet command reference
Synthesises every Bose SoundTouch port-17000 telnet command we have evidence
for, across six community sources: flarn2006's 2014 root-shell post,
Sam Hobbs's 2016 ST 10 setup-mode walkthrough, izndgroup's 2021 reissue,
sijeffrey's 2017 `bose` remote-control script, the 2026 r/bose telnet
probing thread (FW 27.0.6 ST 10), and our own #221 / #236 / soundcork#141
findings.

Groups the commands by family — `key` (front-panel button emulation, the
addition the Reddit thread brought in), `network` (WiFi profile management),
`sys` (verbs + the XML-tag-keyed `sys configuration` setter our migration
uses), `envswitch` (parallel persistence layer), `getpdo` (PDO read), `scm`,
`ws`, `swupdate`, and the historic shell-unlock commands. Each entry notes
firmware-era availability so implementations know whether to expect
"Command not found" on newer builds.

Records the four top-level command roots that S5 confirmed reachable on a
vanilla FW 27.x ST 10 (`key`, `net`, `sys`, `getpdo`), and flags that
`envswitch` works on other ST 20 / Wave models running the same firmware
family — a per-model variation the migration's preflight already handles.

Cross-linked from TELNET-MIGRATION-METHOD.md §2 and indexed in SUMMARY.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:43:36 +02:00
Tobias GesellchenandClaude Opus 4.7 12ca412ed2 feat(ui): wire up telnet migration method and account-id picker
* Migration dropdown gains a "Telnet (Port 17000) — no SSH required" option
  and drops the deprecated /etc/hosts entry from the visible choices. The
  hosts code path still exists in the backend for now; it is just no longer
  reachable through the UI.

* New `telnet-method-pane` shows a brief explanation, the HTTP-only
  limitation, and a hint that pairing may be required after migration.

* New `pair-account-pane` (initially hidden) renders three controls:
  - dropdown of accounts already in the local datastore (so a fresh device
    can be re-attached to an existing account),
  - 7-digit input field with HTML pattern validation,
  - a Generate button that picks a random non-colliding 7-digit ID.
  When :8090/info already exposes a margeAccountUUID the panel pre-fills
  it and offers to keep it; otherwise the device is treated as fresh.

* `pairAccount(deviceId)` POSTs to /setup/pair-account/{deviceId} with the
  selected ID and surfaces the breadcrumb (HTTP vs telnet fallback) in the
  status line.

* `reboot()` now passes ?method=telnet|ssh, derived from the migration
  method dropdown (telnet for telnet, ssh otherwise) so a device that was
  migrated without SSH access can also be rebooted without SSH access.

* After a successful telnet migration, `loadAccountIDSuggestions` runs
  automatically so the user is led straight into the pairing step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:43:36 +02:00
Tobias GesellchenandClaude Opus 4.7 fb47807f70 feat(telnet): add port-17000 migration method and account pairing
Adds an SSH-free third migration path that drives the SoundTouch device's
diagnostic shell on TCP port 17000, plus a hardened replacement for the
fragile /setMargeAccount HTTP pairing call.

* `pkg/telnet` — new reusable, dependency-free client (sibling of `pkg/ssh`)
  with deadline-driven Dial / Probe / SendCommand / Close. Mock-server tests
  cover happy path, command-not-found, mid-stream close, and the wedged-device
  read-timeout scenario.

* `setup.MigrationMethodTelnet` — runs `sys configuration` for all four URLs
  plus the parallel `envswitch boseurls set` persistence layer that otherwise
  wins on reboot, then verifies with `getpdo CurrentSystemConfiguration`.
  Aborts on the first non-OK response so configuration is never half-written.
  No SSH backup or rw pre-flight (the path is SSH-free by design).

* `setup.PairAccount` — probes :8090/supportedURLs first, time-bounds
  POST /setMargeAccount aggressively (5s connect / 12s total) to avoid the
  hangs reported in #236, and falls back to `envswitch accountid set <id>`
  over telnet when the HTTP endpoint is missing or wedged. Returns a
  PairAccountResult breadcrumb so the UI can show which path actually
  succeeded.

* `setup.Reboot(deviceIP, method)` — gains a RebootMethod selector;
  RebootMethodSSH stays the default (preserving prior behavior),
  RebootMethodTelnet sends `sys reboot` over a fresh telnet session and
  treats the inevitable socket-close as success.

* New endpoints on `/setup`:
  - GET  /account-id-suggestions/{deviceId} — returns the device's current
    margeAccountUUID (from :8090/info) plus known account IDs from the
    datastore, so the UI can offer reuse.
  - POST /pair-account/{deviceId}?account_id=NNNNNNN — invokes PairAccount;
    the existing reboot endpoint reads ?method=ssh|telnet from the query
    string.

* Helpers `IsValidAccountID` (exactly 7 digits) and `GenerateAccountID`
  (crypto/rand, retries on collision against a known-IDs list).

Documentation in docs/analysis/TELNET-MIGRATION-METHOD.md is updated to match
the implementation: bare-URL convention for `soundtouch-service`, no automatic
`sys reboot` (user-initiated via the existing button with a method selector),
and the realised package layout. The /etc/hosts method is intentionally not
exposed in the new flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:43:36 +02:00
Tobias GesellchenandClaude Opus 4.7 d9894be7db docs(analysis): add Telnet (port 17000) migration method analysis
Documents the SSH-free third migration path on top of the device's diagnostic
shell, synthesised from #221, #236, scheilch/opencloudtouch#167,
deborahgu/soundcork#228, and deborahgu/soundcork#141.

Captures the URL configuration command sequence, the dual persistence layers
(`sys configuration` + `envswitch boseurls set`), the `/setMargeAccount`
failure modes (404, hang, post-migration 502 on power_on) with their bounded
fallbacks, port-17000 preflight requirements, and account-ID sourcing rules
(reuse from `:8090/info`, pick from `DataStore.ListAccounts`, or 7-digit
manual/randomized entry). Cross-links the new doc from
DEVICE-REDIRECT-METHODS.md, marks the `/etc/hosts` method as deprecated, and
fixes the existing margeServerUrl example to use our service's bare-URL
convention with an explicit note for soundcork's `/marge` sub-path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:43:36 +02:00
Tobias GesellchenandClaude Opus 4.7 255dd9612a fix(datastore): normalize AUX source to canonical id/type after sync (#233)
The on-device Sources.xml carries only displayName + sourceKey for AUX,
no id and no type. The previous read path synthesized id="2000001+i" and
type="AUX" (echoed from SourceKey.Type), which the speaker rejects as
INVALID_SOURCE once it pulls config from soundtouch-service after
migration. Look up known providers in getDefaultSources and fill
canonical id/type/sourceproviderid; also drop the AUX carve-out in
marge's ensureSourceType so existing poisoned type="AUX" entries are
normalized to type="Audio" at the served-XML layer.

Relates to https://github.com/gesellix/Bose-SoundTouch/issues/195

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:16:19 +02:00
Tobias GesellchenandClaude Opus 4.7 cf81fc033f ci: build all binaries on 7 platforms and publish PR preview Docker images (#237)
- Match release matrix: linux/amd64, linux/arm64, linux/armv7,
darwin/amd64, darwin/arm64, windows/amd64, freebsd/amd64; build cli,
service, web, backup
- Push Docker images on same-repo PRs with preview-pr-N /
preview-sha-<sha> tags so previews are unambiguous and tied to the PR
(forks build but skip push)
- Add a step summary listing each published image as docker pull
commands

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:12:27 +02:00
dependabot[bot]andlnx01 653652b57d deps(deps): bump the golang group with 6 updates (#231)
Bumps the golang group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [golang.org/x/crypto](https://github.com/golang/crypto) | `0.50.0` |
`0.51.0` |
| [golang.org/x/term](https://github.com/golang/term) | `0.42.0` |
`0.43.0` |
| [golang.org/x/image](https://github.com/golang/image) | `0.39.0` |
`0.40.0` |
| [golang.org/x/mod](https://github.com/golang/mod) | `0.35.0` |
`0.36.0` |
| [golang.org/x/sys](https://github.com/golang/sys) | `0.43.0` |
`0.44.0` |
| [golang.org/x/text](https://github.com/golang/text) | `0.36.0` |
`0.37.0` |

Updates `golang.org/x/crypto` from 0.50.0 to 0.51.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/golang/crypto/commit/b8a14a8d65f88c0c79c139171f1354c69a6cdb8a"><code>b8a14a8</code></a>
go.mod: update golang.org/x dependencies</li>
<li><a
href="https://github.com/golang/crypto/commit/9d9d5078968ddb8a279092c665a24e7de4178778"><code>9d9d507</code></a>
x509roots/fallback/bundle: fix bundle test with Go 1.27+</li>
<li><a
href="https://github.com/golang/crypto/commit/fd0b90d21f9ab4b5dd398e9526b570bfea86e370"><code>fd0b90d</code></a>
acme: include Problem in OrderError.Error</li>
<li><a
href="https://github.com/golang/crypto/commit/b9e53593a6073e6a786c49e9ad27956a9b77e54e"><code>b9e5359</code></a>
pbkdf2: turn into a wrapper for crypto/pbkdf2</li>
<li><a
href="https://github.com/golang/crypto/commit/cc0e4fc1d49127130b0d00612a2eeed2ab745d40"><code>cc0e4fc</code></a>
hkdf: forward Extract to the standard library</li>
<li><a
href="https://github.com/golang/crypto/commit/a8e9237a216b050e1b11e041863825104a6811db"><code>a8e9237</code></a>
x509roots/fallback: update bundle</li>
<li>See full diff in <a
href="https://github.com/golang/crypto/compare/v0.50.0...v0.51.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `golang.org/x/term` from 0.42.0 to 0.43.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/golang/term/commit/3c3e4855f7d2eb06c3e48933554add9ec6b599b5"><code>3c3e485</code></a>
go.mod: update golang.org/x dependencies</li>
<li>See full diff in <a
href="https://github.com/golang/term/compare/v0.42.0...v0.43.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `golang.org/x/image` from 0.39.0 to 0.40.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/golang/image/commit/542a3d9571611fd83b47afa41e76e7c6c7b3f991"><code>542a3d9</code></a>
go.mod: update golang.org/x dependencies</li>
<li><a
href="https://github.com/golang/image/commit/5cbe89a0e573c3c4e2cc193c1e24d8401bdf3e60"><code>5cbe89a</code></a>
tiff: reject 0-size images</li>
<li>See full diff in <a
href="https://github.com/golang/image/compare/v0.39.0...v0.40.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `golang.org/x/mod` from 0.35.0 to 0.36.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/golang/mod/commit/643da9ba74f1165d8cae1505d453b3de3cf21b7b"><code>643da9b</code></a>
go.mod: update golang.org/x dependencies</li>
<li><a
href="https://github.com/golang/mod/commit/ccc3cdf529d1eee2a832437eb1b85240044d21cb"><code>ccc3cdf</code></a>
zip: include 'but content has correct sum' note in TestVCS</li>
<li><a
href="https://github.com/golang/mod/commit/ab3031803214705d2c9f1102318b083e7086a155"><code>ab30318</code></a>
zip: update zip hashes for new flate compression</li>
<li>See full diff in <a
href="https://github.com/golang/mod/compare/v0.35.0...v0.36.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `golang.org/x/sys` from 0.43.0 to 0.44.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/golang/sys/commit/fb1facd76f95fa87c151018200ea5e4892ff115d"><code>fb1facd</code></a>
windows: avoid uint16 overflow in NewNTUnicodeString</li>
<li><a
href="https://github.com/golang/sys/commit/94ad893e1e59c1d079221324d38945d2aad8703f"><code>94ad893</code></a>
windows: add GetIfTable2Ex, GetIpInterface{Entry,Table},
GetUnicastIpAddressT...</li>
<li><a
href="https://github.com/golang/sys/commit/54fe89f8411576c06b345b341ca79a77d878a4ad"><code>54fe89f</code></a>
cpu: use IsProcessorFeaturePresent to calculate ARM64 on windows</li>
<li><a
href="https://github.com/golang/sys/commit/df7d5d7b60641d17d87e2b50911124cb65f954fd"><code>df7d5d7</code></a>
unix: automatically remove container created by mkall.sh</li>
<li><a
href="https://github.com/golang/sys/commit/68a4a8e945b22751c1a619261b1d755372a1d5f7"><code>68a4a8e</code></a>
unix: avoid nil pointer dereference in Utime</li>
<li><a
href="https://github.com/golang/sys/commit/690c91f6ecf3b3ef141ad2aedb1306a868b3a176"><code>690c91f</code></a>
unix: add CPUSetDynamic for systems with more than 1024 CPUs</li>
<li>See full diff in <a
href="https://github.com/golang/sys/compare/v0.43.0...v0.44.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `golang.org/x/text` from 0.36.0 to 0.37.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/golang/text/commit/3ef517e623a4bfc08d6457f87d73afda7af7d8e1"><code>3ef517e</code></a>
go.mod: update golang.org/x dependencies</li>
<li>See full diff in <a
href="https://github.com/golang/text/compare/v0.36.0...v0.37.0">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 21:42:39 +02:00
467 changed files with 48374 additions and 9458 deletions
+26 -8
View File
@@ -5,6 +5,24 @@
SOUNDTOUCH_HOSTNAME=soundtouch.local
SOUNDTOUCH_VERSION=latest
# Stockholm frontend (used by make prepare-stockholm and by the Go service at startup)
# BACKEND_URL is the base URL your speakers and browser can reach the service at.
# Corresponds to SERVER_URL in the Go service.
# BACKEND_URL=http://soundtouch.local:8000
#
# STREAMING_URL is used for streaming.bose.com rewrites (defaults to BACKEND_URL).
# Set to $(BACKEND_URL)/marge only when routing through a soundcork backend.
# STREAMING_URL=http://soundtouch.local:8000
#
# AUTH_SERVICE_URL is written into config.json as the auth endpoint (defaults to BACKEND_URL).
# A trailing slash is added automatically; the JS appends paths like "oauth/account/..." directly.
# AUTH_SERVICE_URL=http://soundtouch.local:8000
#
# STOCKHOLM_BASE_PATH mounts the Stockholm UI under a URL prefix, freeing / for the management UI.
# The bridge API (/api/native/*, /api/http-proxy) remains at root regardless of this setting.
# Defaults to /stockholm. Set to empty to serve at root.
# STOCKHOLM_BASE_PATH=/stockholm
# Discovery Settings
DISCOVERY_TIMEOUT=5s
UPNP_ENABLED=true
@@ -25,23 +43,23 @@ CACHE_TTL=30s
# Examples:
# Single device with default port:
# PREFERRED_DEVICES="192.168.1.100"
# PREFERRED_DEVICES="192.0.2.100"
# Single device with custom name:
# PREFERRED_DEVICES="Living Room@192.168.1.100"
# PREFERRED_DEVICES="Living Room@192.0.2.100"
# Single device with custom port:
# PREFERRED_DEVICES="192.168.1.100:8091"
# PREFERRED_DEVICES="192.0.2.100:8091"
# Multiple devices with mixed configurations:
PREFERRED_DEVICES="Living Room@192.168.1.100:8090;Kitchen@192.168.1.101;192.168.1.102:8091"
PREFERRED_DEVICES="Living Room@192.0.2.100:8090;Kitchen@192.0.2.101;192.0.2.102:8091"
# Real example based on your devices:
# PREFERRED_DEVICES="Sound Machinechen@192.168.178.35;A Sound Machine@192.168.178.28"
# Example — replace with your speakers' names and IPs:
# PREFERRED_DEVICES="Living Room SoundTouch@192.0.2.10;Kitchen SoundTouch@192.0.2.11"
# Alternative format examples:
# PREFERRED_DEVICES="192.168.178.35;192.168.178.28"
# PREFERRED_DEVICES="SoundTouch 10@192.168.178.35;SoundTouch 20@192.168.178.28"
# PREFERRED_DEVICES="192.0.2.10;192.0.2.11"
# PREFERRED_DEVICES="SoundTouch 10@192.0.2.10;SoundTouch 20@192.0.2.11"
# Spotify Integration
# Create an app at https://developer.spotify.com/dashboard
+1 -1
View File
@@ -30,7 +30,7 @@ A clear and concise description of what you expected to happen.
**Command/Code that failed**
```bash
# If using CLI tool, provide the exact command
soundtouch-cli --host 192.168.1.100 info get
soundtouch-cli --host 192.0.2.100 info get
# If using Go library, provide minimal code example
```
+1 -1
View File
@@ -163,7 +163,7 @@ body:
label: Network Configuration
description: Details about your network setup (if relevant to the issue)
placeholder: |
- Device IP: 192.168.1.100
- Device IP: 192.0.2.100
- Network type: WiFi/Ethernet
- Router model:
- Any firewalls or network restrictions:
@@ -69,8 +69,8 @@ List any features that don't work or behave unexpectedly:
**Testing Commands Used**
```bash
# List the specific commands you used for testing
soundtouch-cli --host 192.168.1.100 info get
soundtouch-cli --host 192.168.1.100 play start
soundtouch-cli --host 192.0.2.100 info get
soundtouch-cli --host 192.0.2.100 play start
# ... etc
```
+1 -1
View File
@@ -50,7 +50,7 @@ client.NewFeature(parameters)
```bash
# CLI example
soundtouch-cli --host 192.168.1.100 new-feature --param value
soundtouch-cli --host 192.0.2.100 new-feature --param value
```
**Priority**
+1 -1
View File
@@ -131,7 +131,7 @@ body:
render: go
placeholder: |
// Example of how you envision using this feature
client := soundtouch.New("192.168.1.100", 8090)
client := soundtouch.New("192.0.2.100", 8090)
// Your desired API call
result, err := client.NewFeature(options)
+22
View File
@@ -98,3 +98,25 @@ updates:
- "dependencies"
- "docker"
rebase-strategy: "auto"
# npm dependency updates
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
day: "thursday"
time: "09:00"
timezone: "UTC"
open-pull-requests-limit: 3
reviewers:
- "gesellix"
assignees:
- "gesellix"
commit-message:
prefix: "deps"
include: "scope"
labels:
- "dependencies"
- "npm"
- "frontend"
rebase-strategy: "auto"
+1 -1
View File
@@ -3,7 +3,7 @@
"retryOn429": true,
"retryCount": 3,
"fallbackRetryDelay": "30s",
"aliveStatusCodes": [200, 206],
"aliveStatusCodes": [200, 202, 206],
"ignorePatterns": [
{
"pattern": "^http://localhost"
+2 -2
View File
@@ -50,7 +50,7 @@ Please check the type of change your PR introduces:
**Device(s) tested with:**
- Device model: [e.g. SoundTouch 10]
- Device IP: [e.g. 192.168.1.100]
- Device IP: [e.g. 192.0.2.100]
- Test results: [brief description]
### Test Commands
@@ -58,7 +58,7 @@ Please check the type of change your PR introduces:
# Commands used to test this change
make test
go test ./pkg/client -v -run TestNewFeature
soundtouch-cli --host 192.168.1.100 new-command
soundtouch-cli --host 192.0.2.100 new-command
```
## Documentation
+133 -41
View File
@@ -17,15 +17,15 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
- name: Cache Go modules
uses: actions/cache@v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.cache/go-build
@@ -53,7 +53,7 @@ jobs:
run: make test-http-client
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v6
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
file: ./coverage.out
flags: unittests
@@ -66,10 +66,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
@@ -77,7 +77,7 @@ jobs:
run: sudo apt-get install -y libpcap-dev
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v9
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
with:
version: latest
args: --timeout=5m
@@ -86,39 +86,76 @@ jobs:
name: Build
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
exclude:
# Windows ARM64 builds are experimental
- goos: windows
include:
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
- goos: linux
goarch: arm
goarm: 7
- goos: darwin
goarch: amd64
- goos: darwin
goarch: arm64
- goos: windows
goarch: amd64
- goos: freebsd
goarch: amd64
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
- name: Build CLI
- name: Cache Go modules
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.mod') }}-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Build binaries
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
GOARM: ${{ matrix.goarm }}
CGO_ENABLED: 0
run: |
output_name="soundtouch-cli-${{ matrix.goos }}-${{ matrix.goarch }}"
if [ "${{ matrix.goos }}" = "windows" ]; then
output_name="${output_name}.exe"
ARCH_SUFFIX="${{ matrix.goos }}-${{ matrix.goarch }}"
if [[ -n "${{ matrix.goarm }}" ]]; then
ARCH_SUFFIX="${ARCH_SUFFIX}v${{ matrix.goarm }}"
fi
go build -trimpath -ldflags="-s -w" -o "$output_name" ./cmd/soundtouch-cli
EXT=""
if [[ "${{ matrix.goos }}" == "windows" ]]; then
EXT=".exe"
fi
mkdir -p build
for binary in soundtouch-cli soundtouch-service soundtouch-web soundtouch-backup; do
OUTPUT="build/${binary}-${ARCH_SUFFIX}${EXT}"
echo "Building $OUTPUT"
go build -trimpath -ldflags="-s -w" -o "$OUTPUT" "./cmd/$binary"
done
ls -la build/
- name: Upload build artifacts
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: soundtouch-cli-${{ matrix.goos }}-${{ matrix.goarch }}
path: soundtouch-cli-*
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm }}
path: build/
security:
name: Basic Security Check
@@ -126,10 +163,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
@@ -153,7 +190,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check documentation links
run: |
@@ -211,10 +248,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
@@ -238,7 +275,7 @@ jobs:
func main() {
// Test basic client creation
c := client.NewClientFromHost("192.168.1.100")
c := client.NewClientFromHost("192.0.2.100")
fmt.Printf("Client created for %s\n", c.BaseURL())
// Test models can be imported
@@ -266,14 +303,28 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Determine push eligibility
id: push-check
run: |
# Push on main, and on same-repo PRs (forks can't push to GHCR via GITHUB_TOKEN).
SHOULD_PUSH="false"
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then
SHOULD_PUSH="true"
elif [[ "${{ github.event_name }}" == "pull_request" && \
"${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]]; then
SHOULD_PUSH="true"
fi
echo "should-push=$SHOULD_PUSH" >> "$GITHUB_OUTPUT"
echo "Will push: $SHOULD_PUSH"
- name: Log in to GitHub Container Registry
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v4
if: steps.push-check.outputs.should-push == 'true'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -281,20 +332,22 @@ jobs:
- name: Extract metadata (tags, labels) for soundtouch-service
id: meta-service
uses: docker/metadata-action@v6
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
type=ref,event=pr
type=ref,event=pr,prefix=preview-pr-
type=sha,prefix=preview-sha-,format=short,enable=${{ github.event_name == 'pull_request' }}
type=ref,event=branch,prefix=preview-branch-,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
- name: Build and push soundtouch-service Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
target: soundtouch-service
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
push: ${{ steps.push-check.outputs.should-push == 'true' }}
tags: ${{ steps.meta-service.outputs.tags }}
labels: ${{ steps.meta-service.outputs.labels }}
cache-from: type=gha
@@ -302,25 +355,64 @@ jobs:
- name: Extract metadata (tags, labels) for soundtouch-web
id: meta-web
uses: docker/metadata-action@v6
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ghcr.io/${{ github.repository }}-web
tags: |
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
type=ref,event=pr
type=ref,event=pr,prefix=preview-pr-
type=sha,prefix=preview-sha-,format=short,enable=${{ github.event_name == 'pull_request' }}
type=ref,event=branch,prefix=preview-branch-,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
- name: Build and push soundtouch-web Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
target: soundtouch-web
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
push: ${{ steps.push-check.outputs.should-push == 'true' }}
tags: ${{ steps.meta-web.outputs.tags }}
labels: ${{ steps.meta-web.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Summarize published images
if: steps.push-check.outputs.should-push == 'true'
env:
SERVICE_TAGS: ${{ steps.meta-service.outputs.tags }}
WEB_TAGS: ${{ steps.meta-web.outputs.tags }}
EVENT_NAME: ${{ github.event_name }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REF_NAME: ${{ github.ref_name }}
run: |
{
echo "## 🐳 Published Docker Images"
echo ""
if [[ "$EVENT_NAME" == "pull_request" ]]; then
echo "**Preview** images for PR #${PR_NUMBER}. These are not release builds."
elif [[ "$REF_NAME" == "main" ]]; then
echo "**Edge** images from \`main\`."
else
echo "**Preview** images from branch \`${REF_NAME}\`. These are not release builds."
fi
echo ""
echo "### soundtouch-service"
echo ""
echo '```bash'
while IFS= read -r tag; do
[[ -n "$tag" ]] && echo "docker pull $tag"
done <<< "$SERVICE_TAGS"
echo '```'
echo ""
echo "### soundtouch-web"
echo ""
echo '```bash'
while IFS= read -r tag; do
[[ -n "$tag" ]] && echo "docker pull $tag"
done <<< "$WEB_TAGS"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
notify:
name: Notify Status
runs-on: ubuntu-latest
@@ -355,7 +447,7 @@ jobs:
- name: Update commit status
if: always()
uses: actions/github-script@v9
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
try {
+5 -5
View File
@@ -20,18 +20,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Pages
uses: actions/configure-pages@v6
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Build with Jekyll
uses: actions/jekyll-build-pages@v1
uses: actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697 # v1.0.13
with:
source: 'docs/'
destination: '_site'
- name: Upload artifact
uses: actions/upload-pages-artifact@v5
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: '_site'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
+22 -22
View File
@@ -28,7 +28,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
@@ -64,7 +64,7 @@ jobs:
fi
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: ${{ env.GO_VERSION_FILE }}
@@ -102,15 +102,15 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: ${{ env.GO_VERSION_FILE }}
- name: Cache Go modules
uses: actions/cache@v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.cache/go-build
@@ -206,7 +206,7 @@ jobs:
echo "✅ Checksums generated successfully"
- name: Upload build artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm }}
path: |
@@ -223,7 +223,7 @@ jobs:
steps:
- name: Download binary artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: binaries-*
path: ./binaries
@@ -280,7 +280,7 @@ jobs:
fi
- name: Upload checksums
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: checksums
path: |
@@ -291,7 +291,7 @@ jobs:
retention-days: 1
- name: Upload all release assets
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-assets
path: binaries/release-files/
@@ -305,12 +305,12 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Download release assets
uses: actions/download-artifact@v8
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-assets
path: ./release-assets
@@ -358,7 +358,7 @@ jobs:
func main() {
// Create client
c := client.New("192.168.1.100", 8090)
c := client.New("192.0.2.100", 8090)
// Get device info
info, err := c.GetInfo()
@@ -468,7 +468,7 @@ jobs:
echo "release_notes_file=release_notes.md" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ github.event.inputs.tag }}
name: "Bose SoundTouch Go Library ${{ github.event.inputs.tag }}"
@@ -494,13 +494,13 @@ jobs:
steps:
- name: Download release assets
uses: actions/download-artifact@v8
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-assets
path: ./release-assets
- name: Upload additional assets to existing release
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ github.event.release.tag_name }}
files: |
@@ -521,13 +521,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -535,7 +535,7 @@ jobs:
- name: Extract metadata (tags, labels) for soundtouch-service
id: meta-service
uses: docker/metadata-action@v6
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ghcr.io/${{ github.repository }}
tags: |
@@ -544,7 +544,7 @@ jobs:
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
- name: Build and push soundtouch-service Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
target: soundtouch-service
@@ -557,7 +557,7 @@ jobs:
- name: Extract metadata (tags, labels) for soundtouch-web
id: meta-web
uses: docker/metadata-action@v6
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ghcr.io/${{ github.repository }}-web
tags: |
@@ -566,7 +566,7 @@ jobs:
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
- name: Build and push soundtouch-web Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
target: soundtouch-web
+13 -13
View File
@@ -19,10 +19,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
@@ -48,7 +48,7 @@ jobs:
- name: Upload vulnerability scan results
if: failure()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vulnerability-scan-results
path: |
@@ -63,10 +63,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
@@ -84,7 +84,7 @@ jobs:
echo "::endgroup::"
- name: Run Semgrep security analysis
uses: semgrep/semgrep-action@v1
uses: semgrep/semgrep-action@713efdd345f3035192eaa63f56867b88e63e4e5d # v1 (no v1.x.y semver tag exists)
with:
config: >-
p/security-audit
@@ -95,7 +95,7 @@ jobs:
- name: Upload Semgrep SARIF results
if: always()
uses: github/codeql-action/upload-sarif@v4
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
with:
sarif_file: semgrep.sarif
continue-on-error: true
@@ -110,22 +110,22 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install libpcap
run: sudo apt-get install -y libpcap-dev
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
with:
languages: go
config-file: ./.github/codeql-config.yml
- name: Autobuild
uses: github/codeql-action/autobuild@v4
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
with:
category: "/language:go"
@@ -138,10 +138,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Dependency Review
uses: actions/dependency-review-action@v4
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with:
fail-on-severity: moderate
allow-ghsas: GHSA-xxxx-xxxx-xxxx # Add specific allowlisted advisories if needed
+50
View File
@@ -0,0 +1,50 @@
name: Update Static Dependencies
on:
pull_request:
paths:
- 'package.json'
- 'package-lock.json'
workflow_dispatch:
permissions:
contents: write
jobs:
update-deps:
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]' || github.event_name == 'workflow_dispatch'
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.head_ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
cache: 'npm'
- name: Update static dependencies
run: make update-static-deps
- name: Check for changes
id: git-check
run: |
git status --short pkg/service/soundtouchweb/static/lib/
if [ -n "$(git status --short pkg/service/soundtouchweb/static/lib/)" ]; then
echo "changed=true" >> $GITHUB_OUTPUT
else
echo "changed=false" >> $GITHUB_OUTPUT
fi
- name: Commit and push changes
if: steps.git-check.outputs.changed == 'true'
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add pkg/service/soundtouchweb/static/lib/
git commit -m "chore: sync static dependencies with package.json"
git push
+23
View File
@@ -16,12 +16,14 @@ dist/
/soundtouch-cli
/soundtouch-service
/soundtouch-web
/dummy-speaker
/example-mdns
/example-upnp
/example-unified
/mdns-scanner
/websocket-demo
/main
/screenshots
# Environment configuration
.env
@@ -41,10 +43,13 @@ go.work.sum
# Dependency directories
vendor/
node_modules/
# IDE and editor files
.vscode/
.idea/
.claude/
.junie/
*.swp
*.swo
*~
@@ -96,3 +101,21 @@ pids
# dotenv environment variables file (but keep .env.example)
!.env.example
# Stockholm frontend — generated by `make prepare-stockholm`, not committed
stockholm/
!pkg/service/stockholm/
# Stockholm source zip — large binary, place manually at stockholm_zip/stockholm.zip
stockholm_zip/*.zip
# Local working-tree notes — running pickup-here log (NEXT) + archive of
# resolved items (DONE). Both are session-local scratch, not project docs.
NEXT.md
DONE.md
# Plan/tracking note for the Health-tab debug-utility programme.
# Living document; commit history of the checks themselves is the
# source of truth for what shipped.
SERVICE-HEALTH.md
+7
View File
@@ -78,6 +78,13 @@ linters:
linters:
- errcheck
# Carry-over from cmd/soundtouch-web/handlers relocation: same code,
# same waiver. Tighten in a follow-up if/when the package is reviewed.
- path: pkg/service/soundtouchweb/.*\.go
text: "Error return value of.*is not checked"
linters:
- errcheck
settings:
errcheck:
check-type-assertions: true
+228
View File
@@ -0,0 +1,228 @@
# CLAUDE.md
Entry point for any Claude Code (or human) session working on this
repository. Read it before touching code.
## What this project is
Go library and toolset for controlling Bose SoundTouch speakers via
the local network API, plus a local cloud-service emulator. Bose
discontinued the SoundTouch cloud — this project keeps existing
speakers usable without it.
**Module:** `github.com/gesellix/bose-soundtouch`
Key binaries:
- `soundtouch-cli` — command-line control of one or more speakers
(status, play, presets, groups, migration, …).
- `soundtouch-service` — replacement for `streaming.bose.com`
and the `bmx` services, default port `8000`.
- `soundtouch-web` — Web UI for Radio browsing and device control.
- `soundtouch-backup` — Helper for on-device backup and restore.
Per-session pickup notes live in two local files at the repo root (they are `.gitignore`d and only exist if created during a session):
- `NEXT.md` — current "pick up here" log of open items.
- `DONE.md` — archive of recently resolved items.
## How a new session should start
1. Read this file.
2. Read `NEXT.md` if it's present — that's where running context lives.
3. Skim `README.md` for the user-facing pitch.
4. Skim `docs/` for the area you're touching. Long-form notes
(analysis, guides, troubleshooting) live there, not in the code.
5. Run `make check` once to confirm the local environment compiles,
vets, and tests cleanly.
## Build, test, run
```bash
# Build
make build # All binaries
make build-cli # Just CLI
make build-service # Just service
make build-web # Just web UI
make build-all # Cross-platform builds (Linux, macOS, Windows)
make install # Install to $GOPATH/bin
# Quality
make test # Unit tests
make test-coverage # Coverage reports
make check # fmt + vet + test
make lint # golangci-lint
make update-static-deps # Update frontend libraries (preact, htm) from node_modules
# Automation
A GitHub Action automatically runs `make update-static-deps` on Dependabot PRs that modify `package.json` to keep the vendored `.js` files in sync. Note: This requires `npm` to be installed.
# Development
make dev-service # Run local service on port 8000
make dev-discover # Discover devices on the LAN
make dev-info HOST=<ip> # Get device info
# Docker
make docker-build
make docker-run-host
```
**Pre-push quality gate:** `make lint` (golangci-lint) must be clean
before `git push`. CI runs it on every PR; running it locally first
saves a round-trip. `make check` covers `lint` is its own target —
combine as needed.
## Integration tests
The `.http` integration tests under `tests/integration/http-client/`
run via `make test-http-client`, which spins up the service plus
support mocks (`spotify-mock`, `amazon-mock`) using
`docker-compose.yml` + `docker-compose.ci.yml`, executes the suite
through the JetBrains HTTP client image, then tears the stack down.
Requires Docker.
The compose CI override mounts `tests/integration/testdata/` into the
service container as its persistent data dir. That directory is
listed in `tests/.gitignore` — it's local developer state, not source.
**Treat the testdata dir as debug evidence, not disposable scratch.**
When a fixture or schema change makes the old state stale (e.g.
post-anonymisation, the previous run's IPs no longer match the
assertions), don't `rm -rf` it — archive it:
```bash
make test-http-client-rotate # renames testdata/ → testdata_<timestamp>/
make test-http-client # fresh run on a clean slate
```
The rotate target is non-destructive (it moves, never deletes) and
opt-in (no other target invokes it). Old archives stay around for
retrospective diffing whenever something goes sideways.
## Project structure
```
cmd/
soundtouch-cli/ # CLI tool for device control
soundtouch-service/ # Local cloud service emulator
soundtouch-web/ # Web UI (TuneIn browser, device control)
soundtouch-backup/ # On-device backup helper
example-*/ # Usage examples
pkg/
client/ # HTTP + WebSocket client for the SoundTouch Web API
models/ # XML/JSON data structures
discovery/ # Device discovery (mDNS + UPnP, unified interface)
config/ # Configuration management
service/
bmx/ # Bose Media eXchange service emulation
marge/ # Device-management service emulation
handlers/ # HTTP request handlers (pkg/service/handlers/)
proxy/ # HTTP proxy with request recording
datastore/ # Persistent device data storage
certmanager/ # TLS certificate management
setup/ # Device migration and configuration
spotify/ # Spotify integration
stockholm/ # Optional Stockholm frontend bridge
soundtouchweb/ # SoundTouch Web UI service logic
examples/ # Feature demonstration programs
docs/ # Long-form analysis, guides, troubleshooting
.junie/ # Communication-style guidelines (see below)
```
## Key technologies
- **Go 1.26.3+**
- **chi v5** — HTTP router
- **gorilla/websocket** — WebSocket for real-time events
- **hashicorp/mdns** — mDNS device discovery
- **miekg/dns** — DNS operations and a custom DNS server
- **urfave/cli/v2** — CLI framework
## Architecture notes
- `pkg/client` is the core library for device API calls (HTTP + WebSocket).
- `pkg/service` is the local cloud replacement; routes wire to the
handlers in `pkg/service/handlers/` via chi middleware.
- Discovery supports both mDNS and UPnP/SSDP behind a unified interface.
- The SoundTouch Web API uses XML on the wire; internal service-to-service
messages use JSON.
- Tests cover unit, integration, parity (local vs. official Bose API
recordings), and regression. Reproducer tests should be refactored
into permanent regression or documentation tests rather than deleted.
## Load-bearing gotchas
### `ETag` header literal must stay capitalised
Bose speakers emit the response header with exact capitalisation
`ETag`. Go's `http.Header.Set` canonicalises to `Etag` (lowercase `t`).
Real speakers parse strictly — `Etag` is rejected. The codebase
deliberately bypasses the canonicalisation path; do **not** rewrite
the string literal `"ETag"` to `"Etag"` anywhere in `pkg/service/handlers/`
or in tests.
The contrast is encoded in two named constants in
`pkg/service/handlers/handlers_etag_test.go`:
```go
const normalizedEtag = "Etag" // what http.Header.Set produces
const caseSensitiveETag = "ETag" // what the speaker actually expects
```
Linter suppressions on the canonical-header check live alongside the
test code. Static-analysis warnings about `"ETag"` are expected;
don't "fix" them.
### Destructive git or filesystem actions need explicit confirmation
`git reset --hard`, `git checkout` that would overwrite local changes,
`git clean -fd`, `rm -rf` on non-build paths, `git stash drop` — all
should be proposed in writing with their consequences before running,
unless the user has already authorised that specific action in this
session. Prefer reversible alternatives (`git stash` over
`git reset --hard`).
## What never goes into this repo
This repository is public. The following must never be committed:
- **Real LAN IPs** of personal networks. Use RFC-5737 documentation
ranges in examples and fixtures: `192.0.2.0/24`, `198.51.100.0/24`,
`203.0.113.0/24`.
- **Real MAC addresses** or speaker device IDs from anyone's actual
hardware. Use `AA:BB:CC:DD:EE:FF` or `DEVICEID01` style placeholders.
- **Bose account IDs**, serial numbers, or tokens belonging to anyone
other than the committer's own test devices — and even those should
be sanitised before publication when feasible.
- **Bose firmware binaries, NAND dumps, or decompiled Bose code.**
- **Wi-Fi SSIDs or credentials**, captured or otherwise.
- **Network captures, traces, or logs** that include data from
accounts or devices other than your own test hardware.
- **Personal identifiers**: real names of speakers ("LivingRoom",
custom device names), private email addresses, household member
names visible in source IDs.
If you spot any of the above already in the tree, treat it as a
sanitisation task: stop, flag it to the maintainer, propose a
remediation commit before continuing.
## Disclaimers
"SoundTouch" and "Bose" are registered trademarks of Bose Corporation.
This project is an unofficial, community-built effort, not affiliated
with, endorsed by, or authorised by Bose.
## Communication style
When working with a human user in this repo:
- **Prioritise direct answers** to the question being asked, even when
it sits outside the current task or project context. Don't divert
back to whatever you were doing when the user asks something else.
- **Don't substitute assumptions for real information.** When something
is unclear, ask or check, rather than guessing and proceeding.
These principles also apply to other AI assistants pointed at this
repo. Tool-specific config dirs (e.g. `.junie/`, `.claude/`) should
defer to this file as the source of truth instead of carrying their
own copies.
+26 -6
View File
@@ -2,6 +2,17 @@
Thank you for your interest in contributing to the Bose SoundTouch API Client! This project aims to provide a comprehensive, reliable, and well-tested Go library for controlling Bose SoundTouch devices.
## Ways to Contribute
All contributions are welcome — large or small:
- **Code suggestions** — bug fixes, new features, refactoring, performance improvements.
- **Documentation updates** — README, guides, examples, troubleshooting notes, inline doc comments.
- **Bug fixes** — even just a clear reproducer in an issue is a real contribution.
- **Donations** — if the project kept a speaker (or several) of yours alive past the Bose cloud shutdown and you want to give back, [GitHub Sponsors](https://github.com/sponsors/gesellix) is open. No expectation; everything in this repo stays MIT regardless.
By submitting a code or documentation contribution you agree to license it under MIT. The detailed guides below cover the mechanics.
## Table of Contents
- [Code of Conduct](#code-of-conduct)
@@ -15,6 +26,7 @@ Thank you for your interest in contributing to the Bose SoundTouch API Client! T
- [Reporting Issues](#reporting-issues)
- [Device Testing](#device-testing)
- [Community](#community)
- [Support the Project](#support-the-project)
## Code of Conduct
@@ -145,7 +157,7 @@ golangci-lint run --fix
go install ./cmd/soundtouch-cli
# Run integration tests (requires real device)
make test-integration HOST=192.168.1.100
make test-integration HOST=192.0.2.100
```
### Environment Setup
@@ -154,7 +166,7 @@ For development with real devices, create a `.env` file:
```env
# Optional: Pre-configured device for testing
SOUNDTOUCH_HOST=192.168.1.100
SOUNDTOUCH_HOST=192.0.2.100
SOUNDTOUCH_PORT=8090
# Optional: Enable debug logging
@@ -328,7 +340,7 @@ When possible, test with real SoundTouch devices:
```bash
# Set device IP for integration tests
export SOUNDTOUCH_HOST=192.168.1.100
export SOUNDTOUCH_HOST=192.0.2.100
go test -tags integration ./pkg/client/
```
@@ -349,7 +361,7 @@ go test -tags integration ./pkg/client/
// Basic usage:
//
// client := client.NewClient(&client.Config{
// Host: "192.168.1.100",
// Host: "192.0.2.100",
// Port: 8090,
// })
//
@@ -398,8 +410,8 @@ If you have access to other SoundTouch models:
2. **Test basic functionality**:
```bash
./soundtouch-cli -h 192.168.1.100 info get
./soundtouch-cli -h 192.168.1.100 now-playing get
./soundtouch-cli -h 192.0.2.100 info get
./soundtouch-cli -h 192.0.2.100 now-playing get
```
3. **Report compatibility** in your PR or issue
@@ -465,6 +477,14 @@ Contributors will be:
- **Mentioned in release notes** for significant contributions
- **Credited in documentation** where appropriate
## Support the Project
If you want to support the maintenance effort beyond code:
[![GitHub Sponsors](https://img.shields.io/github/sponsors/gesellix?label=Sponsor%20on%20GitHub&logo=GitHub&color=ea4aaa)](https://github.com/sponsors/gesellix)
Sponsorship is entirely optional. Code, docs, and bug reports remain the most useful contributions for the project itself.
## Additional Resources
- [Go Documentation](https://golang.org/doc/)
+40
View File
@@ -0,0 +1,40 @@
# Dockerfile.stockholm — builds the Stockholm frontend preparation image.
#
# This image clones krahl/soundcork-stockholm-app, installs the required tools
# (prettier, patch, unzip, jq), and is used exclusively to run the entrypoint
# preparation step that extracts and patches the Stockholm frontend.
#
# Java is NOT included — we stop before `exec java`.
#
# Usage (see Makefile targets build-stockholm-image / prepare-stockholm):
#
# docker build --build-arg STOCKHOLM_APP_REF=main \
# -f Dockerfile.stockholm -t soundcork-stockholm-app .
#
# docker run --rm \
# -v "$PWD/stockholm_zip:/app/stockholm_zip:ro" \
# -v "$PWD/stockholm:/app/stockholm" \
# --entrypoint bash soundcork-stockholm-app \
# -c 'awk "/^exec java/{exit} {print}" /app/docker-entrypoint.sh | bash'
FROM debian:bookworm-slim
ARG STOCKHOLM_APP_REF=main
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
git \
jq \
unzip \
nodejs \
npm \
patch && \
rm -rf /var/lib/apt/lists/*
RUN npm install -g prettier@3.8.3 && npm cache clean --force
RUN git clone --depth 1 --branch "${STOCKHOLM_APP_REF}" \
https://github.com/krahl/soundcork-stockholm-app /app
WORKDIR /app
+131 -6
View File
@@ -1,4 +1,7 @@
.PHONY: all build build-cli test test-coverage check fmt vet lint clean dev help
.PHONY: all build build-cli test test-coverage test-http-client test-http-client-rotate check fmt vet lint clean dev help screenshots build-stockholm-image prepare-stockholm update-static-deps
# Load .env if present (simple KEY=VALUE format, no shell quoting)
-include .env
# Go parameters
GOCMD=go
@@ -31,6 +34,22 @@ BUILD_DIR=./build
# Build flags: strip debug info/DWARF for smaller binaries, remove local paths for reproducibility
BUILDFLAGS=-trimpath -ldflags="-s -w"
# Stockholm frontend preparation (see Dockerfile.stockholm and docs/stockholm-port-guide.md)
# STOCKHOLM_APP_REF can be overridden to pin a specific commit: make build-stockholm-image STOCKHOLM_APP_REF=<sha>
STOCKHOLM_IMAGE ?= soundcork-stockholm-app
STOCKHOLM_APP_REF ?= main
STOCKHOLM_ZIP_DIR ?= $(CURDIR)/stockholm_zip
STOCKHOLM_DIR ?= $(CURDIR)/stockholm
# URLs baked into stockholm/json/config.json during prepare-stockholm.
# The Go service rewrites these again at startup using SERVER_URL / MARGE_URL,
# so these only matter for static-file-only deployments or when pre-baking is desired.
# Default to localhost:8000 (matches the Go service default).
BACKEND_URL ?= http://localhost:8000
# STREAMING_URL defaults to BACKEND_URL (no /marge suffix — set to $(BACKEND_URL)/marge for soundcork).
STREAMING_URL ?= $(BACKEND_URL)
# AUTH_SERVICE_URL defaults to BACKEND_URL; override to point at a different auth endpoint.
AUTH_SERVICE_URL ?= $(BACKEND_URL)
all: check build
build: build-cli build-service build-web build-examples build-favicon-gen build-backup
@@ -130,6 +149,20 @@ test-coverage:
check: fmt vet test test-http-client
# Archive any existing tests/integration/testdata/ to a timestamped sibling
# so the next `make test-http-client` starts from a clean slate. Keeps the
# old state around for retrospective debugging — never destructive.
# Run BEFORE test-http-client when fixtures or schemas have changed and
# stale state would otherwise be reused via the compose volume mount.
test-http-client-rotate:
@if [ -d tests/integration/testdata ]; then \
archive=tests/integration/testdata_$$(date +%Y%m%d-%H%M%S); \
mv tests/integration/testdata "$$archive"; \
echo "Archived existing testdata to $$archive"; \
else \
echo "No tests/integration/testdata/ to archive — already fresh."; \
fi
test-http-client:
@echo "Starting services with docker compose..."
@docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d --build
@@ -169,7 +202,9 @@ test-http-client:
/workdir/get_api_versions.http \
/workdir/post_musicprovider_is_eligible.http \
/workdir/get_full_account.http \
/workdir/create_group.http \
/workdir/get_group.http \
/workdir/rename_device.http \
/workdir/unregister_device.http \
--report; \
EXIT_CODE=$$?; \
@@ -212,6 +247,18 @@ dev-service-proxy: build-service
fi
PYTHON_BACKEND_URL=$(PROXY_URL) $(BUILD_DIR)/$(SERVICE_NAME)
# Run the service with the Stockholm frontend enabled. Requires that
# `make prepare-stockholm` has been run at least once (the check below
# avoids re-running the Docker container on every dev launch).
dev-service-stockholm: build-service
@if [ ! -f "$(STOCKHOLM_DIR)/index.html" ]; then \
echo "Error: Stockholm not prepared at $(STOCKHOLM_DIR)."; \
echo "Run 'make prepare-stockholm' first (needs stockholm_zip/stockholm.zip)."; \
exit 1; \
fi
@echo "Starting development service with Stockholm enabled from $(STOCKHOLM_DIR)..."
STOCKHOLM_DIR=$(STOCKHOLM_DIR) $(BUILD_DIR)/$(SERVICE_NAME)
dev-discover: build-cli
@echo "Running device discovery..."
$(BUILD_DIR)/$(BINARY_NAME) -discover
@@ -219,7 +266,7 @@ dev-discover: build-cli
dev-info: build-cli
@echo "Getting device info (requires -host flag)..."
@if [ -z "$(HOST)" ]; then \
echo "Usage: make dev-info HOST=192.168.1.10"; \
echo "Usage: make dev-info HOST=192.0.2.10"; \
exit 1; \
fi
$(BUILD_DIR)/$(BINARY_NAME) -host $(HOST) -info
@@ -295,7 +342,7 @@ dev-backup-local: build-backup
dev-web-host: build-web
@echo "Starting web UI with specific host..."
@if [ -z "$(HOST)" ]; then \
echo "Usage: make dev-web-host HOST=192.168.1.10"; \
echo "Usage: make dev-web-host HOST=192.0.2.10"; \
exit 1; \
fi
cd cmd/soundtouch-web && ../../$(BUILD_DIR)/$(WEB_NAME) -host $(HOST)
@@ -307,6 +354,10 @@ install: build-cli build-service build-web build-backup
cp $(BUILD_DIR)/$(WEB_NAME) $(GOPATH)/bin/
cp $(BUILD_DIR)/$(BACKUP_NAME) $(GOPATH)/bin/
update-static-deps:
@echo "Updating static frontend dependencies..."
@./scripts/update-static-deps.sh
clean:
@echo "Cleaning..."
$(GOCLEAN)
@@ -327,6 +378,69 @@ docker-build:
@echo "Building Docker image..."
docker build --target soundtouch-service -t soundtouch-service .
# Stockholm frontend preparation.
# Requires: Docker, internet access (clones github.com/krahl/soundcork-stockholm-app).
# No pre-built image is published; the image must be built locally before running prepare-stockholm.
build-stockholm-image:
@echo "Building Stockholm preparation image (clones upstream, installs prettier/patch)..."
docker build \
--build-arg STOCKHOLM_APP_REF=$(STOCKHOLM_APP_REF) \
-f Dockerfile.stockholm \
-t $(STOCKHOLM_IMAGE) \
.
# Extracts and patches the Stockholm frontend using the upstream container image.
# Requires: build-stockholm-image to have been run, and stockholm_zip/stockholm.zip to be present.
# The resulting stockholm/ directory is used by the soundtouch-service at runtime.
prepare-stockholm:
@mkdir -p "$(STOCKHOLM_DIR)"
@[ -f "$(STOCKHOLM_ZIP_DIR)/stockholm.zip" ] || { \
echo "Error: $(STOCKHOLM_ZIP_DIR)/stockholm.zip not found."; \
echo "Download the Stockholm zip and place it at stockholm_zip/stockholm.zip first."; \
exit 1; }
docker run --rm \
-e BACKEND_URL=$(BACKEND_URL) \
-e STREAMING_URL=$(STREAMING_URL) \
-e AUTH_SERVICE_URL=$(AUTH_SERVICE_URL) \
-v "$(STOCKHOLM_ZIP_DIR):/app/stockholm_zip:ro" \
-v "$(STOCKHOLM_DIR):/app/stockholm" \
--entrypoint bash \
$(STOCKHOLM_IMAGE) \
-c 'awk "/^exec java/{exit} {print}" /app/docker-entrypoint.sh | bash'
@# Patch update-urls.sh: replace the hardcoded ${BACKEND_URL}/marge with
@# ${STREAMING_URL:-${BACKEND_URL}} so the streaming URL is configurable and
@# defaults to BACKEND_URL (no /marge suffix) rather than the soundcork convention.
@script="$(STOCKHOLM_DIR)/json/update-urls.sh"; \
awk '{ gsub(/\$$\{BACKEND_URL\}\/marge/, "$${STREAMING_URL:-$${BACKEND_URL}}"); print }' \
"$$script" > "$$script.tmp" && mv "$$script.tmp" "$$script"
@# Restore config.json from the backup that update-urls.sh created.
@# The Go service rewrites URLs at startup via RewriteConfigURLs, so we start
@# from the original Bose URLs rather than whatever update-urls.sh produced.
@[ ! -f "$(STOCKHOLM_DIR)/json/backup.json" ] || \
cp "$(STOCKHOLM_DIR)/json/backup.json" "$(STOCKHOLM_DIR)/json/config.json"
@# Patch browse.js: guard against empty browse-path array so that
@# funcObj.browse.getPath() returning undefined does not throw when the user
@# has not browsed yet (causes "Now playing error: topLevel" console spam and
@# aborts the now-playing update handler).
@sed -i.bak \
-e 's/: (l()\.topLevel/: ((l() || {}).topLevel/' \
-e 's/var a = l()\.topLevel,/var a = (l() || {}).topLevel,/' \
-e 's/E() === 0 || funcObj\.browse\.getPath()\.topLevel/E() === 0 || (funcObj.browse.getPath() || {}).topLevel/' \
"$(STOCKHOLM_DIR)/js/browse.js" && \
rm -f "$(STOCKHOLM_DIR)/js/browse.js.bak"
@# Patch bridge JS: replace hardcoded /api/* paths with __stockholmBase-prefixed
@# versions so the bridge works when Stockholm is mounted under a base path.
@# browser_http_proxy.js declares the proxy URL as a top-level constant;
@# without patching it, requests from a /stockholm/* page hit /api/http-proxy
@# directly and 404 because the proxy is mounted under the base path.
@# Also fix resolveWebviewUrl to include the base path when resolving relative URLs.
@python3 scripts/patch-stockholm-bridge.py \
"$(STOCKHOLM_DIR)/js/browser_http_proxy.js" \
"$(STOCKHOLM_DIR)/js/browser_native_bridge.js" \
"$(STOCKHOLM_DIR)/js/app_comm.js" \
"$(STOCKHOLM_DIR)/setup/js/app_comm.js"
@echo "Stockholm frontend prepared at $(STOCKHOLM_DIR)"
docker-run-host:
@echo "Running Docker container..."
@echo "Note: --network host is used for discovery (Linux only). For macOS/Windows use port mapping."
@@ -336,6 +450,10 @@ docker-run-ports:
@echo "Running Docker container with port mapping (discovery will be manual)..."
docker run --rm -it -p 8000:8000 -v $$(pwd)/data:/app/data soundtouch-service
screenshots:
@echo "Capturing documentation screenshots..."
@bash scripts/screenshots/run.sh
help:
@echo "Available targets:"
@echo " build - Build the CLI tool, service, and examples"
@@ -348,6 +466,8 @@ help:
@echo " build-linux-armv7 - Build for Linux ARMv7 (kernel 3.14+ compatible, CGO_ENABLED=0)"
@echo " test - Run tests"
@echo " test-coverage - Run tests with coverage report"
@echo " test-http-client - Run .http integration tests via Docker Compose"
@echo " test-http-client-rotate - Archive tests/integration/testdata/ before a fresh run (non-destructive)"
@echo " check - Run fmt, vet, and tests"
@echo " fmt - Format code"
@echo " vet - Run go vet"
@@ -356,6 +476,8 @@ help:
@echo " dev - Build and show CLI help"
@echo " dev-service - Build and run service locally"
@echo " dev-service-proxy - Build and run service with proxy (PROXY_URL=url required)"
@echo " dev-service-stockholm - Build and run service with Stockholm frontend (requires prior 'make prepare-stockholm')"
@echo " screenshots - Capture documentation screenshots (headless Chrome via chromedp)"
@echo " dev-discover - Build and run device discovery"
@echo " dev-info - Build and get device info (HOST=ip required)"
@echo " dev-mdns - Build and run mDNS discovery example"
@@ -379,13 +501,16 @@ help:
@echo " docker-build - Build Docker image"
@echo " docker-run-host - Run container with host networking (Linux discovery)"
@echo " docker-run-ports - Run container with port mapping (macOS/Windows/No discovery)"
@echo " build-stockholm-image - Build Stockholm prep image (requires Docker + internet)"
@echo " prepare-stockholm - Extract and patch Stockholm frontend (requires build-stockholm-image"
@echo " and stockholm_zip/stockholm.zip; see docs/stockholm-port-guide.md)"
@echo " help - Show this help message"
@echo ""
@echo "Examples:"
@echo " make dev-service"
@echo " make dev-service-proxy PROXY_URL=http://192.168.1.50:8001"
@echo " make dev-service-proxy PROXY_URL=http://192.0.2.50:8001"
@echo " make dev-discover"
@echo " make dev-info HOST=192.168.1.10"
@echo " make dev-info HOST=192.0.2.10"
@echo " make dev-mdns"
@echo " make dev-mdns-verbose"
@echo " make dev-mdns-timeout TIMEOUT=10s"
@@ -396,6 +521,6 @@ help:
@echo " make dev-scan-soundtouch"
@echo " make dev-web"
@echo " make dev-web-port PORT=8888"
@echo " make dev-web-host HOST=192.168.1.10"
@echo " make dev-web-host HOST=192.0.2.10"
@echo " make test"
@echo " make build-all"
+36 -4
View File
@@ -1,10 +1,13 @@
# Bose SoundTouch Toolkit
# <img src="media/favicon-braille.svg" width="32" height="32" valign="middle"> AfterTouch
<p style="margin-top: -10px; font-style: italic; color: #666;">Bose SoundTouch Toolkit</p>
[![Go Reference](https://pkg.go.dev/badge/github.com/gesellix/bose-soundtouch.svg)](https://pkg.go.dev/github.com/gesellix/bose-soundtouch)
[![Go Report Card](https://goreportcard.com/badge/github.com/gesellix/bose-soundtouch)](https://goreportcard.com/report/github.com/gesellix/bose-soundtouch)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
> Independent project. Not affiliated with or endorsed by Bose Corporation.
> Independent project. **Not affiliated with, endorsed by, sponsored
> by, or otherwise connected to Bose Corporation.** See
> [Disclaimer](#disclaimer) for the full statement.
## Context: Cloud Shutdown
@@ -20,6 +23,8 @@ 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.
**Two scenarios:**
**Before shutdown — migrate your existing setup**
@@ -120,8 +125,35 @@ See the [API Reference](https://gesellix.github.io/Bose-SoundTouch/reference/API
---
## Contributing
Issues and pull requests welcome — code, documentation, bug reports, and feature ideas all land in the same place. By submitting a contribution you agree to license it under MIT. For significant changes please open an issue first to discuss the approach. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide.
## Support the project
If this toolkit kept a speaker (or several) of yours alive past the Bose cloud shutdown and you want to give back, [GitHub Sponsors](https://github.com/sponsors/gesellix) is open. No expectation — everything in this repo stays MIT regardless.
[![GitHub Sponsors](https://img.shields.io/github/sponsors/gesellix?label=Sponsor%20on%20GitHub&logo=GitHub&color=ea4aaa)](https://github.com/sponsors/gesellix)
## Disclaimer
This is an independent open-source project. **Bose** and **SoundTouch**
are registered trademarks of Bose Corporation in the United States and
other countries. This project is **not affiliated with, endorsed by,
sponsored by, or otherwise connected to** Bose Corporation.
The toolkit exists solely to restore functionality of Bose SoundTouch
speakers after the official cloud service shutdown on May 6, 2026.
Reverse engineering for the sole purpose of interoperability is
permitted under [EU Directive 2009/24/EC, Article 6](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32009L0024)
("Decompilation"), and comparable provisions in other jurisdictions.
The optional Stockholm frontend integration (`STOCKHOLM_DIR`) requires
the user to supply the Stockholm web-app sources themselves; no Bose
code is redistributed in this repository.
The software is provided AS IS, without warranty. Use at your own risk.
## License
MIT — see [LICENSE](LICENSE).
SoundTouch is a trademark of Bose Corporation.
+107
View File
@@ -0,0 +1,107 @@
// Command dummy-speaker runs an HTTP-only fake SoundTouch speaker and
// optionally registers it with a running soundtouch-service so the web UI
// has a device to display.
//
// Intended for documentation screenshots and local UI smoke checks. Do not
// use against a real network — the fixture payload is synthetic and would
// confuse other tooling that expects live device data.
//
// Example:
//
// dummy-speaker --port 8090 --register http://localhost:8000
package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/testing/fakespeaker"
)
func main() {
listen := flag.String("listen", "127.0.0.1:8090", "bind address for the fake speaker's HTTP API")
telnetListen := flag.String("telnet-listen", "127.0.0.1:17000", "bind address for the fake speaker's telnet diagnostic shell (empty to disable)")
register := flag.String("register", "", "service base URL (e.g. http://localhost:8000) to self-register with via POST /setup/devices")
registerAs := flag.String("register-as", "", "address to send to /setup/devices (defaults to --listen)")
flag.Parse()
s, err := fakespeaker.Start(fakespeaker.Config{
HTTPListen: *listen,
TelnetListen: *telnetListen,
})
if err != nil {
log.Fatalf("start fake speaker: %v", err)
}
log.Printf("fake speaker HTTP listening on http://%s", s.HTTPAddr())
if addr := s.TelnetAddr(); addr != "" {
log.Printf("fake speaker telnet listening on tcp://%s", addr)
}
if *register != "" {
target := *registerAs
if target == "" {
target = s.HTTPAddr()
}
if err := registerWithService(*register, target); err != nil {
log.Printf("self-register failed: %v (continuing anyway)", err)
} else {
log.Printf("registered %s with service at %s", target, *register)
}
}
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
log.Printf("shutting down")
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := s.Stop(ctx); err != nil {
log.Printf("stop: %v", err)
}
}
func registerWithService(serviceURL, deviceAddr string) error {
body, err := json.Marshal(map[string]string{"ip": deviceAddr})
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, serviceURL+"/setup/devices", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 300 {
return fmt.Errorf("service responded %s", resp.Status)
}
return nil
}
+2 -2
View File
@@ -107,10 +107,10 @@ Backs up each speaker over its HTTP API on port 8090. With `--ssh`, also capture
soundtouch-backup local
# Specific speaker
soundtouch-backup local --host 192.168.178.28
soundtouch-backup local --host 192.0.2.11
# Multiple speakers
soundtouch-backup local --host 192.168.178.28 --host 192.168.178.35
soundtouch-backup local --host 192.0.2.11 --host 192.0.2.10
# Include SSH filesystem backup
soundtouch-backup local --ssh
+27
View File
@@ -157,6 +157,33 @@ func setClockTimeNow(c *cli.Context) error {
return nil
}
// setClockDisplayTimezone POSTs only the timezoneInfo attribute,
// leaving format/brightness untouched. Useful after a clock now to
// make the speaker's logs and front-panel display tick in local time
// instead of UTC.
func setClockDisplayTimezone(c *cli.Context) error {
clientConfig := GetClientConfig(c)
tz := c.String("tz")
PrintDeviceHeader(fmt.Sprintf("Setting clock timezone to %s", tz), clientConfig.Host, clientConfig.Port)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
request := models.NewClockDisplayRequest().SetTimeZone(tz)
if err := client.SetClockDisplay(request); err != nil {
PrintError(fmt.Sprintf("Failed to set timezone: %v", err))
return err
}
PrintSuccess(fmt.Sprintf("Timezone set to %s", tz))
return nil
}
// getClockDisplay retrieves the current clock display settings
func getClockDisplay(c *cli.Context) error {
clientConfig := GetClientConfig(c)
+118 -4
View File
@@ -23,6 +23,12 @@ func eventSubscribe(c *cli.Context) error {
filterStr := c.String("filter")
filters := parseEventFilters(filterStr)
debugMode, err := parseDebugMode(c.String("debug"))
if err != nil {
PrintError(err.Error())
return err
}
// Parse duration
duration := c.Duration("duration")
verbose := c.Bool("verbose")
@@ -60,6 +66,10 @@ func eventSubscribe(c *cli.Context) error {
// Set up event handlers
setupEventHandlers(wsClient, filters, verbose)
if debugMode != debugOff {
installDebugHook(wsClient, debugMode)
}
// Connect to WebSocket
fmt.Println("🔌 Connecting to WebSocket...")
@@ -127,11 +137,78 @@ func eventSubscribe(c *cli.Context) error {
return nil
}
// debugMode controls when the WebSocket subscribe loop prints raw frames
// to stderr. "off" disables debug output entirely (the production default
// when --debug is unset).
type debugMode int
const (
debugOff debugMode = iota
debugAll
debugUnknown
debugErrors
)
func parseDebugMode(s string) (debugMode, error) {
switch strings.TrimSpace(s) {
case "":
return debugOff, nil
case "all":
return debugAll, nil
case "unknown":
return debugUnknown, nil
case "errors":
return debugErrors, nil
default:
return debugOff, fmt.Errorf("invalid --debug value %q (want one of: all, unknown, errors)", s)
}
}
// installDebugHook wires an OnRawMessage handler that prints the raw
// frame to stderr based on the chosen mode. Stays out of stdout so
// debug output can be filtered/grep'd independently of normal events.
func installDebugHook(ws *client.WebSocketClient, mode debugMode) {
ws.OnRawMessage(func(data []byte, parseErr error) {
switch mode {
case debugAll:
printRawFrame(data, parseErr, "all")
case debugErrors:
if parseErr != nil {
printRawFrame(data, parseErr, "errors")
}
case debugUnknown:
// "Unknown" = parsed successfully but no known event types
// matched. Parse errors also qualify, since they're frames
// the client couldn't interpret either.
if parseErr != nil {
printRawFrame(data, parseErr, "unknown:parse-error")
return
}
ev, err := models.ParseWebSocketEvent(data)
if err != nil || len(ev.GetEventTypes()) == 0 {
printRawFrame(data, err, "unknown")
}
case debugOff:
// nothing
}
})
}
func printRawFrame(data []byte, parseErr error, tag string) {
prefix := "[ws-debug:" + tag + "]"
if parseErr != nil {
fmt.Fprintf(os.Stderr, "%s parse-error: %v\n", prefix, parseErr)
}
fmt.Fprintf(os.Stderr, "%s %s\n", prefix, string(data))
}
// parseEventFilters validates and parses the filter string
func parseEventFilters(eventFilter string) map[string]bool {
validFilters := map[string]bool{
"nowPlaying": true, "volume": true, "connection": true,
"preset": true, "zone": true, "bass": true,
"preset": true, "zone": true, "group": true, "bass": true,
"sdkInfo": true, "userActivity": true,
}
@@ -217,6 +294,13 @@ func setupEventHandlers(wsClient *client.WebSocketClient, filters map[string]boo
})
}
// Stereo-pair (group) events — ST-10 only
if filters == nil || filters["group"] {
wsClient.OnGroupUpdated(func(event *models.GroupUpdatedEvent) {
handleGroupEvent(event)
})
}
// Bass events
if filters == nil || filters["bass"] {
wsClient.OnBassUpdated(func(event *models.BassUpdatedEvent) {
@@ -329,9 +413,11 @@ func handlePresetEvent(event *models.PresetUpdatedEvent, verbose bool) {
for _, preset := range presets.Preset {
fmt.Printf(" 📻 Preset %d:", preset.ID)
if preset.ContentItem != nil {
fmt.Printf(" %s", preset.ContentItem.ItemName)
fmt.Printf(" (%s)", preset.ContentItem.Source)
// IsEmpty catches both <preset/> and INVALID_SOURCE
// placeholders; using the nil-safe helpers below means the
// inner Printf never dereferences a nil ContentItem.
if !preset.IsEmpty() {
fmt.Printf(" %s (%s)", preset.GetDisplayName(), preset.GetSource())
}
fmt.Println()
@@ -358,6 +444,34 @@ func handleZoneEvent(event *models.ZoneUpdatedEvent) {
}
}
func handleGroupEvent(event *models.GroupUpdatedEvent) {
group := &event.Group
fmt.Printf("\n🎧 Stereo-Pair Update [%s]:\n", event.DeviceID)
if group.IsEmpty() {
fmt.Println(" ⛓️‍💥 Pair dissolved (no group configured)")
return
}
fmt.Printf(" 🆔 ID: %s\n", group.ID)
fmt.Printf(" 📛 Name: %s\n", group.Name)
fmt.Printf(" 👑 Master: %s\n", group.MasterDeviceID)
if group.Status != "" {
fmt.Printf(" ✅ Status: %s\n", group.Status)
}
for _, r := range group.Roles.Roles {
fmt.Printf(" %-5s %s", r.Role, r.DeviceID)
if r.IPAddress != "" {
fmt.Printf(" (IP: %s)", r.IPAddress)
}
fmt.Println()
}
}
func handleBassEvent(event *models.BassUpdatedEvent) {
bass := &event.Bass
fmt.Printf("\n🎵 Bass Update [%s]:\n", event.DeviceID)
+315
View File
@@ -0,0 +1,315 @@
package main
import (
"fmt"
"net"
"sync"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/speaker"
"github.com/urfave/cli/v2"
)
// getGroupStatus retrieves and prints the device's current stereo-pair state.
func getGroupStatus(c *cli.Context) error {
clientConfig := GetClientConfig(c)
PrintDeviceHeader("Getting group information", clientConfig.Host, clientConfig.Port)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
group, err := client.GetGroup()
if err != nil {
PrintError(fmt.Sprintf("Failed to get group: %v", err))
return err
}
if group.IsEmpty() {
fmt.Println("Device is not in a stereo pair")
return nil
}
printGroup(group)
return nil
}
// createGroup forms a stereo pair by POSTing /addGroup to both speakers in
// parallel. LEFT is the master. Addressing each speaker directly (instead of
// only the master and letting it propagate via marge) sidesteps the
// inter-device round-trip that surfaced as client timeouts in #252.
func createGroup(c *cli.Context) error {
leftIP := c.String("left")
rightIP := c.String("right")
name := c.String("name")
if net.ParseIP(leftIP) == nil {
PrintError(fmt.Sprintf("Invalid left IP address: %s", leftIP))
return fmt.Errorf("invalid left IP: %s", leftIP)
}
if net.ParseIP(rightIP) == nil {
PrintError(fmt.Sprintf("Invalid right IP address: %s", rightIP))
return fmt.Errorf("invalid right IP: %s", rightIP)
}
PrintDeviceHeader(fmt.Sprintf("Creating stereo pair: LEFT=%s RIGHT=%s", leftIP, rightIP), leftIP, speaker.HTTPPort)
leftInfo, err := fetchDeviceInfo(c, leftIP)
if err != nil {
PrintError(fmt.Sprintf("Failed to read LEFT device info: %v", err))
return err
}
rightInfo, err := fetchDeviceInfo(c, rightIP)
if err != nil {
PrintError(fmt.Sprintf("Failed to read RIGHT device info: %v", err))
return err
}
if name == "" {
name = fmt.Sprintf("%s + %s", leftInfo.Name, rightInfo.Name)
}
req := &models.Group{
Name: name,
MasterDeviceID: leftInfo.DeviceID,
Roles: models.GroupRoles{
Roles: []models.GroupRole{
{DeviceID: leftInfo.DeviceID, Role: "LEFT", IPAddress: leftIP},
{DeviceID: rightInfo.DeviceID, Role: "RIGHT", IPAddress: rightIP},
},
},
// SenderIPAddress is intentionally omitted on the base request.
// propagateAddGroup adds it to the slave's copy only — see comment there.
}
leftClient, err := clientForHost(c, leftIP)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client for LEFT: %v", err))
return err
}
rightClient, err := clientForHost(c, rightIP)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client for RIGHT: %v", err))
return err
}
leftOut, rightOut := propagateAddGroup(leftClient, rightClient, leftIP, rightIP, req)
if leftOut.err != nil {
PrintError(fmt.Sprintf("LEFT (%s) /addGroup failed: %v", leftIP, leftOut.err))
}
if rightOut.err != nil {
PrintError(fmt.Sprintf("RIGHT (%s) /addGroup failed: %v", rightIP, rightOut.err))
}
if leftOut.err != nil || rightOut.err != nil {
if (leftOut.err == nil) != (rightOut.err == nil) {
succeeded := leftIP
if leftOut.err != nil {
succeeded = rightIP
}
PrintError(fmt.Sprintf("Partial group state on %s — clean up with `soundtouch-cli --host %s group remove`", succeeded, succeeded))
}
return fmt.Errorf("/addGroup propagation failed")
}
// The LEFT (master) response carries the assigned group ID; use it for display.
PrintSuccess(fmt.Sprintf("Stereo pair created (id=%s)", leftOut.group.ID))
printGroup(leftOut.group)
return nil
}
// addGroupOutcome is the per-speaker result of a parallel /addGroup call.
type addGroupOutcome struct {
host string
group *models.Group
err error
}
// propagateAddGroup POSTs /addGroup to both speakers concurrently and returns
// the (LEFT, RIGHT) outcomes. A non-GROUP_OK Status in the response is
// reported as an error so callers don't have to re-inspect the body.
//
// The two POSTs carry different payloads: the master (LEFT) receives the base
// request with no senderIPAddress so its state machine forms the group as the
// master, while the slave (RIGHT) receives a copy with senderIPAddress set to
// the master's IP so its state machine joins as the slave. Sending the same
// payload to both makes both speakers think they're the slave — they enter
// AddingSlave, wait for a master that never confirms, time out after 5 s, and
// revert (issue #252).
func propagateAddGroup(left, right *client.Client, leftIP, rightIP string, req *models.Group) (addGroupOutcome, addGroupOutcome) {
masterReq := *req
masterReq.SenderIPAddress = ""
slaveReq := *req
slaveReq.SenderIPAddress = leftIP
var (
wg sync.WaitGroup
leftOut, rightOut addGroupOutcome
)
wg.Add(2)
go func() {
defer wg.Done()
leftOut = postAddGroup(left, leftIP, &masterReq)
}()
go func() {
defer wg.Done()
rightOut = postAddGroup(right, rightIP, &slaveReq)
}()
wg.Wait()
return leftOut, rightOut
}
func postAddGroup(cli *client.Client, host string, req *models.Group) addGroupOutcome {
out := addGroupOutcome{host: host}
g, err := cli.AddGroup(req)
if err != nil {
out.err = err
return out
}
out.group = g
if g != nil && g.Status != "" && g.Status != "GROUP_OK" {
out.err = fmt.Errorf("device returned status %q (want GROUP_OK)", g.Status)
}
return out
}
// renameGroup updates the name of the existing stereo pair. The device
// requires the full structure on every update, so we fetch the current
// state first.
func renameGroup(c *cli.Context) error {
clientConfig := GetClientConfig(c)
newName := c.String("name")
if newName == "" {
PrintError("--name is required")
return fmt.Errorf("name is required")
}
PrintDeviceHeader(fmt.Sprintf("Renaming stereo pair to %q", newName), clientConfig.Host, clientConfig.Port)
stClient, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
current, err := stClient.GetGroup()
if err != nil {
PrintError(fmt.Sprintf("Failed to read current group: %v", err))
return err
}
if current.IsEmpty() {
PrintError("Device is not in a stereo pair — nothing to rename")
return fmt.Errorf("no group configured")
}
// Status is read-only on the device side; don't echo it back.
current.Status = ""
current.Name = newName
result, err := stClient.UpdateGroup(current)
if err != nil {
PrintError(fmt.Sprintf("Failed to rename group: %v", err))
return err
}
PrintSuccess(fmt.Sprintf("Stereo pair renamed to %q", result.Name))
printGroup(result)
return nil
}
// removeGroup tears down the device's stereo pair.
func removeGroup(c *cli.Context) error {
clientConfig := GetClientConfig(c)
PrintDeviceHeader("Removing stereo pair", clientConfig.Host, clientConfig.Port)
stClient, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
if err := stClient.RemoveGroup(); err != nil {
PrintError(fmt.Sprintf("Failed to remove group: %v", err))
return err
}
PrintSuccess("Stereo pair removed")
return nil
}
// fetchDeviceInfo builds a one-off client for the given IP and reads /info.
// Reused for both halves of a `create` invocation so the caller doesn't have
// to babysit two host/port pairs.
func fetchDeviceInfo(c *cli.Context, host string) (*models.DeviceInfo, error) {
stClient, err := clientForHost(c, host)
if err != nil {
return nil, err
}
return stClient.GetDeviceInfo()
}
// clientForHost mirrors CreateSoundTouchClient but overrides the host so we
// can talk to a speaker other than the one named in --host.
func clientForHost(c *cli.Context, host string) (*client.Client, error) {
cfg, err := loadConfig(c.Duration("timeout"))
if err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
}
return client.NewClient(&client.Config{
Host: host,
Port: speaker.HTTPPort,
Timeout: cfg.HTTPTimeout,
UserAgent: cfg.UserAgent,
}), nil
}
func printGroup(g *models.Group) {
fmt.Println("Stereo Pair Configuration:")
fmt.Printf(" ID: %s\n", g.ID)
fmt.Printf(" Name: %s\n", g.Name)
fmt.Printf(" Master: %s\n", g.MasterDeviceID)
if g.Status != "" {
fmt.Printf(" Status: %s\n", g.Status)
}
for _, r := range g.Roles.Roles {
fmt.Printf(" %-5s %s", r.Role, r.DeviceID)
if r.IPAddress != "" {
fmt.Printf(" (IP: %s)", r.IPAddress)
}
fmt.Println()
}
}
+184
View File
@@ -0,0 +1,184 @@
package main
import (
"encoding/xml"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
// happyAddGroupServer fakes a speaker's /addGroup that echoes the request
// with an assigned ID and GROUP_OK status, matching real hardware behaviour.
func happyAddGroupServer(t *testing.T, assignedID string) (*httptest.Server, *[]string) {
t.Helper()
bodies := make([]string, 0)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/addGroup" || r.Method != http.MethodPost {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
http.NotFound(w, r)
return
}
body, _ := io.ReadAll(r.Body)
bodies = append(bodies, string(body))
var got models.Group
if err := xml.Unmarshal(body, &got); err != nil {
t.Fatalf("decode request body: %v", err)
}
got.ID = assignedID
got.Status = "GROUP_OK"
w.Header().Set("Content-Type", "application/xml")
enc, _ := xml.Marshal(&got)
_, _ = w.Write(enc)
}))
return srv, &bodies
}
func newTestGroupClient(serverURL string) *client.Client {
return client.NewClientFromHost(serverURL)
}
func sampleGroupRequest(leftIP, rightIP string) *models.Group {
return &models.Group{
Name: "Living Room",
MasterDeviceID: "9070658C9D4A",
Roles: models.GroupRoles{
Roles: []models.GroupRole{
{DeviceID: "9070658C9D4A", Role: "LEFT", IPAddress: leftIP},
{DeviceID: "F45EAB3115DA", Role: "RIGHT", IPAddress: rightIP},
},
},
// senderIPAddress is intentionally not set here; propagateAddGroup
// adds it to the slave's copy only.
}
}
func TestPropagateAddGroup_BothSucceed(t *testing.T) {
leftSrv, leftBodies := happyAddGroupServer(t, "9999999")
defer leftSrv.Close()
rightSrv, rightBodies := happyAddGroupServer(t, "9999999")
defer rightSrv.Close()
leftClient := newTestGroupClient(leftSrv.URL)
rightClient := newTestGroupClient(rightSrv.URL)
req := sampleGroupRequest("192.0.2.131", "192.0.2.134")
leftOut, rightOut := propagateAddGroup(leftClient, rightClient, "192.0.2.131", "192.0.2.134", req)
if leftOut.err != nil {
t.Errorf("LEFT err = %v, want nil", leftOut.err)
}
if rightOut.err != nil {
t.Errorf("RIGHT err = %v, want nil", rightOut.err)
}
if leftOut.group == nil || leftOut.group.ID != "9999999" || leftOut.group.Status != "GROUP_OK" {
t.Errorf("LEFT group = %+v, want id=9999999 status=GROUP_OK", leftOut.group)
}
if rightOut.group == nil || rightOut.group.Status != "GROUP_OK" {
t.Errorf("RIGHT group = %+v, want status=GROUP_OK", rightOut.group)
}
// Both speakers must have received the roles, but only the slave's payload
// carries senderIPAddress — see propagateAddGroup for the why.
for label, bodies := range map[string]*[]string{"LEFT": leftBodies, "RIGHT": rightBodies} {
if len(*bodies) != 1 {
t.Fatalf("%s: expected exactly one POST, got %d", label, len(*bodies))
}
body := (*bodies)[0]
for _, want := range []string{"<role>LEFT</role>", "<role>RIGHT</role>"} {
if !strings.Contains(body, want) {
t.Errorf("%s body missing %q\nbody:\n%s", label, want, body)
}
}
}
leftBody := (*leftBodies)[0]
if strings.Contains(leftBody, "<senderIPAddress>") {
t.Errorf("LEFT (master) body must NOT carry <senderIPAddress>, otherwise the master flips into slave mode (issue #252)\nbody:\n%s", leftBody)
}
rightBody := (*rightBodies)[0]
if !strings.Contains(rightBody, "<senderIPAddress>192.0.2.131</senderIPAddress>") {
t.Errorf("RIGHT (slave) body must carry <senderIPAddress>192.0.2.131</senderIPAddress>\nbody:\n%s", rightBody)
}
}
func TestPropagateAddGroup_RightFails(t *testing.T) {
leftSrv, _ := happyAddGroupServer(t, "9999999")
defer leftSrv.Close()
rightSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
}))
defer rightSrv.Close()
leftClient := newTestGroupClient(leftSrv.URL)
rightClient := newTestGroupClient(rightSrv.URL)
req := sampleGroupRequest("192.0.2.131", "192.0.2.134")
leftOut, rightOut := propagateAddGroup(leftClient, rightClient, "192.0.2.131", "192.0.2.134", req)
if leftOut.err != nil {
t.Errorf("LEFT err = %v, want nil", leftOut.err)
}
if rightOut.err == nil {
t.Error("RIGHT err = nil, want non-nil")
}
}
func TestPostAddGroup_StatusOtherThanGroupOKIsError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<group><status>GROUP_NOT_READY</status></group>`))
}))
defer srv.Close()
out := postAddGroup(newTestGroupClient(srv.URL), "test", sampleGroupRequest("1.1.1.1", "2.2.2.2"))
if out.err == nil {
t.Fatal("expected error for non-GROUP_OK status")
}
if !strings.Contains(out.err.Error(), "GROUP_NOT_READY") {
t.Errorf("error %q does not mention returned status", out.err)
}
}
func TestPostAddGroup_EmptyStatusIsAccepted(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<group id="42"><name>n</name></group>`))
}))
defer srv.Close()
out := postAddGroup(newTestGroupClient(srv.URL), "test", sampleGroupRequest("1.1.1.1", "2.2.2.2"))
if out.err != nil {
t.Errorf("err = %v, want nil for empty status (some firmware omits it)", out.err)
}
if out.group == nil || out.group.ID != "42" {
t.Errorf("group = %+v, want id=42", out.group)
}
}
+20 -7
View File
@@ -177,23 +177,36 @@ func getPresets(c *cli.Context) error {
fmt.Printf("Device Presets:\n")
if len(presets.Preset) == 0 {
// Filter out placeholder presets the firmware emits for unconfigured
// slots (issue #308): self-closing <preset/> after factory reset,
// or <ContentItem source="INVALID_SOURCE"/> on healthy devices.
// IsEmpty covers both shapes; accessing fields like ContentItem.Source
// directly on the first shape panics.
configured := make([]models.Preset, 0, len(presets.Preset))
for _, p := range presets.Preset {
if !p.IsEmpty() {
configured = append(configured, p)
}
}
if len(configured) == 0 {
fmt.Printf(" No presets configured\n")
return nil
}
fmt.Printf(" Configured Presets:\n")
for _, preset := range presets.Preset {
for _, preset := range configured {
fmt.Printf(" %d. %s\n", preset.ID, preset.GetDisplayName())
fmt.Printf(" Source: %s\n", preset.ContentItem.Source)
fmt.Printf(" Source: %s\n", preset.GetSource())
if preset.ContentItem.SourceAccount != "" && preset.ContentItem.SourceAccount != preset.ContentItem.Source {
fmt.Printf(" Account: %s\n", preset.ContentItem.SourceAccount)
if account := preset.GetSourceAccount(); account != "" && account != preset.GetSource() {
fmt.Printf(" Account: %s\n", account)
}
if preset.ContentItem.Location != "" {
fmt.Printf(" Location: %s\n", preset.ContentItem.Location)
if location := preset.GetLocation(); location != "" {
fmt.Printf(" Location: %s\n", location)
}
// Show preset creation time if available
+4 -4
View File
@@ -17,7 +17,7 @@ func TestIntrospectCommands(t *testing.T) {
}{
{
name: "introspect service with source flag",
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "source", "introspect", "--source", "SPOTIFY"},
args: []string{"soundtouch-cli", "--host", "192.0.2.100", "source", "introspect", "--source", "SPOTIFY"},
expectedOutput: []string{
"Getting introspect data for SPOTIFY",
"=== SPOTIFY Service Introspect Data ===",
@@ -47,7 +47,7 @@ func TestIntrospectCommands(t *testing.T) {
},
{
name: "introspect spotify convenience command",
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "source", "introspect-spotify"},
args: []string{"soundtouch-cli", "--host", "192.0.2.100", "source", "introspect-spotify"},
expectedOutput: []string{
"Getting Spotify introspect data",
"=== Spotify Service Introspect Data ===",
@@ -60,7 +60,7 @@ func TestIntrospectCommands(t *testing.T) {
},
{
name: "introspect with account parameter",
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "source", "introspect", "--source", "SPOTIFY", "--account", "my_spotify_account"},
args: []string{"soundtouch-cli", "--host", "192.0.2.100", "source", "introspect", "--source", "SPOTIFY", "--account", "my_spotify_account"},
expectedOutput: []string{
"Getting introspect data for SPOTIFY",
"Source Account: my_spotify_account",
@@ -68,7 +68,7 @@ func TestIntrospectCommands(t *testing.T) {
},
{
name: "introspect missing source flag",
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "source", "introspect"},
args: []string{"soundtouch-cli", "--host", "192.0.2.100", "source", "introspect"},
expectError: true,
},
{
+4 -4
View File
@@ -17,7 +17,7 @@ func TestRecentsCommands(t *testing.T) {
}{
{
name: "recents list command",
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "recents", "list"},
args: []string{"soundtouch-cli", "--host", "192.0.2.100", "recents", "list"},
expectedOutput: []string{
"Getting recently played content",
"Recent Items Summary:",
@@ -26,7 +26,7 @@ func TestRecentsCommands(t *testing.T) {
},
{
name: "recents filter by source",
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "recents", "filter", "--source", "SPOTIFY"},
args: []string{"soundtouch-cli", "--host", "192.0.2.100", "recents", "filter", "--source", "SPOTIFY"},
expectedOutput: []string{
"Getting filtered recent content",
"filtered by source: SPOTIFY",
@@ -34,7 +34,7 @@ func TestRecentsCommands(t *testing.T) {
},
{
name: "recents latest command",
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "recents", "latest"},
args: []string{"soundtouch-cli", "--host", "192.0.2.100", "recents", "latest"},
expectedOutput: []string{
"Getting most recent item",
"Most Recent Item:",
@@ -42,7 +42,7 @@ func TestRecentsCommands(t *testing.T) {
},
{
name: "recents stats command",
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "recents", "stats"},
args: []string{"soundtouch-cli", "--host", "192.0.2.100", "recents", "stats"},
expectedOutput: []string{
"Getting recent items statistics",
"Recent Items Statistics",
File diff suppressed because it is too large Load Diff
+310
View File
@@ -0,0 +1,310 @@
package main
import (
"bytes"
"io"
"os"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
)
// captureStdout runs fn and returns whatever it wrote to os.Stdout.
// renderSourceTable prints directly via fmt.Print* — this lets us assert
// on its output without restructuring the renderer to take an io.Writer.
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
orig := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
os.Stdout = w
done := make(chan struct{})
buf := &bytes.Buffer{}
go func() {
_, _ = io.Copy(buf, r)
close(done)
}()
fn()
_ = w.Close()
os.Stdout = orig
<-done
return buf.String()
}
func TestRenderSourceTable_AlignsColumnsAndDedupsDisplayName(t *testing.T) {
items := []models.SourceItem{
// displayName != account → kept as "AUX (AUX IN)"
{Source: "AUX", SourceAccount: "AUX", DisplayName: "AUX IN", Status: "READY", IsLocal: true, MultiroomAllowed: true},
// displayName == account → dropped (would otherwise duplicate the next column)
{Source: "AMAZON", SourceAccount: "amzn1.account.AFKTQOUNVZL7ODQCF4STPAAMVMPA", DisplayName: "amzn1.account.AFKTQOUNVZL7ODQCF4STPAAMVMPA", Status: "READY", MultiroomAllowed: true},
// No displayName at all, no account
{Source: "BLUETOOTH", Status: "UNAVAILABLE", IsLocal: true, MultiroomAllowed: true},
// Long source name, no catalog entry → provider#?
{Source: "STORED_MUSIC_MEDIA_RENDERER", SourceAccount: "StoredMusicUserName", DisplayName: "StoredMusicUserName", Status: "UNAVAILABLE", MultiroomAllowed: true},
}
out := captureStdout(t, func() { renderSourceTable(items) })
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
if len(lines) != 4 {
t.Fatalf("got %d output lines, want 4:\n%s", len(lines), out)
}
// (1) AUX keeps "(AUX IN)" because it differs from both source and account.
if !strings.Contains(lines[0], "AUX (AUX IN)") {
t.Errorf("AUX line should keep displayName parenthesis: %q", lines[0])
}
// (2) AMAZON drops "(amzn1…)" because displayName equals sourceAccount.
if strings.Contains(lines[1], "(amzn1.account") {
t.Errorf("AMAZON line should drop displayName when it duplicates account: %q", lines[1])
}
// (3) provider#? for the uncatalogued source.
if !strings.Contains(lines[3], "provider#?") {
t.Errorf("uncatalogued source should be tagged provider#?: %q", lines[3])
}
// (4) Column starts must align across all rows — find the column index
// where "status=" appears in each line; they should all match.
statusCols := make([]int, len(lines))
for i, l := range lines {
statusCols[i] = strings.Index(l, "status=")
if statusCols[i] < 0 {
t.Fatalf("line %d missing status= column: %q", i, l)
}
}
for i := 1; i < len(statusCols); i++ {
if statusCols[i] != statusCols[0] {
t.Errorf("status= column misaligned: line 0 at col %d, line %d at col %d\n%s",
statusCols[0], i, statusCols[i], out)
}
}
// (5) account= column should likewise align across all rows.
accountCols := make([]int, len(lines))
for i, l := range lines {
accountCols[i] = strings.Index(l, "account=")
if accountCols[i] < 0 {
t.Fatalf("line %d missing account= column: %q", i, l)
}
}
for i := 1; i < len(accountCols); i++ {
if accountCols[i] != accountCols[0] {
t.Errorf("account= column misaligned: line 0 at col %d, line %d at col %d\n%s",
accountCols[0], i, accountCols[i], out)
}
}
}
func TestRenderSourceTable_EmptyShowsNonePlaceholder(t *testing.T) {
out := captureStdout(t, func() { renderSourceTable(nil) })
if !strings.Contains(out, "(none)") {
t.Errorf("expected (none) placeholder for empty list, got: %q", out)
}
}
func TestRecommendMigrationMethod_PrefersTelnet(t *testing.T) {
method, reason := recommendMigrationMethod("http://aftertouch.local:8000", &setup.MigrationSummary{
TelnetReachable: true,
SSHSuccess: true,
})
if method != setup.MigrationMethodTelnet {
t.Errorf("method = %q, want telnet (simplest path when telnet works)", method)
}
if !strings.Contains(reason, "Telnet") {
t.Errorf("reason should mention Telnet: %q", reason)
}
}
func TestRecommendMigrationMethod_HTTPSAddsCaveatToTelnet(t *testing.T) {
_, reason := recommendMigrationMethod("https://aftertouch.local:8443", &setup.MigrationSummary{
TelnetReachable: true,
})
if !strings.Contains(reason, "install-ca") {
t.Errorf("HTTPS service URL should flag the CA-install caveat in the reason: %q", reason)
}
}
func TestRecommendMigrationMethod_FallsBackToResolvWhenTelnetDown(t *testing.T) {
method, _ := recommendMigrationMethod("http://aftertouch.local:8000", &setup.MigrationSummary{
TelnetReachable: false,
SSHSuccess: true,
})
if method != setup.MigrationMethodResolvConf {
t.Errorf("method = %q, want resolv (DNS redirect via SSH)", method)
}
}
func TestRecommendMigrationMethod_EmptyWhenNoTransport(t *testing.T) {
method, _ := recommendMigrationMethod("http://aftertouch.local:8000", &setup.MigrationSummary{
TelnetReachable: false,
SSHSuccess: false,
})
if method != "" {
t.Errorf("method = %q, want empty when no transport works", method)
}
}
func TestBuildPlanSteps_NoOpWhenAlreadyMigratedAndPaired(t *testing.T) {
summary := &setup.MigrationSummary{IsMigrated: true, IsPaired: true, TelnetMigrated: true}
inspect := &setup.InspectReport{Info: &setup.DeviceInfoXML{DeviceID: "AABBCCDDEEFF"}}
steps := buildPlanSteps("192.0.2.42", "http://aftertouch.local:8000", "", true, false, inspect, summary)
if len(steps) != 0 {
t.Errorf("expected no steps for fully-set-up device, got %d:\n%v", len(steps), steps)
}
}
func TestBuildPlanSteps_RecommendsPairWhenMigratedButUnpaired(t *testing.T) {
summary := &setup.MigrationSummary{IsMigrated: true, IsPaired: false, TelnetMigrated: true, TelnetReachable: true}
inspect := &setup.InspectReport{Info: &setup.DeviceInfoXML{DeviceID: "AABBCCDDEEFF"}}
steps := buildPlanSteps("192.0.2.42", "http://aftertouch.local:8000", "", true, false, inspect, summary)
if len(steps) != 1 {
t.Fatalf("expected exactly the pair step, got %d:\n%v", len(steps), steps)
}
if !strings.Contains(steps[0].cmd, "setup pair") {
t.Errorf("expected pair command, got %q", steps[0].cmd)
}
}
func TestBuildPlanSteps_MigrateRebootThenPairWhenFresh(t *testing.T) {
summary := &setup.MigrationSummary{TelnetReachable: true, SSHSuccess: false, IsPaired: false}
inspect := &setup.InspectReport{Info: &setup.DeviceInfoXML{DeviceID: "AABBCCDDEEFF"}}
steps := buildPlanSteps("192.0.2.42", "http://aftertouch.local:8000", "", true, false, inspect, summary)
// migrate → reboot → pair. The reboot step exists because envswitch's
// parallel-persistence layer only fully wins on the next boot, and we
// want the new URLs locked in before pairing posts to the speaker.
if len(steps) != 3 {
t.Fatalf("expected migrate+reboot+pair, got %d steps:\n%v", len(steps), steps)
}
if !strings.Contains(steps[0].cmd, "setup migrate") || !strings.Contains(steps[0].cmd, "method=telnet") {
t.Errorf("step 1 should be telnet migrate, got %q", steps[0].cmd)
}
if !strings.Contains(steps[1].cmd, "setup reboot") {
t.Errorf("step 2 should be reboot, got %q", steps[1].cmd)
}
if !strings.Contains(steps[2].cmd, "setup pair") {
t.Errorf("step 3 should be pair, got %q", steps[2].cmd)
}
}
func TestBuildPlanSteps_DNSMethodPrependsCAInstall(t *testing.T) {
// Telnet down, SSH up, CA not yet trusted → plan must install-ca
// before applying the resolv migration.
summary := &setup.MigrationSummary{
TelnetReachable: false,
SSHSuccess: true,
CACertTrusted: false,
IsPaired: false,
}
inspect := &setup.InspectReport{Info: &setup.DeviceInfoXML{DeviceID: "X"}}
steps := buildPlanSteps("192.0.2.42", "http://aftertouch.local:8000", "", false, false, inspect, summary)
if len(steps) < 2 {
t.Fatalf("expected at least install-ca + migrate, got %d steps:\n%v", len(steps), steps)
}
if !strings.Contains(steps[0].cmd, "install-ca") {
t.Errorf("install-ca should come first when DNS method is chosen and CA is not trusted, got %q", steps[0].cmd)
}
if !strings.Contains(steps[1].cmd, "method=resolv") {
t.Errorf("step 2 should be resolv migrate, got %q", steps[1].cmd)
}
}
func TestBuildPlanSteps_ResetModeIncludesManualNetworkSwitches(t *testing.T) {
inspect := &setup.InspectReport{
Info: &setup.DeviceInfoXML{DeviceID: "506583DE4803"},
Network: &models.NetworkInformation{
Interfaces: models.NetworkInterfaces{
Interfaces: []models.NetworkInterface{
{Type: "WIFI_INTERFACE", SSID: "MyHomeNetwork"},
},
},
},
}
summary := &setup.MigrationSummary{IsMigrated: true, IsPaired: true} // doesn't matter in reset mode
steps := buildPlanSteps("192.0.2.42", "http://aftertouch.local:8000", "", true, true, inspect, summary)
// Expected sequence in --reset mode:
// factory-reset, manual AP switch, wait-ap, wifi-push, manual home switch,
// wait-online, migrate, pair (8 steps).
if len(steps) < 7 {
t.Fatalf("expected at least 7 steps in --reset mode, got %d:\n%v", len(steps), steps)
}
manualCount := 0
for _, s := range steps {
if s.manual {
manualCount++
}
}
if manualCount < 2 {
t.Errorf("expected at least 2 manual steps for the Wi-Fi switches, got %d", manualCount)
}
if !strings.Contains(steps[0].cmd, "factory-reset") {
t.Errorf("step 1 must be factory-reset, got %q", steps[0].cmd)
}
// wifi-push step should default to the inspected SSID
foundWiFi := false
for _, s := range steps {
if strings.Contains(s.cmd, "wifi-push") && strings.Contains(s.cmd, "MyHomeNetwork") {
foundWiFi = true
break
}
}
if !foundWiFi {
t.Errorf("expected wifi-push step to default to inspected SSID 'MyHomeNetwork'")
}
// wait-online --match should use the deviceID suffix
foundMatch := false
for _, s := range steps {
if strings.Contains(s.cmd, "wait-online") && strings.Contains(s.cmd, "--match=DE4803") {
foundMatch = true
break
}
}
if !foundMatch {
t.Errorf("expected wait-online step to use --match=DE4803 from deviceID suffix")
}
}
+152
View File
@@ -0,0 +1,152 @@
// Package main — `soundtouch-cli source tunein` subcommand.
//
// Convenience shortcut for the verbose `source content --source TUNEIN
// --type … --location …` pattern. Picks the right Type + location template
// from the TuneIn guide-ID prefix, optionally fetches name + artwork from
// TuneIn's describe endpoint, then calls the same SelectContentItem path
// the generic `source content` command uses.
//
// Implements #226.
package main
import (
"fmt"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/bmx"
"github.com/urfave/cli/v2"
)
// tuneInKind captures the three guide-ID shapes the SoundTouch firmware
// distinguishes; each picks a different Bose `/v1/playback/...` location
// template and a different ContentItem Type.
type tuneInKind struct {
flag string // CLI flag name (`station`, `episode`, `program`)
prefix string // single-letter guide-ID prefix (`s`, `e`, `p`)
location string // printf template, %s = guide ID
itemType string // ContentItem.Type the speaker expects
humanName string // user-facing kind label for log lines
}
var tuneInKinds = []tuneInKind{
{flag: "station", prefix: "s", location: "/v1/playback/station/%s", itemType: "stationurl", humanName: "live station"},
{flag: "episode", prefix: "e", location: "/v1/playback/episode/%s", itemType: "stationurl", humanName: "podcast episode"},
{flag: "program", prefix: "p", location: "/v1/playback/episodes/%s", itemType: "tracklisturl", humanName: "podcast program"},
}
// resolveTuneInKind picks a kind from the CLI flags. Exactly one of
// --station / --episode / --program must be set, OR --id with a prefix we
// recognise. Returns the kind plus the bare guide ID.
func resolveTuneInKind(c *cli.Context) (*tuneInKind, string, error) {
// Explicit kind flags take precedence over --id.
var picked *tuneInKind
var id string
for i, k := range tuneInKinds {
v := c.String(k.flag)
if v == "" {
continue
}
if picked != nil {
return nil, "", fmt.Errorf("only one of --station, --episode, --program may be set")
}
picked = &tuneInKinds[i]
id = v
}
if picked != nil {
return picked, strings.TrimSpace(id), nil
}
// Fall back to --id with prefix auto-detect.
raw := strings.TrimSpace(c.String("id"))
if raw == "" {
return nil, "", fmt.Errorf("one of --station, --episode, --program, or --id is required")
}
if raw == "" {
return nil, "", fmt.Errorf("--id is empty")
}
for i, k := range tuneInKinds {
if strings.HasPrefix(raw, k.prefix) {
return &tuneInKinds[i], raw, nil
}
}
return nil, "", fmt.Errorf("--id %q has no recognised TuneIn prefix; use --station/--episode/--program explicitly", raw)
}
// playTuneIn is the action wired into `soundtouch-cli source tunein`.
func playTuneIn(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
kind, id, err := resolveTuneInKind(c)
if err != nil {
return err
}
name := c.String("name")
artwork := c.String("artwork")
// Optional metadata enrichment — only fetch if the user hasn't already
// supplied both, and they haven't asked us to skip it.
if !c.Bool("no-lookup") && (name == "" || artwork == "") {
fetchedName, fetchedLogo, lookupErr := bmx.TuneInDescribeMeta(id)
if lookupErr != nil {
// Non-fatal: the speaker can resolve the title itself; just
// note the failure so an operator sees what went wrong.
fmt.Printf(" Note: TuneIn describe lookup failed (%v); proceeding without enrichment.\n", lookupErr)
} else {
if name == "" {
name = fetchedName
}
if artwork == "" {
artwork = fetchedLogo
}
}
}
if name == "" {
// Fall back to a sensible non-empty default so the speaker's
// now-playing UI doesn't show a blank source label.
name = "TuneIn"
}
contentItem := &models.ContentItem{
Source: "TUNEIN",
Type: kind.itemType,
Location: fmt.Sprintf(kind.location, id),
ItemName: name,
ContainerArt: artwork,
IsPresetable: true,
}
PrintDeviceHeader("Playing TuneIn "+kind.humanName, clientConfig.Host, clientConfig.Port)
fmt.Printf(" ID: %s\n", id)
fmt.Printf(" Location: %s\n", contentItem.Location)
fmt.Printf(" Type: %s\n", contentItem.Type)
fmt.Printf(" Name: %s\n", contentItem.ItemName)
if contentItem.ContainerArt != "" {
fmt.Printf(" Artwork: %s\n", contentItem.ContainerArt)
}
if err := client.SelectContentItem(contentItem); err != nil {
return fmt.Errorf("failed to select TuneIn content: %w", err)
}
PrintSuccess("TuneIn content selected")
return nil
}
+157
View File
@@ -0,0 +1,157 @@
package main
import (
"flag"
"strings"
"testing"
"github.com/urfave/cli/v2"
)
// newCtx wires a *cli.Context with the kind-selection flags the resolver
// reads, plus whatever values the test wants set. Empty-string values are
// the default (flag not provided).
func newCtx(t *testing.T, kv map[string]string) *cli.Context {
t.Helper()
fs := flag.NewFlagSet("test", flag.ContinueOnError)
for _, name := range []string{"station", "episode", "program", "id"} {
fs.String(name, "", "")
}
for k, v := range kv {
if err := fs.Set(k, v); err != nil {
t.Fatalf("fs.Set(%q, %q): %v", k, v, err)
}
}
return cli.NewContext(nil, fs, nil)
}
func TestResolveTuneInKind_Station(t *testing.T) {
c := newCtx(t, map[string]string{"station": "s14991"})
k, id, err := resolveTuneInKind(c)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if k.flag != "station" || k.itemType != "stationurl" {
t.Errorf("wrong kind: %+v", k)
}
if id != "s14991" {
t.Errorf("wrong id: %q", id)
}
}
func TestResolveTuneInKind_Episode(t *testing.T) {
c := newCtx(t, map[string]string{"episode": "e789012"})
k, id, err := resolveTuneInKind(c)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if k.flag != "episode" || k.itemType != "stationurl" {
t.Errorf("wrong kind: %+v", k)
}
if id != "e789012" {
t.Errorf("wrong id: %q", id)
}
if !strings.Contains(k.location, "/v1/playback/episode/") {
t.Errorf("wrong location template: %q", k.location)
}
}
func TestResolveTuneInKind_Program(t *testing.T) {
c := newCtx(t, map[string]string{"program": "p123456"})
k, id, err := resolveTuneInKind(c)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if k.flag != "program" || k.itemType != "tracklisturl" {
t.Errorf("wrong kind: %+v", k)
}
if id != "p123456" {
t.Errorf("wrong id: %q", id)
}
if !strings.Contains(k.location, "/v1/playback/episodes/") {
t.Errorf("wrong location template: %q", k.location)
}
}
func TestResolveTuneInKind_IDPrefixAutoDetect(t *testing.T) {
cases := []struct {
id string
wantFlag string
}{
{"s14991", "station"},
{"e789012", "episode"},
{"p123456", "program"},
}
for _, tc := range cases {
t.Run(tc.id, func(t *testing.T) {
c := newCtx(t, map[string]string{"id": tc.id})
k, id, err := resolveTuneInKind(c)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if k.flag != tc.wantFlag {
t.Errorf("auto-detect picked %q; want %q", k.flag, tc.wantFlag)
}
if id != tc.id {
t.Errorf("id round-tripped wrong: got %q want %q", id, tc.id)
}
})
}
}
func TestResolveTuneInKind_NoFlags(t *testing.T) {
c := newCtx(t, nil)
_, _, err := resolveTuneInKind(c)
if err == nil {
t.Fatal("expected error when no flags are set")
}
if !strings.Contains(err.Error(), "required") {
t.Errorf("error message should mention required flag: %v", err)
}
}
func TestResolveTuneInKind_ConflictingFlags(t *testing.T) {
c := newCtx(t, map[string]string{"station": "s14991", "episode": "e789012"})
_, _, err := resolveTuneInKind(c)
if err == nil {
t.Fatal("expected error when conflicting flags are set")
}
if !strings.Contains(err.Error(), "only one of") {
t.Errorf("error message should mention exclusivity: %v", err)
}
}
func TestResolveTuneInKind_UnknownPrefix(t *testing.T) {
c := newCtx(t, map[string]string{"id": "x999"})
_, _, err := resolveTuneInKind(c)
if err == nil {
t.Fatal("expected error for unknown ID prefix")
}
if !strings.Contains(err.Error(), "no recognised TuneIn prefix") {
t.Errorf("error message should explain prefix mismatch: %v", err)
}
}
+117 -1
View File
@@ -1054,6 +1054,43 @@ func main() {
},
},
},
{
Name: "tunein",
Usage: "Play a TuneIn station / episode / program by guide ID (#226)",
Action: playTuneIn,
Before: RequireHost,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "station",
Usage: "TuneIn live-station guide ID (e.g. s14991)",
},
&cli.StringFlag{
Name: "episode",
Usage: "TuneIn single-episode guide ID (e.g. e789012)",
},
&cli.StringFlag{
Name: "program",
Usage: "TuneIn podcast/program guide ID (e.g. p123456)",
},
&cli.StringFlag{
Name: "id",
Usage: "TuneIn guide ID; kind auto-detected from s/e/p prefix",
},
&cli.StringFlag{
Name: "name",
Aliases: []string{"n"},
Usage: "Override the display name (skips name lookup)",
},
&cli.StringFlag{
Name: "artwork",
Usage: "Override the artwork URL (skips artwork lookup)",
},
&cli.BoolFlag{
Name: "no-lookup",
Usage: "Skip the TuneIn describe lookup; send the bare ContentItem",
},
},
},
{
Name: "availability",
Usage: "Show service availability",
@@ -1312,6 +1349,19 @@ func main() {
},
Before: RequireHost,
},
{
Name: "timezone",
Usage: "Set display timezone (IANA zone, e.g. Europe/Berlin)",
Action: setClockDisplayTimezone,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "tz",
Usage: "IANA timezone identifier (e.g. Europe/Berlin, America/New_York)",
Required: true,
},
},
Before: RequireHost,
},
},
},
},
@@ -1478,6 +1528,64 @@ func main() {
},
},
},
// Stereo-pair (group) commands — ST-10 only
{
Name: "group",
Aliases: []string{"g"},
Usage: "ST-10 stereo-pair management (left/right channel pairing)",
Subcommands: []*cli.Command{
{
Name: "status",
Usage: "Show the device's current stereo-pair configuration",
Action: getGroupStatus,
Before: RequireHost,
},
{
Name: "create",
Usage: "Form a stereo pair (LEFT speaker becomes master)",
Action: createGroup,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "left",
Aliases: []string{"l"},
Usage: "IP address of the LEFT speaker (will be master)",
Required: true,
},
&cli.StringFlag{
Name: "right",
Aliases: []string{"r"},
Usage: "IP address of the RIGHT speaker",
Required: true,
},
&cli.StringFlag{
Name: "name",
Aliases: []string{"n"},
Usage: "Pair name (defaults to \"<left> + <right>\")",
},
},
},
{
Name: "rename",
Usage: "Rename the existing stereo pair on the device",
Action: renameGroup,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "name",
Aliases: []string{"n"},
Usage: "New pair name",
Required: true,
},
},
Before: RequireHost,
},
{
Name: "remove",
Usage: "Dissolve the device's stereo pair",
Action: removeGroup,
Before: RequireHost,
},
},
},
// Advanced Audio commands
{
Name: "audio",
@@ -2093,7 +2201,7 @@ func main() {
&cli.StringFlag{
Name: "filter",
Aliases: []string{"f"},
Usage: "Filter events by type (comma-separated): nowPlaying,volume,connection,preset,zone,bass,sdkInfo,userActivity",
Usage: "Filter events by type (comma-separated): nowPlaying,volume,connection,preset,zone,group,bass,sdkInfo,userActivity",
},
&cli.DurationFlag{
Name: "duration",
@@ -2105,6 +2213,10 @@ func main() {
Name: "no-reconnect",
Usage: "Disable automatic reconnection on connection loss",
},
&cli.StringFlag{
Name: "debug",
Usage: "Print raw WebSocket frames to stderr — one of: all, unknown, errors",
},
&cli.BoolFlag{
Name: "verbose",
Aliases: []string{"v"},
@@ -2117,6 +2229,10 @@ func main() {
},
}
// Speaker provisioning (factory-reset, Wi-Fi, URL rewrite, pairing).
// Defined in cmd_setup.go to keep the top-level command list readable.
app.Commands = append(app.Commands, setupCommand())
// Sort commands alphabetically (including subcommands and flags recursively)
sortCommands(app.Commands)
+32 -32
View File
@@ -14,16 +14,16 @@ func TestParseHostPort(t *testing.T) {
}{
{
name: "IPv4 with port",
input: "192.168.1.10:8090",
input: "192.0.2.10:8090",
defaultPort: 8080,
wantHost: "192.168.1.10",
wantHost: "192.0.2.10",
wantPort: 8090,
},
{
name: "IPv4 without port",
input: "192.168.1.10",
input: "192.0.2.10",
defaultPort: 8080,
wantHost: "192.168.1.10",
wantHost: "192.0.2.10",
wantPort: 8080,
},
{
@@ -63,30 +63,30 @@ func TestParseHostPort(t *testing.T) {
},
{
name: "invalid port - non-numeric",
input: "192.168.1.10:abc",
input: "192.0.2.10:abc",
defaultPort: 8080,
wantHost: "192.168.1.10",
wantHost: "192.0.2.10",
wantPort: 8080,
},
{
name: "invalid port - too high",
input: "192.168.1.10:99999",
input: "192.0.2.10:99999",
defaultPort: 8080,
wantHost: "192.168.1.10",
wantHost: "192.0.2.10",
wantPort: 8080,
},
{
name: "invalid port - zero",
input: "192.168.1.10:0",
input: "192.0.2.10:0",
defaultPort: 8080,
wantHost: "192.168.1.10",
wantHost: "192.0.2.10",
wantPort: 8080,
},
{
name: "invalid port - negative",
input: "192.168.1.10:-123",
input: "192.0.2.10:-123",
defaultPort: 8080,
wantHost: "192.168.1.10",
wantHost: "192.0.2.10",
wantPort: 8080,
},
{
@@ -105,37 +105,37 @@ func TestParseHostPort(t *testing.T) {
},
{
name: "multiple colons - malformed",
input: "192.168.1.100:8090:extra",
input: "192.0.2.100:8090:extra",
defaultPort: 8080,
wantHost: "192.168.1.100:8090:extra",
wantHost: "192.0.2.100:8090:extra",
wantPort: 8080,
},
{
name: "standard SoundTouch default",
input: "192.168.1.10",
input: "192.0.2.10",
defaultPort: 8090,
wantHost: "192.168.1.10",
wantHost: "192.0.2.10",
wantPort: 8090,
},
{
name: "valid high port",
input: "192.168.1.100:65535",
input: "192.0.2.100:65535",
defaultPort: 8080,
wantHost: "192.168.1.100",
wantHost: "192.0.2.100",
wantPort: 65535,
},
{
name: "valid low port",
input: "192.168.1.100:1",
input: "192.0.2.100:1",
defaultPort: 8080,
wantHost: "192.168.1.100",
wantHost: "192.0.2.100",
wantPort: 1,
},
{
name: "real SoundTouch device example",
input: "192.168.1.10:8090",
input: "192.0.2.10:8090",
defaultPort: 8080,
wantHost: "192.168.1.10",
wantHost: "192.0.2.10",
wantPort: 8090,
},
{
@@ -166,8 +166,8 @@ func BenchmarkParseHostPort(b *testing.B) {
name string
input string
}{
{"with_port", "192.168.1.100:8090"},
{"without_port", "192.168.1.100"},
{"with_port", "192.0.2.100:8090"},
{"without_port", "192.0.2.100"},
{"hostname_with_port", "soundtouch.local:8090"},
{"ipv6_with_port", "[::1]:8090"},
}
@@ -193,26 +193,26 @@ func TestParseHostPortSoundTouchScenarios(t *testing.T) {
}{
{
name: "typical_cli_usage",
input: "192.168.1.10:8091",
input: "192.0.2.10:8091",
defaultPort: 8090,
description: "User specifies full host:port",
wantHost: "192.168.1.10",
wantHost: "192.0.2.10",
wantPort: 8091,
},
{
name: "discovery_result_host_only",
input: "192.168.1.10",
input: "192.0.2.10",
defaultPort: 8090,
description: "Discovery returns IP, CLI uses default port",
wantHost: "192.168.1.10",
wantHost: "192.0.2.10",
wantPort: 8090,
},
{
name: "custom_port_override",
input: "192.168.1.100:9000",
input: "192.0.2.100:9000",
defaultPort: 8090,
description: "User overrides default SoundTouch port",
wantHost: "192.168.1.100",
wantHost: "192.0.2.100",
wantPort: 9000,
},
{
@@ -225,10 +225,10 @@ func TestParseHostPortSoundTouchScenarios(t *testing.T) {
},
{
name: "invalid_port_fallback",
input: "192.168.1.10:invalid",
input: "192.0.2.10:invalid",
defaultPort: 8090,
description: "Malformed port should fallback to default",
wantHost: "192.168.1.10",
wantHost: "192.0.2.10",
wantPort: 8090,
},
}
+239 -86
View File
@@ -7,6 +7,7 @@ import (
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
@@ -15,6 +16,7 @@ import (
"path/filepath"
"runtime"
"runtime/debug"
"strconv"
"strings"
"time"
@@ -23,9 +25,11 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
"github.com/gesellix/bose-soundtouch/pkg/service/logbuf"
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
"github.com/gesellix/bose-soundtouch/pkg/service/stockholm"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/urfave/cli/v2"
@@ -172,9 +176,44 @@ func initMusicServices(config serviceConfig, server *handlers.Server) {
}
}
// logBufferCapacityFromEnv reads SOUNDTOUCH_LOG_BUFFER_LINES and
// returns a positive capacity. Invalid or unset values fall back
// to the default; a value of 0 or negative is treated as "disable"
// and returns 0 so the caller can skip wiring the buffer.
func logBufferCapacityFromEnv(defaultCap int) int {
raw := os.Getenv("SOUNDTOUCH_LOG_BUFFER_LINES")
if raw == "" {
return defaultCap
}
v, err := strconv.Atoi(raw)
if err != nil {
log.Printf("[Logs] Invalid SOUNDTOUCH_LOG_BUFFER_LINES=%q, using default %d", raw, defaultCap)
return defaultCap
}
if v < 0 {
return 0
}
return v
}
func main() {
updateBuildInfo()
// Mirror log output to an in-memory ring buffer so the admin
// UI can show a live trace. Stderr keeps receiving every line
// verbatim — the buffer is a second sink, not a replacement.
// Installing this before the cli.Action runs means every
// log.Printf from initialisation onwards is captured.
var logBuf *logbuf.Buffer
if bufCap := logBufferCapacityFromEnv(2000); bufCap > 0 {
logBuf = logbuf.New(bufCap)
log.SetOutput(io.MultiWriter(os.Stderr, logBuf))
}
app := &cli.App{
Name: "soundtouch-service",
Usage: "Local service for Bose SoundTouch cloud emulation and management",
@@ -336,26 +375,16 @@ func main() {
Usage: "External base URL for OAuth callbacks behind reverse proxy",
EnvVars: []string{"BASE_URL"},
},
&cli.BoolFlag{
Name: "mirror-enabled",
Usage: "Enable background mirroring to Bose Cloud",
EnvVars: []string{"MIRROR_ENABLED"},
},
&cli.StringSliceFlag{
Name: "mirror-endpoints",
Usage: "Endpoints to mirror to Bose Cloud (comma-separated or multiple flags)",
EnvVars: []string{"MIRROR_ENDPOINTS"},
},
&cli.StringSliceFlag{
Name: "skip-mirror-endpoints",
Usage: "Endpoints to skip mirroring to Bose Cloud (comma-separated or multiple flags)",
EnvVars: []string{"SKIP_MIRROR_ENDPOINTS"},
},
&cli.StringSliceFlag{
Name: "internal-paths",
Usage: "Paths for internal requests (comma-separated or multiple flags)",
EnvVars: []string{"INTERNAL_PATHS"},
},
&cli.StringSliceFlag{
Name: "tls-extra-host",
Usage: "Additional DNS name or IP to include in the server TLS certificate SAN list (repeatable)",
EnvVars: []string{"TLS_EXTRA_HOST"},
},
&cli.BoolFlag{
Name: "migration-enabled",
Usage: "Enable device directory migration from serial to MAC-based structure",
@@ -368,10 +397,15 @@ func main() {
EnvVars: []string{"MIGRATION_DRY_RUN"},
},
&cli.StringFlag{
Name: "preferred-source",
Usage: "Preferred source of truth (local or upstream)",
Value: "local",
EnvVars: []string{"PREFERRED_SOURCE"},
Name: "stockholm-dir",
Usage: "Path to the extracted Stockholm frontend directory (enables Stockholm UI when set)",
EnvVars: []string{"STOCKHOLM_DIR"},
},
&cli.StringFlag{
Name: "stockholm-base-path",
Usage: "URL prefix under which the Stockholm UI is served (e.g. /stockholm). Empty serves at root.",
Value: "/stockholm",
EnvVars: []string{"STOCKHOLM_BASE_PATH"},
},
},
Action: func(c *cli.Context) error {
@@ -391,7 +425,7 @@ func main() {
hostname = "localhost"
}
config.domains = getDomains(config.serverURL, config.httpsServerURL, hostname)
config.domains = getDomains(config.serverURL, config.httpsServerURL, hostname, config.tlsExtraHosts)
cm := initCertificateManager(config.dataDir, config.hostname)
sm := setup.NewManager(config.serverURL, ds, cm)
@@ -399,11 +433,12 @@ func main() {
sm.MgmtPassword = config.mgmtPassword
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record)
sm.GetDNSRunning = server.GetDNSRunning
server.SetLogBuffer(logBuf)
server.SetHTTPServerURL(config.httpsServerURL)
server.SetExpectedHosts(config.domains)
server.SetVersionInfo(version, commit, date, repoURL)
server.SetDiscoverySettings(config.discoveryInterval, config.discoveryEnabled)
server.SetDNSSettings(persisted.DNSEnabled, strings.Join(persisted.DNSUpstream, ","), persisted.DNSBindAddr)
server.SetMirrorSettings(persisted.MirrorEnabled, persisted.MirrorEndpoints, persisted.SkipMirrorEndpoints, persisted.PreferredSource)
server.SetInternalPaths(persisted.InternalPaths)
server.SetSpotifyConfig(config.spotifyClientID, config.spotifyClientSecret, config.spotifyRedirectURI)
server.SetAmazonConfig(config.amazonClientID, config.amazonClientSecret, config.amazonRedirectURI)
@@ -468,7 +503,20 @@ func main() {
startDeviceDiscovery(server)
r := setupRouter(server)
var stockholmHandler *stockholm.Handler
if config.stockholmDir != "" {
sh, shErr := stockholm.New(config.stockholmDir, config.dataDir, config.serverURL, config.stockholmBasePath)
if shErr != nil {
log.Printf("Warning: Failed to initialise Stockholm handler: %v", shErr)
} else {
stockholmHandler = sh
log.Printf("Stockholm frontend enabled from %s", config.stockholmDir)
}
}
r := setupRouter(server, stockholmHandler)
log.Printf("Go service starting on %s", config.serverURL)
@@ -484,6 +532,8 @@ func main() {
}
startHTTPSServer(config.httpsAddr, r, tlsConfig, config.httpsServerURL)
runHTTPSPreflight(config.httpsServerURL, config.serverURL, config.dnsEnabled, server.ResolveServerURLIPForPreflight)
}()
return http.ListenAndServe(config.addr, r)
@@ -528,10 +578,8 @@ type serviceConfig struct {
dnsEnabled bool
dnsUpstream string
dnsBind string
mirrorEnabled bool
mirrorEndpoints []string
skipMirrorEndpoints []string
internalPaths []string
tlsExtraHosts []string
discoveryEnabled bool
discoveryInterval time.Duration
domains []string
@@ -549,7 +597,8 @@ type serviceConfig struct {
mgmtPassword string
migrationEnabled bool
migrationDryRun bool
preferredSource string
stockholmDir string
stockholmBasePath string
}
func loadConfig(c *cli.Context) serviceConfig {
@@ -587,7 +636,8 @@ func loadConfig(c *cli.Context) serviceConfig {
httpsServerURL = "https://" + hostname + ":" + httpsPort
}
domains := getDomains(serverURL, httpsServerURL, hostname)
tlsExtraHosts := c.StringSlice("tls-extra-host")
domains := getDomains(serverURL, httpsServerURL, hostname, tlsExtraHosts)
redact := c.Bool("redact-logs")
logBody := c.Bool("log-bodies")
@@ -619,13 +669,11 @@ func loadConfig(c *cli.Context) serviceConfig {
amazonProfileURL := c.String("amazon-profile-url")
mgmtUsername := c.String("mgmt-username")
mgmtPassword := c.String("mgmt-password")
mirrorEnabled := c.Bool("mirror-enabled")
mirrorEndpoints := c.StringSlice("mirror-endpoints")
skipMirrorEndpoints := c.StringSlice("skip-mirror-endpoints")
internalPaths := c.StringSlice("internal-paths")
migrationEnabled := c.Bool("migration-enabled")
migrationDryRun := c.Bool("migration-dry-run")
preferredSource := c.String("preferred-source")
stockholmDir := c.String("stockholm-dir")
stockholmBasePath := c.String("stockholm-base-path")
return serviceConfig{
port: port,
@@ -642,10 +690,8 @@ func loadConfig(c *cli.Context) serviceConfig {
dnsEnabled: dnsEnabled,
dnsUpstream: dnsUpstream,
dnsBind: dnsBind,
mirrorEnabled: mirrorEnabled,
mirrorEndpoints: mirrorEndpoints,
skipMirrorEndpoints: skipMirrorEndpoints,
internalPaths: internalPaths,
tlsExtraHosts: tlsExtraHosts,
discoveryEnabled: discoveryEnabled,
discoveryInterval: discoveryInterval,
domains: domains,
@@ -663,11 +709,12 @@ func loadConfig(c *cli.Context) serviceConfig {
mgmtPassword: mgmtPassword,
migrationEnabled: migrationEnabled,
migrationDryRun: migrationDryRun,
preferredSource: preferredSource,
stockholmDir: stockholmDir,
stockholmBasePath: stockholmBasePath,
}
}
func getDomains(serverURL, httpsServerURL, hostname string) []string {
func getDomains(serverURL, httpsServerURL, hostname string, extraHosts []string) []string {
domainsMap := map[string]bool{
// RFC-compliant wildcards for API patterns
"*.api.bose.io": true,
@@ -700,6 +747,15 @@ func getDomains(serverURL, httpsServerURL, hostname string) []string {
domainsMap[strings.ToLower(u.Hostname())] = true
}
// Explicit overrides / additions for multi-homed hosts, reverse proxies,
// or browsing the admin UI via a LAN IP that isn't part of serverURL.
for _, h := range extraHosts {
h = strings.ToLower(strings.TrimSpace(h))
if h != "" {
domainsMap[h] = true
}
}
domains := make([]string, 0, len(domainsMap))
for d := range domainsMap {
domains = append(domains, d)
@@ -749,10 +805,6 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
config.dnsBind = persisted.DNSBindAddr
}
config.mirrorEnabled = persisted.MirrorEnabled
config.mirrorEndpoints = persisted.MirrorEndpoints
config.skipMirrorEndpoints = persisted.SkipMirrorEndpoints
config.preferredSource = persisted.PreferredSource
config.internalPaths = persisted.InternalPaths
// CLI/env args take precedence; only apply persisted credentials when not set via CLI.
@@ -791,21 +843,17 @@ func applyPersistedMusicServiceCredentials(config *serviceConfig, persisted data
func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datastore.Settings {
settings := datastore.Settings{
ServerURL: config.serverURL,
HTTPServerURL: config.httpsServerURL,
RedactLogs: config.redact,
LogBodies: config.logBody,
RecordInteractions: config.record,
DiscoveryEnabled: config.discoveryEnabled,
DiscoveryInterval: config.discoveryInterval.String(),
DNSEnabled: config.dnsEnabled,
DNSUpstream: strings.Split(config.dnsUpstream, ","),
DNSBindAddr: config.dnsBind,
MirrorEnabled: config.mirrorEnabled,
MirrorEndpoints: config.mirrorEndpoints,
SkipMirrorEndpoints: config.skipMirrorEndpoints,
PreferredSource: config.preferredSource,
InternalPaths: config.internalPaths,
ServerURL: config.serverURL,
HTTPServerURL: config.httpsServerURL,
RedactLogs: config.redact,
LogBodies: config.logBody,
RecordInteractions: config.record,
DiscoveryEnabled: config.discoveryEnabled,
DiscoveryInterval: config.discoveryInterval.String(),
DNSEnabled: config.dnsEnabled,
DNSUpstream: strings.Split(config.dnsUpstream, ","),
DNSBindAddr: config.dnsBind,
InternalPaths: config.internalPaths,
Shortcuts: map[string]int{
"/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound,
"/sw.js": http.StatusNotFound,
@@ -850,22 +898,42 @@ func startDeviceDiscovery(server *handlers.Server) {
}()
}
func setupRouter(server *handlers.Server) *chi.Mux {
func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *chi.Mux {
r := chi.NewRouter()
// TrustedRealIP must run before any handler that reads r.RemoteAddr —
// SnapshotMiddleware captures the request, and several handlers
// (HandleMargePowerOn, etc.) inspect the source IP. The middleware is
// gated on Settings.TrustForwardedHeaders; when off (the safe default),
// it returns nil and we skip Use'ing it entirely.
if mw := server.TrustedRealIPMiddleware(); mw != nil {
r.Use(mw)
}
r.Use(server.SnapshotMiddleware)
r.Use(server.OriginMiddleware)
r.Use(middleware.Recoverer)
r.Use(server.PeerObserverMiddleware)
r.Use(server.ShortcutMiddleware)
r.Use(server.MirrorMiddleware)
r.Use(server.RecordMiddleware)
r.Get("/", server.HandleRoot)
r.Get("/health", server.HandleHealth)
// Passive peer-reachability probe. Registers a device IP with the
// in-process observer, nudges :8090/swUpdateCheck, and waits for
// any inbound from that IP. Used post-migration where the daemon
// caches its swUpdateUrl at boot and the active round-trip can't
// reach it without a reboot.
r.Post("/setup/peer-probe/{deviceId}", server.HandlePeerProbe)
r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
r.URL.Path = "/media/favicon-braille.svg"
server.HandleMedia()(w, r)
// The favicon lives in the embedded web/img bundle, not under
// static/media — HandleMedia would 404. HandleWeb serves from
// webFS at its native path.
r.URL.Path = "/web/img/favicon-braille.svg"
server.HandleWeb()(w, r)
})
r.Get("/media/aftertouch-ding.wav", server.HandleDing)
r.Get("/media/*", server.HandleMedia())
r.Get("/bmx-icons/*", server.HandleBmxIcons())
r.Get("/ced/*", server.HandleCedStatic())
@@ -889,11 +957,27 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Post("/v1/favorite/{stationID}", server.HandleTuneInFavorite)
r.Delete("/v1/favorite/{stationID}", server.HandleTuneInDeleteFavorite)
})
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
r.Post("/core02/svc-bmx-adapter-orion/prod/orion/token", server.HandleOrionToken)
})
// Orion (LOCAL_INTERNET_RADIO) lives at the top level — the BMX registry
// advertises baseUrl `{BMX_SERVER}/core02/svc-bmx-adapter-orion/prod/orion`
// (no `/bmx/` prefix; verified against the upstream capture in
// pkg/service/handlers/static/bmx_services_ustream.json), so speakers
// reach the token + station endpoints at exactly these paths under
// either DNS-interception or URL-flip migration.
r.Post("/core02/svc-bmx-adapter-orion/prod/orion/token", server.HandleOrionToken)
r.Get("/core02/svc-bmx-adapter-orion/prod/orion/station", server.HandleOrionPlayback)
// SiriusXM lives at the top level by the same convention. bmx_services.json
// advertises baseUrl `{BMX_SERVER}/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter`
// (no /bmx/ prefix), so speakers reach this exact path under either
// migration mode. The bare path returns the service descriptor (matches
// soundcork main.py:805); sub-paths advertised by the descriptor's _links
// (/availability, /navigate, /token, /logout) currently log + 404 so
// future implementation work has visibility into real speaker calls.
r.HandleFunc("/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter", server.HandleSiriusXMLiveAdapter)
r.HandleFunc("/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/*", server.HandleSiriusXMLiveAdapterSubpath)
r.Get("/custom/v1/playback/{encodedURL}", server.HandleCustomPlayback)
r.Route("/streaming", func(r chi.Router) {
@@ -911,31 +995,47 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Get("/presets/all", server.HandleMargeAccountPresets)
r.Get("/provider_settings", server.HandleMargeProviderSettings)
// All `/device` routes share one chi subrouter. Two
// overlapping subrouters (`/device` + `/device/{device}`)
// caused chi's radix-tree resolver to bind a runtime
// request to the more-specific prefix even when only the
// less-specific subrouter had a matching method handler,
// producing the [UNHANDLED] → upstream-proxy fall-through
// behind issue #285's first-attempted fix. One subrouter
// keeps every device-scoped path resolvable; see
// TestPUTRenameRoutesToLocalHandler for the regression
// against the production router.
r.Route("/device", func(r chi.Router) {
r.Post("/", server.HandleMargeAddDevice)
r.Post("/{device}", server.HandleMargeAddDevice)
// PUT is the rename / update path — speakers fire
// this against PUT /streaming/account/{a}/device/{d}
// when the user renames via Bose App or
// `soundtouch-cli name set`. Issue #285.
r.Put("/{device}", server.HandleMargeUpdateDevice)
r.Delete("/{device}", server.HandleMargeRemoveDevice)
r.Get("/{device}/presets", server.HandleMargePresets)
r.Post("/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
r.Put("/{device}/preset/{presetNumber}", server.HandleMargeUpdatePreset)
r.Delete("/{device}/preset/{presetNumber}", server.HandleMargeRemovePreset)
r.Get("/{device}/recent", server.HandleMargeRecents)
r.Get("/{device}/recents", server.HandleMargeRecents)
r.Post("/{device}/recent", server.HandleMargeAddRecent)
r.Get("/{device}/group", server.HandleMargeDeviceGroup)
r.Get("/{device}/group/", server.HandleMargeDeviceGroup)
r.Get("/{device}/group/server", server.HandleMargeDeviceGroupServer)
r.Get("/{device}/group/member", server.HandleMargeDeviceGroupMember)
})
r.Route("/device/{device}", func(r chi.Router) {
r.Get("/presets", server.HandleMargePresets)
r.Post("/presets/{presetNumber}", server.HandleMargeUpdatePreset)
r.Put("/preset/{presetNumber}", server.HandleMargeUpdatePreset)
r.Delete("/preset/{presetNumber}", server.HandleMargeRemovePreset)
r.Get("/recent", server.HandleMargeRecents)
r.Get("/recents", server.HandleMargeRecents)
r.Post("/recent", server.HandleMargeAddRecent)
r.Get("/group", server.HandleMargeDeviceGroup)
r.Get("/group/", server.HandleMargeDeviceGroup)
r.Get("/group/server", server.HandleMargeDeviceGroupServer)
r.Get("/group/member", server.HandleMargeDeviceGroupMember)
})
// Speakers POST to /group/ (with trailing slash) when forwarding
// the addGroup payload to Marge during stereo-pair formation --
// see issue #252. Register both forms so chi accepts either.
r.Post("/group", server.HandleMargeAddGroup)
r.Post("/group/", server.HandleMargeAddGroup)
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
r.Delete("/device/{device}", server.HandleMargeRemoveDevice)
})
r.Get("/device/{device}/streaming_token", server.HandleMargeStreamingToken)
@@ -980,6 +1080,7 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Get("/devices/{device}/group/member", server.HandleMargeDeviceGroupMember)
r.Post("/group", server.HandleMargeAddGroup)
r.Post("/group/", server.HandleMargeAddGroup)
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
r.Get("/devices/{device}/presets", server.HandleMargePresets)
@@ -1003,7 +1104,6 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Post("/account/{account}/music/musicprovider/{sourceID}/token/cs", server.HandleBoseAccountToken)
r.Post("/device/{deviceID}/music/musicprovider/{sourceID}/token/cs1", server.HandleBoseToken)
r.Post("/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3", server.HandleBoseToken)
r.HandleFunc("/*", server.HandleBoseProxy)
})
r.Route("/v1", func(r chi.Router) {
@@ -1055,8 +1155,6 @@ func setupRouter(server *handlers.Server) *chi.Mux {
})
})
r.Get("/proxy/*", server.HandleProxyRequest)
r.Route("/setup", func(r chi.Router) {
r.Get("/devices", server.HandleListDiscoveredDevices)
r.Post("/devices", server.HandleAddManualDevice)
@@ -1070,6 +1168,8 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Post("/migrate/{deviceId}", server.HandleMigrateDevice)
r.Post("/revert/{deviceId}", server.HandleRevertMigration)
r.Post("/reboot/{deviceId}", server.HandleRebootDevice)
r.Get("/account-id-suggestions/{deviceId}", server.HandleAccountIDSuggestions)
r.Post("/pair-account/{deviceId}", server.HandlePairAccount)
r.Post("/trust-ca/{deviceId}", server.HandleTrustCACert)
r.Post("/ensure-remote-services/{deviceId}", server.HandleEnsureRemoteServices)
r.Post("/remove-remote-services/{deviceId}", server.HandleRemoveRemoteServices)
@@ -1079,14 +1179,12 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Post("/test-hosts/{deviceId}", server.HandleTestHostsRedirection)
r.Post("/test-dns/{deviceId}", server.HandleTestDNSRedirection)
r.Get("/ca.crt", server.HandleGetCACert)
r.Get("/proxy-settings", server.HandleGetProxySettings)
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
r.Get("/logging-settings", server.HandleGetLoggingSettings)
r.Post("/logging-settings", server.HandleUpdateLoggingSettings)
r.Get("/version", server.HandleGetVersionInfo)
r.Get("/interaction-stats", server.HandleGetInteractionStats)
r.Get("/interactions", server.HandleListInteractions)
r.Get("/interaction-content", server.HandleGetInteractionContent)
r.Get("/parity-mismatches", server.HandleListParityMismatches)
r.Delete("/parity-mismatches", server.HandleClearParityMismatches)
r.Get("/interactions/sessions/{session}/download", server.HandleDownloadSession)
r.Delete("/interactions/sessions/{session}", server.HandleDeleteSession)
r.Delete("/interactions/sessions", server.HandleCleanupSessions)
@@ -1096,8 +1194,24 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
r.Get("/device-summary/{deviceId}", server.HandleDeviceSummary)
r.Get("/health", server.HandleHealthChecks)
r.Post("/health/fix", server.HandleHealthFix)
r.Get("/logs", server.HandleGetLogs)
// Serve Stockholm setup wizard pages for paths not matched by the management API.
// The Stockholm frontend has a setup/ directory that must be accessible at /setup/*.
if stockholmHandler != nil {
r.Get("/*", stockholmHandler.HandleStatic)
r.Get("/", stockholmHandler.HandleStatic)
}
})
if stockholmHandler != nil {
stockholmHandler.Mount(r)
}
r.NotFound(server.HandleNotFound)
return r
@@ -1160,6 +1274,45 @@ func startHTTPSServer(httpsAddr string, r http.Handler, tlsConfig *tls.Config, h
}()
}
// runHTTPSPreflight checks whether speakers' implicit :443 target reaches
// AfterTouch. Runs after the HTTPS listener has had a moment to come up; if
// the listener is already on :443 the check is skipped. Emits a single WARN
// log line with actionable guidance when either probe fails.
//
// Only runs when dnsEnabled is true: the :443 reachability only matters when
// speakers are reaching AfterTouch via intercepted Bose hostnames (i.e. the
// DNS migration method). For direct SDK-override migration the speaker
// connects to the configured https-port directly, so :443 is irrelevant.
// Users with external DNS interception (Pi-hole, router rules) can still see
// the live result on /setup/settings even when this startup warn is silent.
func runHTTPSPreflight(httpsServerURL, serverURL string, dnsEnabled bool, resolver func(string) (string, error)) {
if !dnsEnabled {
return
}
port := handlers.PortFromHTTPSServerURL(httpsServerURL)
if port == 0 {
// Can't determine the listener port — be silent rather than misleading.
return
}
// Give the listener a head start so a successful bind beats the probe.
time.Sleep(2 * time.Second)
res := handlers.Check443Reachability(port, serverURL, resolver, handlers.ProbeDialTimeoutStartup)
guidance := handlers.FormatPreflightGuidance(port, res)
if guidance == "" {
if !res.Skipped {
log.Printf("HTTPS pre-flight: :443 reachable at localhost and %s ✓", res.LANHost)
}
return
}
log.Print(guidance)
}
// matchesDomain checks if a certificate domain (which may be a wildcard) matches a server name
func matchesDomain(certDomain, serverName string) bool {
if certDomain == serverName {
+56 -1
View File
@@ -3,6 +3,7 @@ package main
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"reflect"
"runtime"
@@ -10,6 +11,7 @@ import (
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
"github.com/go-chi/chi/v5"
)
@@ -17,7 +19,7 @@ import (
func TestPrintRoutes(t *testing.T) {
// Initialize a minimal server to get the router
server := handlers.NewServer(nil, nil, "http://localhost:8000", true, true, true)
r := setupRouter(server)
r := setupRouter(server, nil)
var routes []string
walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
@@ -102,3 +104,56 @@ func TestPrintRoutes(t *testing.T) {
t.Errorf("Router routes changed! Diff the snapshot at %s with %s", snapshotPath, actualPath)
}
}
// TestPUTRenameRoutesToLocalHandler reproduces the runtime routing
// behaviour the user saw on their deployed v0.80.0: a PUT to
// /streaming/account/{a}/device/{d} should land on
// HandleMargeUpdateDevice, not fall through to the [UNHANDLED]
// proxy. The handlers-package test (TestIssue285_*) uses a simplified
// router that doesn't have the overlapping `/device` and
// `/device/{device}` route groups, so it can't catch a chi radix-
// tree resolution that prefers the more-specific subrouter.
//
// This test exercises the actual production setupRouter so a
// regression in the route topology is caught against the same chi
// behaviour speakers will see.
func TestPUTRenameRoutesToLocalHandler(t *testing.T) {
tempDir, err := os.MkdirTemp("", "router-rename-")
if err != nil {
t.Fatalf("mkdir temp: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
server := handlers.NewServer(ds, nil, "http://localhost:8000", false, false, false)
r := setupRouter(server, nil)
ts := httptest.NewServer(r)
defer ts.Close()
body := `<?xml version="1.0" encoding="UTF-8" ?><device deviceid="AABBCCDDEEFF"><name>Living Room SoundTouch</name><macaddress>AABBCCDDEEFF</macaddress></device>`
req, err := http.NewRequest(http.MethodPut,
ts.URL+"/streaming/account/1111111/device/AABBCCDDEEFF",
strings.NewReader(body))
if err != nil {
t.Fatalf("build request: %v", err)
}
req.Header.Set("Content-Type", "application/xml")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("PUT: %v", err)
}
defer func() { _ = resp.Body.Close() }()
// 200 means our local HandleMargeUpdateDevice handled it.
// 401 / 502 / anything else means the request fell through to
// the [UNHANDLED] proxy and got the upstream response — which
// is exactly the failure mode #285 was supposed to fix.
if resp.StatusCode != http.StatusOK {
t.Fatalf("PUT status = %d, want 200 (local handler). Anything else means the request fell through to [UNHANDLED] proxy — chi is routing to a different subrouter than the PUT registration intended.", resp.StatusCode)
}
}
+34 -16
View File
@@ -1,13 +1,15 @@
CONNECT /oauth/* handlers.(*Server).HandleBoseProxy-fm
CONNECT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
CONNECT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
DELETE /accounts/{account}/devices/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
DELETE /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
DELETE /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInDeleteFavorite-fm
DELETE /oauth/* handlers.(*Server).HandleBoseProxy-fm
DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
DELETE /setup/devices/{deviceId} handlers.(*Server).HandleRemoveDevice-fm
DELETE /setup/dns-discoveries handlers.(*Server).HandleClearDNSDiscoveries-fm
DELETE /setup/interactions/sessions handlers.(*Server).HandleCleanupSessions-fm
DELETE /setup/interactions/sessions/{session} handlers.(*Server).HandleDeleteSession-fm
DELETE /setup/parity-mismatches handlers.(*Server).HandleClearParityMismatches-fm
DELETE /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
DELETE /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeRemovePreset-fm
DELETE /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
GET / handlers.(*Server).HandleRoot-fm
@@ -30,12 +32,16 @@ GET /bmx/tunein/v1/playback/episodes/{podcastID} handlers.(
GET /bmx/tunein/v1/playback/station/{stationID} handlers.(*Server).HandleTuneInPlayback-fm
GET /bmx/tunein/v1/search handlers.(*Server).HandleTuneInSearch-fm
GET /ced/* handlers.(*Server).HandleCedStatic
GET /core02/svc-bmx-adapter-orion/prod/orion/station handlers.(*Server).HandleOrionPlayback-fm
GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
GET /custom/v1/playback/{encodedURL} handlers.(*Server).HandleCustomPlayback-fm
GET /customer/account/{account} handlers.(*Server).HandleMargeAccountProfile-fm
GET /docs/* handlers.(*Server).HandleDocs-fm
GET /favicon.ico setupRouter
GET /health handlers.(*Server).HandleHealth-fm
GET /media/* handlers.(*Server).HandleMedia
GET /media/aftertouch-ding.wav handlers.(*Server).HandleDing-fm
GET /mgmt/accounts/ handlers.(*Server).HandleMgmtListAccounts-fm
GET /mgmt/accounts/{accountId} handlers.(*Server).HandleMgmtAccountDetails-fm
GET /mgmt/accounts/{accountId}/speakers handlers.(*Server).HandleMgmtListSpeakers-fm
@@ -46,21 +52,22 @@ GET /mgmt/devices/{deviceId}/events handlers.(
GET /mgmt/spotify/accounts handlers.(*Server).HandleMgmtSpotifyAccounts-fm
GET /mgmt/spotify/callback handlers.(*Server).HandleMgmtSpotifyCallback-fm
GET /mgmt/spotify/token handlers.(*Server).HandleMgmtSpotifyToken-fm
GET /oauth/* handlers.(*Server).HandleBoseProxy-fm
GET /proxy/* handlers.(*Server).HandleProxyRequest-fm
GET /setup/account-id-suggestions/{deviceId} handlers.(*Server).HandleAccountIDSuggestions-fm
GET /setup/ca.crt handlers.(*Server).HandleGetCACert-fm
GET /setup/device-summary/{deviceId} handlers.(*Server).HandleDeviceSummary-fm
GET /setup/devices handlers.(*Server).HandleListDiscoveredDevices-fm
GET /setup/devices/{deviceId}/events handlers.(*Server).HandleGetDeviceEvents-fm
GET /setup/discovery-status handlers.(*Server).HandleGetDiscoveryStatus-fm
GET /setup/dns-discoveries handlers.(*Server).HandleGetDNSDiscoveries-fm
GET /setup/dns-discoveries/download handlers.(*Server).HandleDownloadDNSDiscoveries-fm
GET /setup/health handlers.(*Server).HandleHealthChecks-fm
GET /setup/info/{deviceId} handlers.(*Server).HandleGetDeviceInfo-fm
GET /setup/interaction-content handlers.(*Server).HandleGetInteractionContent-fm
GET /setup/interaction-stats handlers.(*Server).HandleGetInteractionStats-fm
GET /setup/interactions handlers.(*Server).HandleListInteractions-fm
GET /setup/interactions/sessions/{session}/download handlers.(*Server).HandleDownloadSession-fm
GET /setup/parity-mismatches handlers.(*Server).HandleListParityMismatches-fm
GET /setup/proxy-settings handlers.(*Server).HandleGetProxySettings-fm
GET /setup/logging-settings handlers.(*Server).HandleGetLoggingSettings-fm
GET /setup/logs handlers.(*Server).HandleGetLogs-fm
GET /setup/settings handlers.(*Server).HandleGetSettings-fm
GET /setup/summary/{deviceId} handlers.(*Server).HandleGetMigrationSummary-fm
GET /setup/version handlers.(*Server).HandleGetVersionInfo-fm
@@ -86,20 +93,25 @@ GET /streaming/sourceproviders handlers.(
GET /updates/soundtouch handlers.(*Server).HandleMargeSoftwareUpdate-fm
GET /v1/blacklist/{deviceId} setupRouter
GET /web/* setupRouter.(*Server).HandleWeb
HEAD /oauth/* handlers.(*Server).HandleBoseProxy-fm
OPTIONS /oauth/* handlers.(*Server).HandleBoseProxy-fm
PATCH /oauth/* handlers.(*Server).HandleBoseProxy-fm
HEAD /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
HEAD /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
PATCH /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
PATCH /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
POST /accounts/{account}/devices handlers.(*Server).HandleMargeAddDevice-fm
POST /accounts/{account}/devices/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
POST /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeAddRecent-fm
POST /accounts/{account}/group handlers.(*Server).HandleMargeAddGroup-fm
POST /accounts/{account}/group/ handlers.(*Server).HandleMargeAddGroup-fm
POST /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeModifyGroup-fm
POST /alexa/certificate handlers.(*Server).HandleAlexaCertificate-fm
POST /bmx/core02/svc-bmx-adapter-orion/prod/orion/token handlers.(*Server).HandleOrionToken-fm
POST /bmx/orion/v1/playback/station/{data} handlers.(*Server).HandleOrionPlayback-fm
POST /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInFavorite-fm
POST /bmx/tunein/v1/report handlers.(*Server).HandleTuneInReport-fm
POST /bmx/tunein/v1/token handlers.(*Server).HandleTuneInToken-fm
POST /core02/svc-bmx-adapter-orion/prod/orion/token handlers.(*Server).HandleOrionToken-fm
POST /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
POST /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
POST /customer/account/{account} handlers.(*Server).HandleMargeUpdateAccountProfile-fm
POST /customer/account/{account}/password handlers.(*Server).HandleMargeChangePassword-fm
POST /mgmt/accounts/{accountId}/language handlers.(*Server).HandleMgmtUpdateAccountLanguage-fm
@@ -111,7 +123,6 @@ POST /mgmt/spotify/confirm handlers.(
POST /mgmt/spotify/entity handlers.(*Server).HandleMgmtSpotifyEntity-fm
POST /mgmt/spotify/init handlers.(*Server).HandleMgmtSpotifyInit-fm
POST /mgmt/spotify/prime handlers.(*Server).HandleMgmtPrimeDevice-fm
POST /oauth/* handlers.(*Server).HandleBoseProxy-fm
POST /oauth/account/{account}/music/musicprovider/{sourceID}/token/cs handlers.(*Server).HandleBoseAccountToken-fm
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token handlers.(*Server).HandleBoseLegacyToken-fm
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs1 handlers.(*Server).HandleBoseToken-fm
@@ -120,8 +131,11 @@ POST /setup/backup/{deviceId} handlers.(
POST /setup/devices handlers.(*Server).HandleAddManualDevice-fm
POST /setup/discover handlers.(*Server).HandleTriggerDiscovery-fm
POST /setup/ensure-remote-services/{deviceId} handlers.(*Server).HandleEnsureRemoteServices-fm
POST /setup/health/fix handlers.(*Server).HandleHealthFix-fm
POST /setup/logging-settings handlers.(*Server).HandleUpdateLoggingSettings-fm
POST /setup/migrate/{deviceId} handlers.(*Server).HandleMigrateDevice-fm
POST /setup/proxy-settings handlers.(*Server).HandleUpdateProxySettings-fm
POST /setup/pair-account/{deviceId} handlers.(*Server).HandlePairAccount-fm
POST /setup/peer-probe/{deviceId} handlers.(*Server).HandlePeerProbe-fm
POST /setup/reboot/{deviceId} handlers.(*Server).HandleRebootDevice-fm
POST /setup/remove-remote-services/{deviceId} handlers.(*Server).HandleRemoveRemoteServices-fm
POST /setup/revert/{deviceId} handlers.(*Server).HandleRevertMigration-fm
@@ -138,6 +152,7 @@ POST /streaming/account/{account}/device/{device} handlers.(
POST /streaming/account/{account}/device/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
POST /streaming/account/{account}/device/{device}/recent handlers.(*Server).HandleMargeAddRecent-fm
POST /streaming/account/{account}/group handlers.(*Server).HandleMargeAddGroup-fm
POST /streaming/account/{account}/group/ handlers.(*Server).HandleMargeAddGroup-fm
POST /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeModifyGroup-fm
POST /streaming/account/{account}/source handlers.(*Server).HandleMargeAddSource-fm
POST /streaming/device_setting/account/{account}/device/{device}/device_settings handlers.(*Server).HandleMargeUpdateDeviceSettings-fm
@@ -149,6 +164,9 @@ POST /streaming/support/customersupport handlers.(
POST /streaming/support/power_on handlers.(*Server).HandleMargePowerOn-fm
POST /v1/scmudc/{deviceId} handlers.(*Server).HandleAppEvents-fm
POST /v1/stapp/{deviceId} handlers.(*Server).HandleAppEvents-fm
PUT /oauth/* handlers.(*Server).HandleBoseProxy-fm
PUT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
PUT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
PUT /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeUpdateDevice-fm
PUT /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
TRACE /oauth/* handlers.(*Server).HandleBoseProxy-fm
TRACE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
TRACE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
+1 -1
View File
@@ -88,7 +88,7 @@ go build -o soundtouch-web
./soundtouch-web -port 8888
# Connect to specific device
./soundtouch-web -host 192.168.1.100
./soundtouch-web -host 192.0.2.100
```
### Command Line Options
+143 -133
View File
@@ -3,26 +3,52 @@ package main
import (
"context"
"embed"
"io/fs"
"fmt"
"log"
"net"
"net/http"
"os"
"runtime/debug"
"time"
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/handlers"
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/config"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
"github.com/go-chi/chi/v5"
"github.com/urfave/cli/v2"
)
//go:embed static
var staticFS embed.FS
var (
version = "dev"
commit = "unknown"
date = "unknown"
repoURL = "https://github.com/gesellix/bose-soundtouch"
)
func updateBuildInfo() {
if info, ok := debug.ReadBuildInfo(); ok {
if info.Main.Path != "" {
repoURL = "https://" + info.Main.Path
}
if info.Main.Version != "" && info.Main.Version != "(devel)" {
version = info.Main.Version
}
for _, setting := range info.Settings {
switch setting.Key {
case "vcs.revision":
commit = setting.Value
case "vcs.time":
if t, err := time.Parse(time.RFC3339, setting.Value); err == nil {
date = t.Format("2006-01-02 15:04:05")
}
}
}
}
}
func main() {
updateBuildInfo()
app := &cli.App{
Name: "soundtouch-web",
Usage: "Web UI for controlling Bose SoundTouch devices",
@@ -36,13 +62,40 @@ func main() {
},
&cli.StringFlag{
Name: "bind",
Usage: "Network interface to bind to",
Usage: "Address for the HTTP listener: host, IP, or local interface name (e.g. eth0). Leave empty to listen on all interfaces",
EnvVars: []string{"BIND_ADDR"},
},
&cli.StringFlag{
Name: "interface",
Usage: "Network interface name (e.g. eth0) for mDNS and UPnP device discovery. Defaults to the --bind interface name when one was given; leave empty otherwise to auto-pick",
EnvVars: []string{"DISCOVERY_INTERFACE"},
},
&cli.StringSliceFlag{
Name: "devices",
Usage: "SoundTouch device IP address(es) to add manually (can be specified multiple times)",
EnvVars: []string{"SOUNDTOUCH_DEVICES"},
},
},
Action: func(c *cli.Context) error {
port := c.String("port")
bindAddr := c.String("bind")
rawBind := c.String("bind")
bindAddr, err := resolveBindAddr(rawBind)
if err != nil {
log.Fatal(err)
}
if rawBind != "" && bindAddr != rawBind {
log.Printf("Resolved --bind %q to %s", rawBind, bindAddr)
}
rawIface := c.String("interface")
manualHosts := c.StringSlice("devices")
ifaceName := defaultDiscoveryInterface(rawIface, rawBind, bindAddr)
if rawIface == "" && ifaceName != "" {
log.Printf("Defaulting --interface to %q from --bind", ifaceName)
}
addr := ":" + port
if bindAddr != "" {
@@ -50,37 +103,35 @@ func main() {
}
// Create web app without templates (SPA mode)
webApp := handlers.NewWebApp()
webApp := soundtouchweb.NewWebApp()
webApp.Version = version
webApp.Commit = commit
webApp.Date = date
webApp.RepoURL = repoURL
// Initialize discovery service
cfg, err := config.LoadFromEnv()
if err != nil {
log.Printf("Failed to load config: %v, using defaults", err)
cfg = config.DefaultConfig()
}
cfg.DiscoveryTimeout = 10 * time.Second
cfg.CacheEnabled = true
discoveryService := discovery.NewUnifiedDiscoveryService(cfg)
discoveryService := soundtouchweb.NewDiscoveryService(ifaceName)
// Discover devices on startup
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
webApp.BroadcastDiscoveryStatus("starting", len(webApp.Devices))
webApp.BroadcastDiscoveryStatus("starting", webApp.DeviceCount())
discoverDevices(ctx, webApp, discoveryService)
for _, host := range manualHosts {
webApp.AddDeviceByHost(host, 8090, "manual")
}
webApp.BroadcastDiscoveryStatus("completed", len(webApp.Devices))
webApp.DiscoverDevices(ctx, discoveryService)
webApp.BroadcastDiscoveryStatus("completed", webApp.DeviceCount())
webApp.BroadcastDeviceList()
}()
r := setupRoutes(webApp, discoveryService)
r := chi.NewRouter()
webApp.Mount(r, discoveryService)
log.Printf("SoundTouch Web UI starting on http://%s", addr)
log.Printf("AfterTouch Web UI starting on http://%s", addr)
return http.ListenAndServe(addr, r)
},
@@ -91,125 +142,84 @@ func main() {
}
}
func setupRoutes(app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) *chi.Mux {
r := chi.NewRouter()
// Static assets (embedded in binary)
subFS, _ := fs.Sub(staticFS, "static")
r.Get("/static/*", http.StripPrefix("/static", http.FileServer(http.FS(subFS))).ServeHTTP)
// Serve index.html for SPA routes
serveIndex := func(w http.ResponseWriter, _ *http.Request) {
data, _ := staticFS.ReadFile("static/index.html")
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write(data)
// defaultDiscoveryInterface picks the interface name to use for mDNS/UPnP
// discovery. An explicit --interface always wins; otherwise, when --bind was
// given an interface name (i.e. resolveBindAddr substituted an IP for it),
// that name is reused so the common single-interface case "just works".
// Returns the empty string when there is nothing to propagate, leaving the
// discovery service to auto-pick.
func defaultDiscoveryInterface(rawInterface, rawBind, resolvedBind string) string {
if rawInterface != "" {
return rawInterface
}
// WebSocket endpoint
r.Get("/ws", app.HandleWebSocket)
if rawBind != "" && rawBind != resolvedBind {
return rawBind
}
// API endpoints
r.Get("/api/devices", app.HandleAPIDevices)
r.Get("/api/device/{id}", app.HandleAPIDevice)
r.Post("/api/discover", func(w http.ResponseWriter, r *http.Request) {
app.HandleAPIDiscover(w, r)
// Trigger discovery
//nolint:contextcheck // Context is created within goroutine
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Broadcast discovery start
app.BroadcastDiscoveryStatus("starting", len(app.Devices))
discoverDevices(ctx, app, discoveryService)
// Broadcast discovery completion and updated device list
app.BroadcastDiscoveryStatus("completed", len(app.Devices))
app.BroadcastDeviceList()
}()
})
// Device control endpoints (GET for most actions, POST for volume/bass)
r.Get("/api/control/{id}/{action}", app.HandleAPIControl)
r.Post("/api/control/{id}/{action}", app.HandleAPIControl)
// TuneIn browse, search, and playback
r.Get("/api/tunein/search", app.HandleTuneInSearch)
r.Get("/api/tunein/navigate", app.HandleTuneInNavigate)
r.Get("/api/tunein/navigate/*", app.HandleTuneInNavigate)
r.Post("/api/tunein/play/{id}", app.HandlePlayTuneIn)
// Enhanced device control endpoints
r.Post("/api/device-key/{id}/{key}", app.HandleDeviceKey)
r.Post("/api/device-volume/{id}/{volume}", app.HandleDirectVolumeControl)
r.Post("/api/device-power/{id}", app.HandleDevicePower)
r.Get("/api/device-power-status/{id}", app.HandleDevicePowerStatus)
r.Get("/api/device-ws/{id}", app.HandleDeviceWebSocket)
// SPA routes - serve index.html for client-side routing
r.Get("/", serveIndex)
r.Get("/devices", serveIndex)
r.Get("/device/*", serveIndex)
return r
return ""
}
func discoverDevices(ctx context.Context, app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) {
log.Println("Starting device discovery...")
// resolveBindAddr returns the address to bind the HTTP listener to.
//
// If bindAddr names a local network interface, the interface's single IPv4
// address is returned. When no IPv4 is present, the function falls back to the
// interface's single non-link-local IPv6 address (wrapped in brackets so it
// composes correctly with ":port"). Ambiguous interfaces (multiple addresses
// in the chosen family) or interfaces with no usable address produce an error,
// so misconfiguration surfaces immediately instead of becoming an obscure DNS
// lookup failure at listen time.
//
// If bindAddr is not an interface name — including the empty string, a host
// name, or a literal IP — it is returned unchanged.
func resolveBindAddr(bindAddr string) (string, error) {
// A lookup failure here just means bindAddr isn't an interface name
// (it's a host, IP, or empty); fall through to pass-through.
iface, _ := net.InterfaceByName(bindAddr)
if iface == nil {
return bindAddr, nil
}
devices, err := discoveryService.DiscoverDevices(ctx)
addrs, err := iface.Addrs()
if err != nil {
log.Printf("Discovery failed: %v", err)
app.BroadcastDiscoveryStatus("failed", len(app.Devices))
return
return "", fmt.Errorf("--bind %q: failed to list addresses for interface: %w", bindAddr, err)
}
log.Printf("Found %d devices", len(devices))
var ipv4, ipv6 []net.IP
for _, device := range devices {
deviceID := device.Host // Use host as unique ID for now
for _, addr := range addrs {
var ip net.IP
// Skip if we already have this device
if _, exists := app.Devices[deviceID]; exists {
app.Devices[deviceID].LastSeen = time.Now()
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip == nil {
continue
}
// Create new device connection
clientConfig := &client.Config{
Host: device.Host,
Port: device.Port,
Timeout: 10 * time.Second,
if v4 := ip.To4(); v4 != nil {
ipv4 = append(ipv4, v4)
} else if !ip.IsLinkLocalUnicast() {
// Skip IPv6 link-local (fe80::); it requires a zone ID and
// can't be used as a plain "[ip]:port" listen address.
ipv6 = append(ipv6, ip)
}
}
soundTouchClient := client.NewClient(clientConfig)
// Get device info
deviceInfo, err := soundTouchClient.GetDeviceInfo()
if err != nil {
log.Printf("Failed to get device info for %s: %v", device.Host, err)
continue
}
// Create device connection
conn := &webtypes.DeviceConnection{
Client: soundTouchClient,
DeviceInfo: deviceInfo,
LastSeen: time.Now(),
Status: webtypes.DeviceStatus{
IsConnected: false,
LastActivity: time.Now(),
},
}
// Initial status fetch asynchronously to avoid blocking discovery
go app.UpdateDeviceStatus(deviceID, conn)
app.Devices[deviceID] = conn
log.Printf("Added device: %s (%s) at %s", deviceInfo.Name, deviceInfo.Type, device.Host)
switch {
case len(ipv4) == 1:
return ipv4[0].String(), nil
case len(ipv4) > 1:
return "", fmt.Errorf("--bind %q: interface has multiple IPv4 addresses (%v); specify one directly", bindAddr, ipv4)
case len(ipv6) == 1:
return "[" + ipv6[0].String() + "]", nil
case len(ipv6) > 1:
return "", fmt.Errorf("--bind %q: interface has multiple IPv6 addresses (%v); specify one directly", bindAddr, ipv6)
default:
return "", fmt.Errorf("--bind %q: interface has no usable IPv4 or IPv6 address", bindAddr)
}
}
@@ -0,0 +1,162 @@
package main
import (
"net"
"strings"
"testing"
)
func TestResolveBindAddr_PassThrough(t *testing.T) {
// Inputs that don't match any local interface name must be returned
// unchanged: empty string, hostnames, IPv4/IPv6 literals, and bogus
// strings the user might have typed.
tests := []string{
"",
"localhost",
"127.0.0.1",
"192.0.2.5",
"::1",
"definitely-not-an-iface-xyz",
}
for _, input := range tests {
t.Run(quoted(input), func(t *testing.T) {
got, err := resolveBindAddr(input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != input {
t.Errorf("got %q, want %q (input should pass through unchanged)", got, input)
}
})
}
}
func TestResolveBindAddr_LoopbackInterface(t *testing.T) {
loopback, expected, ok := findLoopbackWithSingleIPv4(t)
if !ok {
t.Skipf("no loopback interface with exactly one IPv4 address found")
}
got, err := resolveBindAddr(loopback)
if err != nil {
t.Fatalf("unexpected error resolving %q: %v", loopback, err)
}
if got != expected {
t.Errorf("got %q, want %q for loopback interface %q", got, expected, loopback)
}
}
// findLoopbackWithSingleIPv4 returns the name of a loopback interface and the
// single IPv4 address attached to it. If the host has multiple loopback
// interfaces or the loopback has zero or several IPv4 addresses, it returns
// ok=false so the caller can skip the test rather than fail on an environment
// quirk.
func findLoopbackWithSingleIPv4(t *testing.T) (name, addr string, ok bool) {
t.Helper()
ifaces, err := net.Interfaces()
if err != nil {
t.Fatalf("net.Interfaces: %v", err)
}
for _, iface := range ifaces {
if iface.Flags&net.FlagLoopback == 0 {
continue
}
addrs, addrErr := iface.Addrs()
if addrErr != nil {
continue
}
var ipv4s []string
for _, a := range addrs {
if ipnet, isIPNet := a.(*net.IPNet); isIPNet {
if v4 := ipnet.IP.To4(); v4 != nil {
ipv4s = append(ipv4s, v4.String())
}
}
}
if len(ipv4s) == 1 {
return iface.Name, ipv4s[0], true
}
}
return "", "", false
}
func TestDefaultDiscoveryInterface(t *testing.T) {
tests := []struct {
name string
rawInterface string
rawBind string
resolvedBind string
want string
}{
{
name: "explicit interface wins over bind-derived default",
rawInterface: "eth1",
rawBind: "eth0",
resolvedBind: "192.0.2.5",
want: "eth1",
},
{
name: "derive from --bind when --bind was an interface name",
rawInterface: "",
rawBind: "eth0",
resolvedBind: "192.0.2.5",
want: "eth0",
},
{
name: "no derivation when --bind was an IP literal",
rawInterface: "",
rawBind: "192.0.2.5",
resolvedBind: "192.0.2.5",
want: "",
},
{
name: "no derivation when --bind was a hostname (pass-through)",
rawInterface: "",
rawBind: "localhost",
resolvedBind: "localhost",
want: "",
},
{
name: "both empty stays empty (auto-pick)",
rawInterface: "",
rawBind: "",
resolvedBind: "",
want: "",
},
{
name: "explicit interface alone, --bind empty",
rawInterface: "eth1",
rawBind: "",
resolvedBind: "",
want: "eth1",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := defaultDiscoveryInterface(tc.rawInterface, tc.rawBind, tc.resolvedBind)
if got != tc.want {
t.Errorf("got %q, want %q (rawInterface=%q rawBind=%q resolvedBind=%q)",
got, tc.want, tc.rawInterface, tc.rawBind, tc.resolvedBind)
}
})
}
}
func quoted(s string) string {
if s == "" {
return "(empty)"
}
return strings.ReplaceAll(s, "/", "_")
}
+11 -16
View File
@@ -7,11 +7,10 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/handlers"
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
"github.com/go-chi/chi/v5"
)
@@ -70,7 +69,7 @@ func TestSPARouting(t *testing.T) {
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>SoundTouch Control Center</title>
<title>AfterTouch Control Center</title>
</head>
<body>
<div id="app">SPA Content</div>
@@ -100,7 +99,7 @@ func TestSPARouting(t *testing.T) {
}
func TestAPIEndpoints(t *testing.T) {
app := handlers.NewWebApp()
app := soundtouchweb.NewWebApp()
tests := []struct {
name string
@@ -171,7 +170,7 @@ func TestAPIEndpoints(t *testing.T) {
}
func TestAPIResponseFormat(t *testing.T) {
app := handlers.NewWebApp()
app := soundtouchweb.NewWebApp()
req := httptest.NewRequest("GET", "/api/devices", nil)
w := httptest.NewRecorder()
@@ -204,7 +203,7 @@ func TestAPIResponseFormat(t *testing.T) {
}
func TestControlAPIValidation(t *testing.T) {
app := handlers.NewWebApp()
app := soundtouchweb.NewWebApp()
tests := []struct {
name string
@@ -250,13 +249,9 @@ func TestControlAPIValidation(t *testing.T) {
}
// Add a mock device for testing unknown action validation
mockDevice := &webtypes.DeviceConnection{
Client: nil,
DeviceInfo: &models.DeviceInfo{Name: "Test Device"},
LastSeen: time.Now(),
Status: webtypes.DeviceStatus{IsConnected: true},
}
app.Devices["testdevice"] = mockDevice
mockDevice := webtypes.NewDeviceConnection(nil, &models.DeviceInfo{Name: "Test Device"})
mockDevice.SetStatus(&webtypes.DeviceStatus{IsConnected: true})
app.AddDevice("testdevice", mockDevice)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -301,7 +296,7 @@ func TestControlAPIValidation(t *testing.T) {
}
func TestWebSocketUpgrade(t *testing.T) {
app := handlers.NewWebApp()
app := soundtouchweb.NewWebApp()
// Test WebSocket upgrade request
req := httptest.NewRequest("GET", "/ws", nil)
@@ -321,7 +316,7 @@ func TestWebSocketUpgrade(t *testing.T) {
}
func TestJSONAPIConsistency(t *testing.T) {
app := handlers.NewWebApp()
app := soundtouchweb.NewWebApp()
endpoints := []string{
"/api/devices",
File diff suppressed because it is too large Load Diff
-200
View File
@@ -1,200 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SoundTouch Control Center</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css"
rel="stylesheet"
/>
<link href="/static/css/app.css" rel="stylesheet" />
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark">
<div class="container">
<a class="navbar-brand" href="#" onclick="showPage('devices')">
<i class="bi bi-speaker"></i>
SoundTouch Control
</a>
<div class="navbar-nav ms-auto">
<a
class="nav-link"
href="#"
onclick="showPage('devices')"
title="Home"
>
<i class="bi bi-house"></i>
</a>
<a
class="nav-link tunein-nav-link"
href="#"
onclick="showPage('tunein')"
title="TuneIn Browse"
>
<img
src="/static/img/tunein-mono.svg"
alt="TuneIn"
class="tunein-nav-icon"
/>
</a>
<a
class="nav-link"
href="#"
onclick="discoverDevices()"
title="Discover Devices"
>
<i class="bi bi-search"></i>
</a>
<button
class="theme-toggle nav-link"
onclick="toggleTheme()"
title="Toggle Dark Mode"
>
<i id="theme-icon" class="bi bi-moon"></i>
</button>
</div>
</div>
</nav>
<div class="container mt-4">
<!-- Device List Page -->
<div id="devices-page" class="page active">
<div
class="d-flex justify-content-between align-items-center mb-4"
>
<h2>Your SoundTouch Devices</h2>
<button class="btn btn-primary" onclick="discoverDevices()">
<i class="bi bi-search"></i>
Discover Devices
</button>
</div>
<div id="devices-loading" class="loading-spinner"></div>
<div id="devices-list" class="row">
<!-- Device cards will be inserted here by JavaScript -->
</div>
<div
id="no-devices"
style="display: none"
class="text-center py-5"
>
<i class="bi bi-speaker display-1 text-muted"></i>
<h4 class="mt-3">No Devices Found</h4>
<p class="text-muted">
Click "Discover Devices" to search for SoundTouch
speakers on your network.
</p>
<button class="btn btn-primary" onclick="discoverDevices()">
<i class="bi bi-search"></i>
Start Discovery
</button>
</div>
</div>
<!-- TuneIn Browse Page -->
<div id="tunein-page" class="page">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2><img src="/static/img/tunein-dark.svg" alt="TuneIn" class="tunein-heading-icon me-2" />TuneIn Browse</h2>
</div>
<div class="tunein-search-bar mb-3">
<div class="input-group">
<input
type="text"
id="tunein-search-input"
class="form-control"
placeholder="Search stations, podcasts..."
/>
<button
class="btn btn-primary"
onclick="tuneInSearch(document.getElementById('tunein-search-input').value)"
>
<i class="bi bi-search"></i>
Search
</button>
<button
class="btn btn-outline-secondary"
onclick="tuneInBrowse()"
title="Browse top level"
>
<i class="bi bi-house"></i>
</button>
</div>
</div>
<nav id="tunein-breadcrumb" class="mb-3" style="display: none">
<!-- filled by JavaScript -->
</nav>
<div id="tunein-results">
<!-- filled by JavaScript -->
</div>
</div>
<!-- Device Control Page -->
<div id="device-page" class="page">
<div class="back-button">
<button
class="btn btn-outline-secondary"
onclick="showPage('devices')"
>
<i class="bi bi-arrow-left"></i>
Back to Devices
</button>
</div>
<div id="device-content">
<!-- Device control content will be inserted here by JavaScript -->
</div>
</div>
</div>
<footer class="footer">
<div class="container text-center">
<small>
SoundTouch Web Control Interface -
<a
href="https://github.com/gesellix/Bose-SoundTouch"
target="_blank"
class="text-decoration-none"
>
Open Source Project
</a>
</small>
</div>
</footer>
<!-- Toast container for notifications -->
<div class="toast-container"></div>
<!-- Device picker for TuneIn playback -->
<div class="modal fade" id="devicePickerModal" tabindex="-1" aria-labelledby="devicePickerLabel" aria-hidden="true">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="devicePickerLabel">
<i class="bi bi-speaker me-2"></i>Play on device
</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-2" id="devicePickerList">
<!-- device buttons filled by JavaScript -->
</div>
</div>
</div>
</div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- Application JavaScript -->
<script src="/static/js/app.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
-74
View File
@@ -1,74 +0,0 @@
// Package webtypes contains type definitions for the SoundTouch web UI.
package webtypes
import (
"time"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
// SoundTouchClient defines the interface for SoundTouch client operations
type SoundTouchClient interface {
Play() error
Pause() error
Stop() error
NextTrack() error
PrevTrack() error
SetVolume(level int) error
SetBass(level int) error
SelectPreset(id int) error
SelectSource(source, account string) error
SendKey(key string) error
GetDeviceInfo() (*models.DeviceInfo, error)
GetNowPlaying() (*models.NowPlaying, error)
GetVolume() (*models.Volume, error)
GetPresets() (*models.Presets, error)
GetSources() (*models.Sources, error)
GetBass() (*models.Bass, error)
NewWebSocketClient(config interface{}) *client.WebSocketClient
}
// DeviceConnection wraps a SoundTouch client with WebSocket connection
type DeviceConnection struct {
Client *client.Client
WebSocket *client.WebSocketClient
DeviceInfo *models.DeviceInfo
LastSeen time.Time
Status DeviceStatus
}
// DeviceStatus represents the current device state
type DeviceStatus struct {
NowPlaying *models.NowPlaying `json:"nowPlaying,omitempty"`
Volume *models.Volume `json:"volume,omitempty"`
Presets *models.Presets `json:"presets,omitempty"`
Sources *models.Sources `json:"sources,omitempty"`
Bass *models.Bass `json:"bass,omitempty"`
IsConnected bool `json:"isConnected"`
LastActivity time.Time `json:"lastActivity"`
}
// APIResponse is a standard JSON response wrapper
type APIResponse struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
// VolumeRequest represents a volume control request
type VolumeRequest struct {
Level int `json:"level"`
}
// BassRequest represents a bass control request
type BassRequest struct {
Level int `json:"level"`
}
// WebSocketMessage represents messages sent over WebSocket
type WebSocketMessage struct {
Type string `json:"type"`
DeviceID string `json:"deviceId,omitempty"`
Data interface{} `json:"data,omitempty"`
}
+8 -6
View File
@@ -355,9 +355,11 @@ func handlePreset(event *models.PresetUpdatedEvent, verbose bool) {
for _, preset := range presets.Preset {
fmt.Printf(" 📻 Preset %d:", preset.ID)
if preset.ContentItem != nil {
fmt.Printf(" %s", preset.ContentItem.ItemName)
fmt.Printf(" (%s)", preset.ContentItem.Source)
// IsEmpty catches both <preset/> and INVALID_SOURCE
// placeholders; using the nil-safe helpers below means the
// inner Printf never dereferences a nil ContentItem.
if !preset.IsEmpty() {
fmt.Printf(" %s (%s)", preset.GetDisplayName(), preset.GetSource())
}
fmt.Println()
@@ -544,13 +546,13 @@ func printHelp() {
fmt.Printf(" %s -discover\n", os.Args[0])
fmt.Println()
fmt.Println(" # Connect to specific device and monitor volume events only")
fmt.Printf(" %s -host 192.168.1.10 -filter volume\n", os.Args[0])
fmt.Printf(" %s -host 192.0.2.10 -filter volume\n", os.Args[0])
fmt.Println()
fmt.Println(" # Monitor for 5 minutes with verbose output")
fmt.Printf(" %s -host 192.168.1.10 -duration 5m -verbose\n", os.Args[0])
fmt.Printf(" %s -host 192.0.2.10 -duration 5m -verbose\n", os.Args[0])
fmt.Println()
fmt.Println(" # Monitor now playing and volume events")
fmt.Printf(" %s -host 192.168.1.10 -filter nowPlaying,volume\n", os.Args[0])
fmt.Printf(" %s -host 192.0.2.10 -filter nowPlaying,volume\n", os.Args[0])
fmt.Println()
fmt.Println("Event Types:")
fmt.Println(" 🎵 nowPlaying - Track changes, playback status")
+1
View File
@@ -1,4 +1,5 @@
accounts/
backend/
certs/
default/
dns/
+2 -2
View File
@@ -27,7 +27,7 @@
// func main() {
// // Create a client for your SoundTouch device
// config := &client.Config{
// Host: "192.168.1.100",
// Host: "192.0.2.100",
// Port: 8090,
// }
// client := client.NewClient(config)
@@ -70,7 +70,7 @@
// soundtouch-cli discover devices
//
// # Control a device
// soundtouch-cli --host 192.168.1.100 play start
// soundtouch-cli --host 192.0.2.100 play start
//
// # Supported Features
//
+4 -4
View File
@@ -143,7 +143,7 @@ Browse stored/local music library.
**Example:**
```go
library, err := client.GetStoredMusicLibrary("A81B6A536A98/0")
library, err := client.GetStoredMusicLibrary("AABBCCDDEEFF/0")
```
**Validation:**
@@ -526,7 +526,7 @@ Search for stations and content.
**Response Body:**
```xml
<results deviceID="A81B6A536A98" source="PANDORA" sourceAccount="user123">
<results deviceID="AABBCCDDEEFF" source="PANDORA" sourceAccount="user123">
<songs>
<searchResult source="PANDORA" sourceAccount="user123" token="S123">
<name>Love Story</name>
@@ -707,7 +707,7 @@ Navigation and station operations generate WebSocket events:
Generated when stations are added/removed that affect presets.
```xml
<presetsUpdated deviceID="A81B6A536A98">
<presetsUpdated deviceID="AABBCCDDEEFF">
<presets>
<!-- Updated preset list -->
</presets>
@@ -719,7 +719,7 @@ Generated when stations are added/removed that affect presets.
Generated when station operations affect current playback.
```xml
<nowPlayingUpdated deviceID="A81B6A536A98">
<nowPlayingUpdated deviceID="AABBCCDDEEFF">
<nowPlaying source="PANDORA">
<ContentItem source="PANDORA" location="R456" sourceAccount="user123" isPresetable="true">
<itemName>Taylor Swift Radio</itemName>
+2 -2
View File
@@ -73,8 +73,8 @@ For web components:
When creating test data for API endpoints, prefer real device responses over hypothetical examples:
- **Available test endpoints**:
- `http://192.168.178.28:8090/now_playing` - Different response type 1
- `http://192.168.178.35:8090/now_playing` - Different response type 2
- `http://192.0.2.11:8090/now_playing` - Different response type 1
- `http://192.0.2.10:8090/now_playing` - Different response type 2
- **Usage**: Fetch real responses to create accurate test fixtures
- **Privacy**: Anonymize any personal data (account names, personal playlists, etc.)
- **Coverage**: Use multiple real devices to cover different response variations
+2 -2
View File
@@ -197,12 +197,12 @@ err := client.SelectContentItem(contentItem)
### CLI Usage
```bash
# streamUrl format
soundtouch-cli --host 192.168.1.100 source internet-radio \
soundtouch-cli --host 192.0.2.100 source internet-radio \
--location "http://contentapi.gmuth.de/station.php?name=MyStation&streamUrl=https://stream.example.com/radio" \
--name "My Station"
# Direct stream
soundtouch-cli --host 192.168.1.100 source internet-radio \
soundtouch-cli --host 192.0.2.100 source internet-radio \
--location "https://stream.example.com/radio" \
--name "Direct Stream"
```
+2 -2
View File
@@ -46,7 +46,7 @@ usb0 Link encap:Ethernet HWaddr CA:FE:BA:BE:1E:47
Sun Feb 1 20:35:24 CET 2026
Device name: "A Sound Machine"
Device name: "Kitchen SoundTouch"
Country EU, Region (not set)
Module type: scm
root@spotty:~#
@@ -83,7 +83,7 @@ usb0 Link encap:Ethernet HWaddr CA:FE:BA:BE:1E:47
Sun Feb 1 19:12:47 CET 2026
Device name: "A Sound Machine"
Device name: "Kitchen SoundTouch"
Country EU, Region (not set)
Module type: scm
root@spotty:~#
+6
View File
@@ -48,6 +48,12 @@ logread -f | grep -Ei '(marge|preset)'
```
This is particularly useful for debugging preset synchronization and service redirection issues.
For HTTPS / connection-refused debugging (e.g. `Curl 7, http 0`), drop the speaker's loopback chatter so only outbound calls remain visible:
```bash
logread -f | grep -v '127.0.0.1'
```
The speaker generates a steady stream of localhost-to-localhost HTTP traffic between its internal services; filtering it out makes the actual cloud / AfterTouch attempts (the ones that matter when diagnosing redirect or TLS issues) easy to read in real time.
---
## 2. Traffic Logging & Interception
+24 -24
View File
@@ -4,7 +4,7 @@ This document tracks the detailed evolution of features and capabilities in the
## Development Timeline
### Phase 1: Foundation (November 2024 - December 2024)
### Phase 1: Foundation (January 2026)
#### Core HTTP Client
- **HTTP Client with XML Support**: Complete client implementation for SoundTouch Web API
@@ -23,7 +23,7 @@ This document tracks the detailed evolution of features and capabilities in the
- Device connectivity testing
- Simple information retrieval commands
### Phase 2: Media Control & Discovery (December 2024)
### Phase 2: Media Control & Discovery (January 2026)
#### Media Controls
- **Key Commands**: Complete implementation of `/key` endpoint
@@ -48,7 +48,7 @@ This document tracks the detailed evolution of features and capabilities in the
- **Comprehensive Commands**: Full coverage of implemented endpoints
- **Interactive Features**: Better user experience with formatted output
### Phase 3: Advanced Audio Controls (January 2025)
### Phase 3: Advanced Audio Controls (January 2026)
#### Audio Management Trilogy
- **Bass Control**: `/bass` GET/POST endpoints
@@ -56,7 +56,7 @@ This document tracks the detailed evolution of features and capabilities in the
- Incremental bass adjustment
- Device capability detection via `/bassCapabilities`
- Safety limits and user warnings
- **Balance Control**: `/balance` GET/POST endpoints
- **Balance Control**: `/balance` GET/POST endpoints
- Stereo balance adjustment (-50 to +50)
- Left/right channel convenience methods
- Balance centering functionality
@@ -81,7 +81,7 @@ This document tracks the detailed evolution of features and capabilities in the
- Preset categorization and filtering
- **API Limitation Documentation**: Clarified that POST `/presets` is officially N/A
### Phase 4: System Features (January 2025)
### Phase 4: System Features (January 2026)
#### Clock and Display Management
- **Clock Time**: `/clockTime` GET/POST endpoints
@@ -104,7 +104,7 @@ This document tracks the detailed evolution of features and capabilities in the
- **Multiple Discovery Protocols**: Fallback discovery methods for different network environments
- **Corporate Network Support**: Discovery options for restricted networks
### Phase 5: Real-time Events (January 2025)
### Phase 5: Real-time Events (January 2026)
#### WebSocket Implementation
- **WebSocket Client**: Complete WebSocket implementation for real-time events
@@ -130,7 +130,7 @@ This document tracks the detailed evolution of features and capabilities in the
- **Formatted Output**: Human-readable event display
- **Demo Applications**: WebSocket demonstration tools
### Phase 6: Multiroom Zone Management (January 2025)
### Phase 6: Multiroom Zone Management (January 2026)
#### Zone Operations
- **Zone Information**: `/getZone` GET endpoint
@@ -166,7 +166,7 @@ This document tracks the detailed evolution of features and capabilities in the
- **Error Handling**: Specific zone-related error types
- **Zone Builder**: Fluent API for zone construction
### Phase 7: Advanced Audio Controls (January 2025)
### Phase 7: Advanced Audio Controls (January 2026)
#### Professional Audio Features
- **DSP Audio Controls**: `/audiodspcontrols` GET/POST endpoints
@@ -188,7 +188,7 @@ This document tracks the detailed evolution of features and capabilities in the
- **Conditional Feature Availability**: Features only available on compatible devices
- **Graceful Degradation**: Fallback to basic controls when advanced features unavailable
### Phase 8: Speaker Notification System (February 2025)
### Phase 8: Speaker Notification System (February 2026)
#### Notification Features
- **Text-to-Speech (TTS)**: `/speaker` POST endpoint for TTS messages
@@ -231,7 +231,7 @@ This document tracks the detailed evolution of features and capabilities in the
- **Parameter Validation**: Complete input validation and error handling
- **Usage Examples**: Extensive real-world usage examples
### Phase 9: Bug Fixes and Stability (February 2025)
### Phase 9: Bug Fixes and Stability (February 2026)
#### Critical Bug Fixes
- **PlayNotificationBeep HTTP Method Fix**: Corrected `/playNotification` endpoint to use GET instead of POST
@@ -249,17 +249,17 @@ This document tracks the detailed evolution of features and capabilities in the
### API Endpoint Coverage Evolution
| Phase | Endpoints Added | Cumulative Total | Completion % |
|-------|-----------------|------------------|--------------|
| Phase 1 | 4 | 4 | 15% |
| Phase 2 | 6 | 10 | 38% |
| Phase 3 | 8 | 18 | 69% |
| Phase 4 | 3 | 21 | 81% |
| Phase 5 | 1 | 22 | 85% |
| Phase 6 | 2 | 24 | 92% |
| Phase 7 | 3 | 27 | 96% |
| Phase 8 | 2 | 29 | 100% |
| Phase 9 | 0 | 29 | 100% (Bug fixes) |
| Phase | Endpoints Added | Cumulative Total | Completion % |
|---------|-----------------|------------------|------------------|
| Phase 1 | 4 | 4 | 15% |
| Phase 2 | 6 | 10 | 38% |
| Phase 3 | 8 | 18 | 69% |
| Phase 4 | 3 | 21 | 81% |
| Phase 5 | 1 | 22 | 85% |
| Phase 6 | 2 | 24 | 92% |
| Phase 7 | 3 | 27 | 96% |
| Phase 8 | 2 | 29 | 100% |
| Phase 9 | 0 | 29 | 100% (Bug fixes) |
### Testing Evolution
@@ -283,7 +283,7 @@ This document tracks the detailed evolution of features and capabilities in the
### CLI Tool Evolution
#### Command Categories Added by Phase
- **Phase 1**: `info`, `name`, `capabilities`
- **Phase 1**: `info`, `name`, `capabilities`
- **Phase 2**: `discover`, `play`, `volume`, `key`
- **Phase 3**: `bass`, `balance`, `source`, `presets`
- **Phase 4**: `clock`, `network`
@@ -294,7 +294,7 @@ This document tracks the detailed evolution of features and capabilities in the
- **Phase 9**: Bug fixes (speaker beep reliability)
#### CLI Feature Enhancements
- **Host:Port Parsing**: Support for `192.168.1.100:8090` format
- **Host:Port Parsing**: Support for `192.0.2.100:8090` format
- **Auto-Discovery Integration**: Seamless device discovery
- **Formatted Output**: Human-readable, structured output
- **Error Handling**: Comprehensive error messages and recovery suggestions
@@ -371,4 +371,4 @@ This document tracks the detailed evolution of features and capabilities in the
---
**This document tracks the evolution of the Bose SoundTouch API client from initial concept to production-ready library.**
**This document tracks the evolution of the Bose SoundTouch API client from initial concept to production-ready library.**
+21 -21
View File
@@ -11,23 +11,23 @@ The SoundTouch CLI now supports parsing host and port combinations in the `-host
### Basic Host:Port Format
```bash
# Specify host and port together
soundtouch-cli -host 192.168.1.100:8090 -info
soundtouch-cli -host 192.168.178.35:8090 -play
soundtouch-cli -host 192.0.2.100:8090 -info
soundtouch-cli -host 192.0.2.10:8090 -play
soundtouch-cli -host soundtouch.local:8090 -pause
```
### Traditional Separate Flags (Still Supported)
```bash
# Traditional separate host and port flags
soundtouch-cli -host 192.168.1.100 -port 8090 -info
soundtouch-cli -host 192.168.178.35 -port 8090 -play
soundtouch-cli -host 192.0.2.100 -port 8090 -info
soundtouch-cli -host 192.0.2.10 -port 8090 -play
```
### Precedence Rules
When both formats are used, the port specified in the host:port format takes precedence:
```bash
# Uses port 8090 from host:port, ignores -port 9999
soundtouch-cli -host 192.168.1.100:8090 -port 9999 -info
soundtouch-cli -host 192.0.2.100:8090 -port 9999 -info
```
## Supported Formats
@@ -35,10 +35,10 @@ soundtouch-cli -host 192.168.1.100:8090 -port 9999 -info
### IPv4 Addresses
```bash
# Standard IPv4 with port
soundtouch-cli -host 192.168.1.100:8090 -info
soundtouch-cli -host 192.0.2.100:8090 -info
# IPv4 without port (uses default 8090)
soundtouch-cli -host 192.168.1.100 -info
soundtouch-cli -host 192.0.2.100 -info
```
### Hostnames
@@ -99,8 +99,8 @@ Comprehensive test coverage in `cmd/soundtouch-cli/main_test.go`:
### Integration Tests
Tested with real SoundTouch devices:
- ✅ SoundTouch 10 (192.168.178.28:8090)
- ✅ SoundTouch 20 (192.168.178.35:8090)
- ✅ SoundTouch 10 (192.0.2.11:8090)
- ✅ SoundTouch 20 (192.0.2.10:8090)
## Benefits
@@ -123,31 +123,31 @@ Tested with real SoundTouch devices:
# Discover devices to find host:port
$ soundtouch-cli -discover
Found SoundTouch devices:
My SoundTouch Device (192.168.1.10:8090) - SoundTouch 20
My SoundTouch Device (192.0.2.10:8090) - SoundTouch 20
# Use discovered host:port directly
$ soundtouch-cli -host 192.168.1.10:8090 -play
$ soundtouch-cli -host 192.0.2.10:8090 -play
```
### Different Port Scenarios
```bash
# Standard SoundTouch port
soundtouch-cli -host 192.168.1.100:8090 -info
soundtouch-cli -host 192.0.2.100:8090 -info
# Custom port (if device configured differently)
soundtouch-cli -host 192.168.1.100:9000 -info
soundtouch-cli -host 192.0.2.100:9000 -info
# Default port fallback
soundtouch-cli -host 192.168.1.100 -info # Uses 8090
soundtouch-cli -host 192.0.2.100 -info # Uses 8090
```
### Error Scenarios
```bash
# Invalid port - uses default 8090
soundtouch-cli -host 192.168.1.100:invalid -info
soundtouch-cli -host 192.0.2.100:invalid -info
# Out of range port - uses default 8090
soundtouch-cli -host 192.168.1.100:99999 -info
soundtouch-cli -host 192.0.2.100:99999 -info
# Malformed input - treats as hostname
soundtouch-cli -host "malformed::input" -info
@@ -163,10 +163,10 @@ Options:
-port <port> SoundTouch device port (default: 8090)
Examples:
soundtouch-cli -host 192.168.1.100 -info
soundtouch-cli -host 192.168.1.100:8090 -info
soundtouch-cli -host 192.168.1.100:8090 -pause
soundtouch-cli -host 192.168.1.100:8090 -preset 1
soundtouch-cli -host 192.0.2.100 -info
soundtouch-cli -host 192.0.2.100:8090 -info
soundtouch-cli -host 192.0.2.100:8090 -pause
soundtouch-cli -host 192.0.2.100:8090 -preset 1
```
## Technical Implementation
@@ -197,7 +197,7 @@ The parsed values are used throughout the CLI:
Potential improvements for the future:
1. **URL Format Support**: Support full URLs like `http://192.168.1.100:8090`
1. **URL Format Support**: Support full URLs like `http://192.0.2.100:8090`
2. **Service Discovery**: Auto-detect port via service discovery protocols
3. **Configuration File**: Save frequently used host:port combinations
4. **Environment Variables**: Support `SOUNDTOUCH_HOST` with host:port format
+5 -5
View File
@@ -316,7 +316,7 @@ MX:3
NOTIFY * HTTP/1.1
HOST:239.255.255.250:1900
CACHE-CONTROL:max-age=1800
LOCATION:http://192.168.1.100:8090/device_description.xml
LOCATION:http://192.0.2.100:8090/device_description.xml
NT:upnp:rootdevice
NTS:ssdp:alive
USN:uuid:12345678-1234-1234-1234-123456789012::upnp:rootdevice
@@ -328,7 +328,7 @@ HTTP/1.1 200 OK
CACHE-CONTROL:max-age=1800
DATE:Wed, 18 Dec 2024 10:30:00 GMT
EXT:
LOCATION:http://192.168.1.100:8090/device_description.xml
LOCATION:http://192.0.2.100:8090/device_description.xml
SERVER:Linux/3.0 UPnP/1.0 Device/1.0
ST:upnp:rootdevice
USN:uuid:12345678-1234-1234-1234-123456789012::upnp:rootdevice
@@ -363,7 +363,7 @@ arp -a
# Scan local network segment (requires nmap)
brew install nmap
nmap -sn 192.168.1.0/24 # Adjust network range as needed
nmap -sn 192.0.2.0/24 # Adjust network range as needed
# Quick ping sweep (built-in)
for i in {1..254}; do ping -c 1 -t 1 192.168.1.$i >/dev/null 2>&1 && echo "192.168.1.$i is up"; done
@@ -439,10 +439,10 @@ sudo tcpdump -i any -n -A 'port 5353' | grep -i soundtouch
netstat -g
# Test UDP connectivity
nc -u 192.168.1.100 8090 # Replace with actual device IP
nc -u 192.0.2.100 8090 # Replace with actual device IP
# Test HTTP connectivity to discovered devices
curl -i http://192.168.1.100:8090/info # SoundTouch info endpoint
curl -i http://192.0.2.100:8090/info # SoundTouch info endpoint
```
## Protocol Comparison
+1 -1
View File
@@ -40,7 +40,7 @@ import (
func main() {
// Create client
config := &client.Config{
Host: "192.168.1.100",
Host: "192.0.2.100",
Port: 8090,
}
soundtouch := client.NewClient(config)
+1 -2
View File
@@ -37,9 +37,8 @@ This document summarizes the improvements made to the **Marge service** to impro
* **Device Identity**: Added `<serialNumber>` and `<updatedOn>` to both the top-level `<device>` and its `<attachedProduct>`, ensuring consistent device identification.
* **Field-Level Parity**: Mapped missing fields like `<contentItemType>` and `<productlabel>` to match upstream expectations.
* **Improved Source Matching**: Enhanced internal logic to correctly link presets and recents to their configured sources based on multiple identifiers (ID, Key, or Type).
* **Verified Parity Mismatch Fixes**: Comprehensive reproduction tests (`TestParityMismatchReproduction_V2` and `TestParityMismatchReproduction_V3`) now confirm parity for identified mismatches in `POST /recent` and `GET /recents`, including credentials and source-specific metadata.
* **Verified Parity Mismatch Fixes**: The reproduction test `TestParityMismatchReproduction_V2` confirms parity for identified mismatches in `POST /recent` and `GET /recents`, including credentials and source-specific metadata.
* **Unified Response Logic**: Refactored the code so that both `POST /recent` and `GET /recents` use the same formatting functions, guaranteeing consistency.
* **Robust Parity Detection**: Updated the local parity checker to be whitespace-insensitive for XML bodies, significantly reducing noise from minor indentation or newline differences.
* **Maintainable XML Generation**: Reduced cyclomatic complexity and code duplication in `marge.go` by extracting focused helper functions for mapping internal data to response-specific XML models.
---
+25 -25
View File
@@ -10,20 +10,20 @@ SoundTouch devices support 6 preset slots that can store your favorite content f
### 1. See Current Presets
```bash
soundtouch-cli --host 192.168.1.100 preset list
soundtouch-cli --host 192.0.2.100 preset list
```
### 2. Store What's Currently Playing
```bash
# Store current song/station as preset 1
soundtouch-cli --host 192.168.1.100 preset store-current --slot 1
soundtouch-cli --host 192.0.2.100 preset store-current --slot 1
```
### 3. Store Specific Content
#### Spotify Playlist
```bash
soundtouch-cli --host 192.168.1.100 preset store \
soundtouch-cli --host 192.0.2.100 preset store \
--slot 2 \
--source SPOTIFY \
--location "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M" \
@@ -32,26 +32,26 @@ soundtouch-cli --host 192.168.1.100 preset store \
#### Radio Station
```bash
soundtouch-cli --host 192.168.1.100 preset store \
soundtouch-cli --host 192.0.2.100 preset store \
--slot 3 \
--source TUNEIN \
--location "/v1/playbook/station/s33828" \
--location "/v1/playback/station/s33828" \
--name "K-LOVE Radio"
```
### 4. Use Your Presets
```bash
# Play preset 1
soundtouch-cli --host 192.168.1.100 preset select --slot 1
soundtouch-cli --host 192.0.2.100 preset select --slot 1
# Play preset 2
soundtouch-cli --host 192.168.1.100 preset select --slot 2
soundtouch-cli --host 192.0.2.100 preset select --slot 2
```
### 5. Remove Presets
```bash
# Remove preset 6
soundtouch-cli --host 192.168.1.100 preset remove --slot 6
soundtouch-cli --host 192.0.2.100 preset remove --slot 6
```
## Getting Content Locations
@@ -61,7 +61,7 @@ To store specific content, you need the `location` parameter. Here's how to get
### Method 1: From Currently Playing Content
```bash
# Play the content you want to save, then:
soundtouch-cli --host 192.168.1.100 play now
soundtouch-cli --host 192.0.2.100 play now
```
**Example output:**
@@ -103,7 +103,7 @@ Just replace `https://open.spotify.com/` with `spotify:` and `/` with `:`.
### Radio Stations
```bash
# TuneIn Radio
--source TUNEIN --location "/v1/playbook/station/s33828"
--source TUNEIN --location "/v1/playback/station/s33828"
# Internet Radio Stream
--source LOCAL_INTERNET_RADIO --location "https://stream.example.com/jazz"
@@ -135,7 +135,7 @@ import (
func main() {
// Create client
c := client.NewClient(&client.Config{
Host: "192.168.1.100",
Host: "192.0.2.100",
Port: 8090,
})
@@ -235,43 +235,43 @@ select {} // Run forever
### Family Setup
```bash
# Dad's morning playlist
soundtouch-cli --host 192.168.1.100 preset store \
soundtouch-cli --host 192.0.2.100 preset store \
--slot 1 --source SPOTIFY \
--location "spotify:playlist:morning-energy" \
--name "Dad's Morning Mix"
# Mom's cooking music
soundtouch-cli --host 192.168.1.100 preset store \
soundtouch-cli --host 192.0.2.100 preset store \
--slot 2 --source SPOTIFY \
--location "spotify:playlist:cooking-vibes" \
--name "Kitchen Tunes"
# Kids' bedtime stories
soundtouch-cli --host 192.168.1.100 preset store \
soundtouch-cli --host 192.0.2.100 preset store \
--slot 3 --source TUNEIN \
--location "/v1/playbook/station/bedtime-stories" \
--location "/v1/playback/station/bedtime-stories" \
--name "Bedtime Stories"
```
### Party Mode
```bash
# Upbeat party playlist
soundtouch-cli --host 192.168.1.100 preset store-current --slot 1
soundtouch-cli --host 192.0.2.100 preset store-current --slot 1
# Chill background music
soundtouch-cli --host 192.168.1.100 preset store-current --slot 2
soundtouch-cli --host 192.0.2.100 preset store-current --slot 2
# Dance music
soundtouch-cli --host 192.168.1.100 preset store-current --slot 3
soundtouch-cli --host 192.0.2.100 preset store-current --slot 3
```
### Smart Home Integration
```bash
# Morning routine (preset 1) - triggered by smart home at 7 AM
soundtouch-cli --host 192.168.1.100 preset select --slot 1
soundtouch-cli --host 192.0.2.100 preset select --slot 1
# Evening routine (preset 2) - triggered at sunset
soundtouch-cli --host 192.168.1.100 preset select --slot 2
soundtouch-cli --host 192.0.2.100 preset select --slot 2
```
## Troubleshooting
@@ -286,26 +286,26 @@ Not all content can be saved as presets:
### "All preset slots are occupied"
```bash
# See which presets you have
soundtouch-cli --host 192.168.1.100 preset list
soundtouch-cli --host 192.0.2.100 preset list
# Remove one you don't need
soundtouch-cli --host 192.168.1.100 preset remove --slot 6
soundtouch-cli --host 192.0.2.100 preset remove --slot 6
# Or overwrite an existing one
soundtouch-cli --host 192.168.1.100 preset store-current --slot 6
soundtouch-cli --host 192.0.2.100 preset store-current --slot 6
```
### Getting Spotify URIs
If you can't find Spotify URIs:
1. **Play the content** in Spotify on your SoundTouch
2. **Check what's playing**: `soundtouch-cli --host 192.168.1.100 play now`
2. **Check what's playing**: `soundtouch-cli --host 192.0.2.100 play now`
3. **Copy the location** from the output
### Device Connection Issues
```bash
# Test connection first
soundtouch-cli --host 192.168.1.100 info
soundtouch-cli --host 192.0.2.100 info
# If that fails, check:
# - Device IP address is correct
+1 -1
View File
@@ -364,7 +364,7 @@ func main() {
```go
func handleClientProxy(w http.ResponseWriter, r *http.Request) {
// Extract device IP from path: /api/client/192.168.1.100/now_playing
// Extract device IP from path: /api/client/192.0.2.100/now_playing
pathParts := strings.Split(r.URL.Path, "/")
if len(pathParts) < 5 {
http.Error(w, "Invalid path", http.StatusBadRequest)
+5 -5
View File
@@ -15,14 +15,14 @@ The current request recording system has fundamental issues when dealing with re
**Local Recording** (complete):
```http
### POST /v1/scmudc/A81B6A536A98
POST /v1/scmudc/A81B6A536A98
### POST /v1/scmudc/AABBCCDDEEFF
POST /v1/scmudc/AABBCCDDEEFF
Host: events.api.bosecm.com
Content-Type: text/json; charset=utf-8
Content-Length: 587
Authorization: Bearer jGwEmFWr...
{"envelope":{"monoTime":234906,"payloadProtocolVersion":"3.1","payloadType":"scmudc","protocolVersion":"1.0","time":"2026-02-25T23:03:14.976349+00:00","uniqueId":"A81B6A536A98"},"payload":{"deviceInfo":{"boseID":"3230304","deviceID":"A81B6A536A98","deviceType":"SoundTouch 10","serialNumber":"I6332527703739342000020","softwareVersion":"27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29","systemSerialNumber":"069231P63364828AE"},"events":[{"data":{"play-state":"PAUSE_STATE"},"monoTime":234904,"time":"2026-02-25T23:03:14.973466+00:00","type":"play-state-changed"}]}}
{"envelope":{"monoTime":234906,"payloadProtocolVersion":"3.1","payloadType":"scmudc","protocolVersion":"1.0","time":"2026-02-25T23:03:14.976349+00:00","uniqueId":"AABBCCDDEEFF"},"payload":{"deviceInfo":{"boseID":"1000001","deviceID":"AABBCCDDEEFF","deviceType":"SoundTouch 10","serialNumber":"I6332527703739342000020","softwareVersion":"27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29","systemSerialNumber":"069231P63364828AE"},"events":[{"data":{"play-state":"PAUSE_STATE"},"monoTime":234904,"time":"2026-02-25T23:03:14.973466+00:00","type":"play-state-changed"}]}}
{% raw %}
> {%
@@ -33,8 +33,8 @@ Authorization: Bearer jGwEmFWr...
**Mirror Recording** (missing body):
```http
### POST /v1/scmudc/A81B6A536A98
POST /v1/scmudc/A81B6A536A98
### POST /v1/scmudc/AABBCCDDEEFF
POST /v1/scmudc/AABBCCDDEEFF
Host: events.api.bosecm.com
Content-Type: text/json; charset=utf-8
Content-Length: 587
+4 -4
View File
@@ -59,8 +59,8 @@ Based on analysis of recorded data:
### Before (Raw)
```http
### POST /v1/scmudc/A81B6A536A98
POST /v1/scmudc/A81B6A536A98
### POST /v1/scmudc/AABBCCDDEEFF
POST /v1/scmudc/AABBCCDDEEFF
Host: events.api.bosecm.com
...
@@ -69,7 +69,7 @@ Host: events.api.bosecm.com
### After (Enriched)
```http
### POST /v1/scmudc/A81B6A536A98
### POST /v1/scmudc/AABBCCDDEEFF
// Origin: Internal System (device)
// Action: play-item
// Command: Billie Eilish - bad guy (instrumental version)
@@ -87,7 +87,7 @@ Host: events.api.bosecm.com
// <itemName>Billie Eilish - bad guy (instrumental version)</itemName>
// <containerArt>https://i.scdn.co/image/ab67616d0000b273...</containerArt>
// </ContentItem>
POST /v1/scmudc/A81B6A536A98
POST /v1/scmudc/AABBCCDDEEFF
...
{% raw %}
+1 -1
View File
@@ -121,7 +121,7 @@ sa.GetUnavailableServiceCount()
### Basic Usage
```go
client := client.NewClientFromHost("192.168.1.100")
client := client.NewClientFromHost("192.0.2.100")
serviceAvailability, err := client.GetServiceAvailability()
if err != nil {
+4 -4
View File
@@ -1,7 +1,7 @@
# 🎉 Introducing SoundTouch Service: Local Cloud Service Emulation
**Date**: January 2024
**Version**: v2.0.0+
**Date**: February 2026
**Version**: v2.0.0+
**Status**: Production Ready
## What's New?
@@ -68,7 +68,7 @@ Our implementation is heavily inspired by and based on [SoundCork](https://githu
**Key contributions from SoundCork:**
- Service emulation architecture
- BMX/Marge endpoint discovery
- BMX/Marge endpoint discovery
- Device migration strategies
- Python implementation reference
@@ -137,7 +137,7 @@ LOG_PROXY_BODY=true soundtouch-service
## 🚀 Future Plans
- **Docker Images**: Official container images for easy deployment
- **Cluster Support**: Multi-instance deployment for high availability
- **Cluster Support**: Multi-instance deployment for high availability
- **Advanced Analytics**: Machine learning-powered usage insights
- **Extended Protocol Support**: Additional Bose protocol implementations
- **Mobile App**: Companion mobile application for device management
+6
View File
@@ -54,6 +54,7 @@
* [Request Recording](REQUEST_RECORDING_CONCEPT.md)
* [Spotify Priming Strategy](concepts/spotify-priming-strategy.md)
* [Spotify OAuth](concepts/spotify-oauth.md)
* [soundtouch-web Roadmap](soundtouch-web-roadmap.md)
## Analysis & Research
* [API Coverage Analysis](analysis/API-COVERAGE.md)
@@ -61,6 +62,10 @@
* [Upstream URLs](analysis/UPSTREAM-URLS.md)
* [Anonymization Summary](analysis/ANONYMIZATION-SUMMARY.md)
* [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
* [Telnet (Port 17000) Migration Method](analysis/TELNET-MIGRATION-METHOD.md)
* [Telnet Command Reference](analysis/TELNET-COMMAND-REFERENCE.md)
* [Setup WebSocket Experiment](analysis/SETUP-WEBSOCKET-EXPERIMENT.md)
* [Factory Reset Protocol](analysis/FACTORY-RESET-PROTOCOL.md)
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
* [IoT Config Summary](analysis/IOT-CONFIG-SUMMARY.md)
* [IoT Configuration Analysis](analysis/IOT-CONFIGURATION-ANALYSIS.md)
@@ -100,3 +105,4 @@
* [SCMUDC Events Analysis](scmudc-events-analysis.md)
* [Parity Improvements](PARITY-IMPROVEMENTS.md)
* [Parity SoundCork](PARITY-SOUNDCORK.md)
* [Stockholm Port Guide](stockholm-port-guide.md)
+10 -10
View File
@@ -275,8 +275,8 @@ Returns detected UPnP/DLNA media servers.
**Response Example:**
```xml
<ListMediaServersResponse>
<media_server id="2f402f80-da50-11e1-9b23-123456789012" mac="0017886e13fe" ip="192.168.1.4" manufacturer="Signify" model_name="Philips hue bridge 2015" friendly_name="Hue Bridge (192.168.1.4)" model_description="Philips hue Personal Wireless Lighting" location="http://192.168.1.4:80/description.xml" />
<media_server id="d09708a1-5953-44bc-a413-123456789012" mac="S-1-5-21-240303764-901663538-1234567890-1001" ip="192.168.1.5" manufacturer="Microsoft Corporation" model_name="Windows Media Player Sharing" friendly_name="My NAS Media Library" model_description="" location="http://192.168.1.5:2869/upnphost/udhisapi.dll?content=uuid:d09708a1-5953-44bc-a413-123456789012" />
<media_server id="2f402f80-da50-11e1-9b23-123456789012" mac="0017886e13fe" ip="192.0.2.4" manufacturer="Signify" model_name="Philips hue bridge 2015" friendly_name="Hue Bridge (192.0.2.4)" model_description="Philips hue Personal Wireless Lighting" location="http://192.0.2.4:80/description.xml" />
<media_server id="d09708a1-5953-44bc-a413-123456789012" mac="S-1-5-21-240303764-901663538-1234567890-1001" ip="192.0.2.5" manufacturer="Microsoft Corporation" model_name="Windows Media Player Sharing" friendly_name="My NAS Media Library" model_description="" location="http://192.0.2.5:2869/upnphost/udhisapi.dll?content=uuid:d09708a1-5953-44bc-a413-123456789012" />
</ListMediaServersResponse>
```
@@ -641,15 +641,15 @@ Gets current stereo pair configuration.
<groupRole>
<deviceId>9070658C9D4A</deviceId>
<role>LEFT</role>
<ipAddress>192.168.1.131</ipAddress>
<ipAddress>192.0.2.131</ipAddress>
</groupRole>
<groupRole>
<deviceId>F45EAB3115DA</deviceId>
<role>RIGHT</role>
<ipAddress>192.168.1.134</ipAddress>
<ipAddress>192.0.2.134</ipAddress>
</groupRole>
</roles>
<senderIPAddress>192.168.1.131</senderIPAddress>
<senderIPAddress>192.0.2.131</senderIPAddress>
<status>GROUP_OK</status>
</group>
```
@@ -671,12 +671,12 @@ Creates new stereo pair group.
<groupRole>
<deviceId>9070658C9D4A</deviceId>
<role>LEFT</role>
<ipAddress>192.168.1.131</ipAddress>
<ipAddress>192.0.2.131</ipAddress>
</groupRole>
<groupRole>
<deviceId>F45EAB3115DA</deviceId>
<role>RIGHT</role>
<ipAddress>192.168.1.134</ipAddress>
<ipAddress>192.0.2.134</ipAddress>
</groupRole>
</roles>
</group>
@@ -707,12 +707,12 @@ Updates stereo pair group name.
<groupRole>
<deviceId>9070658C9D4A</deviceId>
<role>LEFT</role>
<ipAddress>192.168.1.131</ipAddress>
<ipAddress>192.0.2.131</ipAddress>
</groupRole>
<groupRole>
<deviceId>F45EAB3115DA</deviceId>
<role>RIGHT</role>
<ipAddress>192.168.1.134</ipAddress>
<ipAddress>192.0.2.134</ipAddress>
</groupRole>
</roles>
</group>
@@ -762,7 +762,7 @@ Returns network status configuration.
<name>eth0</name>
<mac-addr>1004567890AA</mac-addr>
<bindings>
<ipv4address>192.168.1.131</ipv4address>
<ipv4address>192.0.2.131</ipv4address>
</bindings>
<running>true</running>
<kind>Wireless</kind>
+24
View File
@@ -0,0 +1,24 @@
{%- comment -%}
Render Mermaid diagrams in docs pages.
Markdown ```mermaid fenced blocks are emitted by Kramdown as
<pre><code class="language-mermaid"></code></pre>, but Mermaid only
auto-renders elements with class="mermaid". This snippet rewrites the
pre/code nodes into div.mermaid before initialising the library.
Loaded as an ES module from the jsDelivr CDN so we don't have to vendor
the library into the repo. Pinned to a major version for cache stability.
{%- endcomment -%}
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
document.querySelectorAll('pre > code.language-mermaid').forEach((code) => {
const div = document.createElement('div');
div.className = 'mermaid';
div.textContent = code.textContent;
code.parentElement.replaceWith(div);
});
mermaid.initialize({ startOnLoad: false, securityLevel: 'strict' });
mermaid.run();
</script>
+62 -99
View File
@@ -1,115 +1,78 @@
# Data Anonymization Summary
# Placeholder values for examples
This document summarizes all changes made to anonymize personal and specific data throughout the Bose SoundTouch Go client codebase.
This repo is public. Documentation, READMEs, example configs, and test
fixtures must never carry real LAN IPs, real device MACs, real Bose
account IDs, or personal device names from any maintainer or
contributor.
## Overview
This file is the **canonical mapping table** for the placeholders we
use across the codebase. Use these values in new examples and tests.
All specific IP addresses, device IDs, device names, and other potentially personal information have been replaced with generic, example values to protect privacy while maintaining the functionality and usefulness of the documentation and test examples.
## Placeholder mapping
## Changes Made
| Concept | Placeholder |
|------------------------|------------------------------------------------------------------------|
| Example IP (primary) | `192.0.2.10` |
| Example IP (secondary) | `192.0.2.11` |
| Example IP (third) | `192.0.2.12` |
| Network / CIDR | `192.0.2.0/24` |
| External / non-LAN IP | `198.51.100.10` or `203.0.113.10` |
| Gateway IP | `192.0.2.1` |
| Device MAC (primary) | `AA:BB:CC:DD:EE:FF` (no separator: `AABBCCDDEEFF`) |
| Device MAC (secondary) | `AA:BB:CC:DD:EE:01` (no separator: `AABBCCDDEE01`) |
| Device ID (some XML) | `ABCD1234EFGH` — legacy placeholder still in some fixtures |
| Device display name | `Living Room SoundTouch` / `Kitchen SoundTouch` / `Bedroom SoundTouch` |
| Bose account ID | `1000001` / `1000002` |
### IP Addresses
`192.0.2.0/24`, `198.51.100.0/24`, and `203.0.113.0/24` are reserved
by [RFC 5737](https://www.rfc-editor.org/rfc/rfc5737) exclusively for
documentation. They won't ever route on a real network, so readers
know at a glance that they're placeholders and not addresses they
need to think about.
**Original → Anonymized:**
- `192.168.178.35``192.168.1.10`
- `192.168.178.28``192.168.1.10`
- `192.168.1.100``192.168.1.10`
- `192.168.1.101``192.168.1.11`
- `192.168.1.102``192.168.1.12`
`AA:BB:CC:DD:EE:FF` is the conventional "locally administered" MAC
placeholder used in many vendor docs.
### Device IDs
`1000001` / `1000002` are well outside the range of real Bose customer
account IDs (which are typically 67 digits with no leading 1 0 0…
pattern) but stay numeric for parsers that expect integer-looking IDs.
**Original → Anonymized:**
- `A81B6A536A98``ABCD1234EFGH`
- `1234567890AB``ABCD1234EFGH`
- `1234567890AC``ABCD1234EFGH`
## Why we don't use 192.168.1.x
### Device Names
An earlier anonymisation pass used `192.168.1.x` as its target. That
range is RFC-1918 private space — perfectly valid on real networks,
which means a reader can't tell whether `192.168.1.10` is a
placeholder or a documented LAN address. RFC-5737 ranges fix that:
because they're reserved for documentation only, any reader knows on
sight that they don't represent a real device.
**Original → Anonymized:**
- `Sound Machinechen` `My SoundTouch Device`
The `.md` / `.txt` portion of the `192.168.1.*``192.0.2.x` sweep
is complete. Test files (`.go` / `.xml` / `.http`) still carry the
old placeholder pending Phase 2 in the audit at
`_/RFC-5737-cleanup/assessment.md`.
### MAC Addresses
## How to audit before committing
**Original → Anonymized:**
- `A81B6A536A98``AA:BB:CC:DD:EE:FF`
- `A81B6A849D99``AA:BB:CC:DD:EE:FF`
- `A8:1B:6A:53:6A:98``AA:BB:CC:DD:EE:FF`
- `A8:1B:6A:84:9D:99``AA:BB:CC:DD:EE:01`
When you add or edit examples that contain IP addresses, MACs, account
IDs, or device names, mentally answer: "would I be comfortable
publishing this on a postcard?" If not, swap in a placeholder from
the table above.
## Files Modified
Some patterns flag clearly-non-placeholder values:
### Documentation Files
- `README.md` - Updated all IP addresses and device examples
- `Makefile` - Updated example IP addresses in help text
- `docs/SYSTEM-ENDPOINTS.md` - Anonymized all example data
- `docs/VOLUME-CONTROLS.md` - Updated device IDs and IP addresses
- `docs/KEY-CONTROLS.md` - Updated IP addresses
- `docs/BASS-CONTROLS.md` - Updated device IDs
- `docs/HOST-PORT-PARSING.md` - Updated IP addresses and device names
- `docs/STATUS.md` - Updated IP addresses
```sh
# Any IPv4 not in a documentation range or the 192.168.1.x default:
git ls-files | xargs grep -hoE "[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+" 2>/dev/null \
| grep -vE "^(192\.0\.2\.|198\.51\.100\.|203\.0\.113\.|0\.0\.0\.0|127\.0\.0\.1|255\.255\.255\.255|192\.168\.1\.[0-9])" \
| sort -u
### Source Code Files
- `cmd/soundtouch-cli/main.go` - Updated all example IP addresses in help text
- `cmd/soundtouch-cli/main_test.go` - Updated test IP addresses
# Any colon-separated MAC that doesn't start with AA:BB:CC:DD:EE:
git ls-files | xargs grep -hoE "[0-9A-F]{2}(:[0-9A-F]{2}){5}" 2>/dev/null \
| grep -vE "^AA:BB:CC:DD:EE:" \
| sort -u
```
### Test Data Files
- `pkg/client/testdata/info_response.xml` - Updated device ID, name, and network info
- `pkg/client/testdata/info_response_st20.xml` - Updated device ID and network info
- `pkg/client/testdata/capabilities_response.xml` - Updated device ID
- `pkg/client/testdata/name_response.xml` - Updated device name
- `pkg/client/testdata/networkinfo_response.xml` - Updated device ID and network info
- `pkg/client/testdata/clockdisplay_response.xml` - Updated device ID
### Test Files
- `pkg/client/client_test.go` - Updated device IDs, names, and IP addresses
- `pkg/client/system_test.go` - Updated device IDs and IP addresses
- `pkg/client/balance_test.go` - Updated device IDs in test responses
- `pkg/client/bass_test.go` - Updated device IDs in test responses
- `pkg/models/networkinfo_test.go` - Updated device IDs and network info
## Anonymization Strategy
### IP Addresses
- Used standard RFC 1918 private IP ranges (192.168.1.x)
- Maintained realistic network structure (same subnet for related devices)
- Used sequential numbering (.10, .11, .12) for clarity
### Device IDs
- Used generic alphanumeric pattern `ABCD1234EFGH`
- Maintained consistent usage across all files
- Preserved original length and format
### Device Names
- Used generic but descriptive names like "My SoundTouch Device"
- Removed any potentially personal identifiers
### MAC Addresses
- Used standard placeholder format `AA:BB:CC:DD:EE:FF`
- Used sequential variants (EE:01) when multiple addresses needed
- Maintained proper MAC address format
## Verification
After anonymization:
- ✅ All tests continue to pass
- ✅ All builds succeed
- ✅ Documentation remains accurate and useful
- ✅ No personal data remains in examples
- ✅ Functionality is preserved
## Benefits
1. **Privacy Protection**: No personal network information exposed
2. **Professional Examples**: Clean, generic examples suitable for public documentation
3. **Consistency**: Uniform use of example data across all files
4. **Maintainability**: Easy to identify example vs. real data
## Standards Used
- **IP Addresses**: RFC 1918 private ranges (192.168.1.x/24)
- **Device IDs**: Generic alphanumeric placeholders
- **MAC Addresses**: Standard placeholder format
- **Device Names**: Generic descriptive names
All changes maintain the original functionality while ensuring no personal or specific network information is exposed in the codebase.
If real values slip into a commit, treat it as a sanitisation task:
revert or fix, then audit nearby files for sibling leaks. Personal
device names and Bose account IDs don't have a regex-friendly shape —
catch those at review time.
+55 -55
View File
@@ -1,7 +1,7 @@
# Bose SoundTouch API Coverage Analysis
**Last Updated:** January 2025
**API Version:** Official Bose SoundTouch Web API v1.0
**Last Updated:** February 2026
**API Version:** Official Bose SoundTouch Web API v1.0
**Implementation Status:** 100% Official Coverage + Extended Features
## Executive Summary
@@ -10,7 +10,7 @@ This Go implementation provides **complete coverage** of the Bose SoundTouch Web
### Key Findings
- ✅ **All essential user functionality implemented**
- ✅ **Complete zone management implementation**
- ✅ **Complete zone management implementation**
- ✅ **Real-time WebSocket event system**
- ✅ **Extended features beyond official specification**
- ✅ **Complete advanced audio controls implementation**
@@ -22,42 +22,42 @@ This Go implementation provides **complete coverage** of the Bose SoundTouch Web
### Implemented Endpoints: 20/21 (95%)
| Endpoint | Method | Status | Implementation | Notes |
|----------|--------|--------|----------------|--------|
| `/key` | POST | ✅ **Complete** | `SendKey()`, `SendKeyPress()`, `SendKeyRelease()` | Full key simulation with press/release states |
| `/select` | POST | ✅ **Complete** | `SelectSource()`, `SelectSpotify()`, etc. | Source selection with validation |
| `/sources` | GET | ✅ **Complete** | `GetSources()` | Available audio sources |
| `/bassCapabilities` | GET | ✅ **Complete** | `GetBassCapabilities()` | Bass capability detection |
| `/bass` | GET/POST | ✅ **Complete** | `GetBass()`, `SetBass()`, `SetBassSafe()` | Bass control (-9 to +9) with safety limits |
| `/getZone` | GET | ✅ **Complete** | `GetZone()`, `GetZoneStatus()`, `GetZoneMembers()` | Multiroom zone information |
| `/setZone` | POST | ✅ **Complete** | `SetZone()`, `CreateZone()`, `AddToZone()`, `RemoveFromZone()` | Zone configuration and management |
| `/now_playing` | GET | ✅ **Complete** | `GetNowPlaying()` | Current playback status with full metadata |
| `/trackInfo` | GET | ❌ **Non-functional** | `GetTrackInfo()` | Documented but times out on real devices |
| `/volume` | GET/POST | ✅ **Complete** | `GetVolume()`, `SetVolume()`, `SetVolumeSafe()` | Volume and mute control with safety features |
| `/presets` | GET | ✅ **Complete** | `GetPresets()`, `GetNextAvailablePresetSlot()` | Preset configurations (read-only per API spec) |
| `/info` | GET | ✅ **Complete** | `GetDeviceInfo()` | Device information and capabilities |
| `/name` | POST | ✅ **Complete** | `SetName()` | Device name modification |
| `/capabilities` | GET | ✅ **Complete** | `GetCapabilities()` | Device feature capabilities |
| `/addZoneSlave` | POST | ✅ **Complete** | `AddZoneSlave()`, `AddZoneSlaveByDeviceID()` | Individual device addition to zone |
| `/removeZoneSlave` | POST | ✅ **Complete** | `RemoveZoneSlave()`, `RemoveZoneSlaveByDeviceID()` | Individual device removal from zone |
| `/audiodspcontrols` | GET/POST | ✅ **Complete** | `GetAudioDSPControls()`, `SetAudioDSPControls()`, `SetAudioMode()`, `SetVideoSyncAudioDelay()` | DSP audio modes and video sync delay |
| `/audioproducttonecontrols` | GET/POST | ✅ **Complete** | `GetAudioProductToneControls()`, `SetAudioProductToneControls()`, `SetAdvancedBass()`, `SetAdvancedTreble()` | Advanced bass/treble controls |
| `/audioproductlevelcontrols` | GET/POST | ✅ **Complete** | `GetAudioProductLevelControls()`, `SetAudioProductLevelControls()`, `SetFrontCenterSpeakerLevel()`, `SetRearSurroundSpeakersLevel()` | Speaker level controls |
| `/speaker` | POST | ✅ **Complete** | `PlayTTS()`, `PlayURL()`, `PlayCustom()` | TTS and URL content playback for notifications |
| `/playNotification` | GET | ✅ **Complete** | `PlayNotificationBeep()` | Simple notification beep sound |
| Endpoint | Method | Status | Implementation | Notes |
|------------------------------|----------|----------------------|--------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------|
| `/key` | POST | ✅ **Complete** | `SendKey()`, `SendKeyPress()`, `SendKeyRelease()` | Full key simulation with press/release states |
| `/select` | POST | ✅ **Complete** | `SelectSource()`, `SelectSpotify()`, etc. | Source selection with validation |
| `/sources` | GET | ✅ **Complete** | `GetSources()` | Available audio sources |
| `/bassCapabilities` | GET | ✅ **Complete** | `GetBassCapabilities()` | Bass capability detection |
| `/bass` | GET/POST | ✅ **Complete** | `GetBass()`, `SetBass()`, `SetBassSafe()` | Bass control (-9 to +9) with safety limits |
| `/getZone` | GET | ✅ **Complete** | `GetZone()`, `GetZoneStatus()`, `GetZoneMembers()` | Multiroom zone information |
| `/setZone` | POST | ✅ **Complete** | `SetZone()`, `CreateZone()`, `AddToZone()`, `RemoveFromZone()` | Zone configuration and management |
| `/now_playing` | GET | ✅ **Complete** | `GetNowPlaying()` | Current playback status with full metadata |
| `/trackInfo` | GET | ❌ **Non-functional** | `GetTrackInfo()` | Documented but times out on real devices |
| `/volume` | GET/POST | ✅ **Complete** | `GetVolume()`, `SetVolume()`, `SetVolumeSafe()` | Volume and mute control with safety features |
| `/presets` | GET | ✅ **Complete** | `GetPresets()`, `GetNextAvailablePresetSlot()` | Preset configurations (read-only per API spec) |
| `/info` | GET | ✅ **Complete** | `GetDeviceInfo()` | Device information and capabilities |
| `/name` | POST | ✅ **Complete** | `SetName()` | Device name modification |
| `/capabilities` | GET | ✅ **Complete** | `GetCapabilities()` | Device feature capabilities |
| `/addZoneSlave` | POST | ✅ **Complete** | `AddZoneSlave()`, `AddZoneSlaveByDeviceID()` | Individual device addition to zone |
| `/removeZoneSlave` | POST | ✅ **Complete** | `RemoveZoneSlave()`, `RemoveZoneSlaveByDeviceID()` | Individual device removal from zone |
| `/audiodspcontrols` | GET/POST | ✅ **Complete** | `GetAudioDSPControls()`, `SetAudioDSPControls()`, `SetAudioMode()`, `SetVideoSyncAudioDelay()` | DSP audio modes and video sync delay |
| `/audioproducttonecontrols` | GET/POST | ✅ **Complete** | `GetAudioProductToneControls()`, `SetAudioProductToneControls()`, `SetAdvancedBass()`, `SetAdvancedTreble()` | Advanced bass/treble controls |
| `/audioproductlevelcontrols` | GET/POST | ✅ **Complete** | `GetAudioProductLevelControls()`, `SetAudioProductLevelControls()`, `SetFrontCenterSpeakerLevel()`, `SetRearSurroundSpeakersLevel()` | Speaker level controls |
| `/speaker` | POST | ✅ **Complete** | `PlayTTS()`, `PlayURL()`, `PlayCustom()` | TTS and URL content playback for notifications |
| `/playNotification` | GET | ✅ **Complete** | `PlayNotificationBeep()` | Simple notification beep sound |
### Non-functional Endpoints: 1/21 (5%)
| Endpoint | Method | Status | Reason | Impact |
|----------|--------|--------|--------|---------|
| `/trackInfo` | GET | ❌ **Non-functional** | Times out on real devices (AllegroWebserver timeout) | **None** - Use `/now_playing` instead |
| Endpoint | Method | Status | Reason | Impact |
|--------------|--------|----------------------|------------------------------------------------------|---------------------------------------|
| `/trackInfo` | GET | ❌ **Non-functional** | Times out on real devices (AllegroWebserver timeout) | **None** - Use `/now_playing` instead |
### Official Endpoints Not Supported by API: 1
| Endpoint | Method | Status | Official API Status |
|----------|--------|--------|-------------------|
| `/storePreset` | POST | ✅ **IMPLEMENTED** | Found via [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API) (official docs marked `/presets` POST as "N/A") |
| `/removePreset` | POST | ✅ **IMPLEMENTED** | Found via [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API) |
| Endpoint | Method | Status | Official API Status |
|-----------------|--------|-------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `/storePreset` | POST | ✅ **IMPLEMENTED** | Found via [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API) (official docs marked `/presets` POST as "N/A") |
| `/removePreset` | POST | ✅ **IMPLEMENTED** | Found via [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API) |
---
@@ -67,24 +67,24 @@ This Go implementation provides **complete coverage** of the Bose SoundTouch Web
**Note**: The `/speaker` and `/playNotification` endpoints were discovered via the [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API) and are now part of the official coverage.
| Endpoint | Method | Status | Notes |
|----------|--------|--------|--------|
| `/name` | GET | 🔍 **Extra** | Official API only documents POST, but GET works with real hardware |
| `/balance` | GET/POST | 🔍 **Extra** | Stereo balance control (-50 to +50) - not in API v1.0 |
| `/clockTime` | GET/POST | 🔍 **Extra** | Device time management - works with real devices |
| `/clockDisplay` | GET/POST | 🔍 **Extra** | Clock display settings and brightness |
| `/networkInfo` | GET | 🔍 **Extra** | Network connectivity information |
| Endpoint | Method | Status | Notes |
|-----------------|----------|--------------|--------------------------------------------------------------------|
| `/name` | GET | 🔍 **Extra** | Official API only documents POST, but GET works with real hardware |
| `/balance` | GET/POST | 🔍 **Extra** | Stereo balance control (-50 to +50) - not in API v1.0 |
| `/clockTime` | GET/POST | 🔍 **Extra** | Device time management - works with real devices |
| `/clockDisplay` | GET/POST | 🔍 **Extra** | Clock display settings and brightness |
| `/networkInfo` | GET | 🔍 **Extra** | Network connectivity information |
### Advanced Implementation Features
| Feature | Status | Description |
|---------|--------|-------------|
| **WebSocket Events** | ✅ **Complete** | Real-time device state monitoring (`nowPlayingUpdated`, `volumeUpdated`, etc.) |
| **Device Discovery** | ✅ **Complete** | UPnP/SSDP + mDNS/Bonjour automatic discovery |
| **Safety Features** | ✅ **Enhanced** | Volume limiting, bass clamping, input validation |
| **High-Level Zone API** | ✅ **Superior** | Fluent zone management API replacing low-level slave operations |
| **Preset Management** | ✅ **Wiki Documented** | Full preset CRUD via `/storePreset` and `/removePreset` endpoints (found via SoundTouch Plus Wiki) |
| **Content Navigation** | ✅ **Complete** | Browse and search content via `/navigate`, `/searchStation`, `/addStation` (via SoundTouch Plus Wiki) |
| Feature | Status | Description |
|-------------------------|-----------------------|-------------------------------------------------------------------------------------------------------|
| **WebSocket Events** | ✅ **Complete** | Real-time device state monitoring (`nowPlayingUpdated`, `volumeUpdated`, etc.) |
| **Device Discovery** | ✅ **Complete** | UPnP/SSDP + mDNS/Bonjour automatic discovery |
| **Safety Features** | ✅ **Enhanced** | Volume limiting, bass clamping, input validation |
| **High-Level Zone API** | ✅ **Superior** | Fluent zone management API replacing low-level slave operations |
| **Preset Management** | ✅ **Wiki Documented** | Full preset CRUD via `/storePreset` and `/removePreset` endpoints (found via SoundTouch Plus Wiki) |
| **Content Navigation** | ✅ **Complete** | Browse and search content via `/navigate`, `/searchStation`, `/addStation` (via SoundTouch Plus Wiki) |
---
@@ -95,17 +95,17 @@ This Go implementation provides **complete coverage** of the Bose SoundTouch Web
**Official Low-Level API:**
```go
// Individual slave operations (exact official API implementation)
client.AddZoneSlave("MASTER123", "SLAVE456", "192.168.1.101")
client.RemoveZoneSlave("MASTER123", "SLAVE456", "192.168.1.101")
client.AddZoneSlave("MASTER123", "SLAVE456", "192.0.2.101")
client.RemoveZoneSlave("MASTER123", "SLAVE456", "192.0.2.101")
```
**Enhanced High-Level API:**
```go
// High-level fluent API (enhanced implementation)
zone := client.CreateZoneWithIPs("192.168.1.100", []string{"192.168.1.101", "192.168.1.102"})
client.AddToZone("192.168.1.100", "192.168.1.103")
client.RemoveFromZone("192.168.1.100", "192.168.1.101")
client.DissolveZone("192.168.1.100")
zone := client.CreateZoneWithIPs("192.0.2.100", []string{"192.0.2.101", "192.0.2.102"})
client.AddToZone("192.0.2.100", "192.0.2.103")
client.RemoveFromZone("192.0.2.100", "192.0.2.101")
client.DissolveZone("192.0.2.100")
```
**Advantages:**
@@ -221,4 +221,4 @@ The single non-functional endpoint (`/trackInfo`) is **broken on real devices**
**Note**: All official API endpoints are implemented. The `/trackInfo` endpoint times out on real devices but is implemented and tested.
**Overall Assessment: Complete** ⭐⭐⭐⭐⭐
**Overall Assessment: Complete** ⭐⭐⭐⭐⭐
+6 -6
View File
@@ -26,7 +26,7 @@ Read on for the full manual walkthrough and the rationale behind each step.
> **BLE limitation**: Android emulators do not expose Bluetooth hardware. The Bose app's default setup path (BLE Wi-Fi provisioning) therefore cannot be used to configure a factory-reset speaker from the emulator. Use **AP mode** instead: provision the speaker's Wi-Fi credentials via the Mac command line first (see [DEVICE-INITIAL-SETUP.md § 6](../guides/DEVICE-INITIAL-SETUP.md)), then the app can discover the already-networked speaker via mDNS/SSDP without BLE.
> **Emulator ↔ local network**: The emulator routes all traffic through the Mac's active network interface. Once the speaker is on the same LAN as the Mac, the emulator can reach it at its normal LAN IP (e.g. `192.168.1.50`) — no extra routing is needed. Use `adb shell ping 192.168.1.50` to confirm reachability.
> **Emulator ↔ local network**: The emulator routes all traffic through the Mac's active network interface. Once the speaker is on the same LAN as the Mac, the emulator can reach it at its normal LAN IP (e.g. `192.0.2.50`) — no extra routing is needed. Use `adb shell ping 192.0.2.50` to confirm reachability.
Add Android SDK tools to your PATH (add to `~/.zshrc`):
@@ -149,13 +149,13 @@ Find your Mac's local IP:
```bash
ipconfig getifaddr en0
# e.g. 192.168.1.123
# e.g. 192.0.2.123
```
Set the proxy:
```bash
adb -s emulator-5554 shell settings put global http_proxy 192.168.1.123:8080
adb -s emulator-5554 shell settings put global http_proxy 192.0.2.123:8080
```
---
@@ -214,7 +214,7 @@ Edit `/tmp/config.js` and set:
```javascript
const CERT_PEM = `<contents of ~/.mitmproxy/mitmproxy-ca-cert.pem>`;
const PROXY_HOST = '192.168.1.123'; // your Mac IP
const PROXY_HOST = '192.0.2.123'; // your Mac IP
const PROXY_PORT = 8080;
```
@@ -251,8 +251,8 @@ Expected output in the Frida REPL:
```
== System certificate trust injected ==
== Proxy system configuration overridden to 192.168.1.123:8080 ==
== Proxy configuration overridden to 192.168.1.123:8080 ==
== Proxy system configuration overridden to 192.0.2.123:8080 ==
== Proxy configuration overridden to 192.0.2.123:8080 ==
== Certificate unpinning completed ==
== Unpinning fallback auto-patcher installed ==
```
+2 -2
View File
@@ -236,11 +236,11 @@ If you cannot see the `Bose-Lab` SSID on your phone:
```bash
sudo nmcli device set wlan0 managed no
```
7. **Ghost IP Conflict:** If `ip addr show wlan0` shows both `192.168.10.1` and another IP (like `192.168.178.x`), `hostapd` will fail. This is usually caused by NetworkManager managing the interface. Ensure you've run:
7. **Ghost IP Conflict:** If `ip addr show wlan0` shows both `192.168.10.1` and another IP (like `192.0.2.x`), `hostapd` will fail. This is usually caused by NetworkManager managing the interface. Ensure you've run:
```bash
sudo nmcli device set wlan0 managed no
# If the ghost IP is still there, remove it manually:
sudo ip addr del 192.168.178.X/24 dev wlan0
sudo ip addr del 192.0.2.0/24 dev wlan0
```
---
+39 -30
View File
@@ -2,6 +2,8 @@
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.
## Overview of Redirection Targets
SoundTouch devices primarily communicate with the following domains:
@@ -32,19 +34,25 @@ The most robust and granular method involves modifying the device's private conf
Requires SSH access to the device.
```xml
<SoundTouchSdkPrivateCfg>
<margeServerUrl>http://192.168.1.10:8000/marge</margeServerUrl>
<statsServerUrl>http://192.168.1.10:8000</statsServerUrl>
<swUpdateUrl>http://192.168.1.10:8000/updates/soundtouch</swUpdateUrl>
<bmxRegistryUrl>http://192.168.1.10:8000/bmx/registry/v1/services</bmxRegistryUrl>
<margeServerUrl>http://192.0.2.10:8000</margeServerUrl>
<statsServerUrl>http://192.0.2.10:8000</statsServerUrl>
<swUpdateUrl>http://192.0.2.10:8000/updates/soundtouch</swUpdateUrl>
<bmxRegistryUrl>http://192.0.2.10:8000/bmx/registry/v1/services</bmxRegistryUrl>
</SoundTouchSdkPrivateCfg>
```
> **Note on `margeServerUrl`**`soundtouch-service` mounts the marge endpoints
> at the **root** of port 8000, so the URL has no `/marge` suffix.
> [`deborahgu/soundcork`](https://github.com/deborahgu/soundcork) routes marge
> under a `/marge` sub-path, so users redirecting to soundcork must append it
> (`http://192.0.2.10:8000/marge`).
### Pros & Cons
| Pros | Cons |
| :--- | :--- |
| **Granular Control**: Redirect specific services while leaving others (e.g., updates) intact. | **Requires SSH**: Must have root/SSH access to the device. |
| **Persistent**: Survives software updates (usually). | **Syntax Sensitive**: Errors in XML can cause boot issues or service failures. |
| **Native**: Uses the device's built-in configuration mechanism. | |
| Pros | Cons |
|:----------------------------------------------------------------------------------------------|:-------------------------------------------------------------------------------|
| **Granular Control**: Redirect specific services while leaving others (e.g., updates) intact. | **Requires SSH**: Must have root/SSH access to the device. |
| **Persistent**: Survives software updates (usually). | **Syntax Sensitive**: Errors in XML can cause boot issues or service failures. |
| **Native**: Uses the device's built-in configuration mechanism. | |
---
@@ -60,17 +68,17 @@ This method uses the standard Linux hosts file to redirect traffic at the networ
### Implementation
Requires SSH access. Add entries for the target domains:
```text
192.168.1.10 streaming.bose.com
192.168.1.10 updates.bose.com
192.168.1.10 stats.bose.com
192.0.2.10 streaming.bose.com
192.0.2.10 updates.bose.com
192.0.2.10 stats.bose.com
```
### Pros & Cons
| Pros | Cons |
| :--- | :--- |
| **Simple**: Easy to understand and implement. | **Requires SSH**: Must have root access. |
| Pros | Cons |
|:--------------------------------------------------------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Simple**: Easy to understand and implement. | **Requires SSH**: Must have root access. |
| **Universal**: Affects all processes on the device attempting to reach those domains. | **HTTPS Issues**: Redirecting HTTPS domains to a local IP will cause SSL certificate errors unless the device is patched to skip verification or trust a custom CA. |
| | **Brittle**: Some firmware versions may overwrite `/etc/hosts` on reboot. |
| | **Brittle**: Some firmware versions may overwrite `/etc/hosts` on reboot. |
---
@@ -104,12 +112,12 @@ sed "s#\^https:....bose.\+apigee..net..#http[aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
4. Restore execution permissions and reboot.
### Pros & Cons
| Pros | Cons |
| :--- | :--- |
| **Bypass Config**: Works even if the firmware ignores XML settings. | **High Risk**: Modifying binaries can lead to permanent bricks or boot loops. |
| Pros | Cons |
|:------------------------------------------------------------------------------------|:--------------------------------------------------------------------------------------|
| **Bypass Config**: Works even if the firmware ignores XML settings. | **High Risk**: Modifying binaries can lead to permanent bricks or boot loops. |
| **Hardcoded Redirects**: Can catch URLs that aren't exposed in configuration files. | **Length Constraint**: Custom URLs must fit within the space of the original strings. |
| | **Firmware Specific**: Patches must be reapplied after every software update. |
| | **Complexity**: Requires understanding of binary structures and potential checksums. |
| | **Firmware Specific**: Patches must be reapplied after every software update. |
| | **Complexity**: Requires understanding of binary structures and potential checksums. |
---
@@ -117,11 +125,11 @@ sed "s#\^https:....bose.\+apigee..net..#http[aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
### Summary Table
| Method | Primary Use Case | Ease | Safety | Persistence | Granularity |
| :--- | :--- | :---: | :---: | :---: | :---: |
| **XML Config** | Logical service redirection | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| **`/etc/hosts`** | Quick global DNS override | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ |
| **Binary Patch** | Bypassing hardcoded checks | ⭐ | ⭐ | ⭐ | ⭐⭐⭐ |
| Method | Primary Use Case | Ease | Safety | Persistence | Granularity |
|:-----------------|:----------------------------|:-----:|:------:|:-----------:|:-----------:|
| **XML Config** | Logical service redirection | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| **`/etc/hosts`** | Quick global DNS override | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ |
| **Binary Patch** | Bypassing hardcoded checks | ⭐ | ⭐ | ⭐ | ⭐⭐⭐ |
---
@@ -133,7 +141,7 @@ A common question is whether these methods can be used in isolation or if they m
If your firmware does not strictly enforce the `IsItBose` check for the specific URLs you are changing, **Method 1 (XML)** is sufficient. This is the cleanest approach and is used by the `soundtouch-service` migration tool.
### Scenario B: XML Config + Binary Patching (The "Locked" Case)
On some newer firmware versions, even if you change the `<margeServerUrl>` in the XML to `http://192.168.1.10`, the internal library (`libBmxAccountHsm.so`) will validate the string against the hardcoded Bose regex.
On some newer firmware versions, even if you change the `<margeServerUrl>` in the XML to `http://192.0.2.10`, the internal library (`libBmxAccountHsm.so`) will validate the string against the hardcoded Bose regex.
* **Symptom**: The device ignores the XML setting or fails to connect despite the correct URL being present.
* **Solution**: You **must** apply the **Binary Patch (Method 3)** to neutralize the `IsItBose` check *in addition* to the XML change.
@@ -176,9 +184,10 @@ As suggested by community members, you can configure the device to trust your ow
- **Method B (Symlinks)**: Add the certificate to `/etc/ssl/certs/` and create a hash symlink using `c_rehash` (if available) or manual mapping.
**Pros & Cons**:
| Pros | Cons |
| :--- | :--- |
| **Secure**: Maintains end-to-end encryption. | **Requires SSH**: Must have root access to modify the trust store. |
| Pros | Cons |
|:-------------------------------------------------------|:-----------------------------------------------------------------------|
| **Secure**: Maintains end-to-end encryption. | **Requires SSH**: Must have root access to modify the trust store. |
| **Clean**: No binary patching required for SSL bypass. | **Update Risk**: Firmware updates might overwrite the `ca-bundle.crt`. |
### Option 2: SSL Verification Bypass
+136
View File
@@ -0,0 +1,136 @@
# 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.
## Sequence
1. **Telnet receives `sys factorydefault`.** The diagnostic shell on port 17000 accepts the command and acknowledges. Some firmwares close the socket as part of the reboot — our CLI's `setup factory-reset` tolerates that as success.
2. **Speaker DELETEs itself from its marge account.** Before wiping anything, the firmware does:
```
[MargeStateAssociated] HandleRemoveDeviceRequest - Removing this device from the user's Marge account
[MargeClient] RemoveDevice calling Marge Server with https://streaming.bose.com/streaming/account/{accountId}/device/{deviceId}
[MargeClient] RemoveDeviceCB - Device removed from the user's Marge account
[MargeStateAssociated] HandleRemoveDeviceRequestSuccessCB, Marge returned: {"ok": true}
```
AfterTouch already handles this — `HandleMargeRemoveDevice` (`pkg/service/handlers/handlers_marge.go:633`) routed via `r.Delete("/device/{device}", …)` in `cmd/soundtouch-service/main.go:955`. The handler calls `marge.RemoveDeviceFromAccount(s.ds, account, device)` and prunes the device from the datastore.
3. **Speaker notifies its LAN peers.** Two HTTP POSTs to each known peer at `:8090/notification`:
```
[NotificationSender] SendNotifyLisas_: URL: >>http://192.0.2.122:8090/notification<<, m_msgdata.size(58)
[SimpleURLFetcher] multipart/form-data text/xml
```
~58 bytes of `multipart/form-data` carrying `text/xml`. "Lisas" is the firmware's internal term for LAN peers (devices on the same account on the same network segment). AfterTouch is **not** on this path — it's pure peer-to-peer over the LAN. Peers presumably refresh their account info as a result.
4. **Local state teardown.** Bluetooth pairings cleared (`BTRemoteDeviceAccess::ClearPrevPairedList`), zone/group state torn down, all source proxies disconnected (`STSAccountProxy::Disconnect Requested` × many).
5. **Persistence cleanup.** Logs, core dumps wiped (`FactoryDefault: Clearing the CoreDump and BoseLogs … rm -rf /mnt/nv/BoseLog/*`). Notably **NOT wiped**: `/mnt/nv/aftertouch.resolv.conf`, `/mnt/nv/rc.local`'s Aftertouch hook, and `/mnt/nv/BoseApp-Persistence/1/SystemConfigurationDB.xml`. The reset only touches log directories and account-specific persistence under the same `/mnt/nv/BoseApp-Persistence/1/` tree.
6. **Reboot into setup mode.** Speaker drops Wi-Fi, comes back as its own AP `Bose SoundTouch XXXX` on 192.0.2.1.
## Implications for migration ordering
The DELETE in step 2 only reaches AfterTouch if the speaker's `margeURL` already points at AfterTouch *at the moment of reset*. A speaker still pointing at `streaming.bose.com` sends it into the void → AfterTouch keeps a stale `account/{id}/device/{id}` entry until someone manually prunes it.
Therefore for a clean datastore lifecycle on an already-Bose-paired speaker:
1. Migrate URLs first (`setup migrate --method=resolv` or `--method=telnet`).
2. Reboot to apply.
3. Factory reset.
4. Re-provision.
`soundtouch-cli setup plan --reset` currently runs factory-reset first (optimal for already-on-AfterTouch speakers); both `setup plan --reset` and `setup factory-reset` print a one-line note explaining the ordering tradeoff so users can pick the right sequence for their starting state.
## Implications for AfterTouch behaviour
- The DELETE handler is already correct; no changes needed.
- AfterTouch is invisible to the LAN-peer notification step — that's just LAN HTTP between speakers.
- If you build a "consolidate account" / "migrate fleet" feature later, the peer-notification channel is the propagation path the firmware uses internally; AfterTouch doesn't need to do anything analogous.
- The persistence layer at `/mnt/nv/` is **factory-reset-resistant**. Our DNS-redirect migration (`setup migrate --method=resolv`) writes there specifically so AfterTouch routing survives a reset. This is intentional — the user can factory-reset a speaker freely without re-running migration.
## Open questions
- Are there other peer endpoints the firmware POSTs to besides `:8090/notification`? Worth checking on a 3-speaker LAN.
- Does the `:8090/notification` payload format match the format used for play-as-notification audio pushes, or is it a distinct message shape? The "size(58)" byte count is too small for an audio URL but big enough for an XML envelope with an event type.
If you want either of these answered, capture two synchronised `logread -f` streams from two LAN speakers while one is being reset.
## Runbook — reset & re-provision an ST10 on AfterTouch
End-to-end command sequence used during the 2026-05-12 bare-pairing experiment, recorded verbatim from the test session. Replace IPs, SSID, password, service URL, and account ID with your own. Two manual Wi-Fi switches happen between `factory-reset` and `wifi-push` (host joins the speaker's AP) and again between `wifi-push` and `wait-online` (host re-joins home Wi-Fi).
```bash
# === 1. Reconnaissance — confirm what state the speaker is in before touching it. ===
# Identity, network, sources, presets.
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup inspect
# Green/red status across every migration axis (SSH, telnet, CA, pairing, …).
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup verify \
--service-url=https://soundtouch.fritz.box
# What `setup plan --reset` would recommend, so you can preview the sequence.
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup plan \
--service-url=https://soundtouch.fritz.box --reset
# === 2. Reset and Wi-Fi re-provisioning. ===
# Tell the speaker to wipe itself. Speaker drops Wi-Fi and reboots into AP mode.
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup factory-reset
# Manual: switch this host to the speaker's setup AP.
# macOS: networksetup -setairportnetwork en0 "Bose SoundTouch XXXX"
# Poll 192.0.2.1:8090/info until the speaker answers (interval=2s, timeout=5m).
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup wait-ap
# Push home Wi-Fi credentials. NOTE the single-quoted password: zsh expands `!`
# inside double quotes as history-expansion and will refuse the command.
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup wifi-push \
--ssid="wifi-name" --pass='a.secure!password'
# Manual: switch host back to home Wi-Fi.
# macOS: networksetup -setairportnetwork en0 "wifi-name" 'a.secure!password'
# mDNS-poll for the speaker on the home network, matched by deviceID suffix
# (which survives the reset since it's the MAC). Returns the new IP.
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup wait-online --match=536A98
# === 3. Clock, migrate, pair. From here on use the new IP wait-online reported. ===
# Set the speaker's wall-clock. `clock set --time=now` fails on FW 27;
# `clock now` is the working subcommand.
go run ./cmd/soundtouch-cli --host 192.0.2.123 clock now
# Reboot to clear any half-initialized resolver / NTP state from the wifi-push flap.
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup reboot
# Apply DNS-redirect migration: routes *.bose.com to AfterTouch and installs its CA.
# Idempotent; safe to re-run.
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup migrate \
--service-url=https://soundtouch.fritz.box --method=resolv
# Reboot again so the envswitch parallel-persistence layer and the resolv hook
# both take effect on the next boot.
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup reboot
# Pair the device with an AfterTouch account — bare experiment variant.
# Drop --mode=bare and add --name=… / --language=… for the full state-machine variant.
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup pair \
--mode=bare --account=1111111 --service-url='https://soundtouch.fritz.box'
# === 4. Verify. ===
# Reboot to verify persistence survives.
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup reboot
# Snapshot the result. margeAccountUUID should still equal --account, and Sources
# should list ~14 entries (TUNEIN, RADIO_BROWSER, LOCAL_INTERNET_RADIO,
# SPOTIFY slots, AIRPLAY, etc.) materialized by the firmware.
go run ./cmd/soundtouch-cli --host 192.0.2.123 setup inspect
```
Total wall-clock for the above on this hardware: roughly 5 minutes including the two manual Wi-Fi switches and three reboots.
+215
View File
@@ -0,0 +1,215 @@
# 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:
> If we open a WebSocket to a factory-reset speaker and send **only** `setMargeAccount` — no surrounding setupState messages — does the device honor it and write its persistence files (`SystemConfigurationDB.xml`, `Sources.xml`) cleanly?
The answer determines the shape of `PairAccount`:
- **If YES:** `PairAccount` becomes uniform: WebSocket-first, HTTP `/setMargeAccount` second, telnet `envswitch accountid set` third. One function, one ordering, all callers.
- **If NO:** WebSocket pairing is only meaningful inside the full state machine. Factory-reset path uses the state machine; re-pair path keeps today's HTTP→telnet ordering.
## Preconditions
- A SoundTouch speaker that has been **factory-reset** and joined to the test Wi-Fi.
- Speaker reachable on `:8090` (HTTP API) and `:8080` (WebSocket).
- Speaker's runtime marge URL already points at AfterTouch (run the existing telnet URL rewrite first — otherwise the device's downstream POST will land on the dead Bose cloud and we will not be able to distinguish "WS message refused" from "downstream cloud failed").
- A free 7-digit account ID — for example, generated via `setup.GenerateAccountID(nil)`.
## Step 0 — Baseline
```bash
DEVICE=192.168.x.x
curl -s http://$DEVICE:8090/info | xmllint --format -
curl -s http://$DEVICE:8090/sources | xmllint --format -
curl -s http://$DEVICE:8090/presets | xmllint --format -
```
Record:
- `<margeAccountUUID>` — expect empty on a factory-reset device.
- `<margeURL>` — expect the AfterTouch URL (preflight already applied).
- `<sources>` — expect a minimal list.
- `<presets>` — expect `<presets/>`.
## Step 1 — Send bare `setMargeAccount` over WebSocket
Build the CLI once:
```bash
make build
```
Then run the bare path against the speaker:
```bash
DEVICE=192.168.x.x
./build/soundtouch-cli setup pair --host=$DEVICE --account=1234567 --mode=bare
```
What it does:
1. Reads `/info` to discover `deviceID`, logs the pre-state.
2. Opens a WebSocket to `$DEVICE:8080` with the `gabbo` subprotocol.
3. Sends exactly one frame — the `setMargeAccount` envelope — **without** any preceding `SETUP_START`/`SETUP_ENTER`.
4. Reads frames for up to `--step-timeout=8s` (configurable), looking for an ack referencing our `requestID`.
5. Closes the WebSocket, waits 2 s, re-reads `/info`, prints whether `margeAccountUUID` now equals our supplied ID.
The exact frame sent (built by `setup.SetupSession.SetMargeAccount`):
```xml
<msg><header deviceID="DEVICE_ID" url="setMargeAccount" method="POST"><request requestID="1"/></header><body>
<PairDeviceWithAccount>
<accountId>1234567</accountId>
<userAuthToken>Bearer aftertouch</userAuthToken>
</PairDeviceWithAccount>
</body></msg>
```
Outcomes the CLI will surface:
- `Device accepted bare pairing.` (post-`/info` shows our ID) → **bare path works**.
- `setMargeAccount: device rejected setMargeAccount: …` → device returned an `<error>` body → **bare path refused explicitly**.
- `setMargeAccount: await ack for setMargeAccount: …` (timeout or EOF) → **bare path refused silently**.
- `Device did NOT persist the pairing — bare path likely refused silently.` → ack received but persistence didn't follow.
## Step 2 — Record outcome
After step 1 (regardless of which branch happened):
```bash
sleep 2
curl -s http://$DEVICE:8090/info | grep margeAccountUUID
```
| Observed result | Verdict |
|------------------------------------------------------------------------------|-----------------------------|
| `<margeAccountUUID>1234567</margeAccountUUID>` appears | **YES** — Option 1 wins |
| `<margeAccountUUID></margeAccountUUID>` still empty, no error frame received | Refused silently → **NO** |
| Error frame returned (e.g. `<error name="UNSUPPORTED_STATE"/>`) | Refused explicitly → **NO** |
| Device drops the WebSocket connection without replying | Refused → **NO** |
If verdict is YES, also verify the device wrote persistence cleanly. Reboot the device, then:
```bash
ssh root@$DEVICE 'cat /mnt/nv/BoseApp-Persistence/1/SystemConfigurationDB.xml'
ssh root@$DEVICE 'cat /mnt/nv/BoseApp-Persistence/1/Sources.xml'
curl -s http://$DEVICE:8090/info | grep margeAccountUUID
```
The UUID must still be present after reboot, and `SystemConfigurationDB.xml` must contain `<AccountUUID>1234567</AccountUUID>`. If it survives reboot, **YES** is confirmed.
## Step 3 — Control: full state machine
Factory-reset the same speaker again and run the full state machine — the same CLI, `--mode=full`:
```bash
./build/soundtouch-cli setup pair --host=$DEVICE --account=1234567 --mode=full
```
This drives `setup.Manager.ExecuteInitPlan` with `SkipURLRewrite=true`, which runs:
```
SETUP_START
SETUP_IDENTIFY_DEVICE_ENTER
language sysLanguage=2
SETUP_ENTER
SETUP_IDENTIFY_DEVICE_LEAVE
setMargeAccount …
SETUP_LEAVE
pushCustomerSupportInfoToMarge
```
The CLI logs every step with status. Confirm `/info`, persistence, and reboot-survival checks pass. If the bare path failed but the full path succeeds, the SETUP bracket is load-bearing — a follow-up bisect (e.g. `SETUP_START + setMargeAccount + SETUP_LEAVE` only) tells us *which* surrounding messages the firmware actually requires.
## Full reset-and-rebuild loop
Once the bare/full question is decided, the loop for repeated experiments is:
```bash
# 0. Speaker is currently on home Wi-Fi at $DEVICE.
# Capture deviceID-suffix + current SSID first so wait-online and
# wifi-push have the right inputs.
./build/soundtouch-cli setup inspect --host=$DEVICE
./build/soundtouch-cli setup factory-reset --host=$DEVICE
# 1. Manually switch this host to the speaker's AP (Bose SoundTouch XXXX).
# macOS: networksetup -setairportnetwork en0 "Bose SoundTouch XXXX"
./build/soundtouch-cli setup wait-ap
./build/soundtouch-cli setup wifi-push --ssid="$HOME_SSID" --pass="$HOME_PASS"
# 2. Manually switch this host back to home Wi-Fi.
./build/soundtouch-cli setup wait-online --match=DE4803 # deviceID suffix from /info before reset
# (note the new IP from the "Speaker discovered" line)
NEW_IP=192.168.x.y
./build/soundtouch-cli setup migrate --host=$NEW_IP --service-url=http://aftertouch.local:8000 # default --method=telnet
# Optional, if you want the DNS-redirect path instead of (or alongside) telnet envswitch:
# 1. ./build/soundtouch-cli setup ssh-check --host=$NEW_IP # USB-stick procedure if 22 is closed
# 2. ./build/soundtouch-cli setup install-ca --host=$NEW_IP --service-url=http://aftertouch.local:8000
# 3. ./build/soundtouch-cli setup migrate --host=$NEW_IP --service-url=http://aftertouch.local:8000 --method=resolv
./build/soundtouch-cli setup pair --host=$NEW_IP --mode=bare # or --mode=full
```
The two manual lines are user-side Wi-Fi switches that can't be automated portably. The `wait-ap` and `wait-online` subcommands poll for the corresponding network state, so timing them is hands-off.
## Recording the result
Append to this file under `## Results`:
```
- Date: YYYY-MM-DD
- Firmware: 27.x.x
- Model: ST10 / ST20 / ST30 / ST300
- Bare setMargeAccount accepted: yes/no
- Persistence written: yes/no
- Survives reboot: yes/no
- Notes: ...
```
One row per device tested. Once two devices on different firmware confirm the same verdict, we treat it as decided.
## Results
- Date: 2026-05-13
- Firmware: 27.0.6.46330.5043500 (build epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29)
- Model: SoundTouch 10 (deviceID AABBCCDDEEFF)
- Bare setMargeAccount accepted: **yes** — pre-/info margeAccountUUID="" → post-/info margeAccountUUID="1111111"
- Persistence written: **yes** — device materialized 14-entry Sources.xml on its own
- Survives reboot: **yes**`setup inspect` after `setup reboot` shows margeAccountUUID still 1111111
- Notes: After bare pairing, the speaker did the full post-pairing handshake against AfterTouch (POST /streaming/support/power_on, GET /streaming/sourceproviders, GET /streaming/account/{id}/full, group/, provider_settings). No SETUP_START/SETUP_ENTER/SETUP_LEAVE was ever sent. Verdict: bare path is functionally equivalent to the full state machine on this firmware.
### Implication for the codebase
- `pkg/service/setup/setup_session.go` keeps the full state machine for completeness, but
- `pkg/service/setup/init_plan.go`'s default could be simplified to "send setMargeAccount only" once we have one more confirming run on a different model.
- The OCT issue-167 SSH-XML seeding workaround is **not required**.
### Appendix — SystemConfigurationDB.xml comparison
Post-experiment we compared the device-written `/mnt/nv/BoseApp-Persistence/1/SystemConfigurationDB.xml` from the bare-paired speaker against two SSH backups taken from speakers originally paired by the official Bose app (account 1000001, devices `A_Sound_Machine` and `Sound_Machinechen`). The diff is much smaller than expected — only two fields differ, and neither is set by the pairing protocol itself:
| Field | Bare-paired (1111111) | Real-Bose-paired (1000001) | Set by |
|--------------------------|--------------------------------------------|----------------------------|-----------------------------------------------------------------------------------------------------------|
| `DeviceName` | `Bose SoundTouch 536A98` (factory default) | `Living Room SoundTouch` | `name` WS message — only sent in `--mode=full` |
| `AccountAssociatedEMail` | empty | **empty** | Never populated, even by real Bose |
| `AccountUUID` | `1111111` | `1000001` | `setMargeAccount` — both paths set it |
| `Locale` | empty | **empty** | Never populated, even by real Bose |
| `acctMode` | `global` | `global` | Firmware-default; no protocol path observed to change it |
| `isMultiDeviceAccount` | `false` | `true` | Derived from the cloud's `/streaming/account/{id}/full` response — count of `<devices>` > 1 flips it true |
| `margeAuthServerToken` | empty | **empty** | Never populated, even by real Bose |
| `Password` | (encrypted blob) | (encrypted blob) | Device-local key; expected to differ |
Three of the seven informational fields are empty even after a real-Bose pairing — the firmware simply doesn't populate `AccountAssociatedEMail`, `Locale`, or `margeAuthServerToken` from the pairing flow. So bare pairing isn't missing any field that real pairing fills.
The two genuinely different fields:
- **`DeviceName`** — pure UX. Settable any time post-pair via `name` POST (`soundtouch-cli name set --value=…`) or by sending the `name` WS message during `--mode=full` pairing.
- **`isMultiDeviceAccount`** — not a pairing concern. It's derived from the account's device count on AfterTouch's side; flips to `true` automatically the next time the speaker refreshes account state if a second speaker has been paired to the same account.
So the experiment's YES verdict stands unqualified: bare `setMargeAccount` produces a `SystemConfigurationDB.xml` functionally equivalent to one written by the official pairing flow.
+2 -2
View File
@@ -5,8 +5,8 @@ This document provides a comprehensive analysis of the `/supportedURLs` endpoint
## Discovery Summary
**Test Devices:**
- Device 1: `192.168.178.28:8090` (deviceID: `08DF1F0BA325`)
- Device 2: `192.168.178.35:8090` (deviceID: `A81B6A536A98`)
- Device 1: `192.0.2.11:8090` (deviceID: `08DF1F0BA325`)
- Device 2: `192.0.2.10:8090` (deviceID: `AABBCCDDEEFF`)
**Key Findings:**
- Both devices return identical endpoint lists
+271
View File
@@ -0,0 +1,271 @@
# Bose SoundTouch Telnet (Port 17000) Command Reference
A consolidated reference for the diagnostic shell that listens on TCP port
17000 across the SoundTouch line. Compiled from multiple community sources
to give a single map of what's been observed in the wild — useful both for
implementing automation against it (see
[TELNET-MIGRATION-METHOD.md](TELNET-MIGRATION-METHOD.md)) and for manual
recovery / WiFi setup.
> **Important caveat.** The command set is firmware-dependent. Anything that
> existed in firmware 1.x7.x (`flarn2006`'s era) was progressively trimmed;
> some commands listed here have been removed on firmware 27.x. Where a
> command's availability is known to vary, the **Availability** column says so.
### Telnet via Docker (when not installed locally)
```shell
docker run --rm --name telnet -it --env IP=192.0.2.123 alpine:edge ash -c 'apk add -U busybox-extras && telnet $IP 17000'
```
## Sources
| # | Source | Era / focus |
|----|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| S1 | [flarn2006: "Hacking the Bose SoundTouch and its Linux insides"](https://flarn2006.blogspot.com/2014/09/hacking-bose-soundtouch-and-its-linux.html) (2014) | Firmware 1.x7.x; root shell discovery, codenames |
| S2 | [Sam Hobbs: "Connect Bose SoundTouch 10 to WiFi using Linux Telnet"](https://samhobbs.co.uk/2016/01/connect-bose-soundtouch-10-wifi-using-linux-telnet) (2016) | ST 10 setup mode; `network`/`sys` families |
| S3 | [izndgroup: "Connect Bose SoundTouch 10 to WiFi"](https://technical.izndgroup.com/2021/02/connect-bose-soundtouch-10-to-wifi.html) (2021) | Reissue of S2 with later-firmware notes |
| S4 | [sijeffrey/SoundTouch — `bose` script](https://github.com/sijeffrey/SoundTouch/blob/master/bose) (2017) | `nc`-based remote-control script using `sys`/`ws` |
| S5 | [r/bose "SoundTouch telnet probing"](https://www.reddit.com/r/bose/comments/1o5zkym/soundtouch_telnet_probing/) | Recent (post-EOS) probing on ST 10 firmware `27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29`; comments mirrored in [#221](https://github.com/gesellix/Bose-SoundTouch/issues/221) |
| S6 | Issue [#221](https://github.com/gesellix/Bose-SoundTouch/issues/221), [#236](https://github.com/gesellix/Bose-SoundTouch/issues/236), [deborahgu/soundcork#141](https://github.com/deborahgu/soundcork/issues/141) | The migration commands we already implement |
---
## Connecting to the shell
### From an already-on-network device
The shell binds to TCP port 17000 on every device family observed (ST 10/20/300, Wave III/IV, ST 520, SA-5 — see §"Firmware era notes" for caveats). No authentication.
```bash
# A no-op probe just to verify reach.
echo '' | nc -w 2 <device-ip> 17000
# Or interactively — works the same.
telnet <device-ip> 17000
```
The `bose` script (S4) goes one level lower and writes commands directly to a `/dev/tcp/<ip>/17000` redirection target instead of using `nc`. That's the same wire protocol with no library between.
### From a factory-fresh / WiFi-less device
Per S2/S3 — newer firmware may have closed this on some models:
1. **Enter setup mode.** Press and hold key **2** + **volume down** for 5 seconds until the WiFi LED turns amber.
2. **Connect your laptop to the speaker's open access point.** The speaker becomes its own AP.
3. **Telnet to `192.0.2.1` on port 17000.**
Once you've added a WiFi profile (see `network wifi profiles add` below) the speaker reboots into station mode and the AP goes away.
### Hardware key combinations on the device itself
| Combo | Effect | Source |
|-------------------|------------------------------------------|--------|
| `1` + volume-down | Factory reset | S2, S3 |
| `2` + volume-down | Setup mode (open WiFi AP at `192.0.2.1`) | S2, S3 |
| `3` + volume-down | Toggle WiFi / Bluetooth | S2, S3 |
| `4` + volume-down | Check for software updates | S2, S3 |
---
## The `network` family — WiFi & interfaces
| Command | Purpose | Availability | Source |
|------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------|----------------------------|--------|
| `network wifi status` | Current SSID, state (e.g. `WIFI_STATION_CONNECTED`), signal strength. Returns XML-like `<WiFiStatus SSID="…" state="…">`. | Wide | S2, S3 |
| `network wifi scan [<maxresults>]` | Site survey. | Wide | S2 |
| `network wifi profiles info` | Lists stored WiFi profiles (passphrases shown encrypted). | Wide | S2, S3 |
| `network wifi profiles add <ssid> <security> [<password>]` | Adds a WiFi network. `<security>``none` \| `wep` \| `wpa_or_wpa2`. | Wide; setup-mode workhorse | S2, S3 |
| `network wifi profiles clear` | Wipes all stored profiles. | Wide | S2 |
| `network status` | All interfaces and IP addresses. | Wide | S2, S3 |
| `network dhcp` | Current DHCP interface info. | Wide | S2 |
| `network mode auto\|wifioff\|wifisetup` | Switch radio / setup-AP state. | Wide | S2 |
**Example session — adding a network from setup mode (S3):**
```
network wifi profiles add foobarHub wpa_or_wpa2 topsecret
```
The speaker stores the profile, drops the setup AP, and reboots into station mode.
---
## The `key` family — front-panel button emulation
Each `key …` command emulates a press of a physical button on the speaker
or remote. Confirmed working on ST 10 / FW `27.0.6.46330.5043500` (S5);
also visible on the ST 20/300/Wave captures in #221. Different from the
`sys presetkey N p` form (S4) — the `key prefix_N` shape on FW 27 is what
the device's own remote sends.
| Command | Effect | Source |
|---------------------------------|---------------------------------------------------------------------------------------|--------|
| `key prefix_1``key prefix_6` | Triggers preset 16 (same as a remote preset press). | S5 |
| `key play` | Begin / resume playback. | S5 |
| `key pause` | Pause playback. | S5 |
| `key stop` | Stop playback (does **not** terminate the underlying stream). | S5 |
| `key prev` | Restart current song / previous track. | S5 |
| `key next` | Next track. | S5 |
| `key aux` | Toggle Bluetooth / AUX input. | S5 |
| `key power` | Echoes "OK" but no observable effect on FW 27.x — possibly handled at a higher layer. | S5 |
The S4 `bose` script's `sys presetkey N p` form still works, but `key prefix_N` is shorter and matches what the remote already does on FW 27.x.
---
## The `sys` family — system control & service URLs
The `sys` family is the one our migration uses (see §"What we use during migration"). Two distinct sub-syntaxes coexist:
- **Single-token verbs:** `sys reboot`, `sys volume`, `sys power`, etc.
- **`sys configuration <key> <value>` setters** that modify persisted runtime configuration. Used for the four service URLs (margeServerUrl, statsServerUrl, swUpdateUrl, bmxRegistryUrl).
| Command | Purpose | Availability | Source |
|---------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------|------------|
| `sys reboot` | Restart the device. | Wide | S2, S6 |
| `sys factorydefault` | Reset to factory defaults. | Wide | S1, S2 |
| `sys ver` | Firmware version string, e.g. `BoseApp version: 27.0.6.46330.5043500 …`. | Wide; confirmed on FW 27.x | S1, S5 |
| `sys power` | Toggle power. Confirmed working on older firmware via S2/S4; on FW 27.x ST 10 the response is `OK` but with **no observable effect** — power state may be controlled elsewhere on that build. | Varies | S2, S4, S5 |
| `sys playpause` | Toggle playback. | Wide | S2 |
| `sys stop`, `sys pause` | Accepted (return `OK`) but **no observable effect** on FW 27.x ST 10 — the working stop/pause path on that firmware is `key stop` / `key pause`. | Wide / no-op | S5 |
| `sys volume` | Print current volume. The S4 script parses the 5th token of the first line. | Wide | S2, S4, S5 |
| `sys volume <int>` | Set absolute volume to `<int>`. | Wide | S5 |
| `sys volume up <n>` / `sys volume down <n>` | Adjust volume by `<n>` (steps, not dB). | Wide | S4 |
| `sys volume <value> updateDisplay` | Set absolute volume and update the front-panel display. | Wide | S2 |
| `sys presetkey <1-6> p` | Trigger a preset (`p` = press). Older shape of `key prefix_<N>`. | Wide | S4 |
| `sys timeout inactivity disable` (or `off`) | Stop the auto-shutoff timer. May need to be sent twice. | Wide | S1, S2 |
| `sys configuration` (no args) | Returns the usage hint `sys configuration <XMLTag> <XMLValue>` — confirms the underlying setter is XML-tag-keyed. | FW 27.x | S5 |
| `sys configuration bmxRegistryUrl <url>` | Set the Bose Media eXchange registry URL. | Wide; **migration** | S6 |
| `sys configuration statsServerUrl <url>` | Set the telemetry/stats endpoint. | Wide; **migration** | S6 |
| `sys configuration margeServerUrl <url>` | Set the marge / streaming endpoint. | Wide; **migration** | S6 |
| `sys configuration swUpdateUrl <url>` | Set the software-update endpoint. | Wide; **migration** | S6 |
Each `sys configuration` setter is reported by users to return `OK` on success. Wait for that token between commands (S6, `foob61451`).
---
## The `envswitch` family — parallel persistence layer
`envswitch` writes to a separate, lower-level persistence store that **wins on next reboot** if the corresponding `sys configuration` value differs. So our migration writes both — see TELNET-MIGRATION-METHOD.md §2.1.
| Command | Purpose | Source |
|---------------------------------------------------|-----------------------------------------------------------------------------------------------|---------|
| `envswitch boseurls set <margeUrl> <swUpdateUrl>` | Persist the marge and update URLs. **Two arguments**, in that order. | S6 |
| `envswitch accountid set <numeric-id>` | Equivalent to the HTTP `/setMargeAccount` POST. Used as fallback in our `PairAccount` helper. | S6 |
| `envswitch accountid get` | Plausible by symmetry but **not yet confirmed** across firmwares; we probe it best-effort. | (probe) |
---
## The `getpdo` family — read persisted configuration
`getpdo <selector>` prints the contents of a persisted-data-object. We use it as the verification step after writing URLs.
| Selector | Purpose | Source |
|-------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------|
| `getpdo CurrentSystemConfiguration` | Echoes the resolved URL set, including margeServerUrl/bmxRegistryUrl/statsServerUrl/swUpdateUrl. We grep our targetURL out of this to confirm a successful migration. | S6 |
---
## The `scm` family — service control
`scm` (System Control / Module manager) lets you inspect and restart internal services.
| Command | Purpose | Availability | Source |
|-------------------------|------------------------------------------------------------------------------------------|----------------|------------------------------------------------------------------------------|
| `scm list` | List running services. | Older firmware | S1 |
| `scm restart <service>` | Restart a service by name. | Older firmware | S1 |
| `scm uboot_ver` | Print bootloader version (`U-Boot 2013.01.01-…`). Confirmed working on SA-5 with FW 9.x. | Older firmware | [deborahgu/soundcork#141](https://github.com/deborahgu/soundcork/issues/141) |
---
## Shell-unlock commands
These are the commands that gated SSH access on older firmware. Both have been progressively removed; on FW 27.x they generally do nothing useful.
| Command | Purpose | Availability | Source |
|-----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------|----------------------------------------------------------------------------------|
| `remote_services on` | Enable SSH on port 22. Volatile (re-enter after reboot). Response: `remote services on`. **Removed in FW 7.x+**. | Old | S1 |
| `local_services on` | Alternative enablement; works on some firmware where `remote_services` was removed. SA-5 FW 9.x reports `local services on`, but this alone does not appear to grant SSH on most models. | Old, hit-or-miss | S1, [deborahgu/soundcork#141](https://github.com/deborahgu/soundcork/issues/141) |
| `demo enter` / `mode enter` | Unlocks demo / button-test mode (used historically to recover bricked units). | Old | S1 |
---
## The `ws` and `swupdate` families
| Command | Purpose | Availability | Source |
|------------------|---------------------------------------------------------------------------------------------------------|--------------|--------|
| `ws getpresets` | Returns an XML list of presets — the S4 script parses the `<itemName>…<text>…` blocks to extract names. | Wide | S4 |
| `swupdate abort` | Cancel a software update in progress. | Wide | S1 |
---
## `help`
Lists the commands available on the running firmware. **Frequently removed** on later firmware — returns `Command not found` on FW 27.x in many of the captures we have. Still worth probing once during preflight: a successful response is a quick way to enumerate what this specific build supports without trial-and-error.
---
## Device codenames (S1)
These show up in `getpdo`, `network status`, and SSH-side hostnames. Useful for matching captures to hardware.
| Codename | Hardware |
|----------|------------------------------------------------|
| `lisa` | Adapter (older speakers running Bose firmware) |
| `spotty` | SoundTouch 20 |
| `rhino` | SoundTouch 10 |
| `mojo` | SoundTouch 30 |
| `taigan` | SoundTouch Portable |
---
## Firmware era notes
- **Firmware 1.x7.x** (S1 era): everything — `help`, `remote_services on`, full `scm`, and an in-shell login prompt. `flarn2006` documents the original Linux insides.
- **Firmware 8.x14.x** (S2 era): `remote_services on` removed; `network`, `sys`, `envswitch`, `getpdo` still present. `local_services on` works on some Wave/SA-5 models.
- **Firmware 27.x** (S5/S6 era — the long-lived "frozen" build that survived through EOS): `help`, `remote_services on`, and `sys ver` removed in some builds; `sys configuration …` and `envswitch …` confirmed working on ST 10, ST 20, ST 300, Wave III, Wave IV. **This is the firmware our migration targets**. The Portable on more recent firmware drops further commands and is the hardest target.
S5 enumerated the **top-level command roots** that don't return "Command not found" on a vanilla ST 10 (`rhino`) running `27.0.6.46330.5043500`:
```
key
net
sys
getpdo
```
Notably absent from that probe: `network`, `envswitch`, `scm`, `ws`, `swupdate`, `remote_services`, `local_services`, `demo`, `mode`, `help`. **However**, other captures on the same firmware family (S6, ST 20 / Wave III / Wave IV) accept `envswitch …`, suggesting either per-model variation in the shipped command table or an SSH/role gate the S5 author didn't trip. Implementations that use `envswitch` should treat its absence as a recoverable preflight outcome (we already do).
`net` is observed as a valid root by S5 but its sub-commands aren't enumerated; it may be a shorthand alias for `network` on FW 27.x ST 10.
---
## What we use during migration
For quick reference, the exact sequence our `pkg/service/setup.migrateViaTelnet` issues, all on the same connection, in this order:
```
sys configuration bmxRegistryUrl <serverURL>/bmx/registry/v1/services
sys configuration statsServerUrl <serverURL>
sys configuration margeServerUrl <serverURL>
sys configuration swUpdateUrl <serverURL>/updates/soundtouch
envswitch boseurls set <serverURL> <serverURL>/updates/soundtouch
getpdo CurrentSystemConfiguration
```
Plus, when pairing a fresh device whose `:8090/setMargeAccount` is missing or wedged, the helper falls back to:
```
envswitch accountid set <7-digit-id>
```
Reboot is **not** part of these sequences — it stays a user-initiated action via the existing reboot button, which now accepts `?method=telnet|ssh` and sends `sys reboot` when telnet is picked.
---
## Out of scope here, but worth recording
- **Setup-mode WiFi onboarding via 192.0.2.1.** The community uses this to add a fresh device to a network without the Bose app. Our `soundtouch-service` does not currently automate this, but `network wifi profiles add` is the entry point if we ever do.
- **Direct preset / playback control via `sys`.** The S4 `bose` script demonstrates a viable headless remote-control path that does not need our marge emulation at all. Useful as a fallback for tooling on devices that refuse to talk to any cloud.
- **`scm restart <service>`.** Not used today, but a possible recovery primitive on older firmware where a stuck service blocks streaming.
+730
View File
@@ -0,0 +1,730 @@
# Telnet (Port 17000) Migration Method — Analysis
This document captures the use cases, community findings, and feasibility analysis
for adding a **Telnet/port 17000** migration path to `soundtouch-service` as a
peer of the existing XML and DNS-based methods. The `/etc/hosts` method stays
deprecated and is intentionally kept off the visible UI options.
> **Sources** — community discussion synthesised from
> [gesellix/Bose-SoundTouch#221](https://github.com/gesellix/Bose-SoundTouch/issues/221),
> [gesellix/Bose-SoundTouch#236](https://github.com/gesellix/Bose-SoundTouch/issues/236),
> [scheilch/opencloudtouch#167](https://github.com/scheilch/opencloudtouch/issues/167),
> [deborahgu/soundcork#228](https://github.com/deborahgu/soundcork/issues/228),
> [deborahgu/soundcork#141](https://github.com/deborahgu/soundcork/issues/141),
> the post-EOS walkthrough PDF in `docs/`,
> [Bose SoundTouch Telnet Probing thread](https://www.reddit.com/r/bose/comments/1o5zkym/soundtouch_telnet_probing/),
> and [flarn2006's blog post on hacking SoundTouch](https://flarn2006.blogspot.com/2014/09/hacking-bose-soundtouch-and-its-linux.html).
---
## 1. Why a third method is needed
The two currently shipped methods both have hard preconditions that block real
users:
| Method | Preconditions | Failure modes seen in the wild |
|-----------------------------------------|--------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **XML** (`SoundTouchSdkPrivateCfg.xml`) | SSH/root access — needs `remote_services` USB unlock first | Some firmware revisions (e.g. SA-5, ST520, latest ST Portable) refuse the USB unlock entirely; `remote_services on` was removed from the telnet command set in firmware 7.x and later. |
| **DNS** (`resolv.conf` priority hook) | SSH/root access; service must own port 53 on the LAN gateway | Won't fit users behind ISP routers they can't reconfigure; still requires the device to be SSH-reachable to write the hook. |
The community has demonstrated a **third path that needs no SSH at all**:
the device's built-in **diagnostic Telnet shell on TCP port 17000** accepts
configuration commands that change exactly the same fields the XML method would.
### 1.1 Confirmed user reports (firmware 27.0.6.46330.5043500 unless noted)
| Reporter | Hardware | Outcome |
|--------------------|---------------------------|-------------------------------------------------------------------------------------------------------------|
| `foob61451` (#221) | ST 10, ST 20 (non-rooted) | All four URLs persisted via `sys configuration …`; `envswitch boseurls set …` survived `sys reboot`. |
| `bveenker` (#221) | Wave III | URLs accepted; presets work after pairing via `/setMargeAccount` (see §3). |
| `stephan48` (#221) | Wave IV | Telnet:1700 + USB stick `remote_services` did **not** work; **port 17000 telnet** worked for all four URLs. |
| `mcdona1d` (#141) | ST 20, ST 300 | Confirmed working with `sys configuration …` + `envswitch …` + `sys reboot`. |
| `TJGigs` (#228) | ST 20 ×2, ST 10 | Wraps telnet:17000 into an admin "Smart Inject" tool; uses `sys reboot` over telnet to nudge devices. |
So the method is plausible across **at least ST 10/20/300 and Wave III/IV** on
the most common firmware that survived the EOS cut, **without the USB unlock
dance** that newer firmware refuses.
---
## 2. The Telnet:17000 command set we rely on
> For a broader catalogue of every telnet command the community has documented
> across firmware eras (the `key`, `network`, `sys`, `envswitch`, `getpdo`,
> `scm`, `ws`, `swupdate`, and shell-unlock families), see
> **[TELNET-COMMAND-REFERENCE.md](TELNET-COMMAND-REFERENCE.md)**. This
> section only lists the subset our migration actually drives.
### 2.1 URL configuration (the migration payload)
The sequence we send for `soundtouch-service` (community-validated in #221, #141):
```
sys configuration bmxRegistryUrl http://<service-host>:8000/bmx/registry/v1/services
sys configuration statsServerUrl http://<service-host>:8000
sys configuration margeServerUrl http://<service-host>:8000
sys configuration swUpdateUrl http://<service-host>:8000/updates/soundtouch
envswitch boseurls set http://<service-host>:8000 http://<service-host>:8000/updates/soundtouch
getpdo CurrentSystemConfiguration
```
`sys reboot` is **not** part of this sequence. The migration flow only writes
configuration — the reboot is user-initiated via the existing reboot button in
the web UI, mirroring what XML/DNS migration already does. See §6.2 for how
that button gains a `?method=ssh|telnet` selector.
Three important details from the discussion:
1. **`sys configuration` alone is not enough.** `stephan48` reported that
without the `envswitch boseurls set …` line his typo in `bmxRegistryUrl` was
silently restored on reboot — i.e. there is a parallel "envswitch" persistence
layer that wins on next boot if you don't also write to it. **We must always
issue both.**
2. **margeServerUrl path is bare for `soundtouch-service`.** We mount the marge
endpoints at the **root** of port 8000, matching what the existing XML
migration writes (`Manager.migrateViaXML` in `pkg/service/setup/setup.go`
sets `MargeServerUrl: targetURL` without any suffix). Some community
recipes appended `/marge` because they were targeting
[`deborahgu/soundcork`](https://github.com/deborahgu/soundcork), which
routes marge under that sub-path. **For our service: bare URL. For users
redirecting to soundcork: append `/marge`** to both `margeServerUrl` and
the first argument of `envswitch boseurls set`.
3. **Each command must be sent one at a time, waiting for the device's `OK`
response** before sending the next one (`foob61451`'s explicit warning).
### 2.2 Account pairing fallback
`envswitch accountid set <numeric-id>` was reported by `bveenker` (#221) as an
in-band equivalent to the HTTP `/setMargeAccount` call, useful when the
`/setMargeAccount` endpoint is missing on the firmware (see §3).
### 2.3 Probing / preflight
- A bare TCP connect to `<deviceIP>:17000` answers (no auth) on devices we care
about.
- Useful read-only verification command: `getpdo CurrentSystemConfiguration`
prints the URLs after the changes have been applied so we can verify before
rebooting.
- `sys reboot` is the trigger that re-reads both layers.
### 2.4 What Telnet:17000 cannot do
- It does **not** install a custom CA. So if a user wants HTTPS rather than HTTP
redirection to our service (the DNS-method scenario, where `resolv.conf`
redirection collides with the device's TLS validation unless our root CA is
trusted on the device), telnet alone won't cover it. This is fine for our
default flow, which uses plain `http://` URLs to the service's port 8000.
- It does not give us a way to read or write `Sources.xml` (third-party
account credentials) — that still requires SSH, but for a migration we don't
actually need it.
---
## 3. The `/setMargeAccount` problem (issue #236, #228)
### 3.1 What it is
A factory-reset speaker has an empty `<margeAccountUUID/>` in `:8090/info`. The
marge endpoints fail with 502 / unhandled until that field is populated, which
is why several users (#221, #236) saw **everything except AUX** broken after
migration:
```
POST http://<deviceIP>:8090/setMargeAccount
Content-Type: application/xml
<PairDeviceWithAccount>
<accountId>1234567</accountId>
<userAuthToken>soundcorkdoesntcare</userAuthToken>
</PairDeviceWithAccount>
```
The values are not validated by the local service, so any numeric `accountId`
will work — soundcork's runbook (#228) literally calls the token
`soundcorkdoesntcare` to make the point.
### 3.2 Why it's broken in practice
There are **three independent failure modes** observed:
| Symptom | Cause | Detection |
|-----------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------|
| Endpoint returns 404 / "not implemented" | Newer firmware (e.g. some BST20 Portable, latest ST Portable) drops the endpoint entirely. | `GET /supportedURLs` does **not** list `/setMargeAccount` in `<URL location="…"/>`. |
| Endpoint hangs (no response / socket stays open) | "Broken state" the user explicitly called out — endpoint advertised, but handler is wedged. | Caller has to time out; we currently have no timeout, so the request appears to hang the migration UI indefinitely. |
| `POST /marge/streaming/support/power_on` → 502 unhandled (#236) | Device keeps polling marge after migration but no `margeAccountUUID` was ever assigned, so all subsequent calls fail. | `:8090/info` shows `<margeAccountUUID/>` empty after reboot. |
### 3.3 Required handling
Per the user's brief, the migration logic must:
1. **Probe** `GET http://<deviceIP>:8090/supportedURLs` and check whether
`/setMargeAccount` is in the list **before** trying to POST it.
2. **Time-bound** the POST aggressively (e.g. ≤5s connect + ≤10s read) and treat
anything over the budget as a failure rather than waiting indefinitely.
3. On either failure mode, **fall back** to the telnet equivalent
`envswitch accountid set <id>` over the same `pkg/telnet` connection used
for the URL flip. Reboot stays a user-initiated action (§6.2).
4. If telnet:17000 is **also** unreachable, surface a clear "your firmware does
not support unattended pairing — please pair manually via the official Bose
app *before* it goes EOS, or open SSH and use the XML method" error rather
than leaving the device in a half-migrated state.
### 3.4 Where the `<id>` comes from
The device's current account ID is already discoverable through endpoints we
control:
- **`GET :8090/info`** returns `<margeAccountUUID>…</margeAccountUUID>`. If it
is non-empty the device is already paired — **reuse that ID**, do not
reassign. Our local marge accepts any ID, so the existing one is fine.
- If it is empty (factory reset), the user picks one in the UI:
1. **Pick from existing accounts.** The setup UI lists IDs returned by
`DataStore.ListAccounts()` so a user can re-attach a fresh device to an
account that already has presets/recents/sources.
2. **Enter manually.** Free-form text input, validated as **exactly 7
numeric digits** (the format every Bose-cloud-issued ID has had in the
captures we've seen, and the format the wider community uses in their
recipes).
3. **Randomize.** A "Generate" button that picks a 7-digit number and
re-rolls if it collides with an existing account in the local datastore.
- **Telnet read-back (best-effort).** `envswitch accountid get` is plausible by
symmetry with `envswitch accountid set` (#221) but is not yet confirmed
across firmwares. We will probe it during preflight; if it returns a value
we cross-check it against `:8090/info` and warn on mismatch.
This means the user is never *forced* to invent a number — the common path is
"the device already has an ID, reuse it" — and the manual/randomize controls
only show up when the device is genuinely fresh.
---
## 4. Port 17000 availability
The diagnostic shell is gated by firmware build and product family. Anecdotally:
- ST 10 / ST 20 / ST 300 / Wave III / Wave IV on FW 27.0.6 → **open**.
- SA-5 with FW 9.x → some commands present (`local_services on`) but
**no `remote_services on`** and no SSH on FW 9.0.43.23466 (#141).
- Modern firmware on some Portables → endpoint set has shrunk further.
Because of this, we cannot assume port 17000 is reachable. The migration flow
must:
1. **Probe** with a TCP connect to `<deviceIP>:17000`, with a tight timeout
(≤2s). A successful TCP handshake is necessary but not sufficient — some
hardened firmware closes the port immediately.
2. **Banner check.** After connecting, read whatever the device sends within
~1s. The diagnostic shell prints a small banner (firmware-dependent); a
blank read or an immediate close means we should treat it as "telnet not
usable" and disable the option.
3. **Capability check.** Issue a no-op like `getpdo CurrentSystemConfiguration`
and look for any non-empty response. If the device replies "Command not
found" we abort and suggest XML or DNS instead.
4. **Surface state to the UI.** The migration form should grey out the Telnet
option when the probe fails and show *why* (closed, banner missing,
command rejected) instead of letting the user click into a dead end.
---
## 5. Implementation feasibility — Telnet client in Go
This is a feasibility check only; no code is written yet.
### 5.1 Protocol
"Telnet" on port 17000 is effectively a line-oriented plain-TCP shell. The
device prints a small prompt (`->` in the SA-5 captures from #141) and reads
newline-terminated commands. There is **no** real Telnet option negotiation
(no `IAC`/`DO`/`WILL` exchanges visible in the wild captures), so we don't
need `golang.org/x/crypto/ssh`-class machinery.
### 5.2 Standard-library only
A minimal client is just `net.DialTimeout("tcp", host+":17000", 2*time.Second)` +
`bufio.Scanner` + `time.Time`-based deadlines on `Conn`. No third-party Telnet
library is needed; `github.com/reiver/go-telnet` would be overkill and adds
maintenance surface for no benefit. This matches the project's KISS principle
in `docs/CLAUDE.md` §3.
### 5.3 Cross-platform compatibility
`net.Dial` over TCP works identically on Windows, macOS, Linux and (with
limitations on listening) WASM. WASM-side: `soundtouch-service` runs server-side
anyway, so this only matters for `soundtouch-cli`, where TCP dial works in any
target other than browser-WASM — an acceptable carve-out documented separately.
### 5.4 Concurrency / safety
Each migration is a single goroutine driving one device. The client must:
- enforce per-command response deadlines so a wedged device cannot stall the
migration UI (mirrors the `/setMargeAccount` requirement);
- abort the rest of the sequence on the first non-`OK` response so we don't
half-write configuration;
- always close the socket on error.
### 5.5 Testing strategy
We can test without a real speaker by spinning up a `net.Listen("tcp", "127.0.0.1:0")`
in the test, scripting it to consume our commands and emit canned `OK`/error
responses. That gives us deterministic coverage for:
- happy path (all four URLs accepted),
- single-command failure → sequence aborts, no further commands sent,
- "command not found" on `envswitch …` → fallback path exercised,
- TCP closed mid-stream → migration aborts cleanly,
- read deadline triggers when the device hangs (the broken-state simulation).
The repo already follows the "real device responses preferred, mock servers
otherwise" rule (see `docs/CLAUDE.md` §1, §8). The tests above are the mock-server
half of that pattern.
### 5.6 Where it lives
The protocol client is **a standalone package**, not buried inside
`pkg/service/setup`, so it can be reused from CLI tools, future setup wizards,
and tests without dragging the migration manager in:
```
pkg/telnet/ # NEW reusable package
client.go # Dial / SendCommand / Probe / Close
client_test.go # mock-server tests against a net.Listen
pkg/service/setup/
telnet_migration.go # NEW thin wrapper that imports pkg/telnet
# and runs the URL config sequence
marge_pairing.go # NEW /setMargeAccount probe + post + telnet
# `envswitch accountid set` fallback
setup.go # add MigrationMethodTelnet const + case
```
UI plumbing is `pkg/service/handlers/web/index.html` (option list) and
`pkg/service/handlers/web/js/script.js` (`toggleMigrationMethod()`). The
deprecated `hosts` option is already hidden from the dropdown when we ship
this; we just add a `telnet` option next to `xml`/`resolv`.
### 5.7 Verdict
**Feasible and small.** Estimated scope: ~200 lines of client code in
`pkg/telnet`, ~300 lines of tests, plus a `MigrationMethodTelnet` branch in
`Manager.MigrateSpeaker`, plus the preflight probe described in §4 and the
`/setMargeAccount` guarding described in §3.
---
## 6. Decisions made (was: open questions)
1. **Account-ID generation.** Resolved — see §3.4. The migration form reads
`:8090/info` first; if `margeAccountUUID` is non-empty it is reused.
Otherwise the UI offers (a) pick from `DataStore.ListAccounts()`,
(b) manual entry validated as 7 numeric digits, (c) a "Generate" button
that randomizes a 7-digit number and re-rolls on collision.
2. **Reboot policy.** Migration writes configuration only — it does **not**
issue `sys reboot` itself. Reboot stays user-initiated via the existing
reboot button in the web UI, the same way XML/DNS migration already works.
That button's endpoint (`POST /setup/reboot/{deviceId}`,
`Manager.Reboot(deviceIP)`) gains an optional `?method=ssh|telnet` query
parameter; default stays `ssh` so existing behavior is preserved. The
button itself uses a plain `confirm()` dialog before firing.
3. **CA / HTTPS story.** Telnet has no way to install a custom CA. Documented
as an explicit limitation: telnet method = HTTP-only redirect to our
service. Users who need end-to-end TLS must use the XML or DNS method.
*Possible future enhancement* — a hybrid "install CA via SSH/XML, then drive
the URL flip via Telnet" path. Feasibility unknown; not in this iteration.
---
> **See §9 for the as-shipped state.** Section 7 below records the
> original forecast; the wizard grew larger during implementation and
> §9 documents what actually landed.
## 7. Summary of what changes when this lands
- **New reusable package `pkg/telnet`** — sibling of `pkg/ssh`, line-oriented
TCP client with `Dial`, `SendCommand`, `Probe`, `Close`, all deadline-driven.
No external dependencies, usable from CLI, service, and tests.
- **New `MigrationMethodTelnet = "telnet"`** constant in `pkg/service/setup/setup.go`
plus a `migrateViaTelnet` branch in `Manager.MigrateSpeaker`.
- **New `pkg/service/setup/telnet_migration.go`** orchestrating the URL
configuration sequence (§2.1) on top of `pkg/telnet`. Configuration only —
no `sys reboot` here.
- **New `pkg/service/setup/marge_pairing.go`** with `PairAccount(deviceIP, id)`:
probes `/supportedURLs`, time-bounded `POST /setMargeAccount`, falls back to
telnet `envswitch accountid set <id>` on missing/wedged endpoint.
- **`Manager.Reboot` and `HandleRebootDevice` gain a method selector** —
signature changes to `Reboot(deviceIP string, method RebootMethod) (string, error)`
with `RebootMethodSSH` (default, today's behavior) and `RebootMethodTelnet`
(sends `sys reboot` over a fresh `pkg/telnet` connection). Handler reads
`?method=ssh|telnet` from the query string.
- **`MigrationSummary` gains** `TelnetReachable`, `TelnetBanner`,
`TelnetCommandsAccepted`, `SetMargeAccountSupported`, `CurrentAccountID`,
`KnownAccountIDs` so the UI can show preflight outcomes and offer reuse.
- **UI**`web/index.html` dropdown gets a `telnet` option (greyed out when
preflight fails) and a new pane for picking/entering/randomizing a 7-digit
account ID when `:8090/info` reports an empty `margeAccountUUID`. The
existing reboot button gets a method selector (radio or dropdown) wired to
the new query param, with `confirm()` before firing. The legacy `hosts`
option stays out of the dropdown (deprecated).
---
## 8. Device compatibility today
What follows is the current best read on which devices our `migrateViaTelnet`
flow handles end-to-end, derived from the same six sources catalogued in
[TELNET-COMMAND-REFERENCE.md](TELNET-COMMAND-REFERENCE.md) plus the issue
threads cited above. This is migration-outcome perspective; for per-command
availability see the reference doc.
### 8.1 Proven to work end-to-end
All on the firmware-27.0.6 family, which is what survived through Bose's
end-of-service cut. Multi-reporter agreement on every row.
| Device | Reporter(s) | Source | Confirmed |
|----------|-----------------------------|------------------|----------------------------------------------------------------------------|
| ST 10 | foob61451, TJGigs | #221, #228 | All four URLs persist; `envswitch boseurls set` survives `sys reboot` |
| ST 20 | foob61451, mcdona1d, TJGigs | #221, #141, #228 | Same; multiple independent reports |
| ST 300 | mcdona1d | #141 | `sys configuration` + `envswitch` + `sys reboot` round-trip |
| Wave III | bveenker | #221 | URLs accepted; presets work after pairing fallback (§3) |
| Wave IV | stephan48 | #221 | Port-17000 path **was the only one that worked** — USB-stick unlock failed |
The exact sequence each reporter ran by hand is the sequence our migration
sends (§2.1). So the migration's happy path is exercised against five
hardware variants in independent captures.
### 8.2 Proven to need the pairing fallback
Migration of the URLs themselves works on these models, but
`POST /setMargeAccount` is missing or wedged on the firmware build, so
pairing has to go through the telnet `envswitch accountid set <id>` path
that `setup.PairAccount` already implements.
| Device | Reporter | Source | Why fallback is needed |
|--------------------------------|----------|-----------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|
| ST Portable / FW 27.0.6 | jmosen | #236 | After migration: `POST /marge/streaming/support/power_on` → 502; `<margeAccountUUID/>` empty. Time-bounded HTTP path fails; envswitch fallback succeeds. |
| BST20 Portable (factory reset) | ubittner | scheilch/opencloudtouch#167 | `<margeAccountUUID/>` empty; `/setMargeAccount` not in `/supportedURLs`. HTTP path skipped entirely; only the telnet fallback works. |
### 8.3 Likely to fail (but the failure is clean)
Our preflight + abort-on-first-rejection design (`TestMigrateViaTelnet_CommandNotFoundAborts`)
means none of these scenarios leave a device half-configured. The user is
told what failed and pointed to the XML or DNS method.
| Device | Source | Likely cause |
|--------------------------------------------|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **SA-5** (sound amplifier) on FW 9.0.43.x | soundcork#141 | FW 9.x has a different shell generation: `->` prompt, `local_services on`, `scm uboot_ver`. **`sys configuration` and `envswitch` are not documented as working there.** Migration fails on command #1. |
| **Recent ST Portable** (post-27.0.6.46330) | #236 (indirect) | `/setMargeAccount` removal points to broader command-set shrinkage. If `envswitch accountid set` is also gone, both migration and pairing fallback fail; user is told to pair via the official Bose app before EOS, or use XML over SSH. |
### 8.4 Unknown — would benefit from real-device verification
| Device | Why unknown | What we'd want to confirm |
|----------------------------|---------------------------------------------------------------------|--------------------------------------------------------------------------|
| **ST 30** (`mojo`) | No concrete capture in any of the six sources | Almost certainly works — same FW family as ST 10/20/300 — but unverified |
| **ST 520 / Home Cinema** | USB-unlock reports failing (#141), no port-17000 capture either way | Whether `sys configuration` and `envswitch` are exposed at all |
| **Wave Music System I/II** | `flarn2006`-era hardware, not seen in 27.x reports | Whether port 17000 is even open on those models |
### 8.5 The S5 "valid roots" tension
S5 (the r/bose telnet-probing thread) lists only `key`, `net`, `sys`,
`getpdo` as command roots that don't return "Command not found" on its
ST 10 / FW 27.0.6 — which would seem to rule out `envswitch`. But foob61451
on the same hardware/firmware ran `envswitch boseurls set` successfully
(#221).
The most plausible reading is that **S5 is a non-exhaustive probe**, not a
negative claim: the author writes "I've made some educated guesses and come
up with the following valid commands" and never says they tested
`envswitch`. We do not down-weight `envswitch` availability on the strength
of S5 alone — but if a real-device run ever shows `envswitch` rejected on
an ST 10, our preflight catches it, the migration aborts on the first
non-OK response, and the user gets a clear error rather than partial state.
### 8.6 Failure-mode matrix
What `migrateViaTelnet` does in each failure mode (verified by
`pkg/telnet` and `pkg/service/setup` unit tests):
| Failure | Outcome | Test |
|------------------------------------------------|---------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------|
| Port 17000 closed / TCP unreachable | `Dial` errors before any command is sent; UI shows the error; nothing persisted | `TestMigrateViaTelnet_DialFailureReturnsError` |
| `sys configuration` rejected (cmd #1) | Sequence aborts; verification not sent; rest of commands not attempted | `TestMigrateViaTelnet_CommandNotFoundAborts` (envswitch variant — generalises) |
| `envswitch boseurls set` rejected | Sequence aborts; runtime-only `sys configuration` state reverts on reboot — no permanent damage | `TestMigrateViaTelnet_CommandNotFoundAborts` |
| Verification mismatch (URLs not echoed back) | Loud "verification failed" error; live state may persist until reboot but UI never claims success | `TestMigrateViaTelnet_VerifyMismatchFails` |
| `/setMargeAccount` 502 / hang | 5s connect + 12s total budget enforced; falls through to telnet `envswitch accountid set` | `TestPairAccount_FallsBackWhenHTTPReturnsServerError` |
| `/setMargeAccount` missing in `/supportedURLs` | HTTP path skipped; goes straight to telnet `envswitch accountid set` | `TestPairAccount_FallsBackWhenSetMargeAccountMissing` |
| Both pairing paths unavailable | Structured error: "use the official Bose app before EOS, or open SSH and use the XML method" | `TestPairAccount_NoTelnetAndHTTPMissingReturnsClearError`, `TestPairAccount_TelnetCommandNotFoundReportsBothPaths` |
### 8.7 TL;DR
- **Green light** — ST 10, ST 20, ST 300, Wave III, Wave IV on FW 27.0.6 (multi-reporter agreement).
- **Yellow** — ST Portable and BST20 Portable: migration works, pairing needs our fallback (already implemented).
- **Red, but fails cleanly** — SA-5 on FW 9.x, possibly newer ST Portable builds.
- **Unverified but expected to work** — ST 30, ST 520, Wave Music System I/II.
The most useful next verification step is touching a real ST 30 and ST 520
— those are the two "expected to work" models with zero concrete captures.
Beyond that, every behaviour the doc predicts is exercised by the unit
tests in `pkg/telnet` and `pkg/service/setup`.
---
## 9. What actually shipped (post-implementation addendum)
§7 forecast the surface area roughly; the wizard ended up larger. This
section is the present-day map of the migration tab and the supporting
backend pieces — kept appended rather than rewritten in place so the
feasibility analysis above stays a faithful design record.
### 9.1 Three-axis state model
`MigrationSummary` now exposes the four mechanism-specific booleans
that `checkIsMigrated` writes individually:
- `XMLMigrated` — parsed SoundTouchSdkPrivateCfg.xml's URLs point at us.
- `HostsMigrated``/etc/hosts` carries Bose-domain redirects (the
deprecated method, kept detectable for legacy speakers).
- `ResolvMigrated` — the `/etc/resolv.conf` priority-nameserver hook
is in place (with CA trusted).
- `TelnetMigrated``getpdo CurrentSystemConfiguration` reports the
service hostname.
`IsMigrated` is the OR. Plus `IsPaired` from the live
`:8090/info.margeAccountUUID` value.
The frontend opens with a state card that surfaces three orthogonal
axes derived from these flags:
| Axis | Verdict semantics |
|-------------------|-----------------------------------------------------------------------------------------------------------------------|
| URL Configuration | URL flip active → ✅; original Bose URLs + DNS hook active → ✅ (intercepted); original + no DNS → ❌ (not intercepted). |
| DNS Interception | None / resolv.conf hook / /etc/hosts (with deprecated badge). |
| CA / TLS | Local root CA installed yes/no. |
Plus a Preconditions row: `remote_services` persistence, account
pairing state, XML config backup presence. Action affordances
(`Trust CA Now`, `Download CA cert`) live inline next to their verdicts.
### 9.2 Plan card with per-field URL editor
Replaces the XML method's `self/proxied/original` dropdowns and the
duplicate URL inputs that used to live inside the telnet method pane:
- Target service URL input with `Save as default` (POSTs to
`/setup/settings`, preserving the `***` secret-unchanged convention).
- Capabilities header: detected transports (SSH / Telnet:17000) and
the recipes AfterTouch can offer given those transports.
- Service URLs table: four free-form URL inputs (Marge / Stats /
SwUpdate / BmxRegistry) with on-keystroke validation
(`validatePlanURLs`), a Soundcork-mode checkbox that flips `/marge`
on `margeServerUrl`, and a `Reset to defaults` button.
- Account pairing section: ID input + Generate + datastore picker;
the implicit intent (`readPlanPairTarget`) queues a pair step at
Apply when the input differs from the current `account_id`.
- Suggested plan box: one-click conservative default — XML + HTTP
when SSH works, Telnet + HTTP otherwise; "Already migrated" info
state when `IsMigrated` is already true.
The per-field URLs feed both XML and Telnet migrations via the
`marge_url` / `stats_url` / `sw_update_url` / `bmx_url` option family
(see §9.6). Live preview rewrites `#planned-config` purely client-side
on every keystroke — optimistic; the backend's perspective gates the
write via §9.4's pre-flight.
### 9.3 Customize three-axis form
The `<details>` "Customize this migration" section replaces the old
migration-method dropdown with three independent radio groups:
1. **URL flip transport**: XML / Telnet:17000 / Skip.
2. **DNS interception**: None / `/etc/resolv.conf` hook.
3. **Local CA install**: checkbox.
Each option carries a per-axis availability hint
(`(SSH unreachable)`, `(already trusted)`, etc.) so users see *why*
an option is disabled. `applyCustomPlan` orchestrates the chosen
combination as a sequence of existing backend calls
(`/setup/migrate?method=…` for each flip/resolv step plus
`/setup/trust-ca` for standalone CA install, and the queued pair
step from §9.2). Resolv already bundles a CA install, so a redundant
standalone CA step is skipped. First failure aborts the rest.
### 9.4 Pre-flight panel
Both Apply paths run a visible pre-flight panel before any backend
operation touches the speaker. Each check renders inline with the
🕐 / ⟳ / ✅ / ❌ / — idiom. On all-green the panel holds for ~700ms so
the success state registers, then auto-proceeds. On any failure the
panel surfaces `Proceed Anyway` / `Cancel` buttons; default is to
abort.
Checks:
| Check | When | Backend route |
|---------------------------------------|------------------------------------------------------------|--------------------------------|
| Backend summary re-check | always | `GET /setup/summary` |
| HTTPS connection from device | `ssh_success && server_https_url` | `POST /setup/test-connection` |
| Reachability check (passive observer) | `telnet_reachable && is_migrated` (see §9.8) | `POST /setup/peer-probe` |
| Round-trip skip explainer | `telnet_reachable && !is_migrated` — runs after reboot | _none_ (UI-side skip row) |
| DNS redirection from device | `methods.includes("resolv") && ssh_success` | `POST /setup/test-dns` |
The HTTPS check uses `use_explicit_ca=true` so it exercises the trust
path even when CA install is part of the plan (i.e. forward-looking).
The reachability skip row is explicit ("neither SSH nor Telnet:17000
is reachable") rather than silently dropped, per the user's
"feedback always visible" requirement.
### 9.5 Telnet round-trip probe — the SSH-less reachability check
> **REMOVED — see §9.8.** Empirical testing showed the swUpdate
> daemon caches its target URL at boot and ignores live config
> writes, so the active flip described below could never reach the
> running daemon. The section is retained as a historical record of
> what was tried; the running code uses the passive observer in §9.8.
The reachability gap §7 left open for USB-unlock-refusing speakers is
closed by `Manager.RunTelnetRoundTripProbe`
(`pkg/service/setup/telnet_probe.go`). Sequence:
1. Telnet `getpdo CurrentSystemConfiguration` to capture the
speaker's current `swUpdateUrl`.
2. Generate a random 24-hex-char token; register a one-shot signal
channel under it on the new `probeRegistry` (sibling field on
`handlers.Server`).
3. Telnet `sys configuration swUpdateUrl <targetURL>/probe/<token>`
— **runtime layer only, deliberately not `envswitch boseurls set
…`**. The persistence layer keeps the original URL, so a reboot
heals the device naturally if our restore step fails.
4. `HTTP GET <deviceIP>:8090/swUpdateCheck` — the cleanest
`:8090` endpoint that triggers exactly one outbound to the
configured `swUpdateUrl`. Read-only on the cloud side
(doesn't initiate an update); independent of `margeAccountUUID`
so it works on factory-reset speakers.
5. Wait on the registered channel up to `telnetProbeTimeout` (6s).
6. Telnet `sys configuration swUpdateUrl <originalURL>` — deferred
restore so it runs even on the failure path.
The new `/probe/{token}[/*]` catch-all on the root router signals the
matching channel when the speaker's outbound lands. The response is
a minimal `<swUpdateIndex/>` so the speaker's `swUpdateCheck`
doesn't choke on a missing structure. The `/*` sub-path is
registered because some firmware appends a path component to the
configured `swUpdateUrl`.
### 9.6 Backend additions worth knowing
| Addition | Where | Why |
|------------------------------------------------------------------------|----------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `applyURLOverrides(cfg, options)` | `pkg/service/setup/setup.go` | Per-field literal `marge_url` / `stats_url` / `sw_update_url` / `bmx_url` overrides win over `applyProxyOptions`. Honored by both `GetMigrationSummary` and `migrateViaXML`. |
| `telnetURLsFromOptions(targetURL, options)` | `pkg/service/setup/telnet_migration.go` | Same option family as above, plus envswitch arg derivation rule (arg1 = final Marge verbatim; the soundcork-suffix case drops out). |
| Per-axis booleans + `IsPaired` + `Warnings` | `MigrationSummary` | Surfaces partial-state cells and SSH-XML ⇄ telnet-getpdo cross-check disagreements. |
| `parseGetpdoConfig` | `pkg/service/setup/preflight_crosscheck.go` | Parses the Protobuf-text-like nested-block reply (`key { text: "..." }`) FW 27.0.6 actually sends, plus the legacy `key=value` shape as a tolerance path. |
| `peerObserver` + `RunPeerReachabilityProbe` + `/setup/peer-probe` | `pkg/service/handlers` / `pkg/service/setup` | §9.8. Replaces the removed `probeRegistry` + `RunTelnetRoundTripProbe` + `/setup/telnet-probe` from §9.5. |
| `migrationOptionKeys` allow-list | `pkg/service/handlers/migration_options.go` | Unknown query keys never reach the manager. Both XML mode keys and `*_url` keys are recognised. |
| Telnet client default timeouts: dial 4s, read 7s, write 3s, idle 600ms | `pkg/telnet/telnet.go` | Bumped from the original 2s/5s/2s/400ms after observing transient i/o-timeout flakes on healthy speakers that recovered on retry. |
### 9.7 Future probe candidates
- `:8090/pushCustomerSupportInfoToMarge` — flagged as a potential
"ask the device about itself" probe that could feed a richer
device-info pane (firmware build dates, hardware revisions). Not
implemented.
- Running the round-trip probe on SSH-capable speakers too (as
additional validation alongside the curl-from-device HTTPS test),
not just as the SSH-less fallback it is today. **Subsumed by §9.8
— the round-trip probe is being removed; the passive observer is
transport-agnostic and replaces it for migrated speakers.**
### 9.8 The swUpdate daemon-cache finding and removal of §9.5
The §9.5 round-trip probe was retired after empirical testing on a
fully-migrated speaker (FW 27.0.6) revealed that the `swUpdate`
daemon **caches its target URL at boot and ignores live config
writes**. The diagnostic sequence:
1. Manual telnet flip of both layers — `sys configuration swUpdateUrl
<probe-url>` (runtime) **and** `envswitch boseurls set <marge>
<probe-url>` (persistence). `getpdo CurrentSystemConfiguration`
confirmed both writes stuck.
2. HTTP GET `:8090/swUpdateCheck` to trigger fan-out.
3. Service access log showed the device outbound landed on
`/updates/soundtouch` (the **previous** `swUpdateUrl` value, current
at the last daemon boot) and `/streaming/software/update/account/<id>`
(a separate Bose URL the daemon hits, routed to this service by DNS
interception). The probe URL was never dialed.
This falsifies the original NEXT.md hypothesis that the persistence
layer would override the runtime layer for the daemon's fan-out, and
points instead at daemon-level URL caching. Two consequences:
- **The §9.5 probe cannot work on migrated speakers without a
reboot.** The cached URL is set when the daemon starts; flipping
config after that point has no effect on what the daemon dials.
- **The §9.5 probe likely cannot work on unmigrated speakers
either**, for the same reason — the daemon caches whatever URL it
read at startup, which on an unmigrated speaker is the Bose cloud
URL. We have no service running with the probe URL registered on
unmigrated speakers, so the original "it worked in testing" claim
has no empirical basis; it likely failed silently because nothing
was watching.
The honest replacement is a **passive observer** (see
`pkg/service/setup/peer_probe.go`):
1. Register the device IP with an in-process observer
(`handlers.peerObserver`, wired via `PeerObserverMiddleware`).
2. Nudge `:8090/swUpdateCheck` to make the daemon fan out *something*
sooner than its ~5min timer.
3. Wait up to 30s for any inbound from that IP. On a migrated
speaker, DNS interception means the daemon's outbounds (update
fan-out, marge polls, BMX registry calls) all funnel through this
service regardless of which URL the daemon resolved internally —
so reachability reduces to *"did the device dial us at all."*
Endpoint: `POST /setup/peer-probe/{deviceId}`. No device-state
mutation; safe to re-run. Returns `{ok, result: {reached,
observed_path, elapsed_ms}, error}` with the same UI keying as the
old probe (`result.reached`).
#### 9.8.1 The pre-flight panel branch
The web UI's pre-flight orchestrator (`runApplyPreflight` in
`script.js`) branches on `summary.is_migrated`:
| Migration state | Reachability row |
|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------|
| Migrated (`is_migrated=true`) | "Reachability check (passive observer)" — calls `POST /setup/peer-probe/{deviceId}`. |
| Not migrated (incl. partial) | Skip row "Round-trip validation runs after Apply + reboot" with the rationale "daemon caches swUpdateUrl at boot". |
Per-axis booleans (`xml_migrated`, `hosts_migrated`, `resolv_migrated`,
`telnet_migrated`) remain visible in the State card, so the user can
see which parts of the migration are already in place even when the
overall flag is false. The skip row does not attempt the active probe
on unmigrated speakers — the canonical telnet flow is:
```
Apply telnet config → user-initiated reboot → re-run pre-flight on
the now-migrated speaker → passive observer confirms fan-out.
```
#### 9.8.2 Removal trail
Removed (or scheduled for removal in a follow-up commit) at the time
of §9.8 landing:
- `pkg/service/setup/telnet_probe.go``RunTelnetRoundTripProbe`,
`ProbeRegistrar`, `TelnetProbeResult`, `generateProbeToken`.
- `pkg/service/handlers/handlers_telnet_probe.go``HandleTelnetProbe`,
`HandleProbeInbound`, `telnetProbeTimeout`, `telnetProbeResponse`.
- `pkg/service/handlers/probe_registry.go``probeRegistry` + tests.
- `Server.probes` field.
- Routes `/probe/{token}`, `/probe/{token}/*`, `/setup/telnet-probe/{deviceId}`.
- The `target_url` query-param plumbing on the deprecated endpoint.
- `script.js``checkTelnetRoundTrip` (orchestrator call site removed
in the commit that added the branch; function itself removed later).
`isCommandNotFound` and `parseGetpdoConfig` stay — they are also used
by the migration writer (`telnet_migration.go`), preflight reader
(`telnet_preflight.go`), pairing path (`marge_pairing.go`), and
cross-check (`preflight_crosscheck.go`).
+4 -4
View File
@@ -656,10 +656,10 @@ func main() {
soundtouch discover
# Device operations
soundtouch --device 192.168.1.100 info
soundtouch --device 192.168.1.100 play
soundtouch --device 192.168.1.100 volume 50
soundtouch --device 192.168.1.100 preset 1
soundtouch --device 192.0.2.100 info
soundtouch --device 192.0.2.100 play
soundtouch --device 192.0.2.100 volume 50
soundtouch --device 192.0.2.100 preset 1
# Interactive mode
soundtouch interactive
+2 -2
View File
@@ -171,7 +171,7 @@ This project implements a comprehensive Go client library and CLI tool for Bose
- **Notification System**: TTS and URL playback with multi-language support
- **API Compliance**: Proper press+release key pattern implementation
- **Safety First**: Volume warnings and limits for user protection
- **User Experience**: Host:port parsing (e.g., `-host 192.168.1.100:8090`)
- **User Experience**: Host:port parsing (e.g., `-host 192.0.2.100:8090`)
- **CLI Enhancement**: Direct flags for common operations and audio control
- **Discovery Excellence**: Multi-protocol discovery (UPnP + mDNS) with caching
- **Real Device Testing**: Validated with SoundTouch 10 and SoundTouch 20
@@ -193,7 +193,7 @@ This project implements a comprehensive Go client library and CLI tool for Bose
- **HTTP Client**: Mock server tests with real response data
### Integration Tests
- **Real Devices**: SoundTouch 10 (192.168.1.10) and SoundTouch 20 (192.168.1.11)
- **Real Devices**: SoundTouch 10 (192.0.2.10) and SoundTouch 20 (192.0.2.11)
- **All Endpoints**: Validated against actual hardware
- **Source Selection**: Tested with Spotify, TuneIn, and other available sources
- **Bass Control**: Tested bass adjustment, validation, and device-specific behavior
+5 -5
View File
@@ -7,7 +7,7 @@ This document serves as the entry point for understanding the comprehensive plan
## Project Objectives
### Primary Goal
Create a robust, local replacement for Bose's upstream services that can seamlessly handle the transition from cloud-dependent to fully autonomous operation while maintaining and improving upon the existing functionality.
Create a robust replacement for Bose's upstream services that can seamlessly handle the transition from cloud-dependent to fully autonomous operation while maintaining and improving upon the existing functionality.
### Key Outcomes
- **Zero-downtime transition** from Bose services to local management
@@ -21,7 +21,7 @@ Create a robust, local replacement for Bose's upstream services that can seamles
### Current State
The existing SoundTouch service provides:
- BMX service for TuneIn integration
- Marge service for account and device management
- Marge service for account and device management
- Basic mirroring of upstream Bose endpoints
- File-based persistence for device data
- Migration support for device directory structures
@@ -42,7 +42,7 @@ The enhanced system will add:
- **Mirror-Enhanced Setup**: Use upstream data to enrich account creation
- **Passive Data Collection**: Record account information during normal operations
### Case 1a: Fresh Device Registration
### Case 1a: Fresh Device Registration
- **Factory Reset Support**: Handle devices with no prior Bose association
- **Default Configuration**: Initialize devices with sensible presets and sources
- **Local-First Setup**: Complete registration without upstream dependencies
@@ -100,7 +100,7 @@ data/
- Basic API endpoints with comprehensive testing
- Integration with existing datastore patterns
### Phase 2: Device Lifecycle (2-3 weeks) - Build on Existing Systems
### Phase 2: Device Lifecycle (2-3 weeks) - Build on Existing Systems
- Event processing using existing WebSocket system
- Lifecycle integration with current discovery and migration
- Enhanced logging building on existing parity detection
@@ -178,4 +178,4 @@ This concept is detailed across several documents:
3. **Resource Planning**: Allocate development resources for the three-phase implementation
4. **Community Engagement**: Share plans with the community for feedback and contributions
This enhanced state management system represents a significant evolution of the SoundTouch service, transforming it from a basic cloud replacement into a comprehensive, future-proof device management platform that can serve users well beyond the Bose service shutdown timeline.
This enhanced state management system represents a significant evolution of the SoundTouch service, transforming it from a basic replacement into a comprehensive, future-proof device management platform that can serve users well beyond the Bose service shutdown timeline.
+11 -26
View File
@@ -1,5 +1,10 @@
# Spotify OAuth Integration
> **New here?** Start with [spotify-overview.md](spotify-overview.md) for the
> mental model (Spotify Connect vs OAuth-intercept, DNS rewrite gotcha,
> end-to-end token lifecycle). This document zooms in on the OAuth flows and
> management endpoints.
The SoundTouch service supports Spotify OAuth integration to broker access tokens for SoundTouch speakers. This is particularly useful for maintaining Spotify Connect functionality after the Bose cloud shutdown (scheduled for May 2026).
## OAuth Flows
@@ -91,43 +96,23 @@ sequenceDiagram
Note over Speaker: Speaker now has Spotify access
```
## Boot Primer Script
## Priming Speakers
A boot primer script that uses these endpoints to feed Spotify tokens to speakers via ZeroConf is available in the `scripts/spotify/` directory: [spotify-boot-primer.sh](../../scripts/spotify/spotify-boot-primer.sh).
This script can be installed on the speaker itself (which runs embedded Linux) to automatically prime Spotify Connect at boot time. See [README.md](../../scripts/spotify/README.md) and [INSTALL.md](../../scripts/spotify/INSTALL.md) for instructions.
### Automated Installation via Service
The SoundTouch service provides a dedicated management endpoint to automatically handle the installation of the Spotify boot primer on the speaker:
`POST /mgmt/devices/{deviceId}/spotify/install-primer`
### Automated Installation Steps
When you run the Spotify primer installation, the service performs the following:
1. **Directories**: Creates `/mnt/nv/bin` and `/mnt/nv/BoseApp-Persistence/1` on the speaker.
2. **Binary**: Uploads the `spotify-boot-primer` script to the speaker.
3. **Configuration**: Automatically generates and uploads `spotify-primer.conf` containing the service's URL and management credentials.
4. **Boot Hook**: Injects a call to the primer in the speaker's `/mnt/nv/rc.local` using idempotent markers.
5. **Environment**: Updates `/mnt/nv/.profile` to include `/mnt/nv/bin` in the `PATH` for easier manual troubleshooting via SSH.
- **Idempotent Patching**: The service uses explicit markers to inject the hook, ensuring it doesn't corrupt existing content.
- **Coexistence**: The service-injected hook is designed to coexist with a manually installed `rc.local` (e.g., from the community gist). It only adds a call to `/mnt/nv/bin/spotify-boot-primer` if it's not already managed by a service-controlled block.
- **Markers**: Look for the following markers in your speaker's `/mnt/nv/rc.local`:
- `# --- Aftertouch Spotify hook START ---`
- `# --- Aftertouch Spotify hook END ---`
- **Cleanup**: Reverting a migration via the service will cleanly remove these marker-delimited blocks.
> **Note:** The on-device boot-primer flow (installing `spotify-boot-primer.sh` onto the speaker's `/mnt/nv` and hooking it from `rc.local`) is **deprecated**. AfterTouch now uses a server-centric model: the service registers a `SPOTIFY` source in marge for the device's paired account and pushes credentials via ZeroConf from the server side, triggered on `power_on` and a manual "Prime" action. See [spotify-priming-strategy.md](spotify-priming-strategy.md) for the current model and rationale.
>
> The artifacts under `scripts/spotify/` are kept as historical reference for users who still rely on the on-device approach. There is no longer a `/mgmt/devices/{deviceId}/spotify/install-primer` endpoint.
## Endpoints
| Method | Path | Auth | Purpose |
|--------|---------------------------------------------------|-------|-----------------------------------------------------------------------|
| POST | `/mgmt/devices/{deviceId}/spotify/install-primer` | Basic | Install Spotify boot primer on speaker (deviceId or IP) |
| GET | `/mgmt/spotify/callback` | None | Browser OAuth callback (redirect from Spotify, returns HTML) |
| POST | `/mgmt/spotify/init` | Basic | Start OAuth flow, returns authorization URL |
| GET | `/mgmt/spotify/callback` | None | Browser OAuth callback (redirect from Spotify, returns HTML) |
| POST | `/mgmt/spotify/confirm` | Basic | Mobile app confirm (ueberboese deep link delivers code, returns JSON) |
| GET | `/mgmt/spotify/accounts` | Basic | List linked Spotify accounts (tokens stripped) |
| GET | `/mgmt/spotify/token` | Basic | Get fresh access token (auto-refreshes if expired) |
| POST | `/mgmt/spotify/entity` | Basic | Resolve Spotify URI to name + image URL |
| POST | `/mgmt/spotify/prime` | Basic | Manually trigger server-side priming of a discovered speaker |
## Security
+183
View File
@@ -0,0 +1,183 @@
# Spotify on SoundTouch — Overview
This is the entry point for understanding how Spotify works on a SoundTouch
speaker behind AfterTouch. Read this first; the deeper docs assume you already
have the mental model below.
> **Premium likely required.** As far as we know, Spotify Connect on
> SoundTouch only works with a Spotify Premium account — this matches our
> testing and matches what other SoundTouch-replacement projects report, but
> we have not exhaustively verified every account tier or region. None of the
> workarounds in this document change Spotify's account-tier requirements.
## Two completely separate Spotify paths
These are routinely confused. They share a speaker and a Spotify account, but
they ride on different infrastructure and fail for different reasons.
### 1. Spotify Connect (speaker-native, independent of AfterTouch)
- The speaker advertises itself on the LAN as a Spotify Connect endpoint
(mDNS service `_spotify-connect._tcp`).
- You open the Spotify app on your phone or desktop, tap the Connect device
picker, and select the SoundTouch.
- Audio streams directly from Spotify's CDN to the speaker. Token handling,
session setup, and playback all happen between Spotify and the speaker.
- **AfterTouch is not involved.** It still works even if AfterTouch is
offline.
This is the simplest path. If you only want to push playback from your phone,
you do not need to link Spotify to AfterTouch at all — see [Manual kick-start
alternative](#manual-kick-start-alternative) below.
### 2. OAuth-intercept path (managed by AfterTouch)
This is what enables features that originate **from the speaker**:
- Spotify presets on the speaker's buttons.
- Spotify playback from the Bose app's source picker.
- "Resume Spotify" after a power cycle without touching the Spotify app.
After Bose's cloud shutdown (May 2026), the speaker can no longer reach
Bose's OAuth server for Spotify token refresh. AfterTouch intercepts those
calls via DNS, brokers tokens with Spotify using your linked account, and
hands them back to the speaker.
The rest of this document describes that path.
## Setup at a glance
Full step-by-step is in
[docs/guides/MUSIC-SERVICES.md](../guides/MUSIC-SERVICES.md). Summary:
1. **Register a Spotify developer app** (one-time, by the AfterTouch operator).
2. **Configure AfterTouch** with the Client ID, Client Secret, and Redirect
URI in the Settings tab.
3. **Authorize your Spotify account** via the Local Account tab — completes
the OAuth flow and persists a long-lived refresh token to AfterTouch's
datastore.
4. **Prime each speaker** so its source list and ZeroConf state know about
Spotify.
After step 4, presets and Bose-app-initiated Spotify playback work.
## The DNS rewrite — easy to miss, breaks everything
Bose firmware does **not** read a separate OAuth server hostname from
configuration. It derives the OAuth host from the marge host by inserting
`oauth` into the first label:
| Purpose | Hostname |
|-----------------|---------------------------|
| Marge / sources | `streaming.bose.com` |
| OAuth refresh | `streamingoauth.bose.com` |
**Both hostnames must resolve to AfterTouch.** AfterTouch's DNS server hijacks
both, but if you bypass that DNS server (e.g. by hard-coding only the marge
hostname in `/etc/hosts`, or by routing only one through a custom resolver),
token refresh will silently die while the speaker still pulls sources.
Symptom: the speaker briefly streams Spotify after priming, then stops at the
first token refresh ~1 hour later.
If you self-host AfterTouch at e.g. `aftertouch.local`, you would equivalently
need `aftertouchoauth.local` for the OAuth interception path.
## End-to-end token lifecycle
What actually happens, from priming to steady-state playback:
1. **Operator links Spotify account.** OAuth flow stores
`{user_id, refresh_token, bose_secret}` in `spotify/accounts.json`. The
`bose_secret` is an opaque surrogate (e.g. `bs-deadbeef…`) that AfterTouch
issues; the speaker only ever sees this surrogate, never the real Spotify
refresh token.
2. **Priming runs.** Either on speaker `power_on`, on discovery, or on a
manual `POST /mgmt/spotify/prime`. AfterTouch:
- Resolves the speaker's currently-paired account via live `:8090/info`
(`margeAccountUUID`).
- Writes a `SPOTIFY` `ConfiguredSource` into marge under that account with
`secret = bose_secret`, `secretType = token_version_3`.
- POSTs `<updates><sourcesUpdated/></updates>` to the speaker's
`:8090/notification`, causing the speaker to re-fetch
`/streaming/account/{account}/full` and pick up the new source.
- Optionally pushes a fresh access token to the speaker's ZeroConf
endpoint (`:8200/zc?action=addUser`). This is best-effort — see
[ZeroConf clientId and benign 404s](#zeroconf-clientid-and-benign-404s).
3. **Speaker pulls sources.** It now has a SPOTIFY entry with the surrogate
as its credential. The speaker stores this; from its perspective the
surrogate is the refresh token.
4. **Speaker uses Spotify.** When it needs a fresh access token (every ~1 h
on Spotify's clock), it POSTs to
`streamingoauth.bose.com/oauth/device/{deviceID}/music/musicprovider/15/token/cs3`
with the surrogate.
5. **AfterTouch translates.** DNS hijack routes the request to AfterTouch,
which looks up the surrogate, performs the real refresh against Spotify
using the stored refresh token, and returns the resulting access token to
the speaker.
6. **Speaker uses the access token** for Spotify Web API metadata calls
(artwork, track lookups, playback container resolution).
Forensic details of the request shapes are in
[docs/reference/spotify-account-addition.md](../reference/spotify-account-addition.md).
The cryptographic specifics of the ZeroConf `addUser` blob are in
[spotify-priming-strategy.md](spotify-priming-strategy.md).
## ZeroConf clientId and benign 404s
`GET http://<speaker>:8200/zc?action=getInfo` returns, among other fields:
```json
"clientID": "79ebcb219e8e4e9a892e796607931810"
"tokenType": "accesstoken"
"activeUser": "<spotify-user-id-or-empty>"
```
That `clientID` is **Bose's official Spotify Connect partner client_id**,
baked into firmware. It is **not** the client_id of the developer app you
registered for AfterTouch — those are two unrelated OAuth apps, by design.
The Bose-baked one is what Spotify Connect uses when a Spotify mobile app
discovers the speaker on the LAN. The AfterTouch-registered one is what
brokers refresh tokens for the OAuth-intercept path. They never converge.
**Implication:** an access token AfterTouch obtained under its own client_id
is not directly usable as a Spotify Connect session token. Pushing it via
ZeroConf `addUser` is best-effort, and the speaker may respond with a `404`
and an empty body when its `activeUser` already matches the username being
pushed — that is the firmware's idiomatic "no transition required" signal,
not a failure. AfterTouch recognises this case (`zeroconf.ErrAddUserNoOp`)
and logs it as an expected no-op rather than an error.
A 404 **with a body**, or any other non-2xx, is treated as a real failure
and logged loudly with the response headers and body so it can be
diagnosed.
## Manual kick-start alternative
You can skip the OAuth setup entirely if you only want playback pushed from
the Spotify app:
1. Open the Spotify mobile/desktop app.
2. Start any track.
3. Open the Connect device picker, select the SoundTouch.
The speaker now holds an in-memory Spotify Connect session and can play
until next reboot. Presets and Bose-app-initiated Spotify playback will
still not work — those require the OAuth-intercept path — but Spotify-app-
initiated playback does.
## Troubleshooting quick reference
| Symptom | Most likely cause |
|----------------------------------------------------|------------------------------------------------------------------------------------------------|
| Preset stores then fails: "invalid SourceID" | No `SPOTIFY` source in marge for the speaker's paired account. Re-run priming. |
| Preset stores fine; playback dies after ~1 hour | `streamingoauth.bose.com` not pointed at AfterTouch (DNS rewrite gap). |
| Speaker has source but `Sources.xml` looks stale | `<sourcesUpdated/>` notification did not reach the speaker. Re-run priming or POST it by hand. |
| ZeroConf `addUser` returns 404, empty body | Benign no-op; speaker already has `activeUser` set. Marge path is authoritative. |
| Spotify Connect device picker doesn't show speaker | Unrelated to AfterTouch; check the speaker's mDNS visibility on the LAN. |
## Where to go next
- **Setup walkthrough:** [docs/guides/MUSIC-SERVICES.md](../guides/MUSIC-SERVICES.md)
- **OAuth flow details (browser + mobile + endpoint table):** [spotify-oauth.md](spotify-oauth.md)
- **Priming strategy, ZeroConf DH protocol, deployment topologies:** [spotify-priming-strategy.md](spotify-priming-strategy.md)
- **Forensic request/response analysis from the Stockholm app:** [docs/reference/spotify-account-addition.md](../reference/spotify-account-addition.md)
@@ -1,5 +1,9 @@
# Spotify Priming Strategy
> **New here?** Start with [spotify-overview.md](spotify-overview.md) for the
> mental model. This document goes deep on the priming protocol, ZeroConf DH
> exchange, and deployment topologies.
This document outlines the strategy for ensuring Bose SoundTouch devices are correctly "primed" for Spotify Connect integration within the AfterTouch ecosystem.
## Overview
+17 -35
View File
@@ -22,7 +22,6 @@
│ SoundTouch Service │
├─────────────────────────────────────────────────────────────┤
│ HTTP Router & Middleware │
│ ├── Mirror Middleware (Enhanced) │
│ ├── Recorder Middleware │
│ ├── Disparity Detection │
│ └── Health Check Middleware │
@@ -35,12 +34,11 @@
├─────────────────────────────────────────────────────────────┤
│ Data Layer │
│ ├── Enhanced DataStore ├── Event Store │
│ ├── Mirror Cache ├── Metrics Store │
│ └── Configuration Store └── Session Store │
├─────────────────────────────────────────────────────────────┤
│ External Integrations │
│ ├── Bose Services (Mirror) ├── Device Discovery
├── BMX/TuneIn Services └── SSH/Setup Manager │
│ ├── Device Discovery ├── BMX/TuneIn Services
│ └── SSH/Setup Manager
└─────────────────────────────────────────────────────────────┘
```
@@ -69,10 +67,6 @@ pkg/service/
│ ├── processor.go
│ ├── queue.go
│ └── storage.go
├── mirror/ # Enhanced mirroring (extends existing)
│ ├── disparity.go
│ ├── analyzer.go
│ └── logger.go
├── health/ # System monitoring
│ ├── monitor.go
│ ├── metrics.go
@@ -115,20 +109,17 @@ type MigrationInfo struct {
CompletedAt *time.Time `json:"completed_at,omitempty"`
DevicesMigrated int `json:"devices_migrated"`
DevicesPending int `json:"devices_pending"`
MirrorActive bool `json:"mirror_active"`
Strategy string `json:"strategy"`
RollbackData string `json:"rollback_data,omitempty"`
}
type DataSourceConfig struct {
Local bool `json:"local"`
BoseMirror bool `json:"bose_mirror"`
Primary string `json:"primary"` // "local" or "bose"
}
type AccountSettings struct {
AutoMigration bool `json:"auto_migration"`
MirrorEndpoints []string `json:"mirror_endpoints"`
RetentionDays int `json:"retention_days"`
}
```
@@ -235,7 +226,6 @@ type EventSource string
const (
EventSourceWebSocket EventSource = "websocket"
EventSourceDiscovery EventSource = "discovery"
EventSourceMirror EventSource = "mirror"
EventSourceSystem EventSource = "system"
EventSourceAPI EventSource = "api"
EventSourceUser EventSource = "user"
@@ -336,12 +326,10 @@ Response: 200 OK
"migration_info": {
"started_at": "2024-01-18T09:00:00Z",
"devices_migrated": 1,
"devices_pending": 1,
"mirror_active": true
"devices_pending": 1
},
"data_sources": {
"local": true,
"bose_mirror": true,
"primary": "bose"
}
}
@@ -368,14 +356,14 @@ POST /api/v1/accounts/{account_id}/devices
Content-Type: application/json
{
"device_id": "A81B6A536A98",
"device_id": "AABBCCDDEEFF",
"name": "Living Room Speaker",
"registration_type": "fresh"
}
Response: 201 Created
{
"device_id": "A81B6A536A98",
"device_id": "AABBCCDDEEFF",
"account_id": "acc_12345",
"state": "registering",
"created_at": "2024-01-20T10:00:00Z"
@@ -388,7 +376,7 @@ GET /api/v1/accounts/{account_id}/devices/{device_id}/state
Response: 200 OK
{
"device_id": "A81B6A536A98",
"device_id": "AABBCCDDEEFF",
"account_id": "acc_12345",
"state": "active",
"metadata": {
@@ -474,8 +462,7 @@ Response: 200 OK
"services": {
"account_manager": "healthy",
"lifecycle_manager": "healthy",
"event_processor": "healthy",
"mirror_service": "warning"
"event_processor": "healthy"
},
"statistics": {
"total_accounts": 5,
@@ -534,18 +521,15 @@ Response: 200 OK
"started_at": "2024-01-18T09:00:00Z",
"devices_migrated": 1,
"devices_pending": 1,
"mirror_active": true,
"strategy": "gradual"
},
"bose_account_id": "bose-original-id",
"data_sources": {
"local": true,
"bose_mirror": true,
"primary": "bose"
},
"settings": {
"auto_migration": false,
"mirror_endpoints": ["/v1/presets", "/v1/recents"],
"retention_days": 30
}
}
@@ -555,7 +539,7 @@ Response: 200 OK
```json
{
"version": "1.0",
"device_id": "A81B6A536A98",
"device_id": "AABBCCDDEEFF",
"account_id": "acc_12345",
"state": "active",
"created_at": "2024-01-20T10:00:00Z",
@@ -568,7 +552,7 @@ Response: 200 OK
"reason": "mdns_discovery",
"source": "discovery",
"context": {
"ip_address": "192.168.1.100",
"ip_address": "192.0.2.100",
"discovery_method": "mdns"
}
},
@@ -585,15 +569,15 @@ Response: 200 OK
"type": "SoundTouch 30",
"serial_number": "I6332527703739342000020",
"firmware_version": "4.8.1.25341.2677643.1597353330",
"mac_address": "A8:1B:6A:53:6A:98",
"ip_address": "192.168.1.100",
"mac_address": "AA:BB:CC:DD:EE:FF",
"ip_address": "192.0.2.100",
"last_seen": "2024-01-20T15:30:00Z",
"is_legacy_id": false,
"capabilities": ["multiroom", "bluetooth", "aux"]
},
"data_sources": {
"presets": "local",
"recents": "mirror_primary",
"recents": "local",
"sources": "local"
},
"migration": {
@@ -615,14 +599,13 @@ Response: 200 OK
### Event Log Format (events.log)
```
# SoundTouch Service Event Log - Device A81B6A536A98
# SoundTouch Service Event Log - Device AABBCCDDEEFF
# Format: TIMESTAMP|EVENT_ID|EVENT_TYPE|SOURCE|DATA_JSON
# Version: 1.0
2024-01-20T15:30:00.123Z|evt_12345|now_playing|websocket|{"source":"SPOTIFY","track":"Song Name","artist":"Artist Name","album":"Album Name"}
2024-01-20T15:30:30.456Z|evt_12346|volume_changed|websocket|{"volume":45,"muted":false,"previous_volume":40}
2024-01-20T15:31:00.789Z|evt_12347|preset_selected|websocket|{"preset":1,"source":"SPOTIFY","location":"spotify:track:123abc"}
2024-01-20T15:31:15.012Z|evt_12348|disparity_detected|mirror|{"endpoint":"/v1/presets","local_hash":"abc123","upstream_hash":"def456","severity":"medium"}
2024-01-20T15:32:00.345Z|evt_12349|health_check|system|{"response_time":42,"status":"healthy","connectivity":"online"}
```
@@ -632,8 +615,8 @@ Response: 200 OK
# Format: TIMESTAMP|DISPARITY_ID|DEVICE_ID|ACCOUNT_ID|ENDPOINT|TYPE|SEVERITY|DETAILS_JSON
# Version: 1.0
2024-01-20T15:31:15.012Z|disp_12345|A81B6A536A98|acc_12345|/v1/presets|count_mismatch|medium|{"field_path":"preset_count","local_value":5,"upstream_value":4,"description":"Local has one additional preset"}
2024-01-20T15:32:45.678Z|disp_12346|A81B6A536A98|acc_12345|/v1/recents|timestamp_format|low|{"field_path":"recent[0].utc_time","local_value":"2024-01-20T15:30:00Z","upstream_value":"1705761000","description":"Timestamp format difference"}
2024-01-20T15:31:15.012Z|disp_12345|AABBCCDDEEFF|acc_12345|/v1/presets|count_mismatch|medium|{"field_path":"preset_count","local_value":5,"upstream_value":4,"description":"Local has one additional preset"}
2024-01-20T15:32:45.678Z|disp_12346|AABBCCDDEEFF|acc_12345|/v1/recents|timestamp_format|low|{"field_path":"recent[0].utc_time","local_value":"2024-01-20T15:30:00Z","upstream_value":"1705761000","description":"Timestamp format difference"}
2024-01-20T15:35:20.901Z|disp_12347|B92C7B647B09|acc_12345|/v1/account/full|structure_diff|high|{"field_path":"device[1].ip_address","local_value":"present","upstream_value":"missing","description":"IP address field missing in upstream response"}
```
@@ -852,7 +835,6 @@ go test -bench=. ./...
### Response Time Targets
- Local API requests: < 100ms (95th percentile)
- Mirror requests: < 200ms overhead (asynchronous)
- Discovery time: < 5s for network scan
### Resource Constraints
@@ -925,12 +907,12 @@ type ServiceError struct {
{
"error": {
"code": "DEVICE_NOT_FOUND",
"message": "Device with ID 'A81B6A536A98' not found in account 'acc_12345'",
"message": "Device with ID 'AABBCCDDEEFF' not found in account 'acc_12345'",
"category": "validation",
"timestamp": "2024-01-20T15:30:00Z",
"context": {
"account_id": "acc_12345",
"device_id": "A81B6A536A98",
"device_id": "AABBCCDDEEFF",
"request_id": "req_67890"
},
"retryable": false,
+14 -17
View File
@@ -2,7 +2,7 @@
## Overview
This document outlines the concept for simulating and replacing upstream Bose services with enhanced state management capabilities. The goal is to create a comprehensive local replacement that can handle device lifecycles, account management, and state synchronization while maintaining compatibility with existing SoundTouch devices.
This document outlines the concept for simulating and replacing upstream Bose services with enhanced state management capabilities. The goal is to create a comprehensive replacement that can handle device lifecycles, account management, and state synchronization while maintaining compatibility with existing SoundTouch devices.
## Use Cases
@@ -95,13 +95,11 @@ data/
"migration_status": {
"started_at": "2024-01-18T09:00:00Z",
"devices_migrated": 1,
"devices_pending": 2,
"mirror_active": true
"devices_pending": 2
},
"bose_account_id": "bose-original-id",
"data_sources": {
"local": true,
"bose_mirror": true,
"primary": "bose"
}
}
@@ -111,7 +109,7 @@ data/
```json
{
"device_id": "A81B6A536A98",
"device_id": "AABBCCDDEEFF",
"account_id": "account-12345",
"state": "active",
"created_at": "2024-01-15T10:30:00Z",
@@ -137,14 +135,14 @@ data/
"type": "SoundTouch 30",
"serial_number": "I6332527703739342000020",
"firmware_version": "4.8.1.25341.2677643.1597353330",
"mac_address": "A8:1B:6A:53:6A:98",
"ip_address": "192.168.1.100",
"mac_address": "AA:BB:CC:DD:EE:FF",
"ip_address": "192.0.2.100",
"last_seen": "2024-01-20T16:20:00Z",
"is_legacy_id": false
},
"data_sources": {
"presets": "local",
"recents": "mirror_primary",
"recents": "local",
"sources": "local"
},
"migration": {
@@ -159,14 +157,13 @@ data/
### Event Log Format
```
# Device Events Log - A81B6A536A98
# Device Events Log - AABBCCDDEEFF
# Format: TIMESTAMP|EVENT_TYPE|SOURCE|DATA
2024-01-20T16:15:00Z|now_playing|websocket|{"source":"SPOTIFY","track":"Song Name","artist":"Artist Name"}
2024-01-20T16:15:30Z|volume_changed|websocket|{"volume":45,"muted":false}
2024-01-20T16:16:00Z|preset_selected|websocket|{"preset":1,"source":"SPOTIFY","location":"spotify:track:123"}
2024-01-20T16:18:00Z|disparity_detected|mirror|{"endpoint":"/v1/account/full","local_hash":"abc123","upstream_hash":"def456"}
2024-01-20T16:20:00Z|device_online|discovery|{"ip":"192.168.1.100","method":"mdns"}
2024-01-20T16:20:00Z|device_online|discovery|{"ip":"192.0.2.100","method":"mdns"}
```
### Disparity Log Format
@@ -175,9 +172,9 @@ data/
# Parity Analysis Log
# Format: TIMESTAMP|ENDPOINT|DEVICE|ACCOUNT|DISPARITY_TYPE|DETAILS
2024-01-20T16:18:00Z|/v1/account/full|A81B6A536A98|account-12345|content_mismatch|preset_count:local=5,upstream=4
2024-01-20T16:19:15Z|/v1/presets|A81B6A536A98|account-12345|xml_structure|missing_container_art_in_local
2024-01-20T16:20:30Z|/v1/recents|A81B6A536A98|account-12345|timestamp_format|local=RFC3339,upstream=custom
2024-01-20T16:18:00Z|/v1/account/full|AABBCCDDEEFF|account-12345|content_mismatch|preset_count:local=5,upstream=4
2024-01-20T16:19:15Z|/v1/presets|AABBCCDDEEFF|account-12345|xml_structure|missing_container_art_in_local
2024-01-20T16:20:30Z|/v1/recents|AABBCCDDEEFF|account-12345|timestamp_format|local=RFC3339,upstream=custom
```
## Implementation Strategy
@@ -268,7 +265,7 @@ POST /api/v1/accounts/{account-id}/devices
Content-Type: application/json
{
"device_id": "A81B6A536A98",
"device_id": "AABBCCDDEEFF",
"name": "Living Room Speaker",
"registration_type": "fresh"
}
@@ -334,7 +331,7 @@ GET /api/v1/accounts/{account-id}/export
### Quality Assurance
- Complete test coverage for all new functionality
- Comprehensive linting with `golangci-lint run --fix`
- Comprehensive linting with `golangci-lint run --fix`
- Full test suite execution `go test ./...` for each milestone
- Integration tests with existing functionality
@@ -390,4 +387,4 @@ Future improvements should maintain the simplicity-first approach:
- Simple reporting mechanisms
- Clear documentation for community contributions
This concept provides a solid, maintainable foundation for replacing Bose's upstream services. The emphasis on simplicity, existing system reuse, and comprehensive testing ensures reliable functionality while maintaining the debugging capabilities needed for small hardware deployments.
This concept provides a solid, maintainable foundation for replacing Bose's upstream services. The emphasis on simplicity, existing system reuse, and comprehensive testing ensures reliable functionality while maintaining the debugging capabilities needed for small hardware deployments.
@@ -39,7 +39,7 @@ The current system uses multiple data collection methods to build a complete dev
<info deviceID="ABCD1234EFGH">
<name>My SoundTouch Device</name>
<type>SoundTouch 10</type>
<margeAccountUUID>3230304</margeAccountUUID>
<margeAccountUUID>1000001</margeAccountUUID>
<components>
<component>
<componentCategory>SCM</componentCategory>
@@ -50,7 +50,7 @@ The current system uses multiple data collection methods to build a complete dev
<margeURL>https://streaming.bose.com</margeURL>
<networkInfo type="SCM">
<macAddress>AA:BB:CC:DD:EE:FF</macAddress>
<ipAddress>192.168.1.10</ipAddress>
<ipAddress>192.0.2.10</ipAddress>
</networkInfo>
<moduleType>sm2</moduleType>
<variant>rhino</variant>
@@ -169,7 +169,7 @@ The `/power_on` endpoint receives comprehensive device data that could replace m
```xml
<device-data>
<device id="A81B6A536A98">
<device id="AABBCCDDEEFF">
<serialnumber>I6332527703739342000020</serialnumber>
<firmware-version>27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29</firmware-version>
<product product_code="SoundTouch 10 sm2" type="5">
@@ -179,12 +179,12 @@ The `/power_on` endpoint receives comprehensive device data that could replace m
<diagnostic-data>
<device-landscape>
<rssi>Excellent</rssi>
<gateway-ip-address>192.168.178.1</gateway-ip-address>
<gateway-ip-address>192.0.2.1</gateway-ip-address>
<macaddresses>
<macaddress>A81B6A536A98</macaddress>
<macaddress>A81B6A849D99</macaddress>
<macaddress>AABBCCDDEEFF</macaddress>
<macaddress>AABBCCDDEE01</macaddress>
</macaddresses>
<ip-address>192.168.178.35</ip-address>
<ip-address>192.0.2.10</ip-address>
<network-connection-type>Wireless</network-connection-type>
</device-landscape>
<network-landscape>
+5 -5
View File
@@ -23,7 +23,7 @@ The `/power_on` endpoint provides rich device data that could eliminate network
### Current /power_on Data
```xml
<device-data>
<device id="A81B6A536A98"> <!-- ✅ Device MAC -->
<device id="AABBCCDDEEFF"> <!-- ✅ Device MAC -->
<serialnumber>I6332527703739342000020</serialnumber> <!-- ✅ Serial -->
<firmware-version>27.0.6.46330.5043500...</firmware-version> <!-- ✅ FW -->
<product product_code="SoundTouch 10 sm2" type="5"> <!-- ✅ Model -->
@@ -33,12 +33,12 @@ The `/power_on` endpoint provides rich device data that could eliminate network
<diagnostic-data>
<device-landscape>
<rssi>Excellent</rssi> <!-- ✅ Signal -->
<gateway-ip-address>192.168.178.1</gateway-ip-address> <!-- ✅ Network -->
<gateway-ip-address>192.0.2.1</gateway-ip-address> <!-- ✅ Network -->
<macaddresses> <!-- ✅ All MACs -->
<macaddress>A81B6A536A98</macaddress>
<macaddress>A81B6A849D99</macaddress>
<macaddress>AABBCCDDEEFF</macaddress>
<macaddress>AABBCCDDEE01</macaddress>
</macaddresses>
<ip-address>192.168.178.35</ip-address> <!-- ✅ Current IP -->
<ip-address>192.0.2.10</ip-address> <!-- ✅ Current IP -->
<network-connection-type>Wireless</network-connection-type> <!-- ✅ Connection -->
</device-landscape>
</diagnostic-data>
+6 -6
View File
@@ -39,12 +39,12 @@ Shows the network layout with Raspberry Pi, router, and SoundTouch devices.
```
Internet Cloud
↑↓ (Optional - during migration)
Home Router (192.168.1.1)
├── Raspberry Pi (192.168.1.10) [SoundTouch Service]
├── Living Room Speaker (192.168.1.100)
├── Kitchen Speaker (192.168.1.101)
├── Bedroom Speaker (192.168.1.102)
└── Office Speaker (192.168.1.103)
Home Router (192.0.2.1)
├── Raspberry Pi (192.0.2.10) [SoundTouch Service]
├── Living Room Speaker (192.0.2.100)
├── Kitchen Speaker (192.0.2.101)
├── Bedroom Speaker (192.0.2.102)
└── Office Speaker (192.0.2.103)
```
### Connections
+2 -2
View File
@@ -200,7 +200,7 @@ echo "Proxy set to ${MAC_IP}:8080"
Confirm emulator can reach the speaker:
```bash
SPEAKER_IP=192.168.1.50 # adjust to your speaker's LAN IP
SPEAKER_IP=192.0.2.50 # adjust to your speaker's LAN IP
adb -s emulator-5554 shell ping -c 3 "$SPEAKER_IP"
```
@@ -394,7 +394,7 @@ Raw log of the first interactive run. To be cleaned up into the runbook above.
### Wi-Fi Provisioning (AP mode)
- `airport` command not available on this macOS version (removed in recent releases)
- Connect Mac to speaker AP via **System Settings → Wi-Fi** (SSID: "Bose SoundTouch XXXX")
- Speaker AP gateway confirmed: `192.0.2.1` (client gets `192.0.2.2`), not `192.168.1.1` as previously assumed
- Speaker AP gateway confirmed: `192.0.2.1` (client gets `192.0.2.2`), not `192.0.2.1` as previously assumed
- `/gabbo_wifi` endpoint was hallucinated — actual endpoint verified from browser network capture (`_/device-reset/wifi-setup.txt`):
- Site survey: `POST http://192.0.2.1:8090/performWirelessSiteSurvey` with `<PerformWirelessSiteSurvey timeout="5"/>`
- Add profile: `POST http://192.0.2.1:8090/addWirelessProfile` with XML body, `securityType="wpa_or_wpa2"`
-9
View File
@@ -50,9 +50,6 @@ make build-service
Service listens on `:8000` by default. Web UI: `http://localhost:8000`
> To also enable mirror mode (forward unhandled requests to official Bose servers
> for comparison), add: `--mirror-enabled --mirror-endpoints /streaming/`
---
## Step 2 — Start mitmproxy + Frida (new capture)
@@ -236,9 +233,6 @@ Endpoints the service doesn't handle return `404 Not Found`. Check:
```bash
# From service stats
curl -s http://localhost:8000/setup/interaction-stats | python3 -m json.tool
# List parity mismatches (local vs upstream divergence, if mirror enabled)
curl -s http://localhost:8000/setup/parity-mismatches | python3 -m json.tool
```
---
@@ -298,8 +292,6 @@ Settings applied in the web UI before migration:
| Target Domain | `soundtouch.local` (resolvable from speaker to `192.168.x.z`) |
| DNS Discovery | enabled |
| Upstream DNS | home Wi-Fi gateway |
| Mirroring | enabled (for tracing while Bose cloud is still up) |
| Mirrored endpoints | `/bmx/*`, `/streaming/*`, `/accounts/*`, `/v1/scmudc/*`, `/oauth/*` |
| Proxy logging | enabled, including bodies |
| Record interactions | enabled |
| Skip recording | `/setup/*`, `/web/*` |
@@ -361,7 +353,6 @@ All tests run from the **Devices → Migrate** panel after selecting the speaker
- Paired speaker to Bose account via app — succeeded ✅
- Set presets via app — worked ✅
- Mirroring active and functional during session ✅
- No visible errors in app behaviour; service logs and interaction recordings not yet reviewed in detail
### Known Shell Warning (safe to ignore)
+127 -127
View File
@@ -68,7 +68,7 @@ soundtouch-cli --host <device> info
**Example:**
```bash
soundtouch-cli --host 192.168.1.10 info
soundtouch-cli --host 192.0.2.10 info
```
#### `name get|set`
@@ -119,16 +119,16 @@ soundtouch-cli --host <device> preset remove --slot <1-6>
**Store Current Content Examples:**
```bash
# Store what's currently playing as preset 1
soundtouch-cli --host 192.168.1.10 preset store-current --slot 1
soundtouch-cli --host 192.0.2.10 preset store-current --slot 1
# Store current Spotify track as preset 3
soundtouch-cli --host 192.168.1.10 preset store-current --slot 3
soundtouch-cli --host 192.0.2.10 preset store-current --slot 3
```
**Store Specific Content Examples:**
```bash
# Store Spotify playlist
soundtouch-cli --host 192.168.1.10 preset store \
soundtouch-cli --host 192.0.2.10 preset store \
--slot 1 \
--source SPOTIFY \
--location "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M" \
@@ -136,14 +136,14 @@ soundtouch-cli --host 192.168.1.10 preset store \
--name "Today's Top Hits"
# Store radio station
soundtouch-cli --host 192.168.1.10 preset store \
soundtouch-cli --host 192.0.2.10 preset store \
--slot 2 \
--source TUNEIN \
--location "/v1/playbook/station/s33828" \
--location "/v1/playback/station/s33828" \
--name "K-LOVE Radio"
# Store internet radio
soundtouch-cli --host 192.168.1.10 preset store \
soundtouch-cli --host 192.0.2.10 preset store \
--slot 3 \
--source LOCAL_INTERNET_RADIO \
--location "https://stream.example.com/jazz" \
@@ -153,13 +153,13 @@ soundtouch-cli --host 192.168.1.10 preset store \
**Selection and Management Examples:**
```bash
# List all presets
soundtouch-cli --host 192.168.1.10 preset list
soundtouch-cli --host 192.0.2.10 preset list
# Select preset 1
soundtouch-cli --host 192.168.1.10 preset select --slot 1
soundtouch-cli --host 192.0.2.10 preset select --slot 1
# Remove preset 6
soundtouch-cli --host 192.168.1.10 preset remove --slot 6
soundtouch-cli --host 192.0.2.10 preset remove --slot 6
```
**Getting Content Locations:**
@@ -168,10 +168,10 @@ To find content locations for the `--location` parameter:
```bash
# Show current content details (includes location for all sources)
soundtouch-cli --host 192.168.1.10 play now
soundtouch-cli --host 192.0.2.10 play now
# Show detailed content information
soundtouch-cli --host 192.168.1.10 play now --verbose
soundtouch-cli --host 192.0.2.10 play now --verbose
```
### Recent Content
@@ -199,28 +199,28 @@ soundtouch-cli --host <device> recents stats
**Basic Usage Examples:**
```bash
# List last 10 recent items (default)
soundtouch-cli --host 192.168.1.10 recents list
soundtouch-cli --host 192.0.2.10 recents list
# Show all recent items with detailed information
soundtouch-cli --host 192.168.1.10 recents list --limit 0 --detailed
soundtouch-cli --host 192.0.2.10 recents list --limit 0 --detailed
# Show only the most recent item
soundtouch-cli --host 192.168.1.10 recents latest
soundtouch-cli --host 192.0.2.10 recents latest
```
**Filtering Examples:**
```bash
# Show only Spotify items
soundtouch-cli --host 192.168.1.10 recents filter --source SPOTIFY
soundtouch-cli --host 192.0.2.10 recents filter --source SPOTIFY
# Show only tracks (no stations or playlists)
soundtouch-cli --host 192.168.1.10 recents filter --type track
soundtouch-cli --host 192.0.2.10 recents filter --type track
# Show only presetable items
soundtouch-cli --host 192.168.1.10 recents filter --type presetable
soundtouch-cli --host 192.0.2.10 recents filter --type presetable
# Show last 5 local music items
soundtouch-cli --host 192.168.1.10 recents filter --source LOCAL_MUSIC --limit 5
soundtouch-cli --host 192.0.2.10 recents filter --source LOCAL_MUSIC --limit 5
```
**Available Sources:**
@@ -242,7 +242,7 @@ soundtouch-cli --host 192.168.1.10 recents filter --source LOCAL_MUSIC --limit 5
**Statistics Example:**
```bash
# Get detailed statistics about recent content
soundtouch-cli --host 192.168.1.10 recents stats
soundtouch-cli --host 192.0.2.10 recents stats
```
#### `presets` (Legacy)
@@ -292,10 +292,10 @@ soundtouch-cli --host <device> preset --preset <1-6>
**Examples:**
```bash
# Select preset 1
soundtouch-cli --host 192.168.1.10 preset --preset 1
soundtouch-cli --host 192.0.2.10 preset --preset 1
# Select preset 6
soundtouch-cli --host 192.168.1.10 preset --preset 6
soundtouch-cli --host 192.0.2.10 preset --preset 6
```
#### `track`
@@ -362,16 +362,16 @@ soundtouch-cli --host <device> volume down [--amount <1-10>]
**Examples:**
```bash
# Get volume
soundtouch-cli --host 192.168.1.10 volume get
soundtouch-cli --host 192.0.2.10 volume get
# Set volume to 50
soundtouch-cli --host 192.168.1.10 volume set --level 50
soundtouch-cli --host 192.0.2.10 volume set --level 50
# Increase volume by 5
soundtouch-cli --host 192.168.1.10 volume up --amount 5
soundtouch-cli --host 192.0.2.10 volume up --amount 5
# Decrease volume by 3 (default amount is 2)
soundtouch-cli --host 192.168.1.10 volume down --amount 3
soundtouch-cli --host 192.0.2.10 volume down --amount 3
```
### Audio Sources
@@ -419,42 +419,42 @@ soundtouch-cli --host <device> source content --source <SOURCE> --location <LOCA
**Examples:**
```bash
# List all sources
soundtouch-cli --host 192.168.1.10 source list
soundtouch-cli --host 192.0.2.10 source list
# Select Spotify
soundtouch-cli --host 192.168.1.10 source spotify
soundtouch-cli --host 192.0.2.10 source spotify
# Select Spotify with specific account
soundtouch-cli --host 192.168.1.10 source select --source SPOTIFY --account user@example.com
soundtouch-cli --host 192.0.2.10 source select --source SPOTIFY --account user@example.com
# Select Bluetooth
soundtouch-cli --host 192.168.1.10 source bluetooth
soundtouch-cli --host 192.0.2.10 source bluetooth
# Select internet radio with streamUrl format
soundtouch-cli --host 192.168.1.10 source internet-radio \
soundtouch-cli --host 192.0.2.10 source internet-radio \
--location "http://contentapi.gmuth.de/station.php?name=MyStation&streamUrl=https://stream.example.com/radio" \
--name "My Radio Station" \
--artwork "https://example.com/art.png"
# Select internet radio with direct stream URL
soundtouch-cli --host 192.168.1.10 source internet-radio \
soundtouch-cli --host 192.0.2.10 source internet-radio \
--location "https://stream.example.com/radio" \
--name "My Stream"
# Select local music content (requires SoundTouch App Media Server)
soundtouch-cli --host 192.168.1.10 source local-music \
soundtouch-cli --host 192.0.2.10 source local-music \
--location "album:983" \
--account "3f205110-4a57-4e91-810a-123456789012" \
--name "Welcome to the New"
# Select stored music content (requires UPnP/DLNA media server)
soundtouch-cli --host 192.168.1.10 source stored-music \
soundtouch-cli --host 192.0.2.10 source stored-music \
--location "6_a2874b5d_4f83d999" \
--account "d09708a1-5953-44bc-a413-123456789012/0" \
--name "Christmas Album"
# Advanced content selection with all options
soundtouch-cli --host 192.168.1.10 source content \
soundtouch-cli --host 192.0.2.10 source content \
--source LOCAL_INTERNET_RADIO \
--location "https://stream.example.com/radio" \
--name "My Stream" \
@@ -462,22 +462,22 @@ soundtouch-cli --host 192.168.1.10 source content \
--presetable
# Get introspect data for Spotify
soundtouch-cli --host 192.168.1.10 source introspect --source SPOTIFY
soundtouch-cli --host 192.0.2.10 source introspect --source SPOTIFY
# Get introspect data with account
soundtouch-cli --host 192.168.1.10 source introspect --source SPOTIFY --account user@spotify.com
soundtouch-cli --host 192.0.2.10 source introspect --source SPOTIFY --account user@spotify.com
# Spotify introspect (convenience command)
soundtouch-cli --host 192.168.1.10 source introspect-spotify
soundtouch-cli --host 192.0.2.10 source introspect-spotify
# Get introspect data for all available services
soundtouch-cli --host 192.168.1.10 source introspect-all
soundtouch-cli --host 192.0.2.10 source introspect-all
# Check service availability
soundtouch-cli --host 192.168.1.10 source availability
soundtouch-cli --host 192.0.2.10 source availability
# Compare sources and availability
soundtouch-cli --host 192.168.1.10 source compare
soundtouch-cli --host 192.0.2.10 source compare
```
**Content Selection Commands:**
@@ -496,12 +496,12 @@ The `internet-radio` command supports the streamUrl proxy format from the [Sound
```bash
# Using contentapi.gmuth.de proxy for complex streams
soundtouch-cli --host 192.168.1.10 source internet-radio \
soundtouch-cli --host 192.0.2.10 source internet-radio \
--location "http://contentapi.gmuth.de/station.php?name=Antenne%20Chillout&streamUrl=https://stream.antenne.de/chillout/stream/aacp" \
--name "Antenne Chillout"
# Using local soundtouch-service for custom streams
soundtouch-cli --host 192.168.1.10 source custom-radio \
soundtouch-cli --host 192.0.2.10 source custom-radio \
--url "https://stream.antenne.de/chillout/stream/aacp" \
--name "Antenne Chillout" \
--service-url "http://localhost:8080"
@@ -543,19 +543,19 @@ soundtouch-cli --host <device> source introspect-all
**Examples:**
```bash
# Get Spotify service status
soundtouch-cli --host 192.168.1.10 source introspect --source SPOTIFY
soundtouch-cli --host 192.0.2.10 source introspect --source SPOTIFY
# Get Spotify status with specific account
soundtouch-cli --host 192.168.1.10 source introspect --source SPOTIFY --account my_spotify_user
soundtouch-cli --host 192.0.2.10 source introspect --source SPOTIFY --account my_spotify_user
# Use Spotify convenience command
soundtouch-cli --host 192.168.1.10 source introspect-spotify
soundtouch-cli --host 192.0.2.10 source introspect-spotify
# Get status for all available streaming services
soundtouch-cli --host 192.168.1.10 source introspect-all
soundtouch-cli --host 192.0.2.10 source introspect-all
# Check which services are available before introspecting
soundtouch-cli --host 192.168.1.10 source availability
soundtouch-cli --host 192.0.2.10 source availability
```
### Music Service Account Management
@@ -604,40 +604,40 @@ soundtouch-cli --host <device> account remove-nas --user <GUID/0> [--name <NAME>
**Examples:**
```bash
# List all configured music service accounts
soundtouch-cli --host 192.168.1.10 account list
soundtouch-cli --host 192.0.2.10 account list
# Add a Spotify Premium account
soundtouch-cli --host 192.168.1.10 account add-spotify \
soundtouch-cli --host 192.0.2.10 account add-spotify \
--user "user@spotify.com" \
--password "mypassword"
# Add a Pandora account
soundtouch-cli --host 192.168.1.10 account add-pandora \
soundtouch-cli --host 192.0.2.10 account add-pandora \
--user "pandora_username" \
--password "pandora_password"
# Add an Amazon Music account
soundtouch-cli --host 192.168.1.10 account add-amazon \
soundtouch-cli --host 192.0.2.10 account add-amazon \
--user "amazon_user" \
--password "amazon_password"
# Add a network music library (NAS/UPnP)
soundtouch-cli --host 192.168.1.10 account add-nas \
soundtouch-cli --host 192.0.2.10 account add-nas \
--user "d09708a1-5953-44bc-a413-123456789012/0" \
--name "My Music Server"
# Remove a Spotify account
soundtouch-cli --host 192.168.1.10 account remove-spotify \
soundtouch-cli --host 192.0.2.10 account remove-spotify \
--user "user@spotify.com"
# Generic account management
soundtouch-cli --host 192.168.1.10 account add \
soundtouch-cli --host 192.0.2.10 account add \
--source DEEZER \
--user "deezer_user" \
--password "deezer_pass" \
--name "Deezer Premium"
soundtouch-cli --host 192.168.1.10 account remove \
soundtouch-cli --host 192.0.2.10 account remove \
--source DEEZER \
--user "deezer_user"
```
@@ -676,16 +676,16 @@ soundtouch-cli --host <device> bass capabilities
**Examples:**
```bash
# Get current bass
soundtouch-cli --host 192.168.1.10 bass get
soundtouch-cli --host 192.0.2.10 bass get
# Set bass to +3
soundtouch-cli --host 192.168.1.10 bass set --level 3
soundtouch-cli --host 192.0.2.10 bass set --level 3
# Increase bass by 2
soundtouch-cli --host 192.168.1.10 bass up --amount 2
soundtouch-cli --host 192.0.2.10 bass up --amount 2
# Decrease bass by 1 (default)
soundtouch-cli --host 192.168.1.10 bass down
soundtouch-cli --host 192.0.2.10 bass down
```
### Balance Control
@@ -716,16 +716,16 @@ soundtouch-cli --host <device> balance center
**Examples:**
```bash
# Get balance
soundtouch-cli --host 192.168.1.10 balance get
soundtouch-cli --host 192.0.2.10 balance get
# Set balance 10 units to the right
soundtouch-cli --host 192.168.1.10 balance set --level 10
soundtouch-cli --host 192.0.2.10 balance set --level 10
# Shift left by 5 units (default)
soundtouch-cli --host 192.168.1.10 balance left
soundtouch-cli --host 192.0.2.10 balance left
# Center the balance
soundtouch-cli --host 192.168.1.10 balance center
soundtouch-cli --host 192.0.2.10 balance center
```
### Clock and Time
@@ -757,22 +757,22 @@ soundtouch-cli --host <device> clock display format --format <12|24>
**Examples:**
```bash
# Get current time
soundtouch-cli --host 192.168.1.10 clock get
soundtouch-cli --host 192.0.2.10 clock get
# Set time to 2:30 PM
soundtouch-cli --host 192.168.1.10 clock set --time "14:30"
soundtouch-cli --host 192.0.2.10 clock set --time "14:30"
# Sync with system time
soundtouch-cli --host 192.168.1.10 clock now
soundtouch-cli --host 192.0.2.10 clock now
# Enable clock display
soundtouch-cli --host 192.168.1.10 clock display enable
soundtouch-cli --host 192.0.2.10 clock display enable
# Set 24-hour format
soundtouch-cli --host 192.168.1.10 clock display format --format 24
soundtouch-cli --host 192.0.2.10 clock display format --format 24
# Set high brightness
soundtouch-cli --host 192.168.1.10 clock display brightness --brightness high
soundtouch-cli --host 192.0.2.10 clock display brightness --brightness high
```
### Network Information
@@ -831,19 +831,19 @@ soundtouch-cli --host <device> zone set --master <ip> --members <ip1,ip2>
**Examples:**
```bash
# Get current zone info
soundtouch-cli --host 192.168.1.10 zone get
soundtouch-cli --host 192.0.2.10 zone get
# Create zone with three speakers
soundtouch-cli --host 192.168.1.10 zone create --members 192.168.1.11,192.168.1.12
soundtouch-cli --host 192.0.2.10 zone create --members 192.0.2.11,192.0.2.12
# Add speaker to existing zone
soundtouch-cli --host 192.168.1.10 zone add --member 192.168.1.13
soundtouch-cli --host 192.0.2.10 zone add --member 192.0.2.13
# Remove speaker from zone
soundtouch-cli --host 192.168.1.10 zone remove --member 192.168.1.12
soundtouch-cli --host 192.0.2.10 zone remove --member 192.0.2.12
# Dissolve the zone (make all speakers independent)
soundtouch-cli --host 192.168.1.10 zone dissolve
soundtouch-cli --host 192.0.2.10 zone dissolve
```
### Browse and Navigation
@@ -877,22 +877,22 @@ soundtouch-cli --host <device> browse container --source <SOURCE> --location <LO
**Examples:**
```bash
# Browse TuneIn stations
soundtouch-cli --host 192.168.1.10 browse tunein
soundtouch-cli --host 192.0.2.10 browse tunein
# Browse first 50 TuneIn stations
soundtouch-cli --host 192.168.1.10 browse tunein --limit 50
soundtouch-cli --host 192.0.2.10 browse tunein --limit 50
# Browse Pandora radio stations
soundtouch-cli --host 192.168.1.10 browse pandora --source-account myuser123
soundtouch-cli --host 192.0.2.10 browse pandora --source-account myuser123
# Browse Pandora with menu navigation
soundtouch-cli --host 192.168.1.10 browse menu --source PANDORA --source-account myuser123 --menu radioStations --sort dateCreated
soundtouch-cli --host 192.0.2.10 browse menu --source PANDORA --source-account myuser123 --menu radioStations --sort dateCreated
# Browse stored music library
soundtouch-cli --host 192.168.1.10 browse stored-music --source-account device_12345
soundtouch-cli --host 192.0.2.10 browse stored-music --source-account device_12345
# Browse into a music album container
soundtouch-cli --host 192.168.1.10 browse container --source STORED_MUSIC --location "album:983" --type dir
soundtouch-cli --host 192.0.2.10 browse container --source STORED_MUSIC --location "album:983" --type dir
```
### Station Search and Management
@@ -926,52 +926,52 @@ soundtouch-cli --host <device> station remove --source <SOURCE> --location <LOCA
**Search Examples:**
```bash
# Search TuneIn for jazz stations
soundtouch-cli --host 192.168.1.10 station search-tunein --query "jazz"
soundtouch-cli --host 192.0.2.10 station search-tunein --query "jazz"
# Search Pandora for Taylor Swift
soundtouch-cli --host 192.168.1.10 station search-pandora --source-account myuser123 --query "Taylor Swift"
soundtouch-cli --host 192.0.2.10 station search-pandora --source-account myuser123 --query "Taylor Swift"
# Search Spotify for workout playlists
soundtouch-cli --host 192.168.1.10 station search-spotify --source-account spotify_user --query "workout playlist"
soundtouch-cli --host 192.0.2.10 station search-spotify --source-account spotify_user --query "workout playlist"
# General search across any source
soundtouch-cli --host 192.168.1.10 station search --source TUNEIN --query "classic rock"
soundtouch-cli --host 192.0.2.10 station search --source TUNEIN --query "classic rock"
```
**Station Management Examples:**
```bash
# Add a station found from search results (use token from search output)
soundtouch-cli --host 192.168.1.10 station add \
soundtouch-cli --host 192.0.2.10 station add \
--source TUNEIN \
--token "c121508" \
--name "Classic Rock Radio"
# Add Pandora station with account
soundtouch-cli --host 192.168.1.10 station add \
soundtouch-cli --host 192.0.2.10 station add \
--source PANDORA \
--source-account myuser123 \
--token "TR:12345" \
--name "My Custom Station"
# Remove a station (use location from browse/search results)
soundtouch-cli --host 192.168.1.10 station remove \
soundtouch-cli --host 192.0.2.10 station remove \
--source TUNEIN \
--location "/v1/playbook/station/s33828"
--location "/v1/playback/station/s33828"
```
**Workflow Example - Discover and Play New Content:**
```bash
# 1. Search for content
soundtouch-cli --host 192.168.1.10 station search-tunein --query "smooth jazz"
soundtouch-cli --host 192.0.2.10 station search-tunein --query "smooth jazz"
# 2. Add interesting station from results (copy token from output)
soundtouch-cli --host 192.168.1.10 station add \
soundtouch-cli --host 192.0.2.10 station add \
--source TUNEIN \
--token "c456789" \
--name "Smooth Jazz 24/7"
# 3. Station is automatically playing! Or browse for more options:
soundtouch-cli --host 192.168.1.10 browse tunein --limit 10
soundtouch-cli --host 192.0.2.10 browse tunein --limit 10
```
### Speaker Notifications and Content
@@ -999,19 +999,19 @@ soundtouch-cli speaker help
**TTS Examples:**
```bash
# Basic TTS in English
soundtouch-cli --host 192.168.1.10 speaker tts \
soundtouch-cli --host 192.0.2.10 speaker tts \
--text "Hello, welcome home" \
--app-key "your-app-key"
# TTS with volume and language
soundtouch-cli --host 192.168.1.10 speaker tts \
soundtouch-cli --host 192.0.2.10 speaker tts \
--text "Bonjour le monde" \
--app-key "your-app-key" \
--volume 70 \
--language FR
# TTS for home automation alert
soundtouch-cli --host 192.168.1.10 speaker tts \
soundtouch-cli --host 192.0.2.10 speaker tts \
--text "Motion detected at front door" \
--app-key "security-system-key" \
--volume 80
@@ -1020,13 +1020,13 @@ soundtouch-cli --host 192.168.1.10 speaker tts \
**URL Content Examples:**
```bash
# Play audio file from URL
soundtouch-cli --host 192.168.1.10 speaker url \
soundtouch-cli --host 192.0.2.10 speaker url \
--url "https://example.com/doorbell.mp3" \
--app-key "your-app-key" \
--volume 75
# Play with custom metadata
soundtouch-cli --host 192.168.1.10 speaker url \
soundtouch-cli --host 192.0.2.10 speaker url \
--url "https://example.com/song.mp3" \
--app-key "your-app-key" \
--service "Music Service" \
@@ -1035,7 +1035,7 @@ soundtouch-cli --host 192.168.1.10 speaker url \
--volume 60
# Emergency alert
soundtouch-cli --host 192.168.1.10 speaker url \
soundtouch-cli --host 192.0.2.10 speaker url \
--url "https://alerts.example.com/fire-alarm.wav" \
--app-key "emergency-system" \
--service "Emergency System" \
@@ -1046,10 +1046,10 @@ soundtouch-cli --host 192.168.1.10 speaker url \
**Simple Notifications:**
```bash
# Quick beep notification
soundtouch-cli --host 192.168.1.10 speaker beep
soundtouch-cli --host 192.0.2.10 speaker beep
# Test device connectivity with beep
soundtouch-cli --host 192.168.1.10 speaker beep
soundtouch-cli --host 192.0.2.10 speaker beep
```
**Supported Languages for TTS:**
@@ -1106,16 +1106,16 @@ soundtouch-cli --host <device> events subscribe [flags]
**Examples:**
```bash
# Monitor all events
soundtouch-cli --host 192.168.1.10 events subscribe
soundtouch-cli --host 192.0.2.10 events subscribe
# Monitor only volume and now playing events
soundtouch-cli --host 192.168.1.10 events subscribe --filter volume,nowPlaying
soundtouch-cli --host 192.0.2.10 events subscribe --filter volume,nowPlaying
# Monitor for 5 minutes with verbose output
soundtouch-cli --host 192.168.1.10 events subscribe --duration 5m --verbose
soundtouch-cli --host 192.0.2.10 events subscribe --duration 5m --verbose
# Monitor zone events without automatic reconnection
soundtouch-cli --host 192.168.1.10 events subscribe --filter zone --no-reconnect
soundtouch-cli --host 192.0.2.10 events subscribe --filter zone --no-reconnect
```
**Notes:**
@@ -1133,59 +1133,59 @@ soundtouch-cli --host 192.168.1.10 events subscribe --filter zone --no-reconnect
soundtouch-cli discover devices
# Get device info
soundtouch-cli --host 192.168.1.10 info
soundtouch-cli --host 192.0.2.10 info
# Set comfortable volume and start playing
soundtouch-cli --host 192.168.1.10 volume set --level 30
soundtouch-cli --host 192.168.1.10 source spotify
soundtouch-cli --host 192.168.1.10 play start
soundtouch-cli --host 192.0.2.10 volume set --level 30
soundtouch-cli --host 192.0.2.10 source spotify
soundtouch-cli --host 192.0.2.10 play start
```
### Daily Usage
```bash
# Morning routine
soundtouch-cli --host 192.168.1.10 preset --preset 1 # Morning playlist
soundtouch-cli --host 192.168.1.10 volume set --level 25
soundtouch-cli --host 192.0.2.10 preset --preset 1 # Morning playlist
soundtouch-cli --host 192.0.2.10 volume set --level 25
# Pause for a call
soundtouch-cli --host 192.168.1.10 play pause
soundtouch-cli --host 192.0.2.10 play pause
# Resume
soundtouch-cli --host 192.168.1.10 play start
soundtouch-cli --host 192.0.2.10 play start
# Evening routine
soundtouch-cli --host 192.168.1.10 preset --preset 3 # Evening playlist
soundtouch-cli --host 192.168.1.10 volume set --level 15
soundtouch-cli --host 192.0.2.10 preset --preset 3 # Evening playlist
soundtouch-cli --host 192.0.2.10 volume set --level 15
```
### Multi-room Setup
```bash
# Create a zone with living room as master
soundtouch-cli --host 192.168.1.10 zone create --members 192.168.1.11,192.168.1.12
soundtouch-cli --host 192.0.2.10 zone create --members 192.0.2.11,192.0.2.12
# Control the whole zone from master
soundtouch-cli --host 192.168.1.10 volume set --level 40
soundtouch-cli --host 192.168.1.10 source spotify
soundtouch-cli --host 192.168.1.10 preset --preset 2
soundtouch-cli --host 192.0.2.10 volume set --level 40
soundtouch-cli --host 192.0.2.10 source spotify
soundtouch-cli --host 192.0.2.10 preset --preset 2
# Later, dissolve the zone
soundtouch-cli --host 192.168.1.10 zone dissolve
soundtouch-cli --host 192.0.2.10 zone dissolve
```
### Audio Tuning
```bash
# Get current audio settings
soundtouch-cli --host 192.168.1.10 volume get
soundtouch-cli --host 192.168.1.10 bass get
soundtouch-cli --host 192.168.1.10 balance get
soundtouch-cli --host 192.0.2.10 volume get
soundtouch-cli --host 192.0.2.10 bass get
soundtouch-cli --host 192.0.2.10 balance get
# Adjust for better sound
soundtouch-cli --host 192.168.1.10 bass set --level 2 # Slight bass boost
soundtouch-cli --host 192.168.1.10 balance set --level -5 # Slightly left
soundtouch-cli --host 192.168.1.10 volume set --level 35 # Good listening level
soundtouch-cli --host 192.0.2.10 bass set --level 2 # Slight bass boost
soundtouch-cli --host 192.0.2.10 balance set --level -5 # Slightly left
soundtouch-cli --host 192.0.2.10 volume set --level 35 # Good listening level
```
## Error Handling
@@ -1235,7 +1235,7 @@ soundtouch-cli zone create --help
You can set default values using environment variables:
```bash
export SOUNDTOUCH_HOST=192.168.1.10
export SOUNDTOUCH_HOST=192.0.2.10
export SOUNDTOUCH_PORT=8090
export SOUNDTOUCH_TIMEOUT=15s
@@ -1249,7 +1249,7 @@ soundtouch-cli volume get
Create `~/.soundtouch.env`:
```
SOUNDTOUCH_HOST=192.168.1.10
SOUNDTOUCH_HOST=192.0.2.10
SOUNDTOUCH_PORT=8090
SOUNDTOUCH_TIMEOUT=15s
SOUNDTOUCH_DISCOVERY_TIMEOUT=10s
+4 -4
View File
@@ -177,8 +177,8 @@ server:
soundtouch:
device_hosts:
- "192.168.1.100"
- "192.168.1.101"
- "192.0.2.100"
- "192.0.2.101"
discovery_timeout: "30s"
request_timeout: "15s"
max_retries: 3
@@ -820,7 +820,7 @@ services:
ports:
- "8080:8080"
environment:
- DEVICE_HOSTS=192.168.1.100,192.168.1.101
- DEVICE_HOSTS=192.0.2.100,192.0.2.101
- LOG_LEVEL=info
- METRICS_ENABLED=true
volumes:
@@ -927,7 +927,7 @@ kind: ConfigMap
metadata:
name: soundtouch-config
data:
device_hosts: "192.168.1.100,192.168.1.101,192.168.1.102"
device_hosts: "192.0.2.100,192.0.2.101,192.0.2.102"
```
#### Systemd Service
+1
View File
@@ -86,6 +86,7 @@ A factory reset wipes Wi-Fi credentials, account pairing, and all presets, retur
| SoundTouch 10 | Power on; hold **Preset 1** + **Volume ** for 10 s | Wi-Fi indicator glows solid amber |
| SoundTouch 20 | Power on; hold **Preset 1** + **Volume ** for 10 s | Lights blink L→R, then solid amber |
| SoundTouch 20 Series III | Hold **Preset 1** + **Preset 6** simultaneously for ~10 s | White LED sweep |
| SoundTouch 30 | Power on; hold **Preset 1** + **Volume ** for 10 s (display counts down 101) | Display shows "Hold to restore factory settings", then restarts |
| SoundTouch 30 Series III | Hold **Preset 1** + **Preset 6** simultaneously for ~10 s | White LED sweep |
| SoundTouch 300 | Hold **Volume ** until light bar blinks rapidly (~15 s) | Rapid blink → off → on |
| SoundTouch 10 (alt) | Press and hold the back recessed **Reset** pinhole for 10 s | Status LED restarts |
+11 -11
View File
@@ -172,14 +172,14 @@ discoverer := discovery.NewDiscoverer(discovery.Config{
devices, err := discoverer.DiscoverDevices()
// Or connect directly if you know the IP
soundtouch := client.NewClientFromHost("192.168.1.100")
soundtouch := client.NewClientFromHost("192.0.2.100")
```
### Client Configuration
```go
config := client.ClientConfig{
Host: "192.168.1.100",
Host: "192.0.2.100",
Port: 8090, // Default SoundTouch port
Timeout: 10 * time.Second,
UserAgent: "MyApp/1.0", // Optional
@@ -318,7 +318,7 @@ import (
func main() {
// Connect to device
soundtouch := client.NewClientFromHost("192.168.1.100")
soundtouch := client.NewClientFromHost("192.0.2.100")
// Create WebSocket client
wsClient := soundtouch.NewWebSocketClient(nil)
@@ -369,7 +369,7 @@ memberIDs := []string{"DEVICE456", "DEVICE789"}
soundtouch.CreateZone(masterID, memberIDs)
// Add device to existing zone
soundtouch.AddToZone("DEVICE999", "192.168.1.15")
soundtouch.AddToZone("DEVICE999", "192.0.2.15")
// Remove device from zone
soundtouch.RemoveFromZone("DEVICE456")
@@ -387,7 +387,7 @@ soundtouch.DissolveZone()
**Solutions:**
- Ensure SoundTouch is powered on
- Check both devices are on same network
- Try specifying IP directly: `client.NewClientFromHost("192.168.1.100")`
- Try specifying IP directly: `client.NewClientFromHost("192.0.2.100")`
- Check firewall settings
### Connection Timeouts
@@ -397,7 +397,7 @@ soundtouch.DissolveZone()
**Solutions:**
- Increase timeout: `Timeout: 30 * time.Second`
- Verify IP address and port (default 8090)
- Check network connectivity with `ping 192.168.1.100`
- Check network connectivity with `ping 192.0.2.100`
### Volume/Control Issues
```
@@ -444,14 +444,14 @@ Use the included CLI for quick testing:
go run ./cmd/soundtouch-cli discover devices
# Device info
go run ./cmd/soundtouch-cli --host 192.168.1.100 info
go run ./cmd/soundtouch-cli --host 192.0.2.100 info
# Basic controls
go run ./cmd/soundtouch-cli --host 192.168.1.100 play start
go run ./cmd/soundtouch-cli --host 192.168.1.100 volume set --level 50
go run ./cmd/soundtouch-cli --host 192.0.2.100 play start
go run ./cmd/soundtouch-cli --host 192.0.2.100 volume set --level 50
# WebSocket monitoring (use websocket-demo)
go run ./cmd/websocket-demo --host 192.168.1.100
go run ./cmd/websocket-demo --host 192.0.2.100
```
### Configuration Management
@@ -462,7 +462,7 @@ import "os"
host := os.Getenv("SOUNDTOUCH_HOST")
if host == "" {
host = "192.168.1.100" // fallback
host = "192.0.2.100" // fallback
}
soundtouch := client.NewClientFromHost(host)
+57 -3
View File
@@ -2,6 +2,14 @@
SoundTouch speakers communicate with cloud services over HTTPS. For the local service to work over HTTPS, speakers must trust the AfterTouch Root CA. The service manages this automatically — it generates a CA on first start and the web UI guides you through installing it on each speaker as part of the migration flow.
> ### ⚠️ Speakers connect to `:443`, AfterTouch defaults to `:8443`
>
> Speakers build their target URLs from Bose hostnames *without* an explicit port, so they connect on the default HTTPS port **443**. AfterTouch's built-in HTTPS listener defaults to **8443** because port 443 is privileged on most Unix systems.
>
> **If you do nothing, speakers will fail with `Curl 7` / connection refused and nothing will appear in the AfterTouch HTTP log.**
>
> Pick one of the three options under [Binding to port 443](#binding-to-port-443) below. The settings page in the web UI shows a ✅ / ❌ indicator for `:443` reachability so you can confirm the routing is in place.
---
## How TLS works in AfterTouch
@@ -41,9 +49,40 @@ http://<server>:8000/setup/ca.crt
Speakers expect HTTPS on the default port 443. Since binding to port 443 requires elevated privileges, you have three options:
1. **Port forwarding (recommended)**: Run the service on port 8443 and forward port 443 to it using `iptables` or your firewall/router.
2. **Capabilities**: Grant the binary permission to bind low ports: `sudo setcap 'cap_net_bind_service=+ep' ./soundtouch-service`
3. **Reverse proxy**: Use Nginx or Caddy in front of the service (see below).
1. **Port forwarding (recommended)**: Run the service on port 8443 and forward port 443 to it using `iptables` or your firewall/router. Inside an LXC/Docker container or on the host:
```bash
iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port 8443
iptables -t nat -A OUTPUT -p tcp --dport 443 -j REDIRECT --to-port 8443
```
The first rule covers traffic arriving from speakers; the second covers loopback connections from the host itself (useful for the in-built pre-flight probe).
2. **Capabilities**: Grant the binary permission to bind low ports and start the listener directly on `:443`:
```bash
sudo setcap 'cap_net_bind_service=+ep' ./soundtouch-service
./soundtouch-service --https-port=443
```
3. **Reverse proxy**: Use Nginx or Caddy on `:443` in front of the service (see below).
### Confirming `:443` is reachable
After applying any of the options above, open the AfterTouch web UI → **Settings**. The Target Domain row will show a second line:
* ✅ `:443 reachable on localhost and <IP> (forwarded to :8443)` — you're good.
* ❌ `Speakers connect to :443 but AfterTouch listens on :8443.` — the routing is missing or not yet active.
A third line follows from the browser itself, which sits on the LAN exactly where the speakers do. The browser can't distinguish an untrusted-CA TLS error from a connection refusal, so it uses timing as a heuristic: a fast error means "no listener / firewall reset", a slower one means "something answered TCP". When the server-side and browser-side checks disagree, the UI flags it — that almost always means NAT, split-horizon DNS, or a host firewall sitting between AfterTouch and the LAN.
The same check runs once at service startup and prints a `[WARN]` log line if `:443` is unreachable, with the exact iptables/setcap commands for your current listener port.
#### When this check is shown
The `:443` indicator is only displayed when **AfterTouch's DNS interception is enabled** (Settings → "Enable DNS Discovery Server"). The check is only meaningful for the **DNS migration method**, where speakers reach AfterTouch via intercepted Bose hostnames and therefore on the implicit `:443`. The other migration method — writing direct `https://<host>:8443/...` URLs into the speaker's private config via SSH — uses the port that's literally in the URL, so `:443` is irrelevant and the check would only add noise.
If you intercept Bose hostnames **outside** AfterTouch (Pi-hole, router DNS rule, `/etc/hosts` on a gateway), the UI gate above will hide the indicator. The data is still in the `GET /setup/settings` JSON response (`https_443_localhost_reachable`, `https_443_lan_reachable`, `https_443_lan_host`) if you want to inspect it directly, or you can briefly enable AfterTouch's DNS server to see the indicator render.
---
@@ -70,6 +109,21 @@ server {
}
```
> **Tell the service to honour `X-Real-IP`/`X-Forwarded-For`.** When deploying
> behind a reverse proxy on the same host as above, set
> `"trust_forwarded_headers": true` in `data/settings.json`. With that flag
> on, the service rewrites `r.RemoteAddr` from the proxy-supplied headers,
> so handlers that act on the source IP (e.g. the Spotify priming triggered
> by `/marge/streaming/support/power_on`) see the speaker's real address
> instead of the proxy's loopback peer.
>
> By default only `127.0.0.0/8` and `::1/128` are trusted to set those
> headers. If your reverse proxy lives on a different host, list its CIDR(s)
> in `"trusted_proxy_cidrs"` (e.g. `["10.0.0.0/8"]`). Do **not** enable
> `trust_forwarded_headers` on a flat LAN deployment without a proxy: a
> malicious speaker on the LAN can send the headers itself and spoof its
> source IP.
---
## Manual CA injection (advanced)
+27 -27
View File
@@ -8,7 +8,7 @@ This guide explains how the SoundTouch service handles device identification thr
The SoundTouch service uses two different identifiers for devices:
- **MAC Address** (`A81B6A536A98`) - Used in HTTP API requests and UPnP discovery
- **MAC Address** (`AABBCCDDEEFF`) - Used in HTTP API requests and UPnP discovery
- **Serial Number** (`I6332527703739342000020`) - Used for internal file storage
The service automatically maps between these identifiers so that API requests using MAC addresses can access files stored using serial numbers.
@@ -17,21 +17,21 @@ The service automatically maps between these identifiers so that API requests us
### Request Flow
```
1. HTTP Request: GET /streaming/account/3230304/device/A81B6A536A98/presets
2. MAC Resolution: A81B6A536A98 → I6332527703739342000020
3. File Access: accounts/3230304/devices/I6332527703739342000020/Presets.xml
1. HTTP Request: GET /streaming/account/1000001/device/AABBCCDDEEFF/presets
2. MAC Resolution: AABBCCDDEEFF → I6332527703739342000020
3. File Access: accounts/1000001/devices/I6332527703739342000020/Presets.xml
```
### UPnP Discovery Integration
The service extracts MAC addresses from UPnP device descriptions:
```xml
<!-- From http://192.168.1.100:8091/XD/BO5EBO5E-F00D-F00D-FEED-A81B6A536A98.xml -->
<!-- From http://192.0.2.100:8091/XD/BO5EBO5E-F00D-F00D-FEED-AABBCCDDEEFF.xml -->
<root xmlns="urn:schemas-upnp-org:device-1-0">
<device>
<friendlyName>Sound Machinery</friendlyName>
<modelName>SoundTouch 10</modelName>
<serialNumber>A81B6A536A98</serialNumber> <!-- MAC address here -->
<serialNumber>AABBCCDDEEFF</serialNumber> <!-- MAC address here -->
</device>
</root>
```
@@ -51,12 +51,12 @@ The service handles all common MAC address formats automatically:
| Format | Example | Status |
|-------------|---------------------|-------------|
| Standard | `A81B6A536A98` | ✅ Supported |
| Standard | `AABBCCDDEEFF` | ✅ Supported |
| Lowercase | `a81b6a536a98` | ✅ Supported |
| With Colons | `A8:1B:6A:53:6A:98` | ✅ Supported |
| With Dashes | `A8-1B-6A-53-6A-98` | ✅ Supported |
| With Colons | `AA:BB:CC:DD:EE:FF` | ✅ Supported |
| With Dashes | `AA-BB-CC-DD-EE-FF` | ✅ Supported |
| Mixed Case | `a81B6a536A98` | ✅ Supported |
| With Spaces | ` A81B6A536A98 ` | ✅ Supported |
| With Spaces | ` AABBCCDDEEFF ` | ✅ Supported |
## 🔧 **Troubleshooting**
@@ -64,22 +64,22 @@ The service handles all common MAC address formats automatically:
**Symptoms:**
```
GET /streaming/account/3230304/device/A81B6A536A98/presets
GET /streaming/account/1000001/device/AABBCCDDEEFF/presets
→ 500 Internal Server Error
→ Log: "open .../devices/A81B6A536A98/Presets.xml: no such file or directory"
→ Log: "open .../devices/AABBCCDDEEFF/Presets.xml: no such file or directory"
```
**Diagnosis:**
1. Check if mapping exists:
```bash
# Look for device directory
ls data/accounts/3230304/devices/
ls data/accounts/1000001/devices/
# Should show serial numbers like: I6332527703739342000020
```
2. Check DeviceInfo.xml:
```bash
cat data/accounts/3230304/devices/I6332527703739342000020/DeviceInfo.xml
cat data/accounts/1000001/devices/I6332527703739342000020/DeviceInfo.xml
# Look for <macAddress> field
```
@@ -96,8 +96,8 @@ Ensure the MAC address is present:
```xml
<info deviceID="I6332527703739342000020">
<networkInfo type="SCM">
<macAddress>A81B6A536A98</macAddress> <!-- Must be present -->
<ipAddress>192.168.178.35</ipAddress>
<macAddress>AABBCCDDEEFF</macAddress> <!-- Must be present -->
<ipAddress>192.0.2.10</ipAddress>
</networkInfo>
</info>
```
@@ -106,16 +106,16 @@ Ensure the MAC address is present:
If the device was added manually, ensure proper structure:
```bash
# Create device directory using serial number
mkdir -p data/accounts/3230304/devices/I6332527703739342000020
mkdir -p data/accounts/1000001/devices/I6332527703739342000020
# Create DeviceInfo.xml with MAC address
cat > data/accounts/3230304/devices/I6332527703739342000020/DeviceInfo.xml << EOF
cat > data/accounts/1000001/devices/I6332527703739342000020/DeviceInfo.xml << EOF
<?xml version="1.0" encoding="UTF-8"?>
<info deviceID="I6332527703739342000020">
<name>My SoundTouch Device</name>
<networkInfo type="SCM">
<macAddress>A81B6A536A98</macAddress>
<ipAddress>192.168.1.100</ipAddress>
<macAddress>AABBCCDDEEFF</macAddress>
<ipAddress>192.0.2.100</ipAddress>
</networkInfo>
</info>
EOF
@@ -126,7 +126,7 @@ EOF
**Check UPnP accessibility:**
```bash
# Test UPnP endpoint directly
curl http://192.168.1.100:8091/XD/BO5EBO5E-F00D-F00D-FEED-A81B6A536A98.xml
curl http://192.0.2.100:8091/XD/BO5EBO5E-F00D-F00D-FEED-AABBCCDDEEFF.xml
# Should return XML with <serialNumber> field
```
@@ -144,9 +144,9 @@ This should be handled automatically, but you can verify:
**Test different formats:**
```bash
# All of these should work the same:
curl http://localhost:8000/streaming/account/3230304/device/A81B6A536A98/presets
curl http://localhost:8000/streaming/account/3230304/device/a81b6a536a98/presets
curl http://localhost:8000/streaming/account/3230304/device/A8:1B:6A:53:6A:98/presets
curl http://localhost:8000/streaming/account/1000001/device/AABBCCDDEEFF/presets
curl http://localhost:8000/streaming/account/1000001/device/a81b6a536a98/presets
curl http://localhost:8000/streaming/account/1000001/device/AA:BB:CC:DD:EE:FF/presets
```
## 📊 **Monitoring and Diagnostics**
@@ -162,7 +162,7 @@ Ensure proper directory organization:
```
data/
└── accounts/
└── 3230304/
└── 1000001/
└── devices/
└── I6332527703739342000020/ # Serial number directory
├── DeviceInfo.xml # Contains MAC address
@@ -186,8 +186,8 @@ For developers interested in the technical details:
// 1. Removing spaces, colons, and dashes
// 2. Converting to uppercase
// Examples:
// "a8:1b:6a:53:6a:98" → "A81B6A536A98"
// "A8-1B-6A-53-6A-98" → "A81B6A536A98"
// "a8:1b:6a:53:6a:98" → "AABBCCDDEEFF"
// "AA-BB-CC-DD-EE-FF" → "AABBCCDDEEFF"
```
### Lookup Process
+135 -28
View File
@@ -1,6 +1,6 @@
# Migration Guide: From Bose Cloud to AfterTouch
This guide walks through the complete process of migrating your SoundTouch speakers from Bose's cloud services to **AfterTouch**, the local replacement provided by `soundtouch-service`. By the end, your speakers will work fully independently of Bose's servers.
This guide walks through the complete process of migrating your SoundTouch speakers from Bose's cloud services to **AfterTouch**, the replacement provided by `soundtouch-service`. By the end, your speakers will work fully independently of Bose's servers.
For a shorter overview, see the [Survival Guide](SURVIVAL-GUIDE.md). For safety considerations and rollback options, see the [Migration & Safety Guide](MIGRATION-SAFETY.md).
@@ -35,7 +35,7 @@ The repository ships a `docker-compose.yml` ready for this use case. Clone or do
```bash
cp .env.example .env
# Edit .env:
# SOUNDTOUCH_HOSTNAME=192.168.1.100 ← your server's address
# SOUNDTOUCH_HOSTNAME=192.0.2.100 ← your server's address
# SOUNDTOUCH_VERSION=v0.70.0 ← pin to a release tag instead of 'latest'
docker compose up -d
```
@@ -82,7 +82,7 @@ Open `http://<server>:8000` and go to the **Settings** tab.
![AfterTouch Settings tab](../images/ui-settings.png)
Set the **Target Domain** to the address your speakers can reach — for example `https://soundtouch.fritz.box` or `http://192.168.1.100:8000`. This must be the host's address on your local network, not `localhost`.
Set the **Target Domain** to the address your speakers can reach — for example `https://soundtouch.fritz.box` or `http://192.0.2.100:8000`. This must be the host's address on your local network, not `localhost`.
If you plan to use DNS/DHCP redirect, enable the **DNS Discovery Server** and set the **DNS Bind Address** to `:53`. The upstream DNS should be your router's IP, not the service's own address.
@@ -90,9 +90,13 @@ If you plan to use DNS/DHCP redirect, enable the **DNS Discovery Server** and se
---
## Step 3: Enable SSH on each speaker
## Step 3: Enable shell access on each speaker
The migration writes updated configuration to the speaker's filesystem, which requires SSH access. Enable it once per device:
The wizard supports **two transports** for talking to the speaker. Pick whichever your device exposes:
### SSH (recommended — required for XML migration, DNS interception, and CA install)
The XML migration writes updated configuration to the speaker's filesystem, which requires SSH access. Enable it once per device:
1. Format a USB drive as FAT (FAT32). Some speakers require the **bootable flag** to be set on the partition — see [SoundCork issue #172](https://github.com/deborahgu/soundcork/issues/172) for details.
2. Create an empty file named **`remote_services`** (no extension) in the root of the drive.
@@ -102,6 +106,12 @@ The migration writes updated configuration to the speaker's filesystem, which re
You only need to do this once per speaker. SSH can remain enabled for future maintenance or be disabled after migration — your choice.
### Telnet:17000 (fallback when SSH isn't possible)
If the USB-stick unlock doesn't work on your speaker (some firmware revisions refuse it — notably SA-5, ST520, and recent ST Portables), the wizard falls back to the speaker's **built-in diagnostic shell on TCP port 17000**. No setup required — most SoundTouch firmware exposes it automatically. The wizard detects which transports are available and picks the right one; you don't have to choose manually.
Telnet-only migrations are limited to HTTP (no CA install possible without SSH). The wizard surfaces this clearly when it applies.
---
## Step 4: Add and sync your speaker
@@ -124,44 +134,64 @@ If the Bose cloud is still running, Sync also fetches your account data from Bos
## Step 5: Migrate
Click **Migrate** next to a device on the Devices tab to open the Migration tab. It shows SSH status, CA trust status, and connection test results before letting you apply the redirect.
Click **Migrate** next to a device on the Devices tab to open the Migration tab. The tab opens with a **Migration Summary** that shows where your speaker currently stands, then offers a one-click suggested plan and a fully customizable form underneath.
![Migration tab showing HTTPS and DNS connection tests](../images/ui-migration.png)
![Migration tab showing the state card and Plan card](../images/ui-migration.png)
Two redirect methods are available:
### What you see at the top — the state card
### XML redirect (recommended for first-time / testing)
Three rows tell you the speaker's current state at a glance:
Uploads a configuration file to the speaker via the SoundTouch Web API. This changes the application-level service URLs without touching the speaker's network configuration. It's the least invasive option.
- **Transports** — whether SSH and Telnet:17000 are reachable. The wizard's choices are driven by these.
- **Migration State** — three orthogonal axes:
- *URL Configuration* — original Bose URLs or AfterTouch URLs (with a special "intercepted via DNS" verdict when the resolv.conf hook is doing the redirect).
- *DNS Interception* — none, or `/etc/resolv.conf` hook active.
- *CA / TLS* — local root CA installed on the device, with `Trust CA Now` and `Download CA cert` actions inline.
- **Preconditions**`remote_services` persistence, account pairing state, and XML config backup presence.
The web UI guides you through:
1. Previewing the config change (current vs. planned XML)
2. Optionally installing the AfterTouch CA certificate on the speaker (requires SSH; needed for HTTPS)
3. Applying the XML redirect
4. Verifying the speaker can reach the local service
### The Plan card — the happy path
### DNS/DHCP redirect (recommended for permanent / all-device setup)
Below the state card is the **Plan** card. For most users this is the only thing you'll touch:
Configures the speaker to use a custom DNS server that resolves Bose cloud hostnames to the local service. This is the most robust method — it covers all Bose endpoints automatically and survives reboots.
1. **Target service URL** — pre-filled from your Settings. Edit inline and click *Save as default* to update Settings without bouncing tabs.
2. **Capabilities** — what transports the speaker exposes and what AfterTouch can offer given those.
3. **Service URLs** — four URL inputs (margeServerUrl, statsServerUrl, swUpdateUrl, bmxRegistryUrl) pre-filled with canonical defaults. Most users leave them as-is; soundcork users tick the *Soundcork mode* checkbox to append `/marge` to `margeServerUrl`. URL validation runs on every keystroke.
4. **Account pairing** — pre-filled with the speaker's current account ID. Leave it to keep the existing pairing, change it to re-pair, or click *Generate* to assign a new 7-digit ID on a factory-reset device.
5. **Suggested plan** — one big green button: *Apply Suggested Plan*. The wizard picks the most conservative recipe for your speaker (XML over SSH with HTTP when SSH works; telnet URL flip with HTTP when only telnet works) and runs it.
Requirements:
- The AfterTouch DNS server must be running and bound to **port 53** on your network. Enable it in the **Settings** tab (`DNS Discovery` → enabled).
- HTTPS is required. The web UI walks you through trusting the CA certificate on the speaker (via SSH).
### What happens when you click Apply
The web UI guides you through:
1. Verifying the DNS server is running and reachable
2. Installing the CA certificate on the speaker
3. Configuring the speaker to use the AfterTouch DNS server
4. Verifying DNS resolution and HTTPS connectivity
The wizard switches to a visible **Pre-flight checks** panel and runs every applicable verification before touching the speaker:
- **Backend summary re-check** — confirms transports, hostname resolution, and that the URLs you plan to write match what the backend would produce.
- **HTTPS connection from device** (SSH-capable speakers) — uploads a temporary CA and runs `curl` from the speaker to your service.
- **Reachability check (passive observer)** (already-migrated speakers) — nudges `:8090/swUpdateCheck` on the device and watches for *any* request from the speaker to land on the service. Used when the speaker is already migrated and the service is the natural target of its outbounds.
- **"Round-trip validation runs after Apply + reboot"** (not-yet-migrated speakers) — surfaced as a skip row with a rationale. The speaker's swUpdate daemon caches its URL at boot, so there is no useful no-reboot round-trip check pre-migration; the canonical telnet flow is Apply → reboot → re-run pre-flight on the migrated speaker.
- **DNS redirection from device** — when DNS interception is part of the plan.
On all-green, the wizard auto-proceeds. On any failure, it pauses with *Proceed Anyway* / *Cancel* buttons so you can override on a known-false-positive (slow DNS, etc.) or fix the underlying issue and retry.
### Customize this migration — for mix-and-match
Expand the `▸ Customize this migration` section to pick any combination of three independent axes:
- **URL flip transport** — XML over SSH / Telnet (Port 17000) / Skip
- **DNS interception** — None / `/etc/resolv.conf` hook
- **Local CA install** — checkbox (SSH-only)
Each option carries a per-axis availability hint (e.g. *(SSH unreachable)*, *(already trusted)*) so you see why an option is disabled before you pick. *Apply Custom Plan* runs the chosen combination as a sequence; the same pre-flight panel gates the execution.
> **Note**: DNS interception bundles the CA install on the backend, so a standalone CA-install step is skipped automatically when DNS is part of the plan. The wizard handles this for you.
---
## Step 6: Reboot and verify
After migration, **power-cycle the speaker** (unplug and replug). This applies all configuration changes.
After a successful Apply the wizard auto-expands the Customize section and highlights the **Reboot Speaker** button. Click it (or power-cycle the speaker manually) to apply all configuration changes. The reboot transport is picked automatically from your URL flip choice — telnet reboot for SSH-less speakers, SSH reboot otherwise.
After reboot:
- The speaker should appear as **migrated** in the Devices tab
- The state card on the Migration tab should now show ✅ for URL Configuration (or "intercepted via DNS" if you used the resolv.conf hook)
- Presets should load and play (served from the local service)
- TuneIn browsing should work
- Recently played items should appear
@@ -176,12 +206,89 @@ Each speaker is migrated independently. You can run multiple migrations in paral
---
## Alternative: CLI-driven factory-reset workflow
If you prefer scripting the migration, or the wizard isn't an option (headless server, automation, batch onboarding of many speakers), `soundtouch-cli` exposes the same building blocks. The flow below is **not** an in-place migration — it factory-resets the speaker and brings it up fresh against AfterTouch, so any data Bose preserved on the device is wiped. Use this when:
- You're starting from a factory-reset speaker anyway.
- The wizard's in-place migration didn't take and you want a clean slate.
- You're scripting setup for many speakers and want a reproducible recipe.
### Prerequisites
- AfterTouch service running and reachable at a stable URL (e.g., `https://soundtouch.local` from your `.env`).
- The speaker reachable on its current IP (passed as `--host`).
- For the AP-mode handover step, your laptop must be able to join the speaker's `Bose SoundTouch` Wi-Fi (you'll switch between home Wi-Fi and the speaker's AP).
### The full sequence
```bash
# 1. Plan what the reset+pair pipeline will write (dry run, no changes yet).
soundtouch-cli --host 192.0.2.50 setup plan \
--reset=true --include-pair=false \
--service-url='https://soundtouch.local'
# 2. Trigger the factory reset. The speaker reboots into AP mode.
soundtouch-cli --host 192.0.2.50 setup factory-reset
# --- Manual step: join the speaker's Wi-Fi AP (SSID "Bose SoundTouch ...") ---
# 3. Wait for the AP-mode endpoint to answer.
soundtouch-cli setup wait-ap
# 4. Push your home Wi-Fi credentials to the speaker.
# Run twice if the first attempt's ACK races the AP teardown — the second
# one is a no-op if the first succeeded.
soundtouch-cli setup wifi-push --ssid="YourHomeSSID" --pass='your-wifi-password'
# --- Manual step: switch your laptop back to the home Wi-Fi network ---
# 5. Wait for the speaker to come back online on the home network.
# --match takes the last 4-6 hex chars of the speaker's MAC (visible on
# the bottom of the device).
soundtouch-cli setup wait-online --match=42CAFE
# 6. Pair the speaker with an AfterTouch account.
# --mode=full runs the canonical WebSocket SETUP sequence (matches the
# Bose app's flow); --account is the 7-digit account ID AfterTouch
# should attach the speaker to.
soundtouch-cli --host 192.0.2.50 setup pair \
--mode=full --account=1111111 \
--service-url='https://soundtouch.local'
```
### Verifying the result
After pairing completes:
- The speaker should appear on the **Devices** tab in the web UI.
- AUX should switch and play audio when selected.
- Pressing presets should fetch their content from AfterTouch (the `[LOG]` rows on the service confirm).
- TuneIn search and playback should work end-to-end.
If any of these fail post-pair, see [Troubleshooting](TROUBLESHOOTING.md) — most commonly the speaker just needs a power cycle to pick up everything cleanly.
### Differences vs the wizard
| Aspect | Wizard (in-place migration) | CLI factory-reset workflow |
|-------------------------------------|---------------------------------------------------------|---------------------------------------------------------|
| Preserves speaker's existing state | yes (Presets, recents, attached account) | **no** — wipes everything |
| Requires Wi-Fi-network switching | no | yes (laptop joins speaker AP, then home network) |
| Scriptable / reproducible | clickable, not scriptable | full bash recipe |
| Cloud-side data (Bose Marge backup) | preserved if Sync ran while cloud was alive | not relevant — fresh account on AfterTouch |
| Best for | "I want this speaker to keep working with what's on it" | "I want a clean, reproducible setup against AfterTouch" |
The wizard is still the recommended path for a one-off migration of an existing setup. The CLI workflow is the right choice when you're scripting, batching, or already starting from a reset.
---
## Rollback
If you need to undo a migration:
- **From the web UI**: Use the **Revert** action on the device — this restores the `.original` backup files created on the speaker during migration.
- **Via SSH**: The original config is backed up on the speaker with a `.original` suffix. Restore it manually if the UI is unreachable.
- **From the web UI**: Use the **Revert to Defaults** action on the device — this restores the `.original` backup files created on the speaker during the XML migration.
- **Telnet-only migrations**: the wizard writes both the runtime configuration layer (`sys configuration …`) and the persistent layer (`envswitch boseurls set …`) so the migration survives reboot. If you want to revert quickly, the cleanest path is to re-run the wizard with the original Bose URLs in the URL editor.
- **Via SSH**: The original XML config is backed up on the speaker with a `.original` suffix. Restore it manually if the UI is unreachable.
- **Factory reset**: As a last resort, perform a factory reset (see [Device Initial Setup](DEVICE-INITIAL-SETUP.md) for button sequences). This wipes all configuration and returns the speaker to out-of-box state.
---

Some files were not shown because too many files have changed in this diff Show More