Skip to content

Conversation

@gunjambi
Copy link

@gunjambi gunjambi commented Jan 5, 2026

Proposed Changes

This PR makes listen() detect changes to Presets. It also avoids fetching presets.json in update() when presets haven't changed.

Potential changes to presets are detected two ways: If WLED's announces a change in pmt aka presetsModifiedTime or if the WLED device is restarted. Restart detection is needed since presetsModifiedTime is only stored in the volatile memory.

Home Assistant uses listen() but also polls update() every 10 seconds. My understanding the polling is needed to update the Presets. The intent of this PR is to allow removing the polling in the future by making the listen() return all needed information, and immediately reduce the amount of polling to presets.json.

Polling presets.json is problematic as it causes visual glitching on WLED devices. On many ESP devices, fetching the file results in reads from the flash storage on the chip. For reasons beoynd my undestanding, this apparently breaks some interrupts on some firmware versions and led output gets corrupted. On better firmware, it only causes the led animations to stutter.

WLED community is very aware of the HASS polling behavior, and has attempted to work around it with some caching tricks. These only reduce the issues and require special hardware (PSRAM) to be installed. Some users have switched to MQTT based HASS integration to avoid the this default HASS integration's issues. To me, it seems more sensible to fix the unnecessary polling than try to work around the issues it causes.

The accessed fields pmt and uptime are supported since WLED 0.11.0 (November 2020 release).

Related Issues

MoonModules/WLED-MM#307
wled/WLED#2480
home-assistant/core#90676
home-assistant/core#134863

A related potential future improvement has been described in the first issue MoonModules/WLED-MM#307 . By adding ETag on WLED's output, client could avoid fetching /presets.json contents when it hasn't changed. This does not however solve the use-case of using listen() to detect Preset changes nor does it fix the issue for the existing fleet, which are the goals of this PR.

For completeness, an ETag implementation is provided in: gunjambi#1

Summary by CodeRabbit

Release Notes

  • New Features
    • Implemented intelligent preset change detection to automatically identify when presets are modified on the device, reducing unnecessary data transfers and improving synchronization efficiency.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Jan 5, 2026

📝 Walkthrough

Walkthrough

The changes introduce a preset change detection mechanism to the WLED client. A new _PresetsVersion dataclass tracks preset modification timestamps and device boot times. The listen and update methods now conditionally fetch presets when changes are detected via the _check_presets_version method, caching results to avoid redundant requests.

Changes

Cohort / File(s) Summary
Preset Change Detection Logic
src/wled/wled.py
Added _PresetsVersion dataclass to track preset state; introduced _check_presets_version() method to detect preset changes by comparing device info (uptime, timestamps); integrated conditional preset fetching into listen() and update() methods with caching via private _presets_version field; added time import.
Preset Caching Test Coverage
tests/test_wled.py
Added test_presets_cache() test validating caching behavior across five polling cycles: initial fetch, no-change skip, change-triggered fetch, timestamp consistency, and device restart scenarios; includes make_json() helper for dynamic JSON payloads with varying uptime and preset modification timestamps.

Sequence Diagram

sequenceDiagram
    participant Device as WLED Device
    participant Client as WLED Client
    participant API as /json & /presets.json
    participant CB as Callback/Handler

    Device->>Client: WebSocket Update (TEXT)
    
    rect rgb(200, 220, 255)
    Note over Client: _check_presets_version()
    Client->>Client: Parse device info<br/>(uptime, time, pmt)
    Client->>Client: Compare with<br/>cached version
    end
    
    alt Presets Changed
        Client->>API: GET /presets.json
        API-->>Client: Preset Data
        Client->>Client: Store new version
        Client->>Client: Update message data
    else Presets Unchanged
        Client->>Client: Skip preset fetch
    end
    
    Client->>CB: Invoke callback<br/>with data
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested labels

bugfix

Poem

🐰 A rabbit hops with glee, detecting preset changes with grace,
Caching wisdom so the device won't be fetched in every race,
When boot times shift and timestamps dance, presets update with care,
No redundant calls shall slow us down—just smart detection there! ✨

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Listen for preset changes' directly and clearly describes the main feature introduced in the PR—detecting WLED preset changes in the listen() method to reduce unnecessary polling of presets.json.
Docstring Coverage ✅ Passed Docstring coverage is 88.89% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Fix all issues with AI Agents 🤖
In @src/wled/wled.py:
- Around line 131-144: The err_msg is mistakenly a one-element tuple due to a
trailing comma in the presets update block (inside the presets_changed branch
that calls request("/presets.json")); remove the trailing comma and construct
err_msg as a single string (e.g., a single f-string combining the host and
message) before passing it into WLEDEmptyResponseError and WLEDConnectionError
so WLEDEmptyResponseError receives a string, not a tuple.
- Around line 281-296: The error message assigned to msg inside the presets
update block is accidentally created as a singleton tuple due to a trailing
comma, which causes WLEDEmptyResponseError to receive a tuple instead of a
string; remove the trailing comma and construct a proper string (e.g., build msg
with f"WLED device at {self.host} returned an empty API response on presets
update") before raising WLEDEmptyResponseError in the branch where
presets_changed is true and request("/presets.json") returns falsy.
🧹 Nitpick comments (1)
tests/test_wled.py (1)

171-185: Consider using f-strings for better readability.

The string.Template approach works, but f-strings are more idiomatic in modern Python and would simplify this helper.

🔎 Optional refactor to f-string
-    def make_json(uptime: int, time: str, pmt: int) -> str:
-        return string.Template(
-            """
-            {"info": {"uptime":$uptime,"time":"$time","fs":{"pmt": $pmt}},
+    def make_json(uptime: int, time_str: str, pmt: int) -> str:
+        return f"""
+            {{"info": {{"uptime":{uptime},"time":"{time_str}","fs":{{"pmt": {pmt}}}}},
             "state":{"bri":127,"on":true,"transition":7,"ps":-1,"pl":-1,
             "nl":{"on":false,"dur":60,"mode":1,"tbri":0,"rem":-1},
             "udpn":{"send":false,"recv":true,"sgrp":1,"rgrp":1},"lor":0,
             "seg":[{"id":0,"start":0,"stop":48,"startY":0,"stopY":19,"len":48,
             "grp":1,"spc":0,"of":0,"on":true,"frz":false,"bri":255,"cct":127,
             "set":0,"lc":1,"col":[[18,22,255],[0,0,0],[0,0,0]],"fx":174,"sx":0,
             "ix":91,"pal":0,"c1":128,"c2":128,"c3":16,"sel":true,"rev":false,
             "mi":false,"rY":false,"mY":false,"tp":false,"o1":false,"o2":false,
-            "o3":false,"si":0,"m12":0,"bm":0}]}}
-            """
-        ).substitute({"uptime": uptime, "time": time, "pmt": pmt})
+            "o3":false,"si":0,"m12":0,"bm":0}]}}}}"
+            """
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4f5a6d5 and 67171ab.

📒 Files selected for processing (2)
  • src/wled/wled.py
  • tests/test_wled.py
🧰 Additional context used
🧬 Code graph analysis (2)
tests/test_wled.py (1)
src/wled/wled.py (2)
  • WLED (41-782)
  • update (259-297)
src/wled/wled.py (2)
src/wled/exceptions.py (2)
  • WLEDConnectionError (12-13)
  • WLEDEmptyResponseError (8-9)
src/wled/models.py (2)
  • update_from_dict (783-841)
  • Device (716-841)
🔇 Additional comments (6)
src/wled/wled.py (4)

7-7: LGTM!

The time import is correctly added to support boot time estimation in _check_presets_version.


34-38: LGTM!

The _PresetsVersion dataclass appropriately encapsulates the state needed for preset change detection.


51-51: LGTM!

The _presets_version field is correctly added to track preset state across calls.


731-782: The code already documents the design trade-offs and constraints this implementation addresses. Lines 770-772 explicitly explain why info.time is not used ("The device time is however unreliable, especially around boot"), and lines 767-768 document the 2-second tolerance rationale ("allow 2 seconds changes for rounding errors and network delay"). The integer truncation is intentional, and safe fallbacks exist for parse failures. These decisions reflect reasonable pragmatic choices for a client-side implementation without access to a reliable centralized time source.

Likely an incorrect or invalid review comment.

tests/test_wled.py (2)

4-4: LGTM!

The string import is added for the Template usage in the test helper function.


167-293: Well-designed test, but consider adding listen() coverage.

The test comprehensively validates preset caching across multiple scenarios (no change, pmt change, restart). However, the listen() method has identical preset caching logic (lines 131-144 in src/wled/wled.py) but lacks test coverage.

Consider adding a similar test for the listen() method to ensure both code paths are covered. The listen method uses WebSocket messages which would require mocking WebSocket responses rather than HTTP responses.

Comment on lines +131 to +144
presets_changed, new_presets_version = self._check_presets_version(
message_data
)
if presets_changed:
if not (presets := await self.request("/presets.json")):
err_msg = (
f"WLED device at {self.host} returned an empty API"
" response on presets update",
)
raise WLEDConnectionError(WLEDEmptyResponseError(err_msg))
message_data["presets"] = presets

device = self._device.update_from_dict(data=message_data)
self._presets_version = new_presets_version
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Fix error message construction.

The error message at lines 136-140 has a trailing comma that creates a tuple instead of a string:

err_msg = (
    f"WLED device at {self.host} returned an empty API"
    " response on presets update",
)

This results in err_msg being a tuple ("message",) rather than a string, which is then incorrectly passed to WLEDEmptyResponseError.

🔎 Proposed fix
                 if presets_changed:
                     if not (presets := await self.request("/presets.json")):
-                        err_msg = (
-                            f"WLED device at {self.host} returned an empty API"
-                            " response on presets update",
-                        )
+                        err_msg = (
+                            f"WLED device at {self.host} returned an empty API"
+                            " response on presets update"
+                        )
                         raise WLEDConnectionError(WLEDEmptyResponseError(err_msg))
🤖 Prompt for AI Agents
In @src/wled/wled.py around lines 131-144, The err_msg is mistakenly a
one-element tuple due to a trailing comma in the presets update block (inside
the presets_changed branch that calls request("/presets.json")); remove the
trailing comma and construct err_msg as a single string (e.g., a single f-string
combining the host and message) before passing it into WLEDEmptyResponseError
and WLEDConnectionError so WLEDEmptyResponseError receives a string, not a
tuple.

Comment on lines +281 to +296
presets_changed, new_presets_version = self._check_presets_version(data)
if presets_changed:
if not (presets := await self.request("/presets.json")):
msg = (
f"WLED device at {self.host} returned an empty API"
" response on presets update",
)
raise WLEDEmptyResponseError(msg)
data["presets"] = presets

if not self._device:
self._device = Device.from_dict(data)
else:
self._device.update_from_dict(data)

self._presets_version = new_presets_version
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Fix error message construction.

The error message at lines 284-288 has the same trailing comma issue as in the listen() method, creating a tuple instead of a string.

🔎 Proposed fix
         presets_changed, new_presets_version = self._check_presets_version(data)
         if presets_changed:
             if not (presets := await self.request("/presets.json")):
-                msg = (
-                    f"WLED device at {self.host} returned an empty API"
-                    " response on presets update",
-                )
+                msg = (
+                    f"WLED device at {self.host} returned an empty API"
+                    " response on presets update"
+                )
                 raise WLEDEmptyResponseError(msg)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
presets_changed, new_presets_version = self._check_presets_version(data)
if presets_changed:
if not (presets := await self.request("/presets.json")):
msg = (
f"WLED device at {self.host} returned an empty API"
" response on presets update",
)
raise WLEDEmptyResponseError(msg)
data["presets"] = presets
if not self._device:
self._device = Device.from_dict(data)
else:
self._device.update_from_dict(data)
self._presets_version = new_presets_version
presets_changed, new_presets_version = self._check_presets_version(data)
if presets_changed:
if not (presets := await self.request("/presets.json")):
msg = (
f"WLED device at {self.host} returned an empty API"
" response on presets update"
)
raise WLEDEmptyResponseError(msg)
data["presets"] = presets
if not self._device:
self._device = Device.from_dict(data)
else:
self._device.update_from_dict(data)
self._presets_version = new_presets_version
🤖 Prompt for AI Agents
In @src/wled/wled.py around lines 281-296, The error message assigned to msg
inside the presets update block is accidentally created as a singleton tuple due
to a trailing comma, which causes WLEDEmptyResponseError to receive a tuple
instead of a string; remove the trailing comma and construct a proper string
(e.g., build msg with f"WLED device at {self.host} returned an empty API
response on presets update") before raising WLEDEmptyResponseError in the branch
where presets_changed is true and request("/presets.json") returns falsy.

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.

1 participant