Skip to content

feat: play your network music library on the speaker, plus four carried-over fixes - #587

Merged
JRpersonal merged 6 commits into
mainfrom
fix/telnet-order-and-multiroom-stray-code
Aug 10, 2026
Merged

feat: play your network music library on the speaker, plus four carried-over fixes#587
JRpersonal merged 6 commits into
mainfrom
fix/telnet-order-and-multiroom-stray-code

Conversation

@JRpersonal

@JRpersonal JRpersonal commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Four fixes, all verified against live hardware or by test.

1. SSH unlock on a stock speaker survives the reboot

buildBootstrapEnableSSHCommands issued envswitch before sys configuration. envswitch commits the runtime layer as it stands when it runs, so the sys configuration write that followed was discarded at the next boot.

  • The fallback we believed we had for chassis we cannot measure ourselves was never actually there.
  • bmxRegistryUrl and statsServerUrl have no envswitch form at all, so sys configuration is their only route and it never persisted.

Measured by @bitranox on a SoundTouch 20 (spotty, FW 27.0.6) in gesellix/Bose-SoundTouch#471. The method is why it is trusted: an already-migrated box was poisoned first, all four URLs pointed at an unreachable address and confirmed to survive a reboot of their own, so a pass could not be inherited from existing config.

Confirmed live on a stock rhino ST10, no stick, SSH closed, which is the only state that runs this path: SSH opened 72 s after the reboot. The order is now pinned by the test.

2. Undoing a stereo pair reaches both speakers

Undo went only to the speaker recorded as pair master, on the understanding that the master is the half that knows about the pair. Measured 2026-08-10 on two SoundTouch 10s, that stops being true once one half lets go: the master answered /getGroup with <group /> while the other speaker still held the whole document naming the master as LEFT, status GROUP_ERROR.

.48 (recorded master) /getGroup -> <group />
.49                   /getGroup -> <group id="str-grp-..."> ... <status>GROUP_ERROR</status>

So every undo was told "nothing to undo" while the panel simultaneously named the pair, and the leftover could not be cleared from the app at all. Sending DELETE /api/box/zone?stereo=1 to .49 cleared both speakers in one call, so the agent-side teardown was already correct; only the addressing was wrong.

Both halves now get the undo, master first. A speaker that is not in a pair answers "nothing to undo" and is left alone, so the only behaviour that changes is the stuck case. Target selection moved into groups.js as stereoUndoTargets and is covered by tests.

3. The diagnostic report records which inputs and services a speaker has

A CineMate 130 owner reported that the "offer every input" feature still showed him Bluetooth alone (discussion #577), and his report could not say why: nothing in a bundle described his inputs.

/sources is now captured per box. Linked streaming accounts are hashed like the other personal data (ACCT#...), while socket labels (AUX, TV, CBL-Sat) and the firmware's ...UserName placeholder slots stay readable, since those are the part that has to be readable for the field to be worth anything.

4. Stray code in the Multi-Room panel

The fillPairBalance call sat inside the innerHTML template literal instead of after it, so the page printed the line verbatim and the balance never loaded.

Verification

  • go build ./... clean, go vet clean, gofmt clean
  • go test ./... in desktop-app passes
  • npm test in desktop-app/frontend: 102 tests pass
  • Live: telnet path on a stock ST10; stereo dissolve on .48/.49, both now report {"members":[]}

JRpersonal and others added 2 commits August 10, 2026 00:23
The two config writes ran in the wrong order. envswitch commits the
runtime layer as it stands when it runs, so the sys configuration write
that followed it was discarded at the next boot. The fallback path we
believed existed for chassis we cannot measure was never actually there,
and bmxRegistryUrl and statsServerUrl, which have no envswitch form at
all, could not be set by any route.

Measured on a SoundTouch 20 (spotty, FW 27.0.6) with the box poisoned
first so a pass could not be inherited from existing config:
gesellix/Bose-SoundTouch#471. Confirmed live on a stock ST10 with no
stick and SSH closed, which is the only state that runs this path: SSH
opened 72 s after the reboot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4ZJXsnpmJuazCKF6ijdcZ
…the panel

The call that fills in the stereo balance had been placed inside the
markup string instead of after it, so the Multi-Room page printed it
verbatim and the balance never loaded. It now runs once the markup
exists, and only when a pair is actually present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4ZJXsnpmJuazCKF6ijdcZ
@JRpersonal JRpersonal added bug Something isn't working go Go-related changes frontend Desktop app frontend (Wails Vite/JS/CSS) changes 🔴 priority: high High priority: user-facing blocker or core regression labels Aug 9, 2026
JRpersonal and others added 2 commits August 10, 2026 00:29
…speaker has

A diagnostic report described everything about a speaker except what it can
play from. A CineMate 130 owner reported that the new "offer every input"
feature still showed him Bluetooth alone (discussion #577), and his report
could not say why, because nothing in it listed his inputs.

The speaker's own source list is now part of the report: every input and
service slot with its name, its status and whether it is a socket. Linked
streaming accounts are replaced by a hash like the other personal data, while
socket names such as AUX, TV or CBL-Sat stay readable, since those are the
part that has to be readable for the report to be worth anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4ZJXsnpmJuazCKF6ijdcZ
…just one

Undo was only ever sent to the speaker recorded as the pair master, on the
understanding that the master is the one that knows about the pair. That holds
while a pair is healthy and stops holding the moment one half lets go.

Measured on two SoundTouch 10s: the master reported no pair at all while the
other speaker still held the entire pair, naming the master as the left
channel. Every undo went to the master, was told there was nothing to undo,
and the panel then said "current stereo pair" and "there is no stereo pair to
undo" at the same time, with no way to clear it from the app. Sending it to
the other speaker cleared both at once.

Both speakers now get it, the master first. A speaker that is not in a pair
answers that there is nothing to undo and is left alone, so the only case this
changes is the one that was stuck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4ZJXsnpmJuazCKF6ijdcZ
@JRpersonal JRpersonal changed the title fix: SSH unlock survives the reboot, and stray code leaves the Multi-Room panel fix: four carried-over fixes (SSH unlock, stereo undo, diagnostics, stray code) Aug 9, 2026
Speakers can see the media servers on your network, a FRITZ!Box, a NAS, a
Plex box, but they will not play from one until that server is added as a
music source. STR can now add it, in the app and in the phone remote.

Once added, the speaker browses and plays the library on its own, and the
library also appears in the original Bose app.

The speaker forgets the setting on every restart, roughly a minute into the
boot, so STR remembers which servers you enabled and puts them back. That
restore reads before it writes: nothing is written when nothing is missing,
so it cannot keep a speaker out of deep standby.

Adding is not instant. The speaker accepts it at once and then confirms the
account before the library can be used, so both interfaces say it is on its
way instead of reporting a source that is not there yet.

Verified end to end on a Portable against a FRITZ!Box 6690: added, browsed
down to a track, played it natively with no help from STR, and confirmed it
comes back by itself after a reboot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4ZJXsnpmJuazCKF6ijdcZ
@JRpersonal JRpersonal changed the title fix: four carried-over fixes (SSH unlock, stereo undo, diagnostics, stray code) feat: play your network music library on the speaker, plus four carried-over fixes Aug 10, 2026
@JRpersonal JRpersonal added the enhancement New feature or request label Aug 10, 2026
@JRpersonal

Copy link
Copy Markdown
Owner Author

Added: network music library as a native speaker source

A speaker can see the DLNA/UPnP media servers on the LAN by itself (GET :8090/listMediaServers) but will not play from one until that server is registered as a STORED_MUSIC account. STR now does that registration, from the desktop app (Speaker settings) and from the phone remote (Speakers tab).

Once registered the speaker browses and plays the library natively: no stream proxy, no UPnP push from STR, and the library also appears in the original Bose app.

The wire format, since none of it is in Bose'"'"'s public API doc

POST :8090/setMusicServiceAccount          (POST, not PUT: PUT answers 405)
<credentials source="STORED_MUSIC" displayName="AVM FRITZ!Mediaserver">
  <user>fa095ecc-...-3C37129F8346/0</user><pass></pass>
</credentials>

The account is the server id from /listMediaServers (the UDN without the uuid: prefix) with /0 appended. The box answers 200, then calls back to STR'"'"'s marge with addSource, which respondAddSource already handled, and only then does the source turn READY.

Browsing (POST :8090/navigate) descends only through an <item> carrying <name> and <type> before its <ContentItem>. A bare <ContentItem> or a <mediaItemContainer> as a direct child is rejected with field not found on '"'"'navigate'"'"'.

The part that needed STR

The registration does not survive a reboot: measured, the source is present for the first ~70 s of the boot and then disappears, because the box re-checks the account against marge and STR'"'"'s record of it lived only in memory. So the user'"'"'s choice is persisted (internal/mediaservers) and reapplied once at agent start. That path reads before it writes and writes nothing when nothing is missing, so it cannot hold a speaker out of deep standby.

Verified on hardware (Portable, FRITZ!Box 6690)

  • registered, source went status="READY", agent logged addSource callback answered ... askedProvider=7
  • browsed root (Musik, Bilder, Filme, Internetradio, Podcasts, Datei-Index) and down to a track
  • played it natively: source=STORED_MUSIC track="Fly FRITZ! Fly" artist="AVM GmbH" PLAY_STATE
  • rebooted, and STR put it back on its own: media server: music source restored after the restart, still READY a minute later
  • canaries AUX and LOCAL_INTERNET_RADIO stayed READY throughout

New UI strings are in all 12 locales in both interfaces. wails generate module was run and the bindings are committed.

Comment thread internal/webui/mediaservers.go Fixed
Comment thread internal/webui/mediaservers.go Fixed
Comment thread internal/webui/mediaservers.go Fixed
Comment thread internal/webui/mediaservers.go Fixed
Comment thread internal/webui/mediaservers.go Fixed
}
}
if err := c.UnregisterMediaServer(ctx, boxapi.MediaServer{ID: id, FriendlyName: name}); err != nil {
s.logger.Warn("media server: the speaker refused the removal", "err", err, "id", id)
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
s.logger.Info("media server: removed as a music source", "id", id)
… of being told

The library was kept alive by writing to the speaker after every restart. It
is now simply part of the account the speaker reads at boot, the same way its
radio stations arrive, so it is there again without anybody sending it
anything.

That is how the original setup worked, and it is better for the speaker: the
old way meant a write on every start, and writes are what reset the countdown
into deep standby. There is now no write at all on that path.

The speaker is still told directly at the moment somebody adds a library, and
only then, so it can be used straight away rather than after the next restart.
Adding one it already has is now recognised and skipped, instead of being
reported as a failure.

Measured on a Portable: the library was removed from the speaker completely,
the agent restarted, and the speaker fetched it back on its own account read
with no registration call anywhere in the log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4ZJXsnpmJuazCKF6ijdcZ
@JRpersonal

Copy link
Copy Markdown
Owner Author

Correction: the registration is a PULL, not a push

Jens pointed out that the original design registered a media server per Bose account and that the boxes polled for it, the way radio arrives, rather than the cloud pushing to them. That is right, and the marge request log proves it. At boot the box does:

GET /streaming/account/stick@local/full        <- its own poll
(no /sources read, and no POST .../source at all)

The box reads the account document and keeps whatever sources it advertises. So putting a media server in that document IS the persistence mechanism. storedMusicSourcesXML now renders the enabled servers into /full, /account and the account sources list, seeded from the NAND store at agent start.

Verified by removing the source from the speaker entirely (/removeMusicServiceAccount), restarting the agent, and watching the box bring it back on its own: READY, browsing still working, and zero media server lines in the agent log, because nothing was pushed.

This deletes the push-based ReapplyMediaServers and with it every write on the persistence path, so that path can no longer touch the standby countdown at all.

The push to /setMusicServiceAccount is kept for one thing only: making a server usable at the moment somebody enables it, instead of at the next boot. Two things had to change there:

  • pushing a server the box already has answers 500 / 1024, so /sources is read first and the push skipped. The previous code reported a perfectly healthy source as a failure.
  • the store and the account publish happen BEFORE the push, since that half cannot fail on the box. A refused push now leaves a setting that still works after the next restart.

The source shape is pinned by a test against staticRadioSourceXML: same elements in the same order, differing only in the provider id (7), the sourcename (STORED_MUSIC) and the username. A source rendered into /full that omits an element is the documented way to break the whole account document rather than just that entry.

One cosmetic difference: a pulled source shows in /sources as STORED_MUSIC, a pushed one as "AVM FRITZ!Mediaserver". The firmware takes that text from sourcename and not from <name>, exactly as it does for radio (<name> is "Local Internet Radio", the sourceItem says LOCAL_INTERNET_RADIO). Whether the Bose app shows the friendlier name is unconfirmed.

// that works, just not until the speaker next reads its account.
if s.mediaServers != nil {
if err := s.mediaServers.Add(mediaservers.Server{ID: srv.ID, Name: srv.FriendlyName}); err != nil {
s.logger.Warn("media server: could not remember the server", "err", err, "id", srv.ID)
// perfectly healthy source as a failure.
pending := false
if have, herr := c.RegisteredMediaServerAccounts(ctx); herr == nil && have[srv.SourceAccount()] {
s.logger.Info("media server: already known to the speaker, nothing to push", "id", srv.ID)
// Not an error the user needs to see as failure: the setting is
// stored, so the library turns up after the speaker's next restart.
s.logger.Warn("media server: the speaker refused the immediate registration, it will appear after a restart",
"err", err, "id", srv.ID)
// measured. Never report this as ready.
pending = true
}
s.logger.Info("media server: enabled as a native music source", "id", srv.ID, "name", srv.FriendlyName)
// measured. Never report this as ready.
pending = true
}
s.logger.Info("media server: enabled as a native music source", "id", srv.ID, "name", srv.FriendlyName)
// stored intent that puts the source back at the next boot.
if s.mediaServers != nil {
if err := s.mediaServers.Remove(id); err != nil {
s.logger.Warn("media server: could not forget the server", "err", err, "id", id)
@JRpersonal
JRpersonal merged commit 79b810d into main Aug 10, 2026
12 checks passed
@JRpersonal
JRpersonal deleted the fix/telnet-order-and-multiroom-stray-code branch August 10, 2026 05:07
gesellix added a commit to gesellix/Bose-SoundTouch that referenced this pull request Aug 10, 2026
…e media server discovery

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working enhancement New feature or request frontend Desktop app frontend (Wails Vite/JS/CSS) changes go Go-related changes 🔴 priority: high High priority: user-facing blocker or core regression

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants