Skip to content

fix for #48 as well#52

Closed
holli73 wants to merge 125 commits into
ulilicht:mainfrom
holli73:main
Closed

fix for #48 as well#52
holli73 wants to merge 125 commits into
ulilicht:mainfrom
holli73:main

Conversation

@holli73
Copy link
Copy Markdown

@holli73 holli73 commented May 1, 2026

No description provided.

holli73 and others added 10 commits May 1, 2026 21:47
Adds a brand/ directory to both the custom_components integration and
the bundled addon copy, providing icon.png, logo.png and their dark-mode
variants. The icon uses the official Raumfeld bars mark + wordmark; the
logo banner combines the Teufel script wordmark with the Raumfeld logo,
separated by a red accent bar. Fixes the missing-icon issue during
integration setup in Home Assistant 2026.3+.

Co-authored-by: Cursor <cursoragent@cursor.com>
Raumfeld devices have a quirk: when play() is called after PAUSED_PLAYBACK,
the device restarts from the last seek anchor (the position set by the most
recent SetAVTransportURI or Seek) rather than the actual paused position.
This is most visible with Music Assistant playback where a track is loaded
via URI and then seeked into -- pausing and resuming would jump back to the
seek target instead of where playback was paused.

Fix: in RaumkernelHelper.play(), if the renderer is in PAUSED_PLAYBACK
state, issue a Seek to the current RelativeTimePosition before calling
play(). This refreshes the anchor to the actual paused position so the
device resumes from the correct spot.

Bumps version to 1.2.14.

Co-authored-by: Cursor <cursoragent@cursor.com>
to test cursors changes
The v1.2.14 fix (seek while paused to refresh the anchor) had two weak
points that could silently prevent it from working:

1. It read RelativeTimePosition from the cached rendererState, which may
   not have been updated by a subscription event since the pause.
   Changed to call renderer.getPositionInfo() for a live query; falls
   back to rendererState.RelativeTimePosition on error.

2. It called renderer.seek() immediately followed by renderer.play()
   with no delay.  The Raumfeld device appears to need a small window
   after processing the Seek before the anchor is committed; adding a
   300 ms pause (matching the delay already used after seeks in
   RaumkernelHelper.seek()) ensures the device has updated its anchor
   before Play is issued.

Also adds an explicit guard for 'NOT_IMPLEMENTED', which some UPnP
devices return for RelTime when position tracking is unsupported, to
avoid sending an invalid Seek target.

Bumps version to 1.2.15.

Co-authored-by: Cursor <cursoragent@cursor.com>
…h (v1.2.16)

v1.2.15 introduced a regression: getPositionInfo().RelTime turns out to
be the *seek anchor* on the Raumfeld zone renderer (it freezes at the
target of the most recent Seek and does not advance during playback).
After the first corrective seek the anchor became the first-pause
position; every subsequent resume then seeked back to that same spot.

Root cause: the device exposes no reliable way to read the advancing
playback position — GetPositionInfo.RelTime and rendererState
RelativeTimePosition both reflect the last explicit seek target, not
the live streaming clock.

Fix: use wall-clock elapsed time as the position source for all resumes
after the first one.

  • First resume (anchor ≠ device RelTime, so getPositionInfo() still
    returns the actual position): unchanged — query getPositionInfo().
  • After the corrective seek succeeds: record the anchor in seconds
    (_resumeAnchorSeconds) and the current wall-clock (_resumeAnchorTime)
    on the room object.
  • Every subsequent resume from PAUSED_PLAYBACK: compute
      estimatedPos = _resumeAnchorSeconds + (now − _resumeAnchorTime)
    This is the total time the track has been playing since the last
    anchor update, giving a position independent of the device's broken
    RelTime reporting.
  • Reset the tracker on: user-initiated seek (new anchor from MA/user),
    stop(), and any play() that is NOT a resume from pause.

Bumps version to 1.2.16.

Co-authored-by: Cursor <cursoragent@cursor.com>
The Raumfeld zone renderer's RelativeTimePosition is frozen at the last
seek anchor and never advances during playback.  This caused the HA
position display to reset to 0 whenever the device sent a PAUSE event
(because the event carried RelativeTimePosition = anchor = 0).

Fix: maintain a wall-clock elapsed-time tracker on each room object that
is independent of the device's own position reporting.

Tracker lifecycle:
  • loadUri / loadContainer / loadSingle: reset to {0, now} when a new
    track starts so position advances correctly from the very first pause.
  • seek(): advance to {seekPos, now} instead of clearing, so the tracker
    stays valid across user/MA-initiated seeks.
  • play() resume-from-pause: after the corrective Seek, update the
    tracker to {resumePos, now} (already done in v1.2.16).
  • play() resume-from-pause: now uses the tracker directly (frozen value
    = correct paused position) instead of requiring getPositionInfo().
  • stop(): still clears the tracker (no meaningful position after stop).

Freeze / thaw (inside _extractNowPlaying, called on every state change):
  • PAUSED/STOPPED: freeze — compute elapsed, add to _resumeAnchorSeconds,
    set _resumeAnchorTime = null.  This gives the exact paused position
    without querying the device, and prevents drift during long pauses.
  • PLAYING (timer frozen): thaw — restart the clock, so external play
    commands (not through our play() path) are also handled.

_getPositionForRoom updated to return:
  anchor + elapsed  (timer running, device is playing)
  anchor            (timer frozen, device is paused)
  defaultPosition   (no tracking yet)

Bumps version to 1.2.17.

Co-authored-by: Cursor <cursoragent@cursor.com>
findRoom() was returning plain objects from _state.availableRooms, which
are rebuilt from scratch on every _broadcastRoomStates() call.  Any
tracker state written to those plain objects (room._resumeAnchorSeconds,
room._resumeAnchorTime, room._lastSeekPosition, room._lastSeekTime) was
silently discarded on the next broadcast, so the position fix in v1.2.17
had no lasting effect and the display always fell back to 0.

Fix: rewrite findRoom() to search this._rooms directly (the persistent
rich room objects that carry all mutable per-room state) using the same
UDN / name matching logic as before.  Command handlers (play, pause,
loadUri, seek, etc.) already call findRoom() so they now automatically
get the object whose fields survive across broadcasts.

Bumps version to 1.2.18.

Co-authored-by: Cursor <cursoragent@cursor.com>
Move the elapsed-time tracker freeze from the transport-state change
handler (_extractNowPlaying) into the pause() command handler. This
avoids a race condition where a late subscription event from a
corrective Seek (carrying TransportState=PAUSED_PLAYBACK) arrives
after _resumeAnchorTime was set, which would trigger a spurious
freeze and reset the position back to the first-pause value.
Co-authored-by: Cursor <cursoragent@cursor.com>
@ulilicht
Copy link
Copy Markdown
Owner

ulilicht commented May 4, 2026

Hello Holli, thanks for your contribution!

It seems the two PRs got mixed up. This PR seems to contain the other PR as well which you opened. Please see my comment there regarding the trademark situation.

In addition, I saw you are changing the repo owner to yourself and pointing the installer to your repo in the repository.yaml file. I assume this is not intended :)

When you are done with your changes, please add a description to the PR and review the AI generated code for any larger issues. For now, I will mark your PR as not ready to merge so that I can keep track of what needs attention.

Just comment again when you want me to have a look.

Best, Uli

@ulilicht ulilicht marked this pull request as draft May 4, 2026 20:20
holli73 and others added 16 commits May 4, 2026 22:38
…2.20)

Raumfeld devices don't support seeking on live streams (TuneIn, internet
radio). Attempting a seek while paused on a live source causes the device
to disconnect from the stream; it plays from whatever is buffered and then
stops. Also skip the seek when the track URI changed externally (e.g. Music
Assistant loaded a new track that bypassed our loadUri), because the
elapsed-time tracker would be stale from the previous track.

Guard logic added to play():
- Check CurrentTrackDuration: "0:00:00" or NOT_IMPLEMENTED = live stream, skip seek
- Check CurrentTransportActions: Seek must be listed, otherwise skip
- Check AVTransportURI vs stored _resumeAnchorUri: if changed externally, skip seek
  and invalidate the stale tracker

Also store _resumeAnchorUri in loadUri() so external URI changes can be detected.

Co-authored-by: Cursor <cursoragent@cursor.com>
Implements UPnP SetPlayMode for shuffle and repeat across all layers:

RaumkernelHelper.js:
- setShuffle(room, bool): maps to SHUFFLE/RANDOM/NORMAL/REPEAT_ALL preserving
  the current repeat state
- setRepeat(room, 'off'|'one'|'all'): maps to NORMAL/REPEAT_ONE/REPEAT_ALL/RANDOM
  preserving the current shuffle state
- _extractNowPlaying now includes shuffle (bool) and repeat ('off'|'one'|'all')
  derived from renderer's CurrentPlayMode

index.js: setShuffle and setRepeat WebSocket command handlers

api.py: set_shuffle() and set_repeat() client methods

media_player.py:
- Imports RepeatMode
- Sets _attr_shuffle and _attr_repeat from state
- Adds SHUFFLE_SET and REPEAT_SET features when multi-track content is available
- Implements async_set_shuffle() and async_set_repeat()

Co-authored-by: Cursor <cursoragent@cursor.com>
SHUFFLE_SET and REPEAT_SET were gated on canPlayNext, so they were
invisible when playing a single track or any content where the device
doesn't report next-track capability. SetPlayMode is always available
via UPnP so the features are now added unconditionally.

Also initialise _attr_shuffle and _attr_repeat in __init__ so HA has
safe defaults before the first real state update arrives.

Co-authored-by: Cursor <cursoragent@cursor.com>
When the device auto-advances to the next track (playlist, Music Assistant
queue) the AVTransportURI changes but our elapsed-time position tracker
kept accumulating from the previous track, showing wrong times.

Fix: detect URI changes in _extractNowPlaying. When AVTransportURI differs
from the last recorded value the tracker is reset to 0 (with the clock
started if the device is already PLAYING, frozen otherwise).

Also re-adds the thaw logic (start the tracker clock when device
transitions to PLAYING) that was removed in v1.2.19. This is now safe
because only the thaw lives in _extractNowPlaying; the freeze remains
exclusively in pause(). Our corrective Seek does not change the URI
and does not change TransportState to PLAYING, so neither the URI check
nor the thaw can be triggered spuriously by late subscription events.

Co-authored-by: Cursor <cursoragent@cursor.com>
media_player.py:
- Fix critical bug in _handle_event: was looking for payload.availableZones
  (always empty) instead of payload.availableRooms on fullStateUpdate events.
  This meant all renderer state changes (play mode, transport state, volume
  changes from fullStateUpdate) were silently ignored. shuffle/repeat values
  from the device were never received by HA entities. Play/pause appeared to
  work only because HA does optimistic UI updates on button press.

RaumkernelHelper.js:
- Replace URI-only track-change detection with a URI::CurrentTrack fingerprint.
  For container/playlist playback the AVTransportURI is a constant
  dlna-playcontainer:// reference; only CurrentTrack increments when the next
  track plays. The URI-only check never fired, so the position tracker kept
  accumulating from the previous track instead of resetting to 0.
- loadUri/loadContainer/loadSingle: also reset _resumeAnchorTrack so the
  fingerprint is re-initialised cleanly on the next state event.
- play(): update the "track changed externally" check to use the same
  URI::CurrentTrack fingerprint.

Co-authored-by: Cursor <cursoragent@cursor.com>
ha-raumkernel-addon/teufel_raumfeld_raumkernel/{media_player.py,api.py}
were missing all changes from v1.2.21 through v1.2.24:
- shuffle/repeat support (SHUFFLE_SET, REPEAT_SET features, async_set_shuffle,
  async_set_repeat, _attr_shuffle/_attr_repeat initialisation)
- fullStateUpdate handler bug fix (availableZones → availableRooms)
- set_shuffle / set_repeat API client methods

The addon auto-installs from ha-raumkernel-addon/teufel_raumfeld_raumkernel/
(mounted as /integration in Docker), not from custom_components/. All
edits to custom_components/ must be mirrored here; now in sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
The previous sync commit kept version 1.2.24, so IntegrationManager would
see installed==bundled and skip the auto-install, leaving the old files
(without shuffle/repeat support) in HA. Bumping to 1.2.25 forces the
auto-install to run and copy the corrected media_player.py and api.py.

Also ran sync-integration.sh to bring the bundled copy fully up to date
with custom_components/ (manifest.json version bump).

Co-authored-by: Cursor <cursoragent@cursor.com>
….2.26)

The previous isLiveStream check only looked at CurrentTrackDuration. Some
TuneIn/internet-radio streams report a non-zero duration (the current song's
length on the station) and advertise 'Seek' in their transport actions. This
caused play() to issue a corrective seek on the live stream, disconnecting
the source; the device played from its buffer for a while and then stopped
("pauses on its own" symptom).

Fix: three independent live-stream guards in play() — any one is sufficient
to skip the seek:
  1. Duration: missing / "0:00:00" / NOT_IMPLEMENTED (unchanged)
  2. Media class: room._isLiveStream, set by _extractNowPlaying whenever
     the UPnP class contains 'audioBroadcast' or 'radio'. Covers streams
     loaded via loadUri/loadContainer/loadSingle.
  3. Fresh metadata parse in play() itself: catches streams loaded
     externally (Music Assistant, original Teufel app) that bypassed
     loadUri() so room._isLiveStream was never set.

Also moves the isRadio calculation in _extractNowPlaying out of the
canPlayNext/canPlayPrev fallback block so it is always computed and can
be stored on the room object.

Co-authored-by: Cursor <cursoragent@cursor.com>
Root cause: when a radio station's "now playing" metadata updates, the
UPnP object class changes from audioBroadcast → musicTrack for the current
song.  _extractNowPlaying was writing that false back to room._isLiveStream,
wiping out the earlier live-stream detection.  On the very next pause/resume
the three guards all evaluated to false and play() issued a corrective seek
on the live stream, which disconnects the source and causes it to stop.

Fix:
- _isLiveStream is now sticky: only ever set to true from _extractNowPlaying,
  never overwritten to false by a song-metadata update on the same URI.
- Reset to undefined only when a genuinely new URI is loaded (loadUri /
  loadContainer / loadSingle) or when the AVTransportURI itself changes
  (detected in track-change logic), not when only CurrentTrack changes
  (which happens for song updates within the same radio stream).
- loadUri / loadContainer / loadSingle each clear _isLiveStream = undefined
  so fresh detection always happens for new media.

Bump to v1.2.27.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Problem: when a radio station's first state update from the device already
shows the current song as musicTrack (not audioBroadcast), _isLiveStream is
never set and the three existing guards all evaluate false simultaneously:
  • guard 1 (duration): non-zero because TuneIn reports the song length
  • guard 2 (class flag): undefined — audioBroadcast never seen
  • guard 3 (fresh metadata): also musicTrack

A corrective seek then fires on the live stream, it plays from its buffer
for a while, and then stops.

Fix — add guard 4 (URI extension):
  Stream URLs (TuneIn, internet radio) have no audio file extension.
  NAS file URLs almost always end with .mp3 / .flac / .wav / etc.
  Any URI without a recognised audio extension is now treated as live and
  will not be seeked. Exception: dlna-playcontainer / dlna-playsingle URIs
  are finite-media containers and continue to receive corrective seeks.

Also add a diagnostic console.log before the seek decision that prints
all four guard values, the URI, and the duration string. This makes future
stream-stop regressions diagnosable from the add-on log without code changes.

Bump to v1.2.28.

Co-authored-by: Cursor <cursoragent@cursor.com>
Root cause (confirmed from logs):
  The <raumfeld:durability>120</raumfeld:durability> tag in TuneIn station
  metadata is a Raumfeld session timer.  After ~120 s the Raumfeld kernel
  drops the stream because the TuneIn session URL has expired and no
  controller renewed it.  The Teufel app renews by calling SetAVTransportURI
  again with the real station metadata (containing raumfeld:ebrowse URL).
  Our add-on used renderer.loadUri() which only passes a generic template
  without the ebrowse URL, so the kernel could never auto-renew.
  Result: PLAYING → STOPPED after ≈90–120 s, no seek involved.

Fix:
  • _parseMetadata now extracts raumfeld:ebrowse into result.ebrowseUrl.
  • _extractNowPlaying caches room._radioMetaXml, room._radioEbrowseUrl, and
    room._radioResUrl whenever the device reports real station metadata
    (metadata that includes an ebrowse URL).
  • When _extractNowPlaying detects a PLAYING → STOPPED/NO_MEDIA_PRESENT
    transition on a live-stream room that was NOT user-initiated, it
    schedules _autoRestartRadio() after a 2 s debounce.
  • _autoRestartRadio() calls renderer.setAvTransportUri(ebrowseUrl, metaXml)
    (the library's lower-level method that accepts custom metadata) so the
    kernel receives the real ebrowse URL and can renew on the next cycle.
    Falls back to renderer.loadUri() if no cached metadata is available.
  • stop() sets room._userInitiatedStop = Date.now() to suppress the
    auto-restart when the user explicitly stops playback.

Bump to v1.2.29.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
The v1.2.29 auto-restart used the raumfeld:ebrowse URL as the
SetAVTransportURI transport URI.  That URL returns OPML XML (not an
M3U stream), so the Raumfeld kernel tried to load it as a stream,
failed, and got stuck in TRANSITIONING indefinitely.

Fix: always use the <res> URL (M3U) as the transport URI; the real
station metadata XML (including raumfeld:ebrowse) is still passed as
CurrentURIMetaData so the kernel has the renewal URL for its own
internal 120-second cycle.

Also add proactive BendAVTransportURI every 90 s for actively playing
radio rooms so the kernel never reaches its durability limit during
normal playback.

Co-authored-by: Cursor <cursoragent@cursor.com>
…on (v1.2.31)

The 90-second BendAVTransportURI interval in v1.2.30 caused the Raumfeld
kernel to reload the live stream on every tick, which the user heard as a
brief audio dropout every ~90 seconds.

Replace with a single BendAVTransportURI call fired as soon as the real
station metadata (raumfeld:ebrowse URL) first arrives from the kernel —
typically while the device is still in TRANSITIONING, before any audio
has started, so there is zero audible disruption.

With the real metadata injected once, the kernel has the ebrowse URL it
needs to renew the TuneIn session internally on its own 120-second cycle,
matching the behaviour of the native Teufel app.

_autoRestartRadio is kept as a last-resort fallback but is suppressed for
10 seconds after the injection to prevent feedback from any brief state
transition caused by BendAVTransportURI.

Co-authored-by: Cursor <cursoragent@cursor.com>
@holli73
Copy link
Copy Markdown
Author

holli73 commented May 5, 2026

hello,

as i have never made a pr - i don't know exactly what i need to do - i changed the file repository.yaml - so i can test all the changes cursor suggested to fix the issues - with my latest commit i/cursor got the following working

  • pause / play on track items
  • added shuffle + repeat all/one on track playback
  • there was an issue with streaming TuneIn as well - we solved this as well

so 1.2.31 on raumkernel looks ok so far - i still need some more testing from my wife - that all works from ha the same way as it does from the raumfeld app - at least my automated node-red events do work - as i do have some raumfeld streamers which do need to fire an amp on playback so music can be heard

it is the first time i use cursor or myself for any PR - i don't know how you want to proceed - or if you can cherry pick what you think is ok for this/your repo

best regards
holli

…1.2.32)

BendAVTransportURI changed AVTransportURI from the station URL to the
session-specific <res> URL.  Our track-change detector saw this as a URI
change, reset _metaInjected, and immediately fired another
BendAVTransportURI — creating an infinite reload loop.  The same loop
prevented station switching: BendAVTransportURI for station A kept
overriding any SetAVTransportURI for station B.

Fix: remove BendAVTransportURI and all _metaInjected/_metaInjectedAt
bookkeeping.  The only renewal mechanism is now _autoRestartRadio, which
fires once if the kernel drops the stream (~120 s session expiry) and
restarts with setAvTransportUri(resUrl, realMeta) so the kernel receives
the raumfeld:ebrowse URL and can handle future renewals internally.
_resumeAnchorUri is updated before the restart call so the subsequent
URI change event is not misidentified as an external track switch.

Co-authored-by: Cursor <cursoragent@cursor.com>
holli73 and others added 28 commits May 14, 2026 22:50
…ngle://

When the kernel or native Raumfeld app sets AVTransportURI to a
dlna-playsingle:// reference, always call renderer.play() instead of
SetAVTransportURI(CDN URL).  The kernel manages TuneIn sessions
natively and shares one session across all rooms playing the same
station — no per-room ebrowse calls, no serial throttle, no 8-110 s
drops from stale durability.

Refs: three-room drop cascade, stale durability 109 s, Path B CDN-direct
corrupting dlna-playsingle:// state.

Co-authored-by: Cursor <cursoragent@cursor.com>
When the user selects a station from HA media browser, loadSingle now
checks the browse-cache refID for the requested item.  If it matches the
room's cached station metadata and we have a CDN URL, SetAVTransportURI
with the CDN URL and durability=0 is called directly, skipping the slow
Tune.ashx session-dispatch endpoint (which can stall for 90+ s when
throttled).  durability=0 triggers immediate ebrowse (fast, unthrottled)
for a fresh CDN session.  Response time drops from 90 s to < 2 s.

Also:
- _parseBrowseXml now stores refID per item for station-match lookups
- _zeroDurability() helper: force durability=0 in DIDL metadata
- _getItemRefIdFromCache() helper: look up item refID from browse cache

Co-authored-by: Cursor <cursoragent@cursor.com>
…~300s drops)

Root cause: with 3 rooms each independently calling TuneIn ebrowse every 60s for
the same station, the (serial, station) pair hits TuneIn's rate limit after ~5
renewal windows (~300 s).  The stream then drops on every room simultaneously.

Fix: add _isPermanentCdnUrl() / _stripTuneInMarkers() helpers and apply them in
all SetAVTransportURI call sites (Path A, Path B, ECONNRESET retry, loadSingle
CDN shortcut).  For permanent direct CDN streams (e.g. orf-live.ors-shoutcast.at)
the DIDL-Lite TuneIn markers (raumfeld:ebrowse, raumfeld:durability,
raumfeld:section, refID) are stripped before passing metadata to the kernel.
Without these markers the kernel plays the URL as a plain HTTP stream — no ebrowse
calls, no rate-limit exposure, plays indefinitely with any number of rooms.

For TuneIn-dispatcher URLs (rndfnk., aggregator=tunein, radiotime.com, etc.) the
existing session-management path is unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
…tcut

v1.2.100 applied _stripTuneInMarkers only to setAvTransportUri call sites but
missed play()'s dlna-playsingle:// guard which fires first and calls bare
renderer.play() — causing 3 rooms to each create an independent TuneIn session
→ 15 ebrowse calls / 5 min → rate-limit → ~280s drops.

Fix: inside the dlna-playsingle:// guard, check for a cached permanent CDN URL
with matching station refID.  If found, call SetAVTransportURI(CDN URL, stripped
metadata) so the kernel plays the URL as a plain HTTP stream — no TuneIn calls,
no throttle.  Falls through to bare play() only when no CDN cache or station mismatch.

The kernel retains CurrentTrackURI across sessions even when AVTransportURI is
dlna-playsingle://, so _lastSeenCdnUri is always populated at startup.

Co-authored-by: Cursor <cursoragent@cursor.com>
Stripping raumfeld:section=RadioTime caused the kernel to treat the stream
as a regular media file instead of a live radio broadcast, triggering an
internal ~120-150s reconnect timer and dropping the stream at 143s.

Fix: keep section=RadioTime so the kernel uses live-radio mode (no Pause,
no reconnect timer, plays indefinitely).  Block ContentDirectory lookup
by changing item id/parentID prefix 0/ → ext/ (non-existent path).
Without ebrowse, refID, or a resolvable item id the kernel has no path
to call TuneIn — streams the CDN URL directly as live radio forever.

Co-authored-by: Cursor <cursoragent@cursor.com>
The CDN server closes TCP connections every ~143 s; the kernel needs
raumfeld:ebrowse to renew the session and reconnect.  _stripTuneInMarkers
was incorrectly removing ebrowse, breaking that renewal path and causing
drops at exactly 143 s on every version since v1.2.101.

_stripTuneInMarkers now:
  - KEEPS  raumfeld:ebrowse (CDN session renewal — critical)
  - KEEPS  raumfeld:section=RadioTime (live-radio kernel mode)
  - ZEROS  raumfeld:durability → 0 (force immediate ebrowse refresh)
  - REMOVES <res Tune.ashx?id=…> (session-dispatch, throttled path)
  - CHANGES id/parentID prefix 0/ → ext/ (blocks ContentDirectory lookup)
  - STRIPS  refID (blocks kernel walk-back to session-dispatch res URL)

Co-authored-by: Cursor <cursoragent@cursor.com>
…gle/native-play

The root cause of the ~40 s (and shortening) drops was a corruption spiral:
- Each play() run read the kernel's already-stripped metadata (no ebrowse,
  no section=RadioTime, id=ext/) as input for SetAVTransportURI
- Sending stripped metadata back to the kernel degraded its state further
- The kernel auto-retried the raw CDN connection without TuneIn credentials,
  each reconnect getting shorter as the CDN grew more hostile

Fix: remove all SetAVTransportURI-with-CDN-URL paths from play().
- dlna-playsingle:// state → bare renderer.play() (kernel manages TuneIn)
- CDN-URL state → renderer.loadSingle(derivedItemId) to restore the kernel
  to dlna-playsingle:// with a fresh TuneIn session (ebrowse + section)
- ECONNRESET retry now uses loadSingle instead of SetAVTransportURI
- loadSingle() CDN shortcut guarded by hasEbrowse: falls through to native
  loadSingle when cached metadata was corrupted by an older run
- _deriveItemIdFromMeta() helper converts ext/X → 0/X for ContentDirectory
- room._lastItemId tracked so play() can reload the correct station

Co-authored-by: Cursor <cursoragent@cursor.com>
…neIn rate limit

Multiple rooms each making independent ebrowse calls every 120 s against the same
TuneIn serial caused the API to throttle, dropping streams at ~10 min intervals.

Fix: loadSingle() now checks if any other room is already PLAYING the same station
(matched via browse-cache refID → TuneIn station ID s-sXXXX).  If a match exists,
connectRoomToZone() is used to join this room to the existing zone rather than
starting a second independent TuneIn session.  Mirrors native Raumfeld app behaviour
(single zone = single TuneIn session regardless of room count).

- room._lastStationId tracked alongside room._lastItemId
- Zone join errors fall through to native loadSingle as safe fallback

Co-authored-by: Cursor <cursoragent@cursor.com>
…guard

Three bugs prevented reliable multi-room zone grouping and clean restarts:

1. Zone-join stationId was derived solely from the browse cache.  If a
   favourite was removed and re-added (new numeric ID), _getItemRefIdFromCache
   returned null and the zone-join block was silently skipped.
   Fix: _extractNowPlaying now extracts the TuneIn station ID directly from
   the live refID attribute in kernel metadata and stores it on
   room._lastStationId.  loadSingle() falls back to scanning other rooms for
   a matching _lastItemId + _lastStationId pair when the browse cache misses.
   _lastStationId is reset when the media URI changes.

2. Calling loadSingle() for an item already in dlna-playsingle:// STOPPED
   state caused the kernel to reply "already active" (HA showed "already
   active but none was playing").
   Fix: loadSingle() detects this case and calls renderer.play() directly.

3. The 60-second dedup guard blocked restarts after a stream drop: if the
   user tried to reload a dropped station within 60 s, the request was
   silently ignored.
   Fix: the guard only fires when TransportState is PLAYING or TRANSITIONING;
   STOPPED rooms always allow an immediate reload.

Co-authored-by: Cursor <cursoragent@cursor.com>
The zone-join logic in v1.2.106 only ran inside loadSingle().  When a
room was in STOPPED state with a dlna-playsingle:// URI already loaded,
pressing Play in HA used the play() STOPPED→native branch which called
renderer.play() directly — skipping the zone-join check.  Both rooms
then independently created TuneIn sessions for the same station.

Fix: the STOPPED→native branch in play() now performs the same zone-join
check as loadSingle(): if another room is PLAYING the same station
(room._lastStationId match), the room joins the existing zone via
zoneManager.connectRoomToZone() rather than restarting independently.
Falls back to native Play() on any error.

Co-authored-by: Cursor <cursoragent@cursor.com>
When TischlerEi joins Kueche's zone, stop() resolves to the shared
zone renderer and calls renderer.stop() which stops the entire zone,
silencing Kueche as well.

Fix: stop() now checks if the room belongs to a multi-room zone
(getRoomCountForZoneUDN > 1).  If so it calls dropRoomFromZone()
instead of stopping the zone renderer — this ejects just the one
room while the others keep playing.  Falls back to zone stop on error.

Co-authored-by: Cursor <cursoragent@cursor.com>
When the TuneIn CDN session renews (~120 s interval), one physical
renderer in a multi-room zone can lose its CDN proxy connection while
the zone itself stays alive (the other room continues playing).

Three coordinated fixes in RaumkernelHelper.js:

1. Per-room state detection: _extractNowPlaying now parses
   state.RoomStates (uuid:Kueche=STOPPED,uuid:TischlerEi=PLAYING)
   to derive the room-specific currState instead of using the
   zone-level TransportState — so partial drops are detected.

2. Auto-recovery (3 s timer): on partial drop (currState=STOPPED,
   zone=PLAYING, not user-stopped), drops the room from the zone
   via dropRoomFromZone() and re-adds it via loadSingle().  The
   zone-join logic in loadSingle finds the still-playing zone and
   calls connectRoomToZone() — Kueche rejoins without interrupting
   TischlerEi.

3. Manual recovery via Play button: play() now checks RoomStates
   for STOPPED-within-PLAYING and performs the same drop+rejoin
   instead of a no-op renderer.play().

Co-authored-by: Cursor <cursoragent@cursor.com>
setVolume/setMute now resolve the physical renderer
(deviceManager.mediaRenderers.get(room.rendererUdn)) instead of the
zone virtual renderer.  The zone renderer's SetVolume applies a
relative delta to all zone members simultaneously; the physical
renderer only adjusts its own hardware amplifier.

Partial-drop auto-recovery guards:
- prevState === 'PLAYING' check prevents scheduling a rejoin on the
  initial STOPPED state that occurs when a room first joins a zone
  (before the kernel promotes it to PLAYING in ~2 s).
- _partialDropRejoinPending flag deduplicates the multiple
  subscription callbacks that fire within ms for the same event,
  preventing redundant timers.

Co-authored-by: Cursor <cursoragent@cursor.com>
Fix: per-room volume was incorrectly showing the zone-master (highest)
volume for every member of a zone.  _extractNowPlaying now reads the
room-specific absolute volume from state.RoomVolumes; negative values
(artefact of a prior buggy zone-level delta) are clamped to 0.  The
device-volume slider in HA now moves independently for each room.

Feature: new "Zone Volume" number entity per room.  Setting it routes
through the virtual zone renderer so all zone members adjust together,
matching the native Raumfeld app's group-volume slider.  A new
setZoneVolume command is added to the addon (RaumkernelHelper.js,
index.js) and a set_zone_volume method to api.py.  zone_volume is also
exposed in the media_player extra_state_attributes.

Co-authored-by: Cursor <cursoragent@cursor.com>
The media-player volume slider is now zone-aware:
- Grouped: shows/controls the zone-master volume (all members together)
- Solo: shows/controls the device volume only

Repurpose the number entity as "Device Volume" (unique_id _device_volume)
so individual speaker levels within a zone can still be fine-tuned from
the device page.  The previous "Zone Volume" entity (1.3.0) is replaced.

Co-authored-by: Cursor <cursoragent@cursor.com>
For live radio the repeat button (meaningless on a live stream) now
toggles the volume slider mode:
  OFF (default) = this device only
  ALL           = zone master (all grouped members together)

HA cycles OFF→ONE→ALL; ONE is silently treated as ALL so the first
press immediately activates zone mode (clean two-state toggle).
The _zone_volume_mode flag is local and not overwritten by server
state updates, so the selection persists while HA is running.

Regular music: repeat works as before (OFF/ONE/ALL sent to device).

Co-authored-by: Cursor <cursoragent@cursor.com>
1. setZoneVolume: delta via physical renderers with [0,100] clamping —
   avoids zone renderer delta-based SetVolume which drove volumes negative.
2. setZoneVolumeMode: syncs _zoneVolumeMode to all zone members and
   broadcasts; nowPlaying.zoneVolumeMode carries the flag to HA so all
   player cards flip the repeat icon in lock-step.
3. async_set_repeat for live radio now calls set_zone_volume_mode on the
   server (server round-trip) instead of patching local state — eliminates
   the race condition that prevented switching back to device mode.

Co-authored-by: Cursor <cursoragent@cursor.com>
Remove the repeat-button-as-zone-volume-toggle feature added in 1.3.2/1.3.3.
Volume slider now always controls this device only (physical renderer).
Repeat and shuffle buttons work normally for all content types.

Co-authored-by: Cursor <cursoragent@cursor.com>
SHUFFLE_SET and REPEAT_SET features are now gated on non-live-radio
content so the radio card only shows controls meaningful for a live stream.

Co-authored-by: Cursor <cursoragent@cursor.com>
…dication

Previously _updateZoneMappings stored zone.rooms[].udn values directly in
room.zoneMembers.  Those UDNs can be virtual-renderer UDNs rather than the
roomUdn that the HA entity exposes as room_udn in its state attributes.
The group_members property uses that attribute to match against zoneMembers,
so a UDN type mismatch caused group_members to always return None and the
group indication to never appear in the player view.

Fix: resolve each member through _findRoomByAnyUdn and store only the
canonical room.roomUdn in zoneMembers.  Bumps to v1.3.6.

Co-authored-by: Cursor <cursoragent@cursor.com>
- RaumkernelHelper.js: fix ReferenceError in pause() — the renderer
  variable was never declared, crashing every pause command with
  "renderer is not defined" (visible in logs after KellerStueberl
  turn-off this morning)
- media_player.py: async_turn_off now calls stop() instead of pause()
  before entering standby — semantically correct (releases the stream)
  and avoids triggering the pause bug on devices that are already stopped
- Bump version to 1.3.7 in all 4 version files; sync integration bundle
  and remove stale nested teufel_raumfeld_raumkernel/ duplicate directory
- CHANGELOG.md updated

Co-authored-by: Cursor <cursoragent@cursor.com>
…isor addon discovery)

Co-authored-by: Cursor <cursoragent@cursor.com>
…visor scans recursively for config.yaml and was treating the bundle copy as a second addon (no Dockerfile), causing 'dockerfile is missing' on every update

Co-authored-by: Cursor <cursoragent@cursor.com>
…sion.sh

- Rename teufel_raumfeld_raumkernel/config.yaml → addon_config_ref.yaml:
  HA Supervisor recursive config.yaml scan was picking the bundle copy
  (same slug, no Dockerfile) over the real addon dir after a Supervisor
  update, causing 'dockerfile is missing' on every addon update attempt
- prepare-build.sh: explicitly remove config.yaml from bundle after copy
- sync-version.sh: fix sed -i '' (macOS) → sed -i (Linux)

Co-authored-by: Cursor <cursoragent@cursor.com>
The Raumfeld kernel sets CurrentTransportActions='Play' after a stream
drop but never restarts on its own.  Add auto-restart in the addon:
after an unintentional PLAYING→STOPPED transition on a live stream,
schedule a play() call with an 8 s back-off for short (throttled)
sessions and 3 s for longer ones.  Guard against user-intentional
stops (_userStopped) and partial zone drops (handled by rejoin block).
Clear _autoRestartPending on every explicit play() entry point so a
manual press cancels any in-flight timer.

Co-authored-by: Cursor <cursoragent@cursor.com>
Previous edit accidentally dropped the `try {` opening brace and the
`dropRoomFromZone` call when inserting `_autoRestartPending = false`,
leaving a bare `catch` that caused a SyntaxError on startup.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ulilicht
Copy link
Copy Markdown
Owner

Hello Holli,

Please see my comment here as well: #55 (comment)

First of all, impressive to see your hard work on your issues!

However I will not be able to review and merge this PR. It contains too many changes instead of a single, focussed change. I understand you used Cursor for creating this changes and don't have a programming background.

While AI coding tools can be helpful (and in fact, ha-raumkernel is created by one) the code still needs review and at least high-level checks.

Cursor added +14.165 lines of code to this, it will not be possible to review the changes and understand all side effects.

If your work helped in your own use case, I suggest to keep your fork of ha-raumkernel and use your own version for your use-case.

Best, Uli

@ulilicht ulilicht closed this May 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants