8000
Skip to content

[sendspin] Clear metadata and controller state on disconnect - #18289

Merged
bdraco merged 5 commits into
esphome:devfrom
kahrendt:sendspin-metadata-clears
Aug 11, 2026
Merged

[sendspin] Clear metadata and controller state on disconnect#18289
bdraco merged 5 commits into
esphome:devfrom
kahrendt:sendspin-metadata-clears

Conversation

@kahrendt
@kahrendt kahrendt commented Aug 11, 2026
Copy link
Copy Markdown
Contributor

What does this implement/fix?

When the connection to a Sendspin server drops, the library clears its cached metadata and controller state, but ESPHome kept displaying whatever the last server had sent. Track title, artist, album, year, and position all stayed frozen on the frontend, and the media player kept reporting that it was playing.

This makes the child entities follow the library's clear events.

Metadata

SendspinHub now implements on_metadata_clear() and fans out an empty state object, so every field reaches the children as nullopt. The children publish an absent field instead of skipping it:

  • Numeric sensors publish NAN, which reads as unknown.
  • Text sensors publish an empty string. This is not the same as unknown: missing_state on the API follows has_state(), and has_state() is never cleared once set, so an empty state is the closest a text sensor can get after it has published a real value. This is documented in the code.

Both paths keep their dedup so a field that stays cleared does not republish on every metadata update. NAN never compares equal to itself, so that case needed an explicit check.

The same handling applies to a field the server clears mid-session, not just to disconnects. Previously an absent field was skipped entirely, so a track with no album would still show 8000 the previous track's album.

Track progress

The progress sensor stops its poller and reports unknown when the position is no longer known, rather than leaving the last position frozen.

It also stops the poller in setup(). PollingComponent starts the poller before setup() runs, so from boot until the first metadata arrived the sensor published a position of 0 on every tick instead of unknown. The metadata callback starts it once playback is running.

Controller state

on_controller_state_clear() is now handled, and the media player returns to idle when the connection drops. The server never gets to send a final stopped group update, so without this the entity keeps reporting playing indefinitely.

This goes through its own callback rather than reusing the controller state fan-out. ServerStateControllerObject stores volume and muted as plain values rather than optionals, so a default constructed object would arrive at the children as a real 0% volume rather than as no value. Volume and mute therefore keep their last values, since media_player has no way to express an unknown volume.

Types of changes

  • Bugfix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • New developer-facing feature (adds functionality for component developers; no end-user configuration change)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected) — policy
  • Developer breaking change (an API change that could break external components) — policy
  • Undocumented C++ API change (removal or change of undocumented public methods that lambda users may depend on) — policy
  • Code quality improvements to existing code or addition of tests
  • Other

Related issue or feature (if applicable): not applicable

Pull request in esphome.io with documentation (if applicable): not applicable

Pull request in developers.esphome.io with developer documentation (if applicable): not applicable

Test Environment

  • ESP32
  • ESP32 IDF
  • ESP8266
  • RP2040/RP2350
  • BK72xx
  • RTL87xx
  • LN882x
  • nRF52840

Example entry for config.yaml:

# Example config.yaml
sendspin:
  id: sendspin_hub_id

sensor:
  - platform: sendspin
    name: "Track Progress"
    type: track_progress
  - platform: sendspin
    name: "Track Duration"
    type: track_duration
  - platform: sendspin
    name: "Year"
    type: year

text_sensor:
  - platform: sendspin
    name: "Title"
    type: title
  - platform: sendspin
    name: "Artist"
    type: artist
  - platform: sendspin
    name: "Album"
    type: album

media_player:
  - platform: sendspin
    id: media_player_id

Checklist:

  • The code change is tested and works locally.
  • Tests have been added to verify that the new code works (under tests/ folder).

If user exposed functionality or configuration variables are added/changed:

…t value

A metadata field the server has cleared arrives as nullopt, the same as one it
never sent, and both were skipped rather than published. A sensor therefore kept
showing the previous track's value indefinitely and never fired on_value, so a
track with no album name left the album sensor stuck on the last album, with no
way for a display to tell "this track has no album" from "the album has not
changed".

Publish the empty string for a cleared text field and NAN for a cleared numeric
one, and stop the track progress poller when progress goes away instead of
leaving a frozen position on the frontend. Both sensor types skip the publish
while has_state() is false, so a field the server never provides does not fire
on_value with an empty value on the first metadata of every connection; that
also avoids reading Sensor::raw_state before it is initialized.

Handle MetadataRoleListener::on_metadata_clear(), which was left unimplemented:
the hub now fans out a default-constructed state object on disconnect, so every
metadata sensor blanks through the same path the per-field clears use rather
than holding the previous server's metadata.
…nect

Follow-ups to the metadata clear handling:

- Stop the track progress poller in setup(). PollingComponent starts it
  before setup() runs, so the sensor published a position of 0 on every
  tick from boot until the first metadata arrived, rather than unknown.
- Handle on_controller_state_clear() in the hub. The media player now
  returns to idle when the connection drops, since the server never sends
  a final stopped group update. This goes through its own callback because
  volume and muted are plain values, so a default constructed state object
  would read as a real 0% volume instead of no value.
- Correct the comment claiming the sensor's raw state is uninitialized
  before the first publish. Sensor's constructor sets it to NAN.
- Document that an empty text sensor state is not the same as unknown.
  missing_state follows has_state(), which is never cleared once set.
Copilot AI lite review requested due to automatic review settings August 11, 2026 18:27
@esphome
esphome Bot commented Aug 11, 2026
Copy link
Copy Markdown
Contributor

To use the changes from this PR as an external component, add the following to your ESPHome configuration YAML file:

external_components:
  - source: github://pr#18289
    components: [sendspin]
    refresh: 1h

(Added by the PR bot)

@codecov
codecov Bot commented Aug 11, 2026
Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 88.02%. Comparing base (7f0d6a8) to head (5f22b04).

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##              dev   #18289   +/-   ##
=======================================
  Coverage   88.02%   88.02%           
=======================================
  Files          65       65           
  Lines       14902    14902           
  Branches     2241     2241           
=======================================
  Hits        13117    13117           
  Misses       1473     1473           
  Partials      312      312           
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copilot AI left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes stale Sendspin entity state after a server disconnect by propagating the sendspin-cpp library’s “clear” events to ESPHome child entities, ensuring metadata, progress, and playback state no longer remain frozen in the frontend.

Changes:

  • Fan out cleared metadata as an “empty” metadata state so child sensors/text_sensors publish unknown/empty values instead of keeping old values.
  • Add controller-state “clear” fan-out and make the Sendspin media player return to idle on disconnect.
  • Improve dedup/initial-state handling for metadata and progress so “unknown” doesn’t churn or incorrectly become a real state.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.

Show a summary per file
File Description
esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp Publish empty string on cleared/absent metadata (with initial-state guard) so old text fields don’t linger.
esphome/components/sendspin/sensor/sendspin_sensor.cpp Publish NAN for cleared numeric fields; stop progress polling and report unknown when progress is cleared/absent; add NAN-aware dedup.
esphome/components/sendspin/sendspin_hub.h Add controller-state-clear callback registration and declare on_*_clear() overrides for controller/metadata.
esphome/components/sendspin/sendspin_hub.cpp Implement on_controller_state_clear() and on_metadata_clear() fan-out behavior.
esphome/components/sendspin/media_player/sendspin_media_player.cpp Subscribe to controller-state-clear and force media player state back to idle on disconnect.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@kahrendt kahrendt added this to the 2026.8.0 milestone Aug 11, 2026
@github-actions
Copy link
Copy Markdown
Contributor

Memory Impact Analysis

Components: sendspin
Platform: esp32-idf

Metric Target Branch This PR Change
RAM 45,288 bytes 45,288 bytes ➡️ +0 bytes (0.00%)
Flash 853,387 bytes 853,387 bytes ➡️ +0 bytes (0.00%)

Note: This analysis measures static RAM and Flash usage only (compile-time allocation).
Dynamic memory (heap) cannot be measured automatically.
⚠️ You must test this PR on a real device to measure free heap and ensure no runtime memory issues.

This analysis runs automatically when components change. Memory usage is measured from a representative test configuration.

@esphbot
esphbot commented Aug 11, 2026
Copy link
Copy Markdown
Contributor

Previous review — superseded by a newer review below.

@esphbot esphbot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tip

No blocking issues found — ready to merge.

bdraco
bdraco previously approved these changes Aug 11, 2026
The !has_state() branches in the two publish_if_changed_ helpers repeated
what the dedup below them already did: the raw state starts as NAN for a
sensor and empty for a text sensor, so a field that is already cleared when
the first update arrives is suppressed either way. Keep the dedup only.

Also note on add_metadata_update_callback() that a disconnect fans out an
all-empty state object, since that is where a subscriber reads the contract.
@esphome esphome Bot added the medium-pr PR < 100 lines label Aug 11, 2026
@kahrendt
Copy link
Copy Markdown
Contributor Author

Implemented the 3 suggested items esphbot suggested.

bdraco
bdraco previously approved these changes Aug 11, 2026
@esphbot
esphbot commented Aug 11, 2026
Copy link
Copy Markdown
Contributor

@kahrendt: Implemented the 3 suggested items esphbot suggested.

Confirmed, all three landed. Both has_state() early-returns gone; dedup now rests on raw_state starting at NaN and state default-constructing to "". Header contract documented where subscribers read it.

One leftover: same redundant has_state() still guards progress-clear, sendspin_sensor.cpp:29. Non-blocking. Two further nits filed inline.

@esphbot
esphbot commented Aug 11, 2026
Copy link
Copy Markdown
Contributor

Previous review — superseded by a newer review below.

@esphbot esphbot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tip

No blocking issues found — ready to merge.

Drop the has_state() term from the track progress clear guard. The raw state
starts as NAN, so the isnan() check already covers the first update, and the
two publish guards in the file now read the same way.

Make controller_state_callbacks_ lazy as well. The switch action and the media
source both enable the controller role without a media_player, so neither
controller callback has a subscriber in those builds. The old comment claimed
only the clear callback was rarely subscribed, which was not true.

Pull the media player state change and log out of the two callbacks into
set_playback_state_(), so they cannot drift apart.
@kahrendt
kahrendt requested a review from esphbot August 11, 2026 19:49
@esphome esphome Bot removed the medium-pr PR < 100 lines label Aug 11, 2026
@esphbot
esphbot commented Aug 11, 2026
Copy link
Copy Markdown
Contributor

PR Review — [sendspin] Clear metadata and controller state on disconnect

All three prior suggestions are resolved and the disconnect-clear semantics check out against the sendspin-cpp 0.7.1 sources. Merge-ready with no findings.

What this revision gets right, verified rather than assumed:

  • The core premise holds. MetadataRole::Impl::drain_events() calls on_metadata(this->metadata) with the merged shadow, and apply_metadata_state_deltas (src/protocol.cpp:735) only overwrites a field when the delta carries an outer-engaged entry — so nullopt genuinely means "never set or explicitly cleared by the server", never "not part of this delta". Treating an absent field as cleared is correct, and the new header contract on add_metadata_update_callback states exactly that.
  • Both clear hooks exist with the assumed semantics: MetadataRoleListener::on_metadata_clear() (metadata_role.h:65) and ControllerRoleListener::on_controller_state_clear() (controller_role.h:106), both fired only from cleanup() via cleanup_connection_state(), i.e. connection teardown. The library's own comment guarantees at most one CLEARED per role per disconnect, so no repeated fan-out.
  • The controller-vs-metadata asymmetry is justified, not stylistic: ServerStateControllerObject really does store volume/muted as plain uint8_t/bool, so a default-constructed object would land on children as a real 0% volume.
  • Dedup is right on both edge cases. Sensor::raw_state is assigned on every publish_state() regardless of filters, and TextSensor::publish_state() updates raw_state_ when a filter list is attached and state otherwise — so get_raw_state() tracks the last published value in both configurations, and first-boot suppression holds by construction (NaN / empty).
  • PollingComponent::call_setup() starts the poller then calls setup(), its own comment saying "allowing setup to cancel it if desired" — the trailing stop_poller() is the sanctioned pattern for the publishes-0-from-boot fix.
  • All three metadata subscribers were updated for the new absent-means-cleared contract; grep confirms there are no others, and SendspinMediaPlayer is the only controller subscriber, so both LazyCallbackManager choices and the reworded comment now match reality.

Prior review items, all confirmed fixed in cfc3e40:

  • The leftover has_state() && guard in the progress-clear branch is gone, leaving one NaN rule per file.
  • controller_state_callbacks_ is now LazyCallbackManager too, and the comment names the actual subscriber set instead of claiming the clear callback is special.
  • The state-change + log block is factored into set_playback_state_(), so the two callbacks can no longer drift.

No blocking issues. clang-format is clean on all six touched files and no line exceeds 120 characters.


✅ Resolved since last review (2)

Previously-flagged issues verified fixed
  • esphome/components/sendspin/sensor/sendspin_sensor.cpp:28 Leftover has_state() guard — the same redundancy just removed from publish_if_changed_
  • esphome/components/sendspin/sendspin_hub.h:193 "Rarely subscribed" rationale does not match the actual subscriber set


Checklist

  • Absent-field-means-cleared matches library merge semantics (apply_metadata_state_deltas)
  • Clear hooks exist in sendspin-cpp 0.7.1 with connection-loss semantics
  • Separate controller-clear callback justified by ServerStateControllerObject field types
  • NaN and empty-string dedup correct, including first publish and filtered entities
  • Poller lifecycle correct relative to PollingComponent::call_setup()
  • All metadata/controller callback subscribers updated; no stragglers
  • No dangling pointer from const char * or the temporary state object
  • No heap allocation added after setup()
  • Preprocessor guards consistent (USE_SENDSPIN_CONTROLLER / USE_SENDSPIN_METADATA)
  • Prior review findings all resolved
  • Comments accurately describe the code they justify
  • Diff matches PR description, no scope creep
  • clang-format clean, 120-char limit respected

Automated review by Kōan (Claude) HEAD=cfc3e40 4 min 41s

@esphbot esphbot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tip

No blocking issues found — ready to merge.

@esphbot
esphbot commented Aug 11, 2026
Copy link
Copy Markdown
Contributor

Note

No code changes since the last review (cfc3e40) — skipping. Push new commits or re-request a review to run again.

@bdraco
bdraco enabled auto-merge (squash) August 11, 2026 20:50
@bdraco
bdraco merged commit e0b112c into esphome:dev Aug 11, 2026
42 checks passed
@kahrendt
kahrendt deleted the sendspin-metadata-clears branch August 11, 2026 21:09
@jesserockz jesserockz removed this from the 2026.8.0 milestone Aug 12, 2026
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 13, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants

0