- setup: resync all four boseurls (not just marge/swUpdate) over telnet
after an SSH-XML migration. `envswitch boseurls set` persists whatever
is currently in the runtime layer, so leaving stats/bmx untouched froze
their stale pre-migration values into the persistence layer permanently
-- surviving reboot and previously requiring a factory reset to clear.
- admin-ui: Migrate tab's Target Domain edits now propagate into the four
service URL fields (tracked via a dataset.autofilled flag so real manual
edits still aren't clobbered), closing the gap where changing Target
Domain to a new value left the four fields pointed at a stale default.
- install.sh: prune stale binary backups before the download too, not
only after a successful install, so a backup left by a previously
aborted (out-of-space) run gets cleaned up instead of compounding.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
validateURL() unconditionally rejected the hostnames "localhost" and
"127.0.0.1" for the four Migrate-tab plan URL fields, with no awareness
of deployment mode. Since the Suggested Plan's URLs are derived from the
page's own configured Target URL, a fresh on-device install (whose
server_url is now correctly http://localhost:8000, since #546) loaded
the Migrate tab with "Apply Suggested Plan" and "Pre-flight" disabled
by default, before the user touched anything -- directly contradicting
the on-device docs' "Migrate -> accept the suggested plan -> apply"
instructions.
Found while investigating why a #614 reporter used the non-standard
"localhost.localdomain" as a workaround, and why a #621 reporter got
stuck with "Migration Status: Migrated (URL mismatch)" trying to follow
the (correct) on-device localhost guidance.
Fix: a loopback URL is only flagged when it doesn't match the plan's
own Target URL origin. A field that's exactly what the service itself
is already configured to answer as (the on-device case) is accepted;
a stray "localhost" typed into one field while Target URL is a real LAN
address (the external-host mistake the check exists to catch) is still
flagged, since the origins differ.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Confirmed via code review: the Settings-save handler only updates the
service's own serverURL/settings.json, never contacts a device, and
neither migration method (telnet or XML/SSH) leaves anything behind that
would make a speaker later re-fetch a new address on its own. Both write
once, at migrate time.
Adds a Troubleshooting entry for this, and a cross-reference from the
Migration Guide's Step 2 (Target Domain) pointing at it, plus a step-
number fix (Migrate is Step 5, not Step 4).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
On an on-device install, soundtouch-service defaulted its server URL to
os.Hostname() when --server-url wasn't set. Since the service runs on the
speaker's own Linux, that returns the speaker's internal variant codename
(e.g. "spotty", "mojo") -- never resolvable, not even by the speaker itself
-- breaking TuneIn/BMX playback with CURL ErrorCode 6 (issue #546).
Add a --deployment-mode/DEPLOYMENT_MODE flag (on-device, private-network,
public-network) so the fallback is chosen deliberately instead of guessed:
on-device defaults to localhost, public-network refuses to start rather
than guess a public address, and the previous hostname-guessing behavior
is kept for private-network/unset installs, now with a startup warning.
The on-device init script sets DEPLOYMENT_MODE=on-device automatically and
now auto-exports aftertouch.conf into the daemon's environment generally,
which also unblocks discussion #610 (setting MGMT_USERNAME/MGMT_PASSWORD
on-device) without any further code change.
Verified end-to-end on real ST20 hardware: service now resolves
http://localhost:8000, a re-migrate updates the speaker's own runtime
config to match, and TuneIn playback works again.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The site sets disablePathToLower, so published URLs keep the source
filename's case, but the README link was lowercased and 404'd.
The walkthrough link was wrong in a second way: it used ../../reference/
with no extension, while cross-document links from guides/ resolve as
../reference/NAME.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On-device installs were only reachable through an SSH tunnel, and the
docs blamed it on the service binding loopback-only. That was wrong.
Some SoundTouch chassis carry a BCO ("SMSC") Wi-Fi/Bluetooth
co-processor, and inbound LAN traffic reaches the main Linux SoC only
for a fixed set of Bose's own service ports, a list that appears to be
compiled into the co-processor firmware. AfterTouch's :8000 was never
part of that design, so connections never arrive at the SoC at all.
Confirmed on an ST20: a port sweep from a LAN client showed Bose's
:82/:8080/:8090/:8091/:8200/:17000 all answering while :8000 failed,
and tcpdump on the speaker's own eth0 recorded zero packets for it.
Ruled out along the way: iptables (empty), nft/ebtables (absent), the
router, Wi-Fi isolation, and the binding itself (0.0.0.0 is correct).
The init script now redirects one of the relayed ports to AfterTouch,
so http://<speaker-ip>:17008 works with no tunnel. 17008 is Bose's
software-update listener, whose cloud no longer exists. Only external
traffic is matched, so anything on the speaker still reaches :8000 as
before. Auto-enabled only where has-bco reports the co-processor, and
configurable via AFTERTOUCH_LAN_PORT (auto/none/port) in
aftertouch.conf. The rule is re-applied on every start and removed on
stop and uninstall, so it needs no watchdog; unlike prior art it is not
pinned to the LAN IP, so it also survives DHCP changes.
Credit for the REDIRECT technique goes to the STR / SoundTouch Reborn
project, which documented and shipped it first.
Also de-hardcodes the service port, which was baked independently into
the daemon args, the readiness poll and status, and makes install.sh
print the speaker's real address instead of a <your-device-ip>
placeholder it never filled in.
Adds a model support matrix, since the repo had no per-model
compatibility record and this behaviour is entirely chassis-dependent.
Only the verified ST20 row is filled in; everything else is marked
unknown rather than inferred.
Verified on hardware: auto-detection, idempotency across restarts,
teardown and restore, persistence across a full reboot, and LAN access
returning the service's health JSON.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The SSH connection-reuse fix (a4c0539) landed after this entry was
originally written describing the bug as open. Update the entry to
reflect the fix, confirmed on the same real hardware that surfaced
the original failure.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three stacked bugs, found and confirmed on real hardware while
downgrading a speaker: the new binary landed on disk correctly, but
the running service kept reporting the old version indefinitely.
- install.sh called `/etc/init.d/aftertouch start`, not `restart`,
after installing. start-stop-daemon silently refuses to launch a
second instance when one is already running, and the init script
never checked its exit status, so the old process was never
replaced.
- The init script started the daemon through a `sh -c "exec ... |
logger"` pipeline, on the assumption that `exec` lets --make-pidfile
record the daemon's own PID. POSIX forks each side of a pipe into
its own process, so the wrapper shell (not the daemon) was the one
actually tracked. `stop` killed the wrapper, which doesn't forward
SIGTERM to its children, orphaning the real daemon to keep running
and keep holding :8000 forever.
- Once the wrapper correctly tracked the daemon's own PID, a further
race surfaced: start-stop-daemon's own "already running?" check
matched on generic `/bin/sh` identity, so a `restart`'s `start`
phase could catch the previous wrapper still mid-teardown and
silently refuse to launch a new one (masked by --quiet, looking
like a 120s hang).
Fixed by calling `restart` instead of `start` in install.sh, and by
having the wrapper shell record the daemon's real PID itself (via $!)
while keying start-stop-daemon's own check on that same pidfile
instead of process identity.
Verified on hardware: three consecutive restart cycles, each fast,
each with the pidfile matching the live daemon PID and daemon output
flowing through syslog again via logread.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Confirmed on real hardware: `VERSION=0.123.0 curl -sSL .../install.sh | sh`
silently does NOT pin the version, despite the docs claiming it "works
with pipe-to-sh". Shell variable-assignment prefixes only apply to the one
command they're attached to; in a pipe each command is its own process, so
the env var was set for `curl` (which never reads it) and never reached
`sh` (which does). A real attempt to pin v0.123.0 for a #614 pure
reproduction silently installed "latest" instead.
Verified the fix with a minimal repro (VERSION=X cat file | sh vs.
cat file | VERSION=X sh) before changing anything.
Moves the VERSION= prefix onto sh -- the last command in the pipe, the one
that actually reads it -- in ON-DEVICE-INSTALL-WALKTHROUGH.md (both
occurrences), scripts/on-device-install/README.md, and
scripts/on-device-install/install.sh's own header comment.
RASPBERRY-PI.md/EXTERNAL-HOST-WALKTHROUGH.md's `sudo VERSION=x ... bash
install.sh` pattern is unaffected -- that's a direct invocation, not a
pipe, so the env var already reaches the right process there.
Confirmed on real hardware (192.168.178.28): RevertMigration's full call
graph (revertXMLConfig/revertHosts/revertResolvConf/revertAftertouchHook/
removeRcLocalHooks/revertCACert) makes 17 separate client.Run() calls, and
pkg/ssh.Client.Run/UploadContent each dialed a brand-new SSH connection
per call with no reuse. Hitting a resource-constrained speaker with 17
rapid reconnects overwhelmed it -- confirmed via a follow-up plain SSH
command timing out at the TCP level, and the speaker going visibly
unresponsive.
Gives pkg/ssh.Client an opt-in persistent connection: Connect() dials
once and caches it, Close() releases it, and a shared dial() helper makes
Run/UploadContent reuse the cached connection when one's open, falling
back to today's per-call dial otherwise. RevertMigration now calls
Connect() once and defer Close(), collapsing 17 connections into 1. The
other ~21 m.NewSSH() call sites in pkg/service/setup never call Connect,
so their behavior is completely unchanged -- this only touches the one
function that was actually causing real-world problems.
SSHClient interface gained Connect()/Close(); both test mocks
(pkg/service/setup/setup_test.go, pkg/service/handlers/handlers_setup_test.go)
got no-op stubs. Added TestClose_NoOpWithoutConnect and
TestConnect_DialFailureLeavesConnNil in pkg/ssh/ssh_test.go -- these don't
prove connection reuse against a real server (Client.Run hardcodes :22,
no configurable port for a test listener), so that specific behavior is
verified by code review (a single `if c.conn != nil` branch) plus the
real-hardware confirmation above, not an automated integration test.
Also fixes the web UI's "Revert to Defaults" button, which calls the same
RevertMigration code path.
Prompted by recovering a real speaker (192.168.178.28) that had gone
stressed/unresponsive: it had previously been through `setup enable-ssh`
without --service-url (leaving margeServerUrl persisted as the
aftertouch.invalid placeholder, firmware retry-looping a failing DNS/curl
lookup against it, #546-shaped), compounded by a burst of SSH connections
from a `setup revert` attempt (see the dial-storm finding, tracked
separately, not fixed here).
Added --marge-url/--stats-url/--sw-update-url/--bmx-url override flags to
`setup migrate`. No new backend logic: telnetURLsFromOptions
(pkg/service/setup/telnet_migration.go) already supported per-field
overrides end-to-end, shared with the XML method's applyURLOverrides --
the CLI just never exposed it. Lets a speaker's URLs be pointed anywhere
(back to AfterTouch, or back to the genuine original Bose cloud) via a
single telnet connection, no SSH and no .original backup required --
confirmed recovering the real speaker above (migration committed, a
previously-timing-out plain SSH command returned instantly afterward).
Documented in TROUBLESHOOTING.md: the enable-ssh placeholder-persistence
gotcha (now with the escape hatch above) and the setup revert dial-storm
risk as a known, not-yet-fixed issue with a workaround (prefer this
lighter telnet-only migrate over repeated revert attempts). Documented the
new flags in CLI-REFERENCE.md's setup migrate section.
Prompted by writing a #614 self-test guide (on-device install walkthrough)
and by helping fully revert a real speaker a factory reset didn't fully
clean up.
New soundtouch-cli commands (cmd/soundtouch-cli/cmd_setup.go):
- `setup sync` — wraps POST /api/setup/sync/{deviceId}, the same operation
as the web UI's Devices -> Sync Data button. Read-only towards the
speaker (presets/recents/sources into the datastore); never writes back.
- `setup revert` — wraps setup.Manager.RevertMigration, the same operation
as the web UI's "Revert to Defaults" button. Restores
SoundTouchSdkPrivateCfg.xml/hosts/resolv.conf from their .original
backups and strips the AfterTouch CA cert from the trust bundle. No
--service-url needed; pure SSH against the speaker. Deliberately leaves
SSH persistence and account pairing untouched, matching the web UI
button (use `setup remote-services --remove` / `account unpair` for
those).
Both are thin wrappers with no new business logic, matching the existing
migrate/pair/reboot pattern. Tests added for setup sync's HTTP plumbing
(auth-retry, device-scoped URL, error propagation); no CLI-level test for
setup revert, consistent with reboot/migrate/pair also having none --
RevertMigration itself is already tested in pkg/service/setup/setup_test.go.
Documentation gaps closed:
- ON-DEVICE-INSTALL-WALKTHROUGH.md never showed the Migrate step at all --
jumped from install/reboot straight to the pairing QuickFix as if the
speaker were already pointed at itself. Added an explicit Migrate step
(web-UI and CLI paths), a CLI alternative for the pairing QuickFix, a
no-USB-stick `enable-ssh` (#471) alternative to the physical stick
procedure, and a "testing a pre-release build" section for cross-
compiling and manually swapping an unreleased binary (soundtouch-cli
deploy step included, mirroring the already-covered soundtouch-service
swap).
- MIGRATION-GUIDE.md's "never use localhost" Target Domain warning had no
on-device exception, even though loopback is exactly correct there since
the speaker and the service are the same machine. Added the callout, and
the same enable-ssh alternative to its SSH-enablement step.
- DEVICE-INITIAL-SETUP.md's AP-mode Wi-Fi provisioning commands were
macOS-only (networksetup, dns-sd) with no Linux/Windows equivalents,
unlike the rest of the docs. Added nmcli/netsh wlan alongside.
- CLI-REFERENCE.md's entire `setup <subcommand>` group was undocumented
(--help was the only reference) -- wrote a full "Setup & Migration"
section covering all 16 subcommands, and added the also-undocumented
`account unpair` to the existing Music Service Account Management
section.
Repo-wide sweep of example version strings still pinned around 0.111.2/
0.111.3 (four releases behind) across install-script comments, README
walkthroughs, the FALLBACK_VERSION defaults in on-device-install and
raspberry-pi install scripts, and the bug-report issue template's version
placeholder. Bumped to 0.123.0, the current release.
Left untouched: RFC-5737 example IPs and Go test fixtures that happened to
match the same version-number pattern, dated blog posts, and the Hugo
theme's own unrelated version pin.
Adds a second QuickFix to the speaker_presets_count warning, reusing
the existing postSourcesUpdated fix (checks_refresh_sources.go). It
nudges the speaker to re-fetch /full, which is confirmed (both from
marge.AccountFullToXML and a genuine captured Bose-cloud response) to
carry presets alongside sources.
Whether firmware actually re-applies /full's preset section back onto
its own local table is unconfirmed — issue253_regression_test.go
already flags that exact link as untested. So this is offered as a
free, non-destructive thing to try first, with the guaranteed
restore_presets_to_speaker push as the fallback. Gives both directions
(pull-style nudge, direct push) rather than only the one.
Refs #614
When the speaker shows 0 preset slots while the service's Presets.xml
has entries (the #614 pattern), replays each stored preset onto the
speaker via :8090/storePreset (client.StorePreset), one slot at a
time. Doesn't require a reboot and doesn't need the content playing
first, unlike a physical preset-button save.
Sync only ever reads from the speaker; this is the missing write
direction, and lets a reporter try recovering presets without
re-entering all 6 by hand.
Refs #614
The speaker_presets_count check told users a power-cycle "usually
re-syncs" missing presets. #614 shows a power-cycle is itself one of
the two reported triggers for the speaker wiping its own presets, so
that advice was actively harmful for this failure mode.
Also fixes the comment's citation: it claimed this was a known pattern
from discussion #295 and #235, but neither actually discusses preset
loss (#295 is a cloud-hosting question, #235 a closed Spotify
preset-save bug). That reference was wrong from the original commit
(7d46ae2); #614 is the first confirmed instance.
Refs #614
Bumps the go directive to 1.26.6 across the main module and both
standalone example modules (preset-management, navigation-station-demo),
plus the builder image in Dockerfile and the three mock-service images
in docker-compose.ci.yml.
Also refreshes the examples' require github.com/gesellix/bose-soundtouch
pin from the stale v0.118.0 to the current v0.123.0 release tag (the
replace directive means they build against local source regardless,
but the pin should still track reality). go mod tidy run in all three
modules; no other dependency changes.
Answers #591's open question 2: CLI-only users get no update notice
from soundtouch-service's periodic background check. Both binaries
gain a soundtouch-cli/soundtouch-backup update-check command that
does a single, on-demand GitHub Releases check via the existing
pkg/service/updatecheck package. Running the command is itself the
opt-in, so unlike the service there's no config flag or persisted
state.
pkg/service/updatecheck.Checker was already designed decoupled from
handlers.Server/main.go specifically so other binaries could import
it directly; this is that follow-through.
The update-check interval field just got an info-toggle explaining Go
duration syntax; Discovery Interval takes the exact same syntax and
had no such help, which would read as inconsistent on the same
Settings page. Pre-existing gap, unrelated to #591 itself, but small
enough to fix alongside it while the pattern is fresh.
Reuses the existing info-toggle/info-details pattern (already used for
the HTTPS override, TLS extra hosts, and DNS upstream fields) rather
than inventing a new affordance, so users aren't left guessing at Go's
duration syntax when typing a custom interval.
Follow-up to #591: UpdateCheckEnabled/UpdateCheckInterval are now
persisted, live-reloaded Settings fields (mirroring the discovery
enabled/interval pattern), editable from the admin Settings page
without a restart. The env var/CLI flag remains the seed value for a
fresh install with no settings.json yet.
The background goroutine now always runs and polls the live settings
every minute (updateCheckPollTick), instead of being started only if
enabled at process launch, so flipping the toggle takes effect within
a minute rather than requiring a restart.
htm/Preact template literals insert text as a DOM text node rather than
parsing it as HTML, so the × entity was never decoded and showed
up literally in the player UI's announcement banner. The admin UI's
equivalent button is unaffected because it's built as an HTML string
inserted via innerHTML, where the browser does decode entities.
Fixes the player-UI regression noted in #591.
Comparing against JRpersonal/streborn#587 surfaced two gaps: no test
pinned that a newly added source type renders the same element shape
as a known-good default (the firmware rejects the whole account
document if one source entry omits an expected element), and our DLNA
discovery only swept SSDP from the service host, missing servers only
visible from a paired speaker's own LAN segment.
Adds TestSourceXMLShapeConsistencyAcrossTypes in pkg/service/marge,
and has HandleDiscoverLibraryServers merge results from each paired
speaker's own /listMediaServers alongside the existing SSDP sweep,
deduped by UDN, with unreachable speakers skipped silently.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Addresses recommendations 4 and 7 from #515 comment 5231931569: a
green-looking getpdo readback only confirms the sys configuration
writes were accepted, not that they'll survive a reboot (that's what
the envswitch-persisted layer decides). Labels the getpdo line in both
migrateViaTelnet and runTelnetInjection's CLI/log output accordingly,
softens migrateViaTelnet's "succeeded" wording to "accepted", and adds
the same one-line caveat to TELNET-MIGRATION-METHOD.md #2.3 (previously
only in TELNET-COMMAND-REFERENCE.md).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Confirmed on hardware (#471 comments 5231997551, 5232046477): after a
reboot, HTTP :8090 and the diagnostic telnet :17000 shell become ready
at very different times, up to ~92s. Our own enable-ssh retry guidance
tells users to power-cycle and re-run immediately, which can hit the
device mid-boot and surface as a raw connection-refused error. Adds a
troubleshooting entry plus the underlying measurement in
TELNET-COMMAND-REFERENCE.md; no code change.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Community hardware testing (bitranox, JRpersonal) on 2026-08-09 retracted
the earlier "inter-command delay is necessary" theory and established that
envswitch boseurls set commits the whole runtime layer (not just its two
arguments), has no read form, and doesn't ack with "OK". Corrects
TELNET-MIGRATION-METHOD.md and TELNET-COMMAND-REFERENCE.md accordingly,
retracts the stale "confirmed necessary" command-delay claim in
enable_ssh.go/cmd_setup.go, and lowers DefaultTelnetCommandDelay 5s -> 3s
as a smaller hedge now that the delay itself is known not to be the
mechanism.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ErrorsResponse.Error() only returned the <error> element's text body,
dropping the name attribute entirely. Some speaker error responses
have a Message that just restates Value as text (e.g. a bare "1047"
for SOURCE_ALREADY_REMOVED), so callers only ever saw the useless
numeric string. Found while live-debugging a Deezer account
add/remove cycle on real hardware, where the raw XML
(<error value="1047" name="SOURCE_ALREADY_REMOVED">1047</error>)
carried real information only in the name attribute.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Guards against whitespace-only stationID/podcastID/encodedName path
segments and tightens the existing empty-string checks on the search
q/cursor query params. Spotted while reviewing stalkerquatre-oss's
fork diff for TuneIn handling improvements; their s0/Radio fallback
defaults were skipped as unprecedented invented values that would
mask malformed requests instead of erroring.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Follow-up to the 3s default from earlier in #515: the reporter agreed
5s is a better trade-off (issue comment 5230881285) — more headroom
than the original guess, still comfortably under the ~7s gap their
manual A/B test used.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
#515 comment 5230833551: on a genuinely unpaired (factory-reset) device,
margeServerUrl is reportedly never polled at all, so the boseurls
SSH-enable injection has no read cycle to fire on regardless of any
command delay. enable-ssh now checks /info first and, if
margeAccountUUID is empty, pairs the device via the existing
PairAccount helper (HTTP /setMargeAccount, telnet fallback) before
running the injection.
Adds setup.Manager.EnsureMargeAccountPaired plus --no-auto-pair (skip
entirely) and --account (use a specific 7-digit ID instead of a
generated one, e.g. to match one already in the datastore) flags on
enable-ssh. Pairing failure is a warning, not fatal, since the claim
is unconfirmed on this specific hardware and existing working flows
must not regress.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
markdown-link-check has no concept of "warning" vs "error" — it's a
binary alive/dead per link, so a transient 429 from a rate-limiting site
we don't control (recently: blogspot.com, izndgroup.com) fails the whole
CI job exactly like a genuine dead link, with no way to tell them apart
from the job's exit code.
New scripts/check-doc-links.sh wraps the tool per file, parses its
"[✖] <url> → Status: <code>" output, and re-decides pass/fail per link:
404 still fails the build (a real dead link, worth fixing), other 4xx
(429, 401, 403, ...) become a GitHub Actions ::warning:: annotation
instead, and anything else (5xx, timeouts, DNS failures) still fails the
build same as before. De-dupes markdown-link-check's own doubled -v
output. Written for bash 3.2 (macOS's default /bin/bash) so it's testable
locally, not just on the ubuntu-latest runner.
Existing retry config in .github/markdown-link-check.json (retryOn429,
3 retries, 30s backoff) is untouched; this only changes what happens once
retries are exhausted.
Verified locally: 6 synthetic scenarios (404/429/500/mixed/clean/
duplicate-line) via a stubbed markdown-link-check, plus a real run
against the docs tree with the actual tool.
Per #515 comment 5228449448: on a real Lifestyle console, the same 6
commands (5 sys configuration/envswitch + reboot) sent back-to-back left
sshd down after reboot, but succeeded sent one at a time with ~7s gaps —
same commands, same order, same device, minutes apart. Sending fast may
not let the device fully process one command before the next arrives.
Adds --command-delay (setup.DefaultTelnetCommandDelay, 3s), threaded
through EnableSSHViaTelnetFullConfig/runTelnetInjection (pause after each
of the 5 commands) and runEnableSSHInjection (one more pause before the
reboot). 0 restores the old back-to-back behavior. The reporter didn't
try to find the true minimum, just confirmed ~7s works and speculated
"a second or two may well be enough" — 3s is a middle ground, tunable via
the flag if a specific device needs more.
Also prints an approximate total for the injection phase up front (6
steps x delay, ~18s at the default) so the command doesn't look hung —
separate from the existing --wait message for sshd coming up after
reboot, which can take much longer.
Refs #515
setup ssh-check's failure message claimed "those commands were removed"
on FW 27.x and jumped straight to the USB-stick fallback — flatly
contradicted by setup enable-ssh, which exists specifically to bootstrap
SSH over telnet via the port-17000 envswitch trick (#471), and by
TELNET-COMMAND-REFERENCE.md's own notes that the injection is
field-confirmed on several FW 27.x models. Success is model/build-
dependent, not universally impossible — some devices (ST Portable, some
CineMate 520 units) need --full-config instead of the default injection.
Reordered the message to point at `setup enable-ssh` (with the
--full-config caveat) first, USB stick as the fallback if that doesn't
work on a given device — this was the exact point where a user hitting a
closed port 22 would previously be told to go find a USB stick without
ever learning the telnet route exists.
Refs #598
On Bose Lifestyle/CineMate consoles, the SoundTouch module is one input
among several, and #160 already established the input can't be switched
from the SoundTouch side. This adds a troubleshooting section covering:
- source="LOCAL" in /now_playing with LOCAL absent from /sources means
the console is on a different input, not that the content is invalid.
- POST /key POWER is not a harmless "stop playback" on these devices like
it is on a plain speaker — it puts the console into standby, and on
waking it returns to the console's OWN input, not back to SoundTouch.
A test loop that uses POWER between trials silently drops off
SoundTouch after the first trial, so every later station reports
INVALID_SOURCE regardless of whether it would actually play fine.
Also adds a cross-reference caveat to the `key power` row in
TELNET-COMMAND-REFERENCE.md, whose existing "no observable effect on FW
27.x" note is speaker-only phrasing that doesn't hold for these consoles.
Refs #597
Follow-up to #591, prompted by the update-check notice showing a raw
https:// URL as plain text instead of a clickable link. Made it general
rather than a one-off fix, since future announcements may also want to
link to docs.
Added Announcement.LinkText/LinkURL (+ LinkURLFunc, the dynamic
counterpart, for the update-check entry's per-release URL) alongside the
existing Message/MessageFunc pair. Both frontends render it as a real
<a> element now: the admin UI (innerHTML) escapes Message/LinkText/LinkURL
via the existing escapeHtml() before composing the markup — previously
Message went into innerHTML unescaped, which this incidentally hardens;
the player (Preact/htm) templates an actual <a> rather than interpolating
a string, since Preact escapes string children by default and a raw
<a href=...> string would otherwise render as literal text, not a link.
Rephrased the #419 admin-gate announcement to use the new field too (was
a plain "See issue #419 for details." text mention).
Bug found while wiring this up: UpdateCheckState never persisted the
release URL, only the version — so after a restart, the announcement
would show a correct message but a broken/empty link until the next live
check completed (which can be up to a full interval away, since a fresh
check is skipped when the persisted last-check is still recent). Fixed by
adding UpdateCheckState.LastReleaseURL and threading it through
Checker.persist/NewChecker's seeding path, with a test
(TestNewChecker_SeedsFromPersistedState) that would have caught it.
Also fixed two gocritic rangeValCopy findings in
handlers_announcements.go (switched to index-based iteration) surfaced by
the Announcement struct growing with the new fields.
Refs #591
Addresses the Semgrep finding on PR #599
(go.lang.security.audit.crypto.math_random.math-random-used) on the
update-check jitter delay, and applies the same treatment to the #419
activity-log filename suffix, which has the same non-security shape but
predates this PR's diff so it wasn't flagged.
Neither value is ever compared, kept secret, or otherwise security-
sensitive (a sleep duration and a filename-uniqueness suffix), so
crypto/rand would only add error-handling overhead for no real benefit.
Suppressed with the same // nosemgrep: <rule-id> pattern already used in
the mock-amazon/mock-spotify/mock-tunein servers, mirroring the existing
//nolint:gosec on the same lines.
Refs #591
Fifth and final piece of #591's initial implementation. Extends
/api/setup/version with update_available/latest_version/
latest_release_url (nil-safe via Server.UpdateCheckResult, defaults to
Available: false when the check was never enabled). Response switched from
map[string]string to map[string]interface{} to carry the new bool field;
updated the one existing test that decoded into the old stricter type.
Documents UPDATE_CHECK_ENABLED/UPDATE_CHECK_INTERVAL in the Configuration
Options reference table, explicit that this is the only network call
AfterTouch makes beyond speaker/provider traffic when enabled, and that it
defaults off.
This closes out the initial #591 implementation per the design doc
(_/i591/design-update-check.md): UpdateCheckState persistence, the
updatecheck.Checker package, background goroutine wiring with jitter/
backoff, reusing #419's Announcements mechanism instead of a second notice
UI, and this version-info exposure. `make check` passes end to end
(including the Docker HTTP integration suite).
Refs #591
Fourth piece of #591 — the "minimal and future-proof at once" move from
the design doc: no new notice UI, just one new entry in the #419
announcements list, which is already rendered in both the admin UI and the
player and already has per-ID dismissal.
Added Announcement.MessageFunc/DismissKeyFunc (nil = use the static
Message/ID, as before, so the existing #419 entry is unaffected) since
this entry's text names a specific version and its dismissal must be
per-version — dismissing the notice for v1.2.0 must not suppress a later
notice for v1.3.0. HandleListAnnouncements/HandleDismissAnnouncement now
compute the effective key through Announcement.dismissKey(s) rather than
reading the static ID field directly.
Refs #591
Third piece of #591. --update-check-enabled/--update-check-interval
(UPDATE_CHECK_ENABLED/UPDATE_CHECK_INTERVAL), default off/24h, following
the same local main.go flag pattern as discovery-enabled — not pkg/config,
which soundtouch-service doesn't import at all (correction to the issue's
proposed location, see the design doc).
Background goroutine modeled on startDeviceDiscovery: startup jitter
(0-5min), skips the immediate check if the persisted last-check is still
fresh, backs off retries to no sooner than 1h after a failure, logs once
per newly-detected version. The decision logic (shouldCheckImmediately,
shouldSkipDueToBackoff, logUpdateIfNewlyAvailable) is split into pure,
directly-testable functions rather than living inline in the goroutine.
Server gets a SetUpdateChecker/UpdateCheckResult pair (nil-safe) so the
next two pieces (announcement, /api/setup/version) can read the current
state without importing updatecheck's construction details.
Manually verified against a running instance: enabled via flags, no panic,
service stays responsive (jitter means the actual first check can take up
to 5 minutes to fire, so this only confirms the wiring, not a live
GitHub response — that's covered by the previous commit's httptest-backed
unit tests).
Refs #591
Second piece of #591. Standalone package (pkg/service/updatecheck):
GitHub releases API client, golang.org/x/mod/semver comparison (promoted
from indirect to direct dependency), persisted state via datastore's
UpdateCheckState. Dev/(devel)/dirty current versions skip the comparison
entirely rather than guessing; prereleases are excluded even though
GitHub's /releases/latest endpoint shouldn't return one anyway (defensive).
Deliberately decoupled from handlers.Server/main.go: repo and current
version are constructor arguments, not hardcoded, so a future CLI-side
check could reuse this as a plain import rather than a rewrite (open
question 2 in the design doc).
Not wired into the service yet — nothing calls NewChecker/CheckNow outside
tests.
Refs #591
First piece of #591 (opt-in periodic update check). A small persisted
state (last_checked_at, last_seen_version) under update-check.json,
mirroring Settings' Get/Save shape — separate from Settings itself since
this is runtime state, not operator-editable config.
Not wired to anything yet.
Refs #591
Prompted by not being able to tell where a locally-run instance's data dir
actually was without inspecting the running process (ps/lsof). Adds
data_dir to /api/setup/version's response, resolved to an absolute path so
it's unambiguous regardless of whether --data-dir/DATA_DIR was relative or
left at the default.
Shown as a read-only line at the top of the Settings tab, not the always-
visible footer — the footer is prime real estate seen on every tab/every
page load, and this is a rarely-needed piece of diagnostic info that
belongs alongside the rest of System Settings instead.
Also filled in a test-helper gap: the pkg/service/handlers package's
internal test router (main_test.go) never registered /setup/version at
all, unlike the real production router — added it so the new test (and any
future one exercising this endpoint) can actually run.
Unrelated to #419, but found while verifying that work against a running
instance.
The backend (field, validation, guard rail, live-reload middleware) has
worked since the first #419 commit, and the announcement banner correctly
told people "you can opt in now in Settings" — but there was never
actually a control in Settings to do that with. Caught by manual testing
against a running instance: the banner rendered fine, proving chunks 1-6
worked, but Settings had nothing to act on.
Adds a select (mirroring default_landing's pattern) with the tri-state
choices spelled out in plain language, wired into updateSettings()/
fetchSettings() alongside the other fields.
Verified end-to-end against a running instance: setting it to "enabled"
(with non-default credentials) persists, and immediately gates /admin
(401 without credentials, 200 with) without a restart.
Refs #419
The design's three-area target model (chooser/app/admin) and the backend
(HandleListAnnouncements' target=app filtering) already supported this, but
nothing in soundtouch-player's frontend called it — chunk 5 only wired the
admin UI. New Announcements Preact component (static/js/components/), mounted
in App() above the main content so it's visible across every page, styled
with the app's existing CSS variables (dark-mode-aware, unlike the admin
UI's hardcoded inline colors). Currently renders nothing, since no
announcement in the list targets "app" yet (only the admin-gate notice,
targeting "admin") — this is just closing the parity gap so a future
app-targeted announcement has somewhere to show up.
Verified end-to-end against a running instance: the component is served
under /app/static/js/components/, app.js references it, and
/api/announcements?target=app responds correctly (empty today).
Refs #419
Seventh and final piece of #419's initial rollout. Adds addActivityLog to
buildDiagnosticArchive, walking stats/activity/ and bundling every event
file verbatim (same idea as the per-device XML bundling, mirroring
addSettingsJSON's placement). Without this, the privacy guarantee discussed
during design ("local-only, but included in an explicit diagnostic export")
would have been aspirational rather than true — caught before documenting
it as fact.
Documents the activity log in DIAGNOSTIC-EXPORT.md, anchored to the
existing "all data stays on your network" language in
SOUNDTOUCH-SERVICE-ANNOUNCEMENT.md.
This closes out the initial #419 implementation: AdminAreaAuth setting +
guard rail, BasicAuthAdmin gate, activity log + dismissal cache,
announcements + dismiss endpoint, admin UI banner, health check nudge, and
now diagnostic-export coverage + docs. Still opt-in only (AdminAreaAuth
defaults to unset) — flipping the default is a separate, later change per
the design doc's rollout plan.
Refs #419