Compare commits

...
114 Commits
Author SHA1 Message Date
Tobias GesellchenandClaude Sonnet 4.6 3cfb3da498 feat(export): encrypted diagnostic report for issue reporting
Adds a "Download diagnostic report" button on the Health tab that
produces an age-encrypted .age file the user can attach to a GitHub
issue without exposing sensitive data.

Archive contents (tar.gz, then age-encrypted with the maintainer's
SSH ed25519 public key):
- diagnostic.json         structured health/device summary (no secrets)
- datastore/…/*.xml       raw on-disk XML verbatim for diff vs HTTP
- http/service/…          live service HTTP responses per account/device
- http/speaker/…          live speaker API responses (port 8090)
- ssh/speaker/…           CA bundles + logread (last 20 min, 127.0.0.1
                          filtered) + dmesg fetched via SSH
- system/ca.pem           service CA cert
- system/resolv.conf      host DNS resolver config
- settings.json           service settings (OAuth secrets redacted)
- env.txt                 filtered process environment
- logs/service.txt        in-memory service log buffer

Supporting tooling:
- scripts/setup-diagnostic-key.sh  one-time SSH key-pair generation
- scripts/decrypt-diagnostic.go    go run helper for maintainer decryption
- keys/public/diagnostic.pub       committed public key (matches github.com/gesellix.keys)
- docs/DIAGNOSTIC-EXPORT.md        maintainer setup + user workflow guide
- docs/concepts/ENCRYPTED-EXPORT.md  research notes and architecture rationale

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 20:02:57 +02:00
Tobias GesellchenandClaude Sonnet 4.6 9312e27019 feat(health): operator-confirmable QuickFix to complete speaker pairing (#329)
Closes the loop on the empty-<margeAccountUUID> finding from
RegisterSpeakerInfoReachable. Operators in #329 quoted that finding
verbatim and asked "What is the recommended way to complete pairing?"
— the framework detected the condition but offered no in-UI recourse.

The QuickFix completes pairing in-place by dispatching through
setup.Manager.PairAccount, which tries HTTP /setMargeAccount first
and falls back to telnet `envswitch accountid set` — same code path
the existing POST /setup/pair-account/{deviceId} handler uses.

Account ID is picked at finding-time when a real (7-digit) account
directory already contains this device on disk (typical scenario:
AfterTouch remembers a previous pairing the speaker forgot). When
no such account exists, the executor generates a fresh 7-digit ID
via setup.GenerateAccountID at click time. Either way, the chosen
ID is named in the Confirm dialog and the CLI ManualCommand
fallback so the operator can see what's about to happen.

Architecturally: the FixID constant lives in the health package
alongside the check that emits the finding, but the executor is
registered from handlers/server.go where setup.Manager is
available. This keeps the health package's transitive dep surface
small (the boundary comment near speakerInfoXML deliberately
forbids importing setup, which would pull SSH/telnet/certmgr).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:44:57 +02:00
Tobias GesellchenandClaude Sonnet 4.6 2b50ef0c98 fix(datastore): prefer named default entry when two default dirs collide in ListAllDevices
When the same device appears under `default/` in two separate data dirs
(e.g. primary DataDir and the legacy st-go/data path), the first-seen entry
was kept unconditionally even when it had an empty name. A subsequent
default entry carrying a real name was silently dropped, causing name loss
in SyncFromAccountFull.

Addresses TestReproduceMissingName regression introduced by the
dedup-default-last change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 fa2f7cd17d feat(health): confirm orphan-account deletion against speaker /info
The orphan-account QuickFix used to rely solely on the operator's
manual log inspection ("Before deleting, verify the speaker isn't
currently PUTting to account X") plus the Confirm dialog. Adds a
defensive layer: the speaker itself answers "which account do I
belong to?" via :8090/info's <margeAccountUUID> element. Wire that
into both ends of the flow.

Detection (consistency check): on each scan we probe /info for each
device with a known IP. When the speaker answers, its
margeAccountUUID overrides the on-disk ListAllDevices guess, and the
finding's Details/Confirm copy quotes the speaker verbatim — "Speaker
/info reports margeAccountUUID=1111111; this directory (account
9569497) is stale because the speaker has stopped targeting it." If
the probe fails the wording falls back to the manual-verify hint.

Executor (deleteOrphanAccountEntry): re-probes /info before deleting
and refuses when the speaker reports target.Account as live. That
closes the race where the operator re-paired between scan and click.
Logs every successful probe + decision for auditability.

fetchSpeakerMargeAccount split into a URL-injectable variant so the
httptest-driven tests can verify the probe end-to-end without
hard-coding :8090 onto an unreachable address.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 884e19c791 feat(health): operator-confirmable QuickFix to reassign canonical source IDs
og-gh's #343 reproducer is built-in radio sources sitting on
non-canonical IDs (the 2000001+i fallback that GetConfiguredSources
hands out when on-disk sources lack canonical IDs). After re-pair
churn, presets binding by <sourceid> end up rebound to whichever
source happened to get the colliding numeric ID — silently rewriting
e.g. a TUNEIN preset to RADIOPLAYER on the next /full fetch.

The strict-match commit (aa449fb) keeps that drift from corrupting
emission downstream, but the underlying Sources.xml is still wrong
and the operator has to either pull-from-speaker (online) or
hand-edit XML (tedious). This commit adds an offline QuickFix that
rewrites the source IDs in Sources.xml back to canonical
(TUNEIN→10004, INTERNET_RADIO→10002, LOCAL_INTERNET_RADIO→10003,
RADIO_BROWSER→10005) and updates every <sourceid> reference in
Presets.xml/Recents.xml in lockstep.

Skipped when the canonical ID is already in use by another source
(e.g. duplicate TUNEIN entries from manual XML editing) — collisions
need operator review. Idempotent: a second click is a no-op when
everything is already canonical.

The fix is reachable from the consistency check finding, gated by
the framework's standard Confirm dialog which enumerates the exact
ID rewrites before executing. No speaker contact required; the
speaker re-fetches /full on its own and picks up the new IDs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 99d7111514 feat(health): operator-confirmable QuickFix to delete orphan account dirs
The orphan-account-entry finding (introduced in 0ac140f) currently just
points the operator at a copy-pasteable rm -rf command. Adds a
QuickFix button that does the same delete in-process after the
operator confirms via the standard health-framework Confirm dialog.

Findings are now one-per-(stale_account, device) pair so each delete
button targets exactly one directory. The Confirm copy spells out the
full path being removed and reminds the operator that the active
account isn't touched. The companion ManualCommands entry keeps the
shell-side rm available for operators who prefer to run it themselves.

deleteOrphanAccountEntry refuses on missing account/device, errors
explicitly when the directory was already cleaned up by hand, and
logs every successful removal so the action is auditable from the
service log.

The framework gates the click on Confirm — destructive operations
need operator consent per CLAUDE.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 8604f1e6ba fix(datastore,health): enumerate all stale account dirs per device
User reported "we might have another issue with the account mapping"
after the prior commit only handled the default-vs-real case. The
backup at /backup/var_20260520_01 showed device A81B6A536A98 living
under four directories — accounts/9569497, accounts/default,
accounts/1111111, and the top-level default/ — only the third of
which currently receives the speaker's PUTs.

The authoritative "which account does this device belong to" signal
is the URL of the speaker's incoming PUT (per "speaker decides"),
which only the live handler observes. mtime is a proxy and can be
fooled by backup tools, manual touches, etc., so this commit drops
the mtime tiebreaker the previous attempt added.

Instead:
  - ListAllDevices' dedup keeps default-deprioritisation (clear
    placeholder semantics) but otherwise picks the first real account
    encountered in stable alphabetical order. No heuristic guessing
    among real accounts.
  - New AllAccountsForDevice(deviceID) enumerates every on-disk
    account directory containing the deviceID.
  - The consistency check's orphan finding now lists every stale
    account dir for each device, with the path the operator needs to
    inspect and a pointer to the service log so they can verify which
    account the speaker is actually targeting before deleting
    anything.

We don't delete automatically — destructive filesystem actions need
explicit operator consent (CLAUDE.md "destructive actions" rule).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 e6954eed60 fix(datastore): real account wins over "default" placeholder in dedup
ListAllDevices used to let an entry under accounts/default/devices/<id>
replace the real-account entry for the same physical device whenever
the default-side DeviceInfo.xml had a non-empty <name>. The consistency
check then reported the device under "account default" even while the
speaker was happily POST/PUT'ing to its actually-paired account — the
operator saw "preset slot 1 present on speaker but missing from
service" for slots that very obviously did exist, just under the real
account they couldn't see.

The dedup now treats "default" as a fallback placeholder: sorts it to
the back of the iteration, and never lets it replace a real-account
entry. A default-only device (fresh discovery, never paired) is still
returned exactly as before.

Also adds an orphan-detection finding in the consistency check that
walks accounts/default/devices/ directly and flags entries whose
deviceID is also paired under a real account, with a copy-pasteable
rm -rf hint. We don't delete automatically — destructive filesystem
actions need explicit operator consent (CLAUDE.md).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 97238eb07a feat(marge): log GH-343-shaped source mismatch on UpdatePreset
The speaker's preset PUT carries only <sourceid> — no symbolic source
name — so we can't strict-match at write time the way we do on /full
emission. Adds a diagnostic-only inference from the preset's location
URL pattern (/v1/playback/station/sNNN -> TUNEIN, /playback/container/
-> SPOTIFY, /custom/v1/playback/ -> LOCAL_INTERNET_RADIO) and logs
when the inference disagrees with the bound source's SourceKeyType.

This is visibility, not enforcement: the binding still proceeds as
the speaker requested (per "speaker wins"). The log gives the operator
a concrete pointer — "the URL looks like TUNEIN but I bound to
RADIOPLAYER, your Sources.xml may be stale, try setup.syncSources" —
instead of leaving them to discover the drift via the consistency
check days later.

URL inference is deliberately fuzzy and one-way: it only triggers a
log when confident, returns "" otherwise, and never feeds the
binding decision. That keeps it from re-introducing the guesswork
the user pushed back on for the actual GH-343 fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 f0a63f19f4 fix(datastore): self-heal legacy Audio leak on read, preserve speaker intent
The pre-fix marge.syncPresets / syncRecents path persisted the upstream
cloud's <source type="Audio"> attribute into ServicePreset.Source /
ServiceRecent.Source. That value doesn't match what the speaker writes
via its own /presets endpoint (which is the source of truth), and one
operator's consistency-check scan surfaced ~50 recent_mismatch findings
all tracing back to this single leak.

GetPresets / GetRecents now repair the leak on load: when persisted
Source is "Audio" (or empty) AND SourceID resolves in the current
Sources.xml, substitute the speaker-perspective SourceKeyType. The
repair fires only on the *leak signature* — when persisted Source
carries a non-leak symbolic value like "TUNEIN", we never touch it.

That asymmetry is load-bearing for GH-343: a TUNEIN preset whose
SourceID has been re-classified to RADIOPLAYER in Sources.xml stays
TUNEIN here. The speaker's previously-stored intent wins over a stale
current source-list entry — soundcork's blind matching_src.source_key_type
substitution is the silent rewrite we're protecting against.

Also:
  - sourceKeyTypeFromFullSource now logs when the providerid isn't
    canonical and we fall back to upstream Type, so future leak
    signatures are visible instead of silent.
  - Removes the loadServiceView workaround that resolved Source via
    SourceID at consistency-check time — datastore now repairs at
    the layer where every consumer benefits.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 9fafe9f960 fix(marge): syncPresets/syncRecents persist speaker-perspective Source
marge.syncPresets / syncRecents were writing the upstream cloud's
<source type="Audio"> attribute into ServicePreset.Source /
ServiceRecent.Source on disk. That's a protocol-level classification,
not the symbolic name the speaker itself uses (TUNEIN, INTERNET_RADIO,
…). The on-disk shape ended up disagreeing with what the speaker writes
via its own /presets endpoint, which IS the source of truth — and the
disagreement surfaced as cross-side mismatches in the new consistency
check (one user saw 30+ recent_mismatch findings, all "speaker source=X
vs service source=Audio").

Project the upstream FullResponseSource back to the speaker's
perspective at persist time via SourceProviderID lookup against
StaticProviders (the inverse of canonicalProviderIDByID). Falls back
to the upstream Type for unknown providerids so non-canonical sources
stay no-worse-than-before.

The consistency-check workaround in loadServiceView (which resolves
Source via SourceID lookup on read) stays in place to cover legacy
on-disk data written by the previous behaviour — that data only gets
cleaned up when the operator re-runs setup.syncPresets from the
speaker directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 ce2935a4bd fix(health): consistency report — cut noise, fix Audio leak, group unsynced
First operator run of the new consistency check surfaced both real bugs
and a lot of noise. This commit refines the report so the remaining
findings are actionable.

Real bugs fixed:

- loadServiceView now resolves preset/recent Source via SourceID lookup
  against Sources.xml, instead of trusting the persisted Source field.
  syncPresets / syncRecents in sync.go currently writes the upstream
  FullResponseSource.Type ("Audio") into ServicePreset.Source, which
  made every cross-side mismatch finding read "service source='Audio'".
  Underlying syncPresets/Recents misfeature is a separate fix; the
  consistency check stops being fooled by it.

- Duplicate-source dedup keyed by type+account, not just type.
  SpotifyConnectUserName + SpotifyAlexaUserName, QPlay1UserName +
  QPlay2UserName are legitimate sub-accounts of the same source type
  and used to falsely trip duplicate_source warnings.

Noise removed:

- Cross-side source_mismatch comparison dropped. Speaker /sources
  enumerates local I/O sources (AUX, BLUETOOTH, AIRPLAY, QPLAY, …),
  service Sources.xml tracks credentialed streaming sources (TUNEIN,
  INTERNET_RADIO, …). They legitimately don't overlap on most types,
  so the asymmetry was pure noise.

- Internal-consistency check restricted to the service side. Streaming
  sources are never in the speaker's /sources by design (they're
  proxied through BMX), so a TUNEIN preset on the speaker always
  looked "dangling" against speaker /sources.

- Service-only / speaker-only recent cascade collapsed into one
  summary line when 5+ speaker recents are missing from service.

- New short-circuit: when the service has nothing (presets, recents,
  sources all empty) for a device the speaker clearly has state for,
  emit one "this device looks unsynced, click Sync" warning instead
  of dozens of per-slot mismatches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 c47cf81a93 feat(health): cross-reference presets/recents/sources consistency check
Adds a new health check that surfaces preset / recent / sources
inconsistencies operators previously had to dig out by hand. For every
paired device, the check runs three analyses:

1. Service-side internal consistency. Verifies every Presets.xml and
   Recents.xml entry's <sourceid> resolves to a Sources.xml entry, and
   flags duplicate source-type entries (mapPresetsToFullResponse picks
   the first match, so duplicates can mask GH-343-style cross-type
   binds).

2. Speaker-side internal consistency. Same analysis applied to the
   speaker's :8090 XML — catches the case where the speaker locally
   knows a TUNEIN preset but the speaker's /sources list doesn't
   advertise TuneIn (a #253-class trigger).

3. Cross-side comparison. Speaker vs service per slot / per recent /
   per source type. A preset whose source attribute disagrees between
   sides is flagged with both values in the detail — that's the
   GH-343 footprint after a reboot, and now it shows up as a Finding
   instead of a forum thread.

Speaker probes fail gracefully with a copy-pasteable curl block; the
service-side internal check still runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 5ff72f2af8 fix(marge): strict-match preset/recent source by type, refuse cross-type binds
GH-343: a TUNEIN preset surviving a reboot used to come back from /full
re-attributed to RADIOPLAYER because mapPresetsToFullResponse's step-1
exact-ID match accepted any source with the matching numeric ID,
regardless of what the preset originally claimed for its Source. The
speaker trusts /full as ground truth, so the local preset got its
source attribute silently rewritten.

Tighten step-1: refuse the bind when the preset's claimed Source and
the configured source's SourceKeyType disagree (both populated). The
existing step-2 type/account fallback then finds the right source, or
synthesise/skip handles the no-match case. The refusal is logged so
the cross-type collision is visible in service logs.

Same fix applied to findMatchingSourceForRecent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 ccdc2bd6a4 fix(datastore): preserve speaker's isPresetable verdict in SavePresets
SavePresets hard-coded isPresetable="true" on every persisted preset,
overwriting the speaker firmware's verdict. The speaker sets
isPresetable="false" for content it can't independently recall later
(notably Spotify Connect pushes from a phone — see GH-235); masking
that flag made the on-disk XML look valid while pressing the preset
on the speaker still did nothing, leaving users debugging a phantom
"stored but won't play" state.

Now preserve the caller's value and default to "true" only when it's
empty. A non-recallable preset is logged at info level so users can
tell from the service log why a stored preset isn't playing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:30:53 +02:00
Tobias GesellchenandClaude Sonnet 4.6 9f8c1cf536 fix(marge): mirror skip-or-synthesise into mapRecentsToFullResponse
Recents had the same protobuf-required-field hazard as presets — an
empty <source/> block inside <recent> would also abort the speaker's
/full sync (the recents poisoned-sourceproviderid regression
documented this once for a related sub-symptom). Apply the same
skip-or-synthesise filter so an orphaned recent can never take the
whole account sync down.

The synthesise/skip code paths log at info level; same visibility
posture as the preset side.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:30:53 +02:00
Tobias GesellchenandClaude Sonnet 4.6 22f60459ba fix(marge): auto-add canonical sources on UpdatePreset, accept Stockholm <username>
UpdatePreset returned "invalid account/source" with a 500 when the
speaker's preset PUT referenced a source that wasn't in AfterTouch's
per-device configured-sources list. After a factory reset the speaker
locally knows the built-in radio sources but AfterTouch's Sources.xml
may not, so a long-press appeared to succeed on the speaker but the
preset was never persisted — and the next /full sync wiped the local
copy. Closes GH-314 (and the underlying trigger described in GH-253).

For the canonical built-in IDs (10001..10005) AfterTouch now auto-adds
the source from the same template post-pair would have used, then lets
the preset land. Non-canonical / account-bound IDs (Spotify "100004",
Amazon, custom) are still rejected — we can't fabricate per-account
credentials. The rejection now logs the diagnostic context so users
don't have to grep source to understand why their long-press didn't
stick.

Also accepts the Stockholm mobile app's <username> field as the preset
name when <name> is empty (soundcork documents the same divergence).

Every code path that silently repairs preset data now logs at info
level: synthesised /full source blocks, skipped presets, auto-added
canonical sources, and the Stockholm name fallback. This makes user
diagnostic dumps actionable without source-spelunking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:30:53 +02:00
Tobias GesellchenandClaude Sonnet 4.6 09ec332375 fix(marge): synthesise or skip presets with unresolvable sources in /full
When a preset on disk referenced a source no longer in the configured-
sources list, mapPresetsToFullResponse appended it with an empty
<source/> block. The speaker decodes /full as protobuf and treats the
inner source fields (id, type, sourceproviderid, credential) as
required, so the malformed block aborted the whole account sync and
wiped the speaker's locally stored presets — the GH-269 symptom of
"/presets empty within seconds of AfterTouch coming online".

For well-known radio providers (TuneIn, InternetRadio,
LocalInternetRadio, RadioBrowser) the preset now gets a synthesised
source block built from canonical defaults; account-bound providers
(Spotify, Amazon) are skipped with a log line so other presets in the
response survive the sync.

Also folds RADIO_BROWSER into resolveSourceName's fallback switch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:27:15 +02:00
Tobias Gesellchen e643495287 chore: update screenshots (v0.87.x) 2026-05-19 23:42:21 +02:00
Tobias Gesellchen 8513d90e34 chore: update screenshots 2026-05-19 23:41:26 +02:00
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
373 changed files with 25499 additions and 8439 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
+2 -2
View File
@@ -53,7 +53,7 @@ jobs:
run: make test-http-client
- name: Upload coverage to Codecov
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
file: ./coverage.out
flags: unittests
@@ -275,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
+1 -1
View File
@@ -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()
+4 -4
View File
@@ -95,7 +95,7 @@ jobs:
- name: Upload Semgrep SARIF results
if: always()
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
with:
sarif_file: semgrep.sarif
continue-on-error: true
@@ -116,16 +116,16 @@ jobs:
run: sudo apt-get install -y libpcap-dev
- name: Initialize CodeQL
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
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@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
with:
category: "/language:go"
+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
+24
View File
@@ -43,10 +43,13 @@ go.work.sum
# Dependency directories
vendor/
node_modules/
# IDE and editor files
.vscode/
.idea/
.claude/
.junie/
*.swp
*.swo
*~
@@ -98,3 +101,24 @@ 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
# Diagnostic encryption keys — private key stays local with the maintainer
keys/private/
+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
+124 -6
View File
@@ -1,4 +1,7 @@
.PHONY: all build build-cli test test-coverage check fmt vet lint clean dev help screenshots
.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
@@ -214,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
@@ -221,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
@@ -297,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)
@@ -309,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)
@@ -329,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."
@@ -354,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"
@@ -362,6 +476,7 @@ 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)"
@@ -386,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"
@@ -403,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"
+35 -5
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,7 +23,7 @@ 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 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.
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:**
@@ -122,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.
+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
+6 -6
View File
@@ -76,9 +76,9 @@ func TestPropagateAddGroup_BothSucceed(t *testing.T) {
leftClient := newTestGroupClient(leftSrv.URL)
rightClient := newTestGroupClient(rightSrv.URL)
req := sampleGroupRequest("192.168.1.131", "192.168.1.134")
req := sampleGroupRequest("192.0.2.131", "192.0.2.134")
leftOut, rightOut := propagateAddGroup(leftClient, rightClient, "192.168.1.131", "192.168.1.134", req)
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)
@@ -117,8 +117,8 @@ func TestPropagateAddGroup_BothSucceed(t *testing.T) {
}
rightBody := (*rightBodies)[0]
if !strings.Contains(rightBody, "<senderIPAddress>192.168.1.131</senderIPAddress>") {
t.Errorf("RIGHT (slave) body must carry <senderIPAddress>192.168.1.131</senderIPAddress>\nbody:\n%s", rightBody)
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)
}
}
@@ -134,9 +134,9 @@ func TestPropagateAddGroup_RightFails(t *testing.T) {
leftClient := newTestGroupClient(leftSrv.URL)
rightClient := newTestGroupClient(rightSrv.URL)
req := sampleGroupRequest("192.168.1.131", "192.168.1.134")
req := sampleGroupRequest("192.0.2.131", "192.0.2.134")
leftOut, rightOut := propagateAddGroup(leftClient, rightClient, "192.168.1.131", "192.168.1.134", req)
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)
+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",
-15
View File
@@ -921,21 +921,6 @@ func renderMigrationSummary(deviceIP, serviceURL string, s *setup.MigrationSumma
fmt.Printf(" %s resolv (/etc/resolv.conf via DHCP hook)\n", checkmark(s.ResolvMigrated))
fmt.Println()
if s.MirrorEnabled || len(s.MirrorEndpoints) > 0 {
fmt.Println("Mirroring")
fmt.Printf(" %s enabled\n", checkmark(s.MirrorEnabled))
if len(s.MirrorEndpoints) > 0 {
fmt.Printf(" endpoints: %s\n", strings.Join(s.MirrorEndpoints, ", "))
}
if len(s.SkipMirrorEndpoints) > 0 {
fmt.Printf(" skip: %s\n", strings.Join(s.SkipMirrorEndpoints, ", "))
}
fmt.Println()
}
if len(s.Warnings) > 0 {
fmt.Println("Warnings")
+5 -5
View File
@@ -169,7 +169,7 @@ 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.168.1.42", "http://aftertouch.local:8000", "", true, false, inspect, summary)
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)
@@ -180,7 +180,7 @@ 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.168.1.42", "http://aftertouch.local:8000", "", true, false, inspect, summary)
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)
@@ -195,7 +195,7 @@ 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.168.1.42", "http://aftertouch.local:8000", "", true, false, inspect, summary)
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
@@ -228,7 +228,7 @@ func TestBuildPlanSteps_DNSMethodPrependsCAInstall(t *testing.T) {
}
inspect := &setup.InspectReport{Info: &setup.DeviceInfoXML{DeviceID: "X"}}
steps := buildPlanSteps("192.168.1.42", "http://aftertouch.local:8000", "", false, false, inspect, summary)
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)
@@ -256,7 +256,7 @@ func TestBuildPlanSteps_ResetModeIncludesManualNetworkSwitches(t *testing.T) {
}
summary := &setup.MigrationSummary{IsMigrated: true, IsPaired: true} // doesn't matter in reset mode
steps := buildPlanSteps("192.168.1.42", "http://aftertouch.local:8000", "", true, true, inspect, summary)
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,
+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)
}
}
+37
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",
+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,
},
}
+137 -66
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)
@@ -530,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
@@ -551,7 +597,8 @@ type serviceConfig struct {
mgmtPassword string
migrationEnabled bool
migrationDryRun bool
preferredSource string
stockholmDir string
stockholmBasePath string
}
func loadConfig(c *cli.Context) serviceConfig {
@@ -589,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")
@@ -621,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,
@@ -644,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,
@@ -665,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,
@@ -702,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)
@@ -751,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.
@@ -793,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,
@@ -852,7 +898,7 @@ 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 —
@@ -869,7 +915,6 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Use(middleware.Recoverer)
r.Use(server.PeerObserverMiddleware)
r.Use(server.ShortcutMiddleware)
r.Use(server.MirrorMiddleware)
r.Use(server.RecordMiddleware)
r.Get("/", server.HandleRoot)
@@ -881,10 +926,14 @@ func setupRouter(server *handlers.Server) *chi.Mux {
// 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())
@@ -919,6 +968,16 @@ func setupRouter(server *handlers.Server) *chi.Mux {
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) {
@@ -1045,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) {
@@ -1097,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)
@@ -1123,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)
@@ -1140,8 +1194,25 @@ 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("/export/diagnostic", server.HandleExportDiagnostic)
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
+4 -4
View File
@@ -19,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 {
@@ -128,14 +128,14 @@ func TestPUTRenameRoutesToLocalHandler(t *testing.T) {
_ = ds.Initialize()
server := handlers.NewServer(ds, nil, "http://localhost:8000", false, false, false)
r := setupRouter(server)
r := setupRouter(server, nil)
ts := httptest.NewServer(r)
defer ts.Close()
body := `<?xml version="1.0" encoding="UTF-8" ?><device deviceid="A81B6A536A98"><name>Sound Machinechen</name><macaddress>A81B6A536A98</macaddress></device>`
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/A81B6A536A98",
ts.URL+"/streaming/account/1111111/device/AABBCCDDEEFF",
strings.NewReader(body))
if err != nil {
t.Fatalf("build request: %v", err)
+26 -14
View File
@@ -1,13 +1,14 @@
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
@@ -32,12 +33,15 @@ GET /bmx/tunein/v1/playback/station/{stationID} handlers.(
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
@@ -48,22 +52,23 @@ 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/export/diagnostic handlers.(*Server).HandleExportDiagnostic-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
@@ -89,9 +94,12 @@ 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
@@ -103,6 +111,8 @@ POST /bmx/tunein/v1/favorite/{stationID} handlers.(
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
@@ -114,7 +124,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
@@ -123,10 +132,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/pair-account/{deviceId} handlers.(*Server).HandlePairAccount-fm
POST /setup/peer-probe/{deviceId} handlers.(*Server).HandlePeerProbe-fm
POST /setup/proxy-settings handlers.(*Server).HandleUpdateProxySettings-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
@@ -155,7 +165,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
+44 -150
View File
@@ -3,28 +3,52 @@ package main
import (
"context"
"embed"
"fmt"
"io/fs"
"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",
@@ -79,24 +103,13 @@ 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
if ifaceName != "" {
cfg.DiscoveryInterface = ifaceName
}
discoveryService := discovery.NewUnifiedDiscoveryService(cfg)
discoveryService := soundtouchweb.NewDiscoveryService(ifaceName)
// Discover devices on startup
go func() {
@@ -106,18 +119,19 @@ func main() {
webApp.BroadcastDiscoveryStatus("starting", webApp.DeviceCount())
for _, host := range manualHosts {
addDevice(webApp, host, 8090, "manual")
webApp.AddDeviceByHost(host, 8090, "manual")
}
discoverDevices(ctx, webApp, discoveryService)
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)
},
@@ -209,123 +223,3 @@ func resolveBindAddr(bindAddr string) (string, error) {
return "", fmt.Errorf("--bind %q: interface has no usable IPv4 or IPv6 address", bindAddr)
}
}
// addDevice registers a SoundTouch device with the WebApp by fetching
// its /info and creating a DeviceConnection. The source label
// ("manual" or "discovered") appears in log lines so the operator can
// tell apart entries that came from --devices from those found via
// mDNS/UPnP. If the host is already known, the existing entry's
// LastSeen is bumped and the function returns without re-fetching.
func addDevice(app *handlers.WebApp, host string, port int, source string) {
// Fast path: skip the network call if we already know this host.
if app.TouchDevice(host) {
return
}
c := client.NewClient(&client.Config{
Host: host,
Port: port,
Timeout: 10 * time.Second,
})
info, err := c.GetDeviceInfo()
if err != nil {
log.Printf("Failed to fetch device info from %s (%s): %v", host, source, err)
return
}
conn := webtypes.NewDeviceConnection(c, info)
if !app.AddDevice(host, conn) {
// Lost a race — another goroutine inserted the same host
// between TouchDevice and AddDevice. AddDevice bumped LastSeen
// on the existing entry; discard our conn.
return
}
go app.UpdateDeviceStatus(host, conn)
log.Printf("Added %s device %s (%s) at %s:%d", source, info.Name, info.Type, host, port)
}
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)
}
// WebSocket endpoint
r.Get("/ws", app.HandleWebSocket)
// 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", app.DeviceCount())
discoverDevices(ctx, app, discoveryService)
// Broadcast discovery completion and updated device list
app.BroadcastDiscoveryStatus("completed", app.DeviceCount())
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
}
func discoverDevices(ctx context.Context, app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) {
log.Println("Starting device discovery...")
devices, err := discoveryService.DiscoverDevices(ctx)
if err != nil {
log.Printf("Discovery failed: %v", err)
app.BroadcastDiscoveryStatus("failed", app.DeviceCount())
return
}
log.Printf("Found %d devices", len(devices))
for _, device := range devices {
addDevice(app, device.Host, device.Port, "discovered")
}
}
+5 -5
View File
@@ -14,7 +14,7 @@ func TestResolveBindAddr_PassThrough(t *testing.T) {
"",
"localhost",
"127.0.0.1",
"192.168.1.5",
"192.0.2.5",
"::1",
"definitely-not-an-iface-xyz",
}
@@ -102,21 +102,21 @@ func TestDefaultDiscoveryInterface(t *testing.T) {
name: "explicit interface wins over bind-derived default",
rawInterface: "eth1",
rawBind: "eth0",
resolvedBind: "192.168.1.5",
resolvedBind: "192.0.2.5",
want: "eth1",
},
{
name: "derive from --bind when --bind was an interface name",
rawInterface: "",
rawBind: "eth0",
resolvedBind: "192.168.1.5",
resolvedBind: "192.0.2.5",
want: "eth0",
},
{
name: "no derivation when --bind was an IP literal",
rawInterface: "",
rawBind: "192.168.1.5",
resolvedBind: "192.168.1.5",
rawBind: "192.0.2.5",
resolvedBind: "192.0.2.5",
want: "",
},
{
+8 -8
View File
@@ -8,9 +8,9 @@ import (
"strings"
"testing"
"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"
)
@@ -69,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>
@@ -99,7 +99,7 @@ func TestSPARouting(t *testing.T) {
}
func TestAPIEndpoints(t *testing.T) {
app := handlers.NewWebApp()
app := soundtouchweb.NewWebApp()
tests := []struct {
name string
@@ -170,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()
@@ -203,7 +203,7 @@ func TestAPIResponseFormat(t *testing.T) {
}
func TestControlAPIValidation(t *testing.T) {
app := handlers.NewWebApp()
app := soundtouchweb.NewWebApp()
tests := []struct {
name string
@@ -296,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)
@@ -316,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
+3 -3
View File
@@ -546,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:~#
+277
View File
@@ -0,0 +1,277 @@
# Device-Local Install: Four User Journeys
A user-journey-shaped view of where AfterTouch sits today and where it could go. The same speaker, the same constraints, but four different audiences with non-overlapping needs:
1. **Initial setup / install** — getting AfterTouch onto a fresh or freshly-orphaned speaker.
2. **Less-technical admin** — migration, maintenance, and recovery without a terminal.
3. **Daily usage** — playing music, switching presets, on the couch or on the phone.
4. **Automation** — driving the speaker from scripts, home automation, schedules.
Each journey is served by a different surface (CLI, web UI, GUI app, REST). Some surfaces serve more than one journey; some journeys are served badly today. This doc is informational; nothing here is a roadmap commitment.
Cross-cutting reference material — lessons from `GameTec-live/soundtouch-tiny`, plus a per-surface capability map — lives in the appendix.
---
## Journey 1: Initial setup / install
**Who.** Someone with a Bose speaker whose cloud just died. Could be technical (knows what SSH is) or not (knows what a USB stick is). Wants the speaker to play Internet Radio again with minimum fuss.
**Goal.** Get an AfterTouch instance reachable from the speaker, whether that instance lives on a separate host or on the speaker itself.
**Surfaces.** Shell (today), GUI installer (planned), pre-flashed stick (commercial offering, hypothetical).
### The three install patterns
#### Pattern A — External host
A separate machine (Raspberry Pi, NAS, always-on laptop) runs `soundtouch-service`. Speakers point at it via DNS rewrite at the router. No code on the speaker, no firmware risk.
- **Pros:** zero invasiveness, easy update (single host), unified for many speakers, no per-speaker storage limit.
- **Cons:** requires an always-on host on the LAN, DNS rewrite at router scope, single point of failure.
#### Pattern B — SSH-curl on-device (current `scripts/on-device-install/`)
User SSHes in once, pipes the installer. Installs to `/mnt/nv/aftertouch`, symlinks `/opt/aftertouch`, registers `/etc/init.d/aftertouch` via `update-rc.d`. Daemon serves `:8000` on the speaker's own LAN address.
- **Pros:** no separate host, per-speaker isolation, survives router replacement.
- **Cons:** SSH required for install and updates, ~12 MB binary stresses tiny rootfs partitions, no in-process restart on crash, some firmware images bind only loopback (issue #196).
#### Pattern C — Stick-driven on-device (*not* implemented here)
USB stick holds binary + bootstrap scripts. First install needs SSH (placing `/mnt/nv/rc.local`). After that, the NAND `rc.local` auto-syncs from any stick inserted with newer files. Stick can also carry one-shot configs (`wlan.conf`, `region.conf`, `name.conf`) consumed and wiped during boot.
- **Pros:** post-bootstrap updates need no SSH, stick wipe behavior keeps credentials short-lived, watchdog inside the bootstrap script restarts the agent on crash without a reboot.
- **Cons:** first install still needs SSH; FAT32 stick on the speaker is unreliable for writes; user has to keep a stick around.
### The technical underpinning: `/mnt/nv/rc.local`
Both pattern C and any "shepherd-less" install on stock firmware depend on a single line in the stock init scripts:
```
# /etc/init.d/shelby_local, start case
[ -x /mnt/nv/rc.local ] && /mnt/nv/rc.local
```
`shelby_local` is a stock Bose SysV script. Its `start` case fires at every boot from an `S`-symlink in `rcS.d/` (the misleading `K99shelby_local` symlink in `rc1.d/` is the *shutdown* path — same script, different case). `/mnt/nv` is the persistent read-write NAND partition; `rc.local` is intentionally exposed as an extension point. By the time it runs, rootfs is mounted read-only, `/mnt/nv` is read-write, network is configured, and `/media/sda1` is *typically* mounted by udev if a USB stick is present — but the mount is asynchronous and races the hook (polling for up to 30 s is one way to handle this).
**Stock firmware does not auto-copy anything from a USB stick into `/mnt/nv/rc.local`.** Inserting a stick alone is not enough. There is no udev rule, no autorun convention, no `shelby_usb` branch that handles this; `shelby_usb` only manages USB ethernet-gadget mode (`g_ether`) and the `microbswitch` helper on certain variants.
Placement happens one of two ways:
1. **Manual SSH bootstrap, once.** Shell access (via the `remote_services` stick trick) runs an installer that writes `/mnt/nv/rc.local`, makes it executable, and exits. After that single SSH session, the stick is no longer required to *trigger* anything — the NAND copy fires on every boot.
2. **Self-update from a newer stick, after step 1.** Once `/mnt/nv/rc.local` exists *and contains the self-update logic*, inserting a stick with a newer `rc.local` (compared by mtime) lets the running NAND copy overwrite itself for the next boot. This gives the stick its "repair channel" property.
**The very first placement requires SSH.** Any zero-SSH install would need either a different stock-firmware hook (we have not found one usable across SoundTouch variants) or a custom firmware image. The `remote_services` stick is the only stick-content convention the stock firmware honors out of the box, and all it does is enable `sshd`.
### App-driven install (the missing middle)
The SSH session does **not** have to be a human SSH session. `pkg/ssh` (`NewClient`, `Run`, `ReadFile`, `ReadDir`, `UploadContent`) is already used by `pkg/service/setup/` to drive migration probes; the same primitives can drive an installer. The user never sees a terminal.
User-visible flow:
1. User runs an admin app on their laptop or phone.
2. App walks them through preparing a `remote_services` stick — or writes one for them, if it can reach the host's USB subsystem.
3. User inserts the stick into the speaker and power-cycles it. Stock firmware's `sshd` starts.
4. App discovers the speaker via mDNS, dials SSH, runs the installer steps that today live behind `curl ... \| sh`. No `ssh` invocation, no `rw &&`, no copy-pasted IP.
5. App verifies `curl http://<box>:8000` from inside the speaker via SSH and surfaces a clear success / failure state.
6. App optionally removes `remote_services` from the stick and reboots the speaker, closing the SSH backdoor automatically.
Mapping each step to existing code:
| Step | Today's installer | App equivalent (`pkg/ssh`) |
|---------------------|-------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------|
| Remount rootfs rw | `mount -o remount,rw /` (inside init script) | `Client.Run("mount -o remount,rw /")` |
| Make NAND dir | `mkdir -p $INSTALL_DIR` | `Client.Run("mkdir -p /mnt/nv/aftertouch")` |
| Download binary | `curl -sSL ... -o binary` | local download on the app side, then `Client.UploadContent(bytes, "/mnt/nv/aftertouch/aftertouch-service")` |
| Mark executable | `chmod +x` | `Client.Run("chmod +x ...")` |
| Symlink `/opt` | `ln -sf $INSTALL_DIR /opt/aftertouch` | `Client.Run("ln -sf ...")` |
| Install init script | `curl ... -o /etc/init.d/aftertouch && update-rc.d aftertouch defaults` | `Client.UploadContent` + `Client.Run` |
| Start | `/etc/init.d/aftertouch start` | `Client.Run("/etc/init.d/aftertouch start")` |
| Verify listener | `curl -fsS http://localhost:8000` inside the box | `Client.Run("curl -fsS http://localhost:8000")` |
No new SSH plumbing required. The pieces already exist for the setup probes.
### Storage budget
The on-device patterns share one hard constraint: storage. ST20 stock rootfs has ~4 MB free (issue #268); even with `/mnt/nv` (~30 MB free) the budget is tight, and a second binary for safe OTA updates doubles it. This is the primary motivation for a slimmer `soundtouch-service-mini` build target — see the appendix.
### Open decisions for this journey
- Do we keep pattern B as the technical-user path while building a Gio admin app for the rest?
- Do we add a pattern-C-style "register a stick-update hook in `/mnt/nv/rc.local`" option as an opt-in, so users who do want a repair stick get one?
- Pre-flashed sticks shipped as a kit: in scope or out?
---
## Journey 2: Less-technical admin (migration + maintenance)
**Who.** The person who already has AfterTouch installed somewhere and now needs to do something *after* install. They are comfortable opening apps and clicking buttons; they are not comfortable opening a terminal. The whole-household admin: parent, partner, roommate doing it for the household.
**Goal.** Migrate a speaker to a new AfterTouch instance, update the agent, view what's going on, recover a stuck device, change WLAN credentials, reapply config after factory reset — all without SSH.
**Surfaces.** GUI admin app (Gio, planned), `soundtouch-service` embedded web UI (today, technical-leaning), CLI (today, technical-only).
### What "admin" covers in practice
- **Migration of a new (or factory-reset) speaker** to an AfterTouch instance: rewrite the server URLs in `/mnt/nv/persistence.json`, restart the device, verify it talks to us.
- **Agent update on an on-device install** (pattern B or C): push a new binary, restart, verify.
- **Status and diagnostics**: is `aftertouch` running, is `:8000` listening, did the last preset save succeed, what does syslog say?
- **Recovery**: speaker is stuck (won't respond to web UI, won't pair, lost WLAN). Today this almost always means SSH; with `pkg/ssh` behind a GUI, it can mean "click 'Diagnose' in the app."
- **Bulk operations**: do all of the above across several speakers at once.
- **Configuration drift**: WLAN password changed, region changed, speaker name changed, hosts file got rewritten — restore the AfterTouch overlay.
### How the GUI admin app shape would serve this
Same `pkg/ssh` primitives as Journey 1's installer, applied to post-install tasks. mDNS discovers all speakers on the LAN; the app fans operations out across them; SSH-driven actions stay hidden behind buttons. On a phone, the same app is the "speakers are unreachable, what now" diagnostic tool from another room.
Where today's surfaces fall short for this user:
- `soundtouch-service` web UI assumes the service is running and reachable. It cannot recover a broken installation or a stuck device.
- CLI works but presumes terminal comfort.
- The setup wizard in `soundtouch-service` handles initial migration well, but reapplying after factory reset is not first-class — see `docs/analysis/FACTORY-RESET-PROTOCOL.md`.
### Open decisions for this journey
- Does the admin app subsume the service web UI's admin tab, or do they coexist (admin app = onboarding + recovery; service web UI = ongoing operations once everything is healthy)?
- WASM as a fallback surface: today's service web UI is browser-accessible from anywhere. Does a Gio admin app sacrifice that, or do we ship both?
- Multi-household / multi-speaker: how much does the admin app need to know about distinguishing speakers vs distinguishing AfterTouch instances?
---
## Journey 3: Daily usage
**Who.** Anyone in the household using the speaker. Children pressing a preset button. The user opening a phone to switch from kitchen to living room. Guests asked to "just put on some jazz." Zero awareness of AfterTouch as a thing; the speaker is the speaker.
**Goal.** Music plays. Pressing preset 3 gives them what preset 3 should give them. Skipping a station, adjusting volume, browsing for a new station — all fast, no friction.
**Surfaces.** Physical preset buttons (always there), `soundtouch-web` (today), mobile app (Journey 2 admin app's daily-use mode), WASM-served browser UI (planned), Bose app while it still functions, voice assistants where wired up.
### What this layer needs to be good at
- **Preset playback works first try, every time.** The reliability bar is "is the kitchen radio still working?" Anything that fails on cold boot or after a Wi-Fi outage breaks the user's trust in the whole system.
- **Switching stations quickly**, including discovery of new ones (e.g. `radio-browser.info`-style search).
- **Volume and play / pause from any device the user has in hand.** Phone in pocket, laptop on table, browser tab open — all should work.
- **Multi-room awareness** if the household has more than one speaker: which speaker is playing what, can I send this to the bedroom.
- **Looking good.** This is the surface that gets seen daily by non-technical users. Visual polish matters more here than anywhere else in the stack.
### How surfaces map
- `soundtouch-web`: primary daily UI for desktop browsers and (responsively) for tablets. This is already shipped.
- Mobile app: daily-use mode of the same Gio app that handles admin. Capability split — admin features only show up when the user is in admin mode.
- WASM: same Gio app, served from `soundtouch-service` to anyone on the LAN. The "I forgot which device my login is on, just open a browser" fallback.
- Physical preset buttons: handled at the agent level (the Bose firmware fires them; AfterTouch or the on-device agent reacts).
### Open decisions for this journey
- Do we keep `soundtouch-web` as a separate codebase (HTML/JS), or does it become a Gio WASM build sharing code with the admin app?
- Mobile app store distribution: TestFlight for iOS (gated, slow), Play Store for Android (faster, AAB only), F-Droid as an open-source-friendly side path.
- Multi-user state: presets per-user vs per-household. Out of scope here, but the daily surface is where it gets felt.
---
## Journey 4: Automation
**Who.** The same household, but acting through code: a Home Assistant config, a NodeRED flow, a cron job, a shell script, a webhook from a smart doorbell. The user is not present at the speaker; they want music to start when something else happens.
**Goal.** Headless, scriptable control. "Play preset 2 at 7:00 every weekday." "When the kids' bedtime alarm fires, fade volume to zero." "If I get home and the speaker is on, switch to my dinner playlist."
**Surfaces.** `soundtouch-cli` (today), REST endpoints on `soundtouch-service` (today), MQTT bridge / webhook outputs (hypothetical), Home Assistant integration (community).
### What this layer needs to be good at
- **Stable, versioned API surface.** Scripts and home automation flows live for years; breaking changes are expensive for users.
- **CLI that works in pipelines.** Exit codes, machine-readable output (JSON), stable flag names. The reverse of the daily UI: zero polish, full predictability.
- **Discoverability of capabilities.** Users need to find out what's possible (`soundtouch-cli help`, openapi spec on the service, examples in the docs).
- **Idempotency.** Calling "set volume to 40" twice should not result in volume 80. Calling "switch to preset 3" when already on preset 3 should be a no-op.
### How surfaces map
- `soundtouch-cli`: the canonical surface for scripted control. Already covers most of the API.
- `soundtouch-service` REST endpoints: same surface, network-accessible. Used by `soundtouch-web` and by third-party automation.
- Home Assistant: external integration; track but do not own.
- Webhooks / MQTT: not present today; would let speakers participate in event-driven flows. Out of scope for a first pass; worth a separate design doc when demand surfaces.
### Open decisions for this journey
- Stability commitments for the CLI and REST API: do we adopt semver for the public surface separately from the service version?
- Authentication for the REST surface when exposed beyond loopback: needed before any internet exposure is sane.
- OpenAPI / typed-client output for the service: nice-to-have for integration developers.
---
## Appendix: which surface serves which journey
| Surface | Journey 1 (install) | Journey 2 (admin) | Journey 3 (daily) | Journey 4 (automation) |
|------------------------------------|---------------------|-------------------|-------------------|------------------------|
| `soundtouch-cli` | partial (today) | partial (today) | no | primary |
| `soundtouch-service` web UI | wizard portion | primary | partial | indirect (REST) |
| `soundtouch-web` | no | no | primary | no |
| GUI admin app (Gio, planned) | primary | primary | mobile mode | no |
| Pre-flashed stick (hypothetical) | primary | recovery | no | no |
| Physical preset buttons | no | no | primary | no |
| Home Assistant / webhooks (future) | no | no | no | primary |
The diagonal isn't full because some journeys lack a polished surface today (Journey 1 mostly works but is shell-only; Journey 2 has gaps for recovery scenarios). The journey frame is what tells us *which* gaps to fill first.
## Appendix: per-surface capability constraints
The Gio admin app, if built, can target Windows / macOS / Linux / iOS / Android / WASM from one codebase. Each target has hard constraints:
- **WASM (browser).** Post-install REST control, device list and status, preset editing, station search. No mDNS (browsers cannot do raw multicast — fall back to manual IP entry or a backend bridge); no raw TCP, so no SSH and no install; no block-device access, so no stick writing. This is the "I just want to use my speakers" surface, equivalent to today's `soundtouch-web`.
- **Mobile iOS.** Everything WASM does, plus Bonjour-based mDNS, plus full SSH client (so app-driven install and recovery work). No FAT32 stick writing — iOS has no filesystem-level block device access for third-party apps. Best paired with a pre-flashed stick or a friend's desktop install for the bootstrap.
- **Mobile Android.** Same as iOS, plus FAT32 stick writing *if* the user grants USB-OTG host permission. UX caveat: most users will not know what USB host mode is.
- **Desktop (Gio).** Full capability set. mDNS, SSH-driven install, FAT32 stick writing via standard block-device APIs, post-install control, recovery. The primary onboarding surface.
The pattern to follow is to write code so each capability degrades automatically based on what the runtime actually offers, rather than gating with build tags.
## Appendix: lessons from adjacent projects
### soundtouch-tiny (GameTec-live)
Minimal on-device cloud replacement: Internet Radio + TuneIn proxy + optional presets. Go stdlib only, small binary. Inspired by AfterTouch but trimmed. The author offered collaboration in PR #292.
This is the gap a **`soundtouch-service-mini` build target** would fill. The full `soundtouch-service` is justified for the external-host pattern (Pattern A) where space is not pressed; on-device (patterns B and C) the calculus is different — many users only need Internet Radio because that's the surface most affected by the cloud shutdown.
A mini build target in this repo would look like:
- same codebase, different `cmd/` entry point,
- compiled with only the packages needed for Internet Radio + TuneIn shim + presets,
- no Spotify, no parity tests, no setup wizard, no Bose-protocol-level proxy,
- target size: under 4 MB so it fits the rootfs without `/mnt/nv` gymnastics, leaving room for a second binary for safe updates.
Open questions before committing:
1. Collaborate upstream with soundtouch-tiny, or build our own mini that shares code with the full service?
2. Where to draw the feature line — "Internet Radio only" is clear; "Spotify too" would already blow the budget on ST20.
3. Mini ships via Pattern B (SSH-curl) or Pattern C (stick)?
4. Full service and mini service coexisting on the same LAN — mDNS service name, port choice, web UI port.
### Wails vs Gio
Both are Go. Different tradeoffs:
- **Wails v2**: bundles a WebView per OS, frontend is HTML/CSS/JS. Faster to a working UI if the team is comfortable with HTML. Targets Windows / macOS / Linux. No mobile, no WASM.
- **Gio**: immediate-mode pure-Go UI. Smaller binaries, no WebView dependency. Targets Windows / macOS / Linux / iOS / Android / WASM. Steeper UI learning curve, mitigated by `gio-mw`.
The deciding factor is **mobile + WASM** (Journey 2 and Journey 3), not desktop alone. If "use a phone to set up a speaker" or "open the admin tool from any browser" is on the roadmap, Wails does not get us there.
## Appendix: documentation gap to close
Separate user-facing material to produce when we are ready (not in this comparison doc):
- **The `/mnt/nv/rc.local` hook** explained in user terms: what it does, when it fires, when *not* to use it, how to remove it cleanly. Bridges Journey 1 and Journey 2.
- **Hooks we already maintain** at OS level: resolv.conf stability, `/etc/hosts` overlay, anything in `pkg/service/setup/` that touches device state. Reference, not narrative. Journey 2 troubleshooting.
- **Storage budget per model**: rootfs free, `/mnt/nv` free, where the binary lands, which path applies to which ST model. Journey 1 sizing.
- **Decision matrix**: external host vs on-device vs mini, plus "do I need Spotify? do I need migration? do I want one host or per-speaker isolation?" Journey 1 entry point.
- **Stick file conventions**: what the `remote_services` stick does today, what we *might* add (presets / wlan / region) if we build a stick-driven path, and how that interacts with FAT credentials residency. Journey 1.
- **Automation cookbook**: example Home Assistant config, example shell scripts, common pitfalls. Journey 4.
## Cross-references
- AfterTouch installer: `scripts/on-device-install/install.sh`, `scripts/on-device-install/aftertouch` (init script), `scripts/on-device-install/README.md`.
- AfterTouch SSH client: `pkg/ssh/ssh.go` (`NewClient`, `Run`, `ReadFile`, `ReadDir`, `UploadContent`), already used by `pkg/service/setup/`.
- Storage limitations: issue #268 (ST20 rootfs free space), issue #196 (loopback-only bind), issue #250 (status reports running but unreachable).
- soundtouch-tiny: `https://github.com/GameTec-live/soundtouch-tiny`, raised in PR #292 (`https://github.com/gesellix/Bose-SoundTouch/pull/292`).
- opencloudtouch parallel discussion: `https://github.com/scheilch/opencloudtouch/discussions/201`.
- Existing parity doc shape: `docs/PARITY-OPENCLOUDTOUCH.md` is the precedent for cross-project comparison documents.
+138
View File
@@ -0,0 +1,138 @@
# Encrypted Diagnostic Export
AfterTouch can produce an encrypted diagnostic report that users can download and
send to the project maintainer without exposing sensitive data to third parties.
The report is encrypted with an SSH public key using
[`age`](https://github.com/FiloSottile/age); only the holder of the matching
private key can read it.
---
## What the report contains
The encrypted `.age` file decrypts to a `.tar.gz` archive with:
- `diagnostic.json` — structured summary:
- Service version and build info
- Full health-check results (same data as the Health tab)
- Per-device state: sources (IDs, names, SourceKeyTypes), presets (slot, name,
Source, SourceID, location), device product code, firmware version, IP, name
- `datastore/accounts/{id}/devices/{id}/*.xml` — raw XML files verbatim from
the sender's datastore (`Presets.xml`, `Sources.xml`, `Recents.xml`, …)
Having both the structured JSON and the raw XML lets you compare what the
service serves via HTTP against what is actually stored on disk.
**What is excluded from the JSON:** authentication tokens, credentials, OAuth
secrets, Spotify refresh tokens. The raw XML files are included as-is.
---
## Maintainer setup (one-time)
> This section is for the project maintainer only.
> Users never need to touch keys.
### 1. Generate the key pair
```bash
bash scripts/setup-diagnostic-key.sh
```
This creates:
- `keys/private/diagnostic` — SSH ed25519 private key (**gitignored**, never commit)
- `keys/private/diagnostic.pub` — copy for reference (**gitignored**)
- `keys/public/diagnostic.pub` — public key committed to the repo
### 2. Add the public key to GitHub
Go to <https://github.com/settings/ssh/new> and paste the contents of
`keys/public/diagnostic.pub`. This makes the key visible at
<https://github.com/gesellix.keys> so users can independently verify that the
key embedded in the binary matches a key actually controlled by the maintainer.
### 3. Embed the public key in the binary
Open `pkg/service/export/encrypt.go` and update the `DiagnosticPublicKey`
constant to match the new public key:
```go
const DiagnosticPublicKey = "ssh-ed25519 AAAA... aftertouch-diagnostic@gesellix"
```
### 4. Commit
```bash
git add keys/public/diagnostic.pub pkg/service/export/encrypt.go
git commit -m "keys: add diagnostic SSH public key"
```
`keys/private/` is `.gitignore`d — the private key will not be committed.
### 5. Back up the private key
The private key is **not** stored in git. Keep a copy in a secure location
(password manager, encrypted USB drive, etc.). If it is lost, a new key pair
must be generated and the constant in `encrypt.go` updated.
---
## Verifying the embedded key (users)
Users who want to confirm that the key embedded in their running binary matches
the maintainer's GitHub SSH keys can run:
```bash
# Compare the raw key text — both should show the same line:
curl -s https://github.com/gesellix.keys
cat keys/public/diagnostic.pub
```
The key should appear verbatim in both outputs.
---
## Decrypting a received report (maintainer)
When a user sends you an `aftertouch-diagnostic-*.age` file, use the helper
script (no extra tools needed — only Go and the private key). Run from the
repository root directory:
```bash
# Decrypt and extract in one step:
go run scripts/decrypt-diagnostic.go aftertouch-diagnostic-<timestamp>.age | tar xz
# Or decrypt to a .tar.gz first, then inspect:
go run scripts/decrypt-diagnostic.go aftertouch-diagnostic-<timestamp>.age > report.tar.gz
tar xzf report.tar.gz
# → diagnostic.json
# → datastore/accounts/{id}/devices/{id}/Presets.xml (and Sources.xml, Recents.xml, …)
```
The script uses only the `filippo.io/age` Go module — no separate `age` CLI
installation required.
---
## User workflow
1. Open the AfterTouch admin UI and go to the **Health** tab.
2. Click **Download diagnostic report**.
3. The browser downloads `aftertouch-diagnostic-<timestamp>.age`.
4. Attach the file to the GitHub issue or send it via a direct channel.
The file is opaque binary — the user cannot read it. All they see is that the
report was generated and downloaded.
---
## Key rotation
If the private key is compromised or lost:
1. Run `scripts/setup-diagnostic-key.sh` (delete the old `keys/private/diagnostic` first).
2. Add the new public key to GitHub and remove the old one.
3. Update `DiagnosticPublicKey` in `encrypt.go`.
4. Commit and tag a new release.
Old reports encrypted with the previous key cannot be decrypted with the new key.
+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.
---
+22 -22
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,7 +32,7 @@ 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/playback/station/s33828" \
@@ -42,16 +42,16 @@ soundtouch-cli --host 192.168.1.100 preset store \
### 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:**
@@ -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,19 +235,19 @@ 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/playback/station/bedtime-stories" \
--name "Bedtime Stories"
@@ -256,22 +256,22 @@ soundtouch-cli --host 192.168.1.100 preset store \
### 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
+4
View File
@@ -3,6 +3,7 @@
* [Introduction](README.md)
## User Guides
* [Device-Local Install Journeys](DEVICE-LOCAL-INSTALL.md)
* [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
* [Self-Hosting AfterTouch](guides/SELF-HOSTING.md)
* [Connecting Music Services](guides/MUSIC-SERVICES.md)
@@ -54,6 +55,8 @@
* [Request Recording](REQUEST_RECORDING_CONCEPT.md)
* [Spotify Priming Strategy](concepts/spotify-priming-strategy.md)
* [Spotify OAuth](concepts/spotify-oauth.md)
* [Encrypted Export](concepts/ENCRYPTED-EXPORT.md)
* [Diagnostic Export (Maintainer Setup)](DIAGNOSTIC-EXPORT.md)
* [soundtouch-web Roadmap](soundtouch-web-roadmap.md)
## Analysis & Research
@@ -105,3 +108,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>
+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
```
---
+9 -9
View File
@@ -34,10 +34,10 @@ 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</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>
```
@@ -45,7 +45,7 @@ Requires SSH access to the device.
> 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.168.1.10:8000/marge`).
> (`http://192.0.2.10:8000/marge`).
### Pros & Cons
| Pros | Cons |
@@ -68,9 +68,9 @@ 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
@@ -141,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.
+15 -15
View File
@@ -17,7 +17,7 @@ Observed live on ST10 firmware `27.0.6.46330.5043500` (build `epdbuild.trunk.hep
3. **Speaker notifies its LAN peers.** Two HTTP POSTs to each known peer at `:8090/notification`:
```
[NotificationSender] SendNotifyLisas_: URL: >>http://192.168.123.122:8090/notification<<, m_msgdata.size(58)
[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.
@@ -63,31 +63,31 @@ End-to-end command sequence used during the 2026-05-12 bare-pairing experiment,
# === 1. Reconnaissance — confirm what state the speaker is in before touching it. ===
# Identity, network, sources, presets.
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup inspect
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.168.123.123 setup verify \
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.168.123.123 setup plan \
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.168.123.123 setup factory-reset
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.168.123.123 setup wait-ap
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.168.123.123 setup wifi-push \
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.
@@ -95,42 +95,42 @@ go run ./cmd/soundtouch-cli --host 192.168.123.123 setup wifi-push \
# 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.168.123.123 setup wait-online --match=536A98
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.168.123.123 clock now
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.168.123.123 setup reboot
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.168.123.123 setup migrate \
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.168.123.123 setup reboot
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.168.123.123 setup pair \
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.168.123.123 setup reboot
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.168.123.123 setup inspect
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.
+5 -5
View File
@@ -178,7 +178,7 @@ One row per device tested. Once two devices on different firmware confirm the sa
- Date: 2026-05-13
- Firmware: 27.0.6.46330.5043500 (build epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29)
- Model: SoundTouch 10 (deviceID A81B6A536A98)
- 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
@@ -192,13 +192,13 @@ One row per device tested. Once two devices on different firmware confirm the sa
### 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 3230304, 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:
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 (3230304) | Set by |
| Field | Bare-paired (1111111) | Real-Bose-paired (1000001) | Set by |
|--------------------------|--------------------------------------------|----------------------------|-----------------------------------------------------------------------------------------------------------|
| `DeviceName` | `Bose SoundTouch 536A98` (factory default) | `Sound Machinechen` | `name` WS message — only sent in `--mode=full` |
| `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` | `3230304` | `setMargeAccount` — both paths set it |
| `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 |
+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
+1 -1
View File
@@ -15,7 +15,7 @@ recovery / WiFi setup.
### Telnet via Docker (when not installed locally)
```shell
docker run --rm --name telnet -it --env IP=192.168.123.123 alpine:edge ash -c 'apk add -U busybox-extras && telnet $IP 17000'
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
+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
+454
View File
@@ -0,0 +1,454 @@
# Encrypting Sensitive Data Exports with SSH/age or GPG
## Problem
Allow users of our software to export potentially sensitive data, encrypt it locally, and send it to us. We decrypt on our side. Goal: no key exchange, minimal user friction.
Two viable options are documented here: **Option A — `age`** (simpler, modern) and **Option B — GPG** (widely known, interoperable with existing tooling). Both support fetching a recipient key from GitHub so users don't need to hand us anything.
## Key Findings
### GPG via SSH keys: not possible
- GitHub's `https://github.com/<user>.keys` serves SSH public keys, not GPG keys.
- SSH and GPG/OpenPGP use different formats, capability flags, and key material (auth vs. encrypt/sign/certify).
- Ed25519 SSH keys can't be directly reused for GPG encryption (encryption requires X25519/ECDH).
### GPG via published GPG keys: possible
- GitHub exposes GPG public keys at `https://github.com/<user>.gpg` — these are real OpenPGP armored keys, not SSH keys.
- Any key the user has uploaded to their GitHub account (or a keyserver like `keys.openpgp.org`) can be used directly for encryption.
- Decryption requires the matching GPG private key on our side.
- The `github.com/ProtonMail/go-crypto/openpgp` package is the actively maintained Go OpenPGP implementation (`golang.org/x/crypto/openpgp` is deprecated and points to it).
### `age` with SSH or native keys: possible (simpler)
- [`age`](https://github.com/FiloSottile/age) natively supports `ssh-rsa` and `ssh-ed25519` public keys as recipients, fetched from `https://github.com/<user>.keys`.
- Also supports its own `age1...` native keys (`age-keygen`), which are X25519-based.
- Written in Go; library is `filippo.io/age` + `filippo.io/age/agessh`.
- Output is age format (not GPG-interoperable). Decrypt with `age -i key file.age` or the Go library.
---
## Option A: `age`
### Architecture
1. Generate a dedicated age key: `age-keygen -o decrypt.key` (produces `age1...` public key).
2. Embed the public key as a constant in the binary — users need no setup.
3. Optionally accept a GitHub username and fetch their SSH keys as recipients so the user can verify independently.
4. Store the private key securely (secret manager, HSM, offline backup).
### Workflow
**User side:**
```
soundtouch-cli export --encrypt
# or: soundtouch-cli export --encrypt-for github:gesellix
```
The CLI encrypts the export using the embedded key (or fetched SSH keys) and writes `export.age`.
The user sends that file through any channel.
**Maintainer side:**
```bash
age -d -i decrypt.key -o export.tar.gz export.age
# or with an SSH private key:
age -d -i ~/.ssh/id_ed25519 -o export.tar.gz export.age
```
### Go Implementation
#### Encrypt with embedded key
```go
import (
"io"
"os"
"filippo.io/age"
)
const recipientKey = "age1..." // embedded public key
func exportEncrypted(plaintext io.Reader, outPath string) error {
recipient, err := age.ParseX25519Recipient(recipientKey)
if err != nil {
return err
}
out, err := os.Create(outPath)
if err != nil {
return err
}
defer out.Close()
w, err := age.Encrypt(out, recipient)
if err != nil {
return err
}
defer w.Close()
_, err = io.Copy(w, plaintext)
return err
}
```
#### Encrypt to a GitHub user's SSH keys (alternative / verification path)
```go
import (
"bufio"
"io"
"log"
"net/http"
"strings"
"filippo.io/age"
"filippo.io/age/agessh"
)
func recipientsFromGitHub(user string) ([]age.Recipient, error) {
resp, err := http.Get("https://github.com/" + user + ".keys")
if err != nil {
return nil, err
}
defer resp.Body.Close()
var recipients []age.Recipient
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
r, err := agessh.ParseRecipient(line)
if err != nil {
log.Printf("skipping unsupported key: %v", err)
continue
}
recipients = append(recipients, r)
}
return recipients, nil
}
```
#### Decrypt (maintainer side)
With a native age key:
```go
package main
import "filippo.io/age"
func decryptAge(encryptedReader io.Reader, privateKeyString string) (io.Reader, error) {
identity, err := age.ParseX25519Identity(privateKeyString)
if err != nil {
return nil, err
}
return age.Decrypt(encryptedReader, identity)
}
```
With an SSH private key:
```go
package main
import (
"io"
"os"
"filippo.io/age"
"filippo.io/age/agessh"
)
func decryptAgeSSH(encryptedReader io.Reader, sshKeyPath string) (io.Reader, error) {
pemBytes, err := os.ReadFile(sshKeyPath)
if err != nil {
return nil, err
}
identity, err := agessh.ParseIdentity(pemBytes)
if err != nil {
return nil, err
}
return age.Decrypt(encryptedReader, identity)
}
```
---
## Option B: GPG (OpenPGP)
### Architecture
1. Generate a dedicated GPG encryption subkey: `gpg --full-gen-key` (choose RSA or Ed25519+X25519).
2. Export and publish the public key, or embed the armored block directly in the binary.
3. Optionally fetch the user's GPG key from `https://github.com/<user>.gpg` or `keys.openpgp.org` so they can confirm the recipient.
4. Store the private key securely. Decryption is `gpg --decrypt export.gpg`.
### Workflow
**User side:**
```
soundtouch-cli export --encrypt-gpg
# or: soundtouch-cli export --encrypt-gpg-for github:gesellix
```
The CLI encrypts the export as an OpenPGP binary message and writes `export.gpg`.
The user sends that file through any channel.
**Maintainer side:**
```bash
# GPG must have the matching private key in its keyring
gpg --decrypt -o export.tar.gz export.gpg
# Or with a specific key file (without importing into the keyring):
gpg --no-default-keyring --secret-keyring ./decrypt.gpg \
--decrypt -o export.tar.gz export.gpg
```
### Go Implementation
Uses `github.com/ProtonMail/go-crypto/openpgp` (the maintained successor to the deprecated `golang.org/x/crypto/openpgp`; API is compatible).
#### Fetch public key from GitHub
```go
package main
import (
"io"
"net/http"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
)
func gpgKeyFromGitHub(user string) (openpgp.EntityList, error) {
resp, err := http.Get("https://github.com/" + user + ".gpg")
if err != nil {
return nil, err
}
defer resp.Body.Close()
block, err := armor.Decode(resp.Body)
if err != nil {
return nil, err
}
return openpgp.ReadKeyRing(block.Body)
}
```
#### Encrypt with embedded or fetched public key
```go
package main
import (
"io"
"os"
"strings"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
)
const embeddedPublicKey = `-----BEGIN PGP PUBLIC KEY BLOCK-----
...
-----END PGP PUBLIC KEY BLOCK-----`
func exportEncryptedGPG(plaintext io.Reader, outPath string) error {
block, err := armor.Decode(strings.NewReader(embeddedPublicKey))
if err != nil {
return err
}
recipients, err := openpgp.ReadKeyRing(block.Body)
if err != nil {
return err
}
out, err := os.Create(outPath)
if err != nil {
return err
}
defer out.Close()
// Encrypt directly (binary, no ASCII armor — smaller output)
w, err := openpgp.Encrypt(out, recipients, nil, nil, nil)
if err != nil {
return err
}
defer w.Close()
_, err = io.Copy(w, plaintext)
return err
}
```
To produce ASCII-armored output (easier to paste into emails/issues), wrap `out` with `armor.Encode`:
```go
package main
import (
"io"
"os"
"strings"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
)
func exportEncryptedGPGArmored(plaintext io.Reader, outPath, embeddedPublicKey string) error {
block, err := armor.Decode(strings.NewReader(embeddedPublicKey))
if err != nil {
return err
}
recipients, err := openpgp.ReadKeyRing(block.Body)
if err != nil {
return err
}
out, err := os.Create(outPath)
if err != nil {
return err
}
defer out.Close()
armorWriter, err := armor.Encode(out, "PGP MESSAGE", nil)
if err != nil {
return err
}
defer armorWriter.Close()
w, err := openpgp.Encrypt(armorWriter, recipients, nil, nil, nil)
if err != nil {
return err
}
defer w.Close()
_, err = io.Copy(w, plaintext)
return err
}
```
#### Decrypt (maintainer side)
```go
package main
import (
"io"
"os"
"strings"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
)
func decryptGPG(encryptedPath, privateKeyArmored string) (io.ReadCloser, error) {
block, err := armor.Decode(strings.NewReader(privateKeyArmored))
if err != nil {
return nil, err
}
keyring, err := openpgp.ReadKeyRing(block.Body)
if err != nil {
return nil, err
}
f, err := os.Open(encryptedPath)
if err != nil {
return nil, err
}
msg, err := openpgp.ReadMessage(f, keyring, nil, nil)
if err != nil {
return nil, err
}
return msg.UnverifiedBody, nil
}
```
---
## Comparison and Recommendation
| Criterion | `age` | GPG |
|-------------------------------------|------------------------------|-----------------------------------------------|
| User familiarity | Low (newer tool) | High (widely known) |
| User already has a key to use | Maybe (SSH on GitHub) | Often (GPG on GitHub/keyserver) |
| Go library quality | Excellent (`filippo.io/age`) | Good (`ProtonMail/go-crypto`) |
| Output interoperability | age format only | Standard OpenPGP — any GPG client can decrypt |
| CLI decrypt UX (maintainer) | `age -d -i key file.age` | `gpg --decrypt file.gpg` |
| Key embedding in binary | Native `age1...` string | Armored PEM block |
| Key rotation story | `age-keygen`, swap constant | Standard GPG subkey rotation |
| Anonymous recipients | Yes (native age keys) | No (key ID visible) |
| Streaming large exports | Yes | Yes |
**Recommendation:** use `age` with an embedded native key for the primary path — simpler dependency, cleaner API, no GPG keyring management needed. Add GPG as an opt-in flag (`--gpg` or `--encrypt-gpg-for github:<user>`) for users who already manage GPG keys and want their own tooling to verify or store the export.
---
## Binary Size
Measured on macOS arm64, stripped binaries (`-ldflags="-s -w"`).
### Standalone cost (no shared deps)
| Option | Binary size | Added vs no-crypto baseline |
|-------------------------------------|-------------|-----------------------------|
| Baseline (no crypto) | 1.44 MB | — |
| `age` native key only (no `agessh`) | 2.40 MB | +0.96 MB |
| `age` + `agessh` (SSH recipients) | 3.06 MB | +1.63 MB |
| GPG (`ProtonMail/go-crypto`) | 3.59 MB | +2.15 MB |
### Marginal cost for this project
This project already imports `golang.org/x/crypto/ssh`, which `agessh` depends on. That ~660 KB is shared and doesn't count against `age`. Against the ~12.9 MB `soundtouch-service` binary:
| Option | Marginal cost | % of service binary |
|------------------|---------------|---------------------|
| `age` + `agessh` | +0.64 MB | ~5% |
| GPG | +1.30 MB | ~10% |
### Why GPG is larger
`age` pulls in only what it needs: `chacha20poly1305`, `hkdf`, `edwards25519`, and `filippo.io/hpke` (post-quantum). `ProtonMail/go-crypto` must ship the full OpenPGP spec: `cloudflare/circl` (Ed448, X448, Goldilocks curves), `bitcurves`, `brainpool`, `EAX`, `OCB`, `CAST5`, `BLAKE2b`, `SHA3`, `Argon2`, S2K key derivation, and zlib/bzip2 compression. Go's dead-code elimination works at the function level but can't remove entire algorithm families wired through a shared codec dispatch.
---
## Gotchas & Risks
| Concern | Mitigation |
|--------------------------------------------------|-------------------------------------------------------------------------------------|
| MITM / GitHub account compromise swaps the key | Pin expected key fingerprint(s); prefer embedded key over runtime fetch |
| SSH key rotation breaks old `agessh` decryption | Use a dedicated long-lived age key, not the user's SSH key, as primary |
| ECDSA SSH keys not supported by `agessh` | Handle "no usable key" gracefully; warn and fall back |
| `agessh` recipients leak a 32-bit key ID | Accept, or use native age keys for full anonymity |
| GPG key expiry breaks encryption | Use a non-expiring encryption subkey, or check and warn before encrypting |
| GPG key without encryption capability | Filter `EntityList` to keys with `EncryptCommunications` flag set |
| Encryption ≠ authentication | Authenticate via the send channel, or require a detached signature |
| Sensitive data leaks via logs or memory dumps | Audit all egress paths; the export must be the only cleartext exit |
| Large exports | Both `age` and `openpgp.Encrypt` stream — never buffer the whole payload |
---
## Dependencies
```bash
# age
go get filippo.io/age
go get filippo.io/age/agessh # only if supporting SSH recipients
# GPG
go get github.com/ProtonMail/go-crypto/openpgp
```
## References
- `age` project: https://github.com/FiloSottile/age
- `age` Go docs: https://pkg.go.dev/filippo.io/age
- `agessh` docs: https://pkg.go.dev/filippo.io/age/agessh
- ProtonMail go-crypto: https://github.com/ProtonMail/go-crypto
- OpenPGP Go docs: https://pkg.go.dev/github.com/ProtonMail/go-crypto/openpgp
- GitHub GPG key endpoint: `https://github.com/<user>.gpg`
- OpenPGP keyserver: https://keys.openpgp.org
+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.
+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)
+125 -125
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/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,35 +926,35 @@ 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/playback/station/s33828"
```
@@ -962,16 +962,16 @@ soundtouch-cli --host 192.168.1.10 station remove \
**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
+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)
+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
+6 -6
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.
@@ -224,12 +224,12 @@ If you prefer scripting the migration, or the wizard isn't an option (headless s
```bash
# 1. Plan what the reset+pair pipeline will write (dry run, no changes yet).
soundtouch-cli --host 192.168.1.50 setup plan \
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.168.1.50 setup factory-reset
soundtouch-cli --host 192.0.2.50 setup factory-reset
# --- Manual step: join the speaker's Wi-Fi AP (SSID "Bose SoundTouch ...") ---
@@ -252,7 +252,7 @@ soundtouch-cli setup wait-online --match=42CAFE
# --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.168.1.50 setup pair \
soundtouch-cli --host 192.0.2.50 setup pair \
--mode=full --account=1111111 \
--service-url='https://soundtouch.local'
```
+2 -2
View File
@@ -69,7 +69,7 @@ In a web browser on any device on your network, go to:
http://<your-server-ip>:8000
```
Replace `<your-server-ip>` with the actual IP address of the computer running AfterTouch. For example: `http://192.168.1.100:8000`.
Replace `<your-server-ip>` with the actual IP address of the computer running AfterTouch. For example: `http://192.0.2.100:8000`.
If you are on the same computer that is running AfterTouch, you can use `http://localhost:8000`.
@@ -82,7 +82,7 @@ You should see the AfterTouch web interface with tabs: Overview, Settings, Devic
This is the most important setting. Go to the **Settings** tab and set the **Target Domain** to the full address of your AfterTouch server — the same address you used to open the web interface:
```
http://192.168.1.100:8000
http://192.0.2.100:8000
```
Use the IP address of your server, **not** `localhost`. Your speakers need to reach this address over the network, and they cannot resolve `localhost`.
+92 -70
View File
@@ -13,8 +13,6 @@ The service provides:
- **🌐 Web Management UI**: Browser-based interface for device management
- **💾 Persistent Data**: Store device configurations, presets, and usage statistics
- **📝 HTTP Recording**: Persist all interactions as re-playable `.http` files
- **🔄 Endpoint Mirroring**: Asynchronously mirror local requests to Bose cloud for parity testing
- **⚖️ Parity Logging**: Detect and record discrepancies between local and official Bose responses
- **📥 Session Archiving**: Download entire interaction sessions as `.tar.gz` for offline analysis
- **🔍 Auto-Discovery**: Automatically detect and configure SoundTouch devices
- **🔒 Offline Operation**: Continue using full device functionality without internet
@@ -154,26 +152,28 @@ The service supports multiple ways to configure its behavior. When multiple sour
### Configuration Options
| Variable | Flag | Description | Default |
|------------------------------------|----------------------------|---------------------------------------------------------------------------------------------------------|---------------------------|
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL | `https://<hostname>:8443` |
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` |
| `MIRROR_ENABLED` | | Enable background mirroring of specific endpoints to Bose cloud | `false` |
| `MIRROR_ENDPOINTS` | | Comma-separated list of path patterns to mirror (e.g., `/streaming/account/*/device/*/recent`) | `[]` |
| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
| Variable | Flag | Description | Default |
|------------------------------------|----------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL | `https://<hostname>:8443` |
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` |
| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
| `STOCKHOLM_DIR` | `--stockholm-dir` | Path to extracted Stockholm frontend directory — enables the Stockholm UI when set | *(disabled)* |
| `MARGE_URL` | | Streaming/marge base URL used when rewriting `stockholm/json/config.json`. Defaults to `SERVER_URL`. Set to `SERVER_URL/marge` only when using a soundcork backend. | *(same as `SERVER_URL`)* |
| `MARGE_AUTH_TOKEN` | | Pre-seeds the Stockholm `margeAuthToken` state (skips the login step for the first session) | *(empty)* |
| `MARGE_ACCOUNT_ID` | | Pre-seeds the Stockholm `margeAccountID` state (used to filter device-discovery results by account) | *(empty)* |
### Configuration Examples
@@ -188,6 +188,57 @@ SERVER_URL=https://my-soundtouch.example.com soundtouch-service --port 443
LOG_PROXY_BODY=true REDACT_PROXY_LOGS=false soundtouch-service
```
## Stockholm Frontend
The Stockholm frontend is the patched Bose SoundTouch app UI served directly by the service. When enabled, opening `http://<server>:8000` in a browser shows the full app interface, which communicates with your speakers via the local service instead of Bose's cloud.
### Getting the Stockholm files
The Stockholm UI files are not bundled in this repository — you supply them from [krahl/soundcork-stockholm-app](https://github.com/krahl/soundcork-stockholm-app). See that project's README for how to obtain the `stockholm.zip`. Once you have it:
```bash
# 1. Place stockholm.zip in stockholm_zip/
mkdir -p stockholm_zip
cp /path/to/stockholm.zip stockholm_zip/
# 2. Build the Docker image that applies the patches
make build-stockholm-image
# 3. Extract and patch the frontend into ./stockholm/
make prepare-stockholm
```
The `./stockholm/` directory is now ready to use.
### Enabling the Stockholm UI
Pass the directory to the service at startup:
```bash
# Development (recommended): builds the service and runs it with
# Stockholm enabled, checking that prepare-stockholm has run.
make dev-service-stockholm
# Binary
soundtouch-service --stockholm-dir ./stockholm
# Environment variable
STOCKHOLM_DIR=./stockholm soundtouch-service
# Docker Compose — add to the environment section of docker-compose.yml
# STOCKHOLM_DIR=/app/stockholm
# and mount the stockholm/ directory into the container
```
### Stockholm environment variables
| Variable | Description |
|--------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `STOCKHOLM_DIR` | Path to the extracted Stockholm frontend (enables the UI) |
| `MARGE_URL` | Override the streaming/marge URL written into `config.json`. Defaults to `SERVER_URL`. Only set this to `SERVER_URL/marge` when routing through a soundcork backend. |
| `MARGE_AUTH_TOKEN` | Pre-seed the session auth token so the first app launch skips the login screen |
| `MARGE_ACCOUNT_ID` | Pre-seed the account ID — device discovery will only show speakers on this account |
## Device Migration
### Understanding Migration
@@ -213,10 +264,10 @@ Device migration switches your SoundTouch devices from Bose's cloud services to
```bash
# Get migration summary first
curl http://localhost:8000/setup/migration-summary/192.168.1.100
curl http://localhost:8000/setup/migration-summary/192.0.2.100
# Perform migration
curl -X POST http://localhost:8000/setup/migrate/192.168.1.100
curl -X POST http://localhost:8000/setup/migrate/192.0.2.100
# Verify migration status
curl http://localhost:8000/setup/devices
@@ -226,16 +277,16 @@ curl http://localhost:8000/setup/devices
```bash
# Migration with custom target URL
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?target_url=https://my-server.com:8000"
curl -X POST "http://localhost:8000/setup/migrate/192.0.2.100?target_url=https://my-server.com:8000"
# Per-field literal URL overrides (preferred — used by the web wizard)
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?method=xml&target_url=http://server:8000&marge_url=http://server:8000/marge"
curl -X POST "http://localhost:8000/setup/migrate/192.0.2.100?method=xml&target_url=http://server:8000&marge_url=http://server:8000/marge"
# SSH-less migration over the device's port-17000 diagnostic shell
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?method=telnet&target_url=http://server:8000"
curl -X POST "http://localhost:8000/setup/migrate/192.0.2.100?method=telnet&target_url=http://server:8000"
# Legacy proxy-fallback for selected fields (kept for API back-compat)
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?proxy_url=http://localhost:8000&marge=original&stats=original"
curl -X POST "http://localhost:8000/setup/migrate/192.0.2.100?proxy_url=http://localhost:8000&marge=original&stats=original"
```
See the full parameter reference at `POST /setup/migrate/{deviceIP}` below for `method`, `target_url`, `*_url`, and the legacy mode selectors.
@@ -249,10 +300,10 @@ After migration, verify the device is working correctly:
curl http://localhost:8000/setup/devices
# Test preset functionality
curl "http://192.168.1.100:8090/presets"
curl "http://192.0.2.100:8090/presets"
# Monitor device events (if needed)
curl "http://localhost:8000/events/192.168.1.100"
curl "http://localhost:8000/events/192.0.2.100"
```
#### DNS/DHCP Migration (DHCP-Aware DNS Redirection)
@@ -324,34 +375,6 @@ You can enable and configure the DNS server via the Web UI or environment variab
#### Manual Discovery via DNS
Even without migrating a device, you can use the DNS server to discover what a device is querying by manually setting your router's DNS or the device's DNS to point to the AfterTouch service.
## Endpoint Mirroring & Parity Logging
The SoundTouch service includes a powerful **Mirroring** feature that allows you to handle requests locally while simultaneously forwarding them to the official Bose cloud in the background. This is primarily used for maintaining long-term compatibility and verifying the accuracy of the local emulation.
### How Mirroring Works
When an endpoint is configured for mirroring:
1. **GET Requests**: Handled locally first (Primary). The response is returned to the speaker immediately. In the background, the same request is sent to Bose.
2. **POST/PUT/DELETE Requests**: Handled locally first. The service then synchronously (but without blocking the speaker's response) forwards the request to Bose to ensure the "official" account state stays in sync with your local changes (e.g., updating a preset).
### Parity Logging
The **Parity Logger** automatically compares the response from your local service with the one received from Bose. If it detects any discrepancies, it:
1. Logs a warning to the console: `[PARITY] Mismatch detected for GET /...`
2. Saves a detailed JSON report to `data/parity_mismatches/`.
Each report includes the full request, both response bodies, and a summary of what differed (status codes, content types, or missing/different XML tags).
### Configuration
Mirroring is configured via the **Settings** tab in the Web UI or through global settings:
- **Mirror Enabled**: Master switch for the mirroring infrastructure.
- **Mirror Endpoints**: A list of URL path patterns to mirror. You can use wildcards (`*`) to match variable parts like account or device IDs.
- Example: `/streaming/account/*/device/*/recent`
- Example: `/accounts/*/devices/*/presets/*`
Mirrored requests are also recorded in the **Interaction Log** under the category `upstream-mirror`, allowing you to see side-by-side exactly how our service's behavior compares to the official one.
## API Reference
### Discovery & Setup
@@ -365,7 +388,7 @@ Lists all discovered SoundTouch devices with their current status.
{
"device_id": "08DF1F0BA325",
"name": "Living Room Speaker",
"ip_address": "192.168.1.100",
"ip_address": "192.0.2.100",
"product_code": "SoundTouch 20",
"firmware_version": "19.0.5",
"migrated": true,
@@ -434,13 +457,13 @@ Migrates device to use local services.
```bash
# Canonical XML migration over SSH to the default service URL
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?method=xml"
curl -X POST "http://localhost:8000/setup/migrate/192.0.2.100?method=xml"
# Telnet migration with the soundcork redirect (only marge gets the /marge suffix)
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?method=telnet&target_url=http://soundcork.local:8000&marge_url=http://soundcork.local:8000/marge"
curl -X POST "http://localhost:8000/setup/migrate/192.0.2.100?method=telnet&target_url=http://soundcork.local:8000&marge_url=http://soundcork.local:8000/marge"
# DNS interception (writes /etc/resolv.conf hook + installs CA) — *_url overrides are ignored
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?method=resolv&target_url=https://my-server.com:8443"
curl -X POST "http://localhost:8000/setup/migrate/192.0.2.100?method=resolv&target_url=https://my-server.com:8443"
```
#### `POST /setup/telnet-probe/{deviceIP}`
@@ -770,7 +793,6 @@ Clears all recorded DNS discovery data from memory and disk.
- `/bmx/tunein/v1/*`: TuneIn radio emulation.
- `/marge/accounts/*`: Account and device management.
- `/marge/updates/soundtouch`: Software update emulation.
- `/proxy/*`: Logging proxy for original Bose services.
## Troubleshooting
@@ -779,25 +801,25 @@ Clears all recorded DNS discovery data from memory and disk.
#### Device Not Discovered
```bash
# Check network connectivity
ping 192.168.1.100
ping 192.0.2.100
# Trigger manual discovery
curl -X POST http://localhost:8000/setup/discover
# Check device accessibility
curl http://192.168.1.100:8090/info
curl http://192.0.2.100:8090/info
```
#### Migration Failures
```bash
# Check SSH connectivity
ssh-keyscan 192.168.1.100
ssh-keyscan 192.0.2.100
# Get migration summary
curl http://localhost:8000/setup/migration-summary/192.168.1.100
curl http://localhost:8000/setup/migration-summary/192.0.2.100
# Verify device configuration
curl http://192.168.1.100:8090/info
curl http://192.0.2.100:8090/info
```
#### Service Connectivity Issues
@@ -876,7 +898,7 @@ func main() {
```yaml
# configuration.yaml
soundtouch:
- host: 192.168.1.100
- host: 192.0.2.100
port: 8090
name: "Living Room Speaker"
@@ -903,7 +925,7 @@ fi
- **Network Security**: The service binds to all interfaces by default. Consider using `BIND_ADDR=127.0.0.1` for localhost-only access.
- **SSH Access**: Migration requires SSH access to devices. Ensure your network security policies allow this.
- **Proxy Logging**: Disable `REDACT_PROXY_LOGS` only in development environments.
- **Logging**: Disable `REDACT_PROXY_LOGS` only in development environments.
- **Data Protection**: The data directory contains device configurations and usage patterns. Secure appropriately.
- **Spotify / Amazon Music credential push (zeroconf)**: outbound credential-push requests are restricted to literal IP hosts on local-network ranges (loopback, RFC1918 private, IPv4/IPv6 link-local). Hostname-style URLs (DNS, mDNS `*.local`) are rejected at runtime; if you have a hostname, resolve it first (`getent hosts <name>` or `dig +short <name>`) and pass the resolved IP. This guards against a malicious LAN-resident speaker pointing the credential push at a non-speaker host (server-side request forgery).
+1 -1
View File
@@ -12,7 +12,7 @@ What **continues to work** regardless:
- Bluetooth, AUX, and AirPlay inputs
- Multiroom zones (local, peer-to-peer)
**AfterTouch** — the `soundtouch-service` — restores everything in the first list by running a local replacement for the Bose cloud on your own network.
**AfterTouch** — the `soundtouch-service` — restores everything in the first list by running a replacement for the Bose cloud on your own network.
---
+37 -37
View File
@@ -14,13 +14,13 @@ Run these commands to quickly diagnose your setup:
go run ./cmd/soundtouch-cli -discover
# 2. Test specific device connection
go run ./cmd/soundtouch-cli -host 192.168.1.100 -info
go run ./cmd/soundtouch-cli -host 192.0.2.100 -info
# 3. Test basic controls
go run ./cmd/soundtouch-cli -host 192.168.1.100 -volume
go run ./cmd/soundtouch-cli -host 192.0.2.100 -volume
# 4. Test network connectivity
ping 192.168.1.100
ping 192.0.2.100
```
---
@@ -70,7 +70,7 @@ discoverer := discovery.NewDiscoverer(discovery.Config{
#### 5. **Use Manual IP**
```go
// Bypass discovery entirely
client := client.NewClientFromHost("192.168.1.100")
client := client.NewClientFromHost("192.0.2.100")
```
### ❌ "Discovery timeout"
@@ -93,10 +93,10 @@ discoverer := discovery.NewDiscoverer(discovery.Config{
2. **Check network performance:**
```bash
# Test network latency
ping -c 4 192.168.1.1
ping -c 4 192.0.2.1
# Check for network congestion
iperf3 -c 192.168.1.1 # If iperf server available
iperf3 -c 192.0.2.1 # If iperf server available
```
3. **Use wired connection if possible**
@@ -213,7 +213,7 @@ If presets still won't play after step 5, capture `logread -f | grep -v '127.0.0
**Symptoms:**
```go
Failed to connect: dial tcp 192.168.1.100:8090: connection refused
Failed to connect: dial tcp 192.0.2.100:8090: connection refused
```
**Diagnostic Steps:**
@@ -221,12 +221,12 @@ Failed to connect: dial tcp 192.168.1.100:8090: connection refused
#### 1. **Verify IP and Port**
```bash
# Test if port 8090 is open
telnet 192.168.1.100 8090
telnet 192.0.2.100 8090
# OR
nc -zv 192.168.1.100 8090
nc -zv 192.0.2.100 8090
# Scan for open ports
nmap -p 8080-8100 192.168.1.100
nmap -p 8080-8100 192.0.2.100
```
#### 2. **Check Device Status**
@@ -237,10 +237,10 @@ nmap -p 8080-8100 192.168.1.100
#### 3. **Router/Network Issues**
```bash
# Check routing
traceroute 192.168.1.100
traceroute 192.0.2.100
# Test basic connectivity
ping -c 4 192.168.1.100
ping -c 4 192.0.2.100
```
### ❌ "Timeout" / "Context deadline exceeded"
@@ -255,7 +255,7 @@ Failed to get device info: context deadline exceeded
#### 1. **Increase Client Timeout**
```go
config := client.ClientConfig{
Host: "192.168.1.100",
Host: "192.0.2.100",
Port: 8090,
Timeout: 30 * time.Second, // Increase from default 10s
}
@@ -264,7 +264,7 @@ config := client.ClientConfig{
#### 2. **Check Network Latency**
```bash
# Test response time
ping -c 10 192.168.1.100
ping -c 10 192.0.2.100
# Should be < 100ms typically
```
@@ -285,7 +285,7 @@ Failed to connect: dial tcp: lookup soundtouch.local: no such host
1. **Use IP instead of hostname:**
```go
client := client.NewClientFromHost("192.168.1.100") // Not "soundtouch.local"
client := client.NewClientFromHost("192.0.2.100") // Not "soundtouch.local"
```
2. **Fix DNS/mDNS:**
@@ -546,8 +546,8 @@ client.SetBalanceSafe(10) // Falls back gracefully
**Symptoms:**
```bash
$ go run ./cmd/soundtouch-cli --host 192.168.178.35 sp beep
Playing notification beep from 192.168.178.35:8090...
$ go run ./cmd/soundtouch-cli --host 192.0.2.10 sp beep
Playing notification beep from 192.0.2.10:8090...
✗ Failed to play notification beep: API request failed with status 400
```
@@ -569,10 +569,10 @@ func (c *Client) PlayNotificationBeep() error {
Both commands should now work identically:
```bash
# CLI command
go run ./cmd/soundtouch-cli --host 192.168.178.35 sp beep
go run ./cmd/soundtouch-cli --host 192.0.2.10 sp beep
# Direct curl (for comparison)
curl http://192.168.178.35:8090/playNotification
curl http://192.0.2.10:8090/playNotification
```
### ❌ "speaker" commands not supported
@@ -630,7 +630,7 @@ fmt.Printf("Current source: %s, status: %s\n",
**Symptoms:**
```go
Failed to connect WebSocket: dial ws://192.168.1.100:8080/: connection refused
Failed to connect WebSocket: dial ws://192.0.2.100:8080/: connection refused
```
**Solutions:**
@@ -638,7 +638,7 @@ Failed to connect WebSocket: dial ws://192.168.1.100:8080/: connection refused
#### 1. **Verify WebSocket Port (8080)**
```bash
# WebSocket uses port 8080, not 8090
nc -zv 192.168.1.100 8080
nc -zv 192.0.2.100 8080
```
#### 2. **Check Protocol Specification**
@@ -647,7 +647,7 @@ nc -zv 192.168.1.100 8080
wsClient := client.NewWebSocketClient(nil)
// Manual connection (if needed)
url := "ws://192.168.1.100:8080/"
url := "ws://192.0.2.100:8080/"
headers := http.Header{}
headers.Set("Sec-WebSocket-Protocol", "gabbo")
```
@@ -677,7 +677,7 @@ wsClient := client.NewWebSocketClient(config)
2. **Check network stability:**
```bash
# Test for packet loss
ping -c 100 192.168.1.100 | grep loss
ping -c 100 192.0.2.100 | grep loss
```
3. **Power management issues:**
@@ -809,7 +809,7 @@ transport := &http.Transport{
}
config := client.ClientConfig{
Host: "192.168.1.100",
Host: "192.0.2.100",
Port: 8090,
Timeout: 10 * time.Second,
}
@@ -831,14 +831,14 @@ config.Logger = &client.DefaultLogger{} // Or custom logger
```bash
# Capture SoundTouch traffic
sudo tcpdump -i any host 192.168.1.100 and port 8090
sudo tcpdump -i any host 192.0.2.100 and port 8090
# Monitor WebSocket traffic
sudo tcpdump -i any host 192.168.1.100 and port 8080
sudo tcpdump -i any host 192.0.2.100 and port 8080
# HTTP debugging with curl
curl -v http://192.168.1.100:8090/info
curl -v http://192.168.1.100:8090/volume
curl -v http://192.0.2.100:8090/info
curl -v http://192.0.2.100:8090/volume
```
---
@@ -1008,9 +1008,9 @@ Use this checklist to systematically troubleshoot issues:
**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"
```
**Cause:** The service uses MAC addresses in API requests but stores files using device serial numbers. A mapping system resolves MAC addresses to serial numbers automatically.
@@ -1025,13 +1025,13 @@ sudo systemctl restart soundtouch-service
2. **Check device directory structure**:
```bash
# Files should be stored by serial number, not MAC
ls data/accounts/3230304/devices/
# Should show: I6332527703739342000020/ (not A81B6A536A98/)
ls data/accounts/1000001/devices/
# Should show: I6332527703739342000020/ (not AABBCCDDEEFF/)
```
3. **Verify DeviceInfo.xml contains MAC address**:
```bash
cat data/accounts/3230304/devices/*/DeviceInfo.xml | grep macAddress
cat data/accounts/1000001/devices/*/DeviceInfo.xml | grep macAddress
```
**For detailed diagnosis and solutions**, see: [**MAC Address Mapping Guide**](MAC-ADDRESS-MAPPING.md)
@@ -1059,7 +1059,7 @@ SSH ping from device failed and service-side DNS lookup also failed
```
or:
```
resolved "soundtouch.local" to 192.168.1.100 from service, not from device —
resolved "soundtouch.local" to 192.0.2.100 from service, not from device —
result may be wrong if NAT or split-DNS is in use
```
@@ -1078,7 +1078,7 @@ ssh root@<speaker-ip>
# Try to resolve the service hostname
ping -c 1 soundtouch.local
# or use the IP directly to verify connectivity
ping -c 1 192.168.1.100
ping -c 1 192.0.2.100
# Check the speaker's current DNS config
cat /etc/resolv.conf
@@ -1096,8 +1096,8 @@ The most reliable fix. If the hostname cannot be resolved from the device, use a
```bash
# In your .env
SERVER_URL=http://192.168.1.100:8000
HTTPS_SERVER_URL=https://192.168.1.100:8443
SERVER_URL=http://192.0.2.100:8000
HTTPS_SERVER_URL=https://192.0.2.100:8443
```
HTTPS works correctly with IP addresses — the service certificate includes the IP as a Subject Alternative Name (SAN).
+1 -1
View File
@@ -6,7 +6,7 @@ Screenshots and diagrams referenced by the documentation.
| File | Shows | Used in |
|------|-------|---------|
| `ui-settings.png` | AfterTouch web UI — Settings tab (Target Domain, DNS Discovery, Mirroring) | Migration Guide |
| `ui-settings.png` | AfterTouch web UI — Settings tab (Target Domain, DNS Discovery) | Migration Guide |
| `ui-devices.png` | AfterTouch web UI — Devices tab (discovered speakers with Sync/Migrate actions) | Migration Guide |
| `ui-sync.png` | AfterTouch web UI — Data Sync tab (successful sync result) | Migration Guide |
| `ui-migration.png` | AfterTouch web UI — Migration tab (HTTPS test, DNS test, method selector) | Migration Guide |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 518 KiB

After

Width:  |  Height:  |  Size: 518 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 482 KiB

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

+3 -3
View File
@@ -504,15 +504,15 @@ func TestHandleMargePowerOnEnhanced(t *testing.T) {
}{
{
name: "new_device_registration",
requestBody: `<device-data><device id="A81B6A536A98">...</device></device-data>`,
requestBody: `<device-data><device id="AABBCCDDEEFF">...</device></device-data>`,
existingDevice: nil,
expectedStatus: "ok",
expectMigration: false,
},
{
name: "existing_device_update",
requestBody: `<device-data><device id="A81B6A536A98">...</device></device-data>`,
existingDevice: &models.ServiceDeviceInfo{DeviceID: "A81B6A536A98"},
requestBody: `<device-data><device id="AABBCCDDEEFF">...</device></device-data>`,
existingDevice: &models.ServiceDeviceInfo{DeviceID: "AABBCCDDEEFF"},
expectedStatus: "ok",
expectMigration: true,
},
+10 -10
View File
@@ -74,10 +74,10 @@ func (c *Client) StoreCurrentAsPreset(id int) error {
```bash
# Store currently playing content as preset
soundtouch-cli --host 192.168.1.100 preset store-current --slot 3
soundtouch-cli --host 192.0.2.100 preset store-current --slot 3
# Store specific content as preset
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:37i9dQZF1DX0XUsuxWHRQd" \
@@ -85,31 +85,31 @@ soundtouch-cli --host 192.168.1.100 preset store \
--name "My Worship Mix"
# Store radio station as preset
soundtouch-cli --host 192.168.1.100 preset store \
soundtouch-cli --host 192.0.2.100 preset store \
--slot 2 \
--source TUNEIN \
--location "/v1/playback/station/s33828" \
--name "K-LOVE Radio"
# Store radio station using TuneIn URL (Name and Artwork are automatically fetched)
soundtouch-cli --host 192.168.1.100 preset store \
soundtouch-cli --host 192.0.2.100 preset store \
--slot 6 \
--location "https://tunein.com/radio/WDR-2-Rheinland-1004-s213886/"
# Store Spotify album using URL (Name and Artwork are automatically fetched)
soundtouch-cli --host 192.168.1.100 preset store \
soundtouch-cli --host 192.0.2.100 preset store \
--slot 1 \
--location "https://open.spotify.com/album/6rT8yer84xoh0t17poLsmn?si=XqxdZazpTLC1ceoC8EeCuA" \
--source-account "yourusername"
# Remove preset
soundtouch-cli --host 192.168.1.100 preset remove --slot 3
soundtouch-cli --host 192.0.2.100 preset remove --slot 3
# Show current content details (including location URI for all sources)
soundtouch-cli --host 192.168.1.100 play now
soundtouch-cli --host 192.0.2.100 play now
# Show detailed content information
soundtouch-cli --host 192.168.1.100 play now --verbose
soundtouch-cli --host 192.0.2.100 play now --verbose
```
## Spotify Integration Examples
@@ -297,7 +297,7 @@ The CLI now automatically shows location details for **all sources** when using
### Automatic Location Display
```bash
# Location automatically shown for any source with location data
go run ./cmd/soundtouch-cli --host 192.168.1.100 play now
go run ./cmd/soundtouch-cli --host 192.0.2.100 play now
```
**Example outputs:**
@@ -335,7 +335,7 @@ Content Details:
### Verbose Mode for Complete Details
```bash
go run ./cmd/soundtouch-cli --host 192.168.1.100 play now --verbose
go run ./cmd/soundtouch-cli --host 192.0.2.100 play now --verbose
```
Shows additional information:
+1 -1
View File
@@ -87,7 +87,7 @@ func getDevicesWithFallback() []*client.Client {
log.Printf("Discovery failed: %v, trying known IPs...", err)
// Fallback to known IP addresses
knownIPs := []string{"192.168.1.100", "192.168.1.101", "192.168.1.102"}
knownIPs := []string{"192.0.2.100", "192.0.2.101", "192.0.2.102"}
var clients []*client.Client
for _, ip := range knownIPs {
+2 -2
View File
@@ -403,7 +403,7 @@ Retrieves network information.
```xml
<networkInfo wifiProfileCount="1">
<interfaces>
<interface type="WIFI_INTERFACE" name="wlan0" macAddress="..." ipAddress="192.168.1.131" ssid="network_name" frequencyKHz="2452000" state="NETWORK_WIFI_CONNECTED" signal="MARGINAL_SIGNAL" mode="STATION" />
<interface type="WIFI_INTERFACE" name="wlan0" macAddress="..." ipAddress="192.0.2.131" ssid="network_name" frequencyKHz="2452000" state="NETWORK_WIFI_CONNECTED" signal="MARGINAL_SIGNAL" mode="STATION" />
<interface type="WIFI_INTERFACE" name="wlan1" macAddress="..." state="NETWORK_WIFI_DISCONNECTED" />
</interfaces>
</networkInfo>
@@ -784,7 +784,7 @@ Retrieves all supported endpoints for the specific device with comprehensive fea
**Notes on Endpoint Discovery:**
- Total discovered endpoints: **103**
- Both test devices (192.168.178.28 and 192.168.178.35) support identical endpoint lists
- Both test devices (192.0.2.11 and 192.0.2.10) support identical endpoint lists
- Many endpoints are undocumented in official API v1.0 but functional on real hardware
- Some endpoints may require specific device types or firmware versions
- Endpoints marked ✅ are currently implemented in this Go library

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