Skip to content

Add clocked playlist scheduling#5663

Open
aeoa wants to merge 8 commits into
wled:mainfrom
aeoa:feature/clocked-playlists
Open

Add clocked playlist scheduling#5663
aeoa wants to merge 8 commits into
wled:mainfrom
aeoa:feature/clocked-playlists

Conversation

@aeoa
Copy link
Copy Markdown

@aeoa aeoa commented Jun 3, 2026

What

Adds an optional clocked mode for playlists. A clocked playlist derives its active entry from synchronized Toki time, so multiple WLED controllers with matching playlists and presets can stay aligned without continuously communicating with each other.

This is useful for setups where controllers may have spotty network connectivity after initial time sync, where relying on frequent live sync packets is undesirable, or where controllers are not on the same local network but can sync to the same time source.

How

When playlist.clocked is enabled, playlist handling uses absolute Toki time modulo the total finite playlist duration to select the active entry. The selected entry offset is also applied to strip.timebase, so effects using strip.now can start from the same relative effect time.

Shuffle remains deterministic per wall-clock playlist cycle, so matching controllers derive the same shuffled order.

The playlist editor now includes a Clock sync checkbox. Manual advance is disabled for clocked playlists because zero-duration entries cannot be placed on a wall-clock timeline.

Limitations

Clocked playlists require an existing time source, such as NTP enabled in WLED Time settings. They do not enable or change NTP settings automatically.

Zero-duration/manual-advance entries are unsupported. Nested playlists keep existing WLED behavior; child playlist duration wins once loaded.

Testing

Hardware sanity-tested on two ESP32-C3 during development

Summary by CodeRabbit

  • New Features

    • "Clock sync" checkbox in playlist editor to align progression with wall-clock time; saved per-playlist.
    • Per-playlist deterministic shuffle option for repeatable ordering.
    • Shuffle/deterministic/clock-sync/manual/repeat controls wired to unified playlist options.
  • Bug Fixes / Improvements

    • Manual-advance now normalizes durations and sets inputs read-only when enabled.
    • New playlists and presets backfilled with sensible defaults; UI tooltips added.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 3, 2026

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds playlist clock-sync and deterministic shuffle: two new PL_OPTION flags, frontend clockSync/deterministic UI and centralized handler, backend deterministic per-cycle ordering and clock-to-cycle mapping, loading/cleanup updates, and serialization of the new flags.

Changes

Clock sync & deterministic shuffle

Layer / File(s) Summary
Playlist option flags
wled00/const.h
Adds PL_OPTION_DETERMINISTIC_SHUFFLE and PL_OPTION_CLOCK_SYNC bitmask constants.
Frontend playlist editor and option handler
wled00/data/index.js
Adds plUpdateOptions(p, changed), removes plM(p), wires clockSync and deterministic into makeP, backfills missing fields on expand, and updates UI tooltips.
Function declaration cleanup
wled00/fcn_declare.h
Removes global declaration of shufflePlaylist().
Backend ordering, state, and clock helpers
wled00/playlist.cpp
Adds prng.h, playlistOrder, playlistCycleNum, deterministic shuffle helpers, unload cleanup, and clock-sync mapping helpers.
Load initialization and option parsing
wled00/playlist.cpp
Allocates/initializes playlistOrder, computes playlistTotalDur, and parses r, deterministic, and clockSync fields from playlist JSON.
Playback control and serialization
wled00/playlist.cpp
Refactors handlePlaylist() to use playlistOrder, applies clock-sync cycle mapping when enabled (disables manual-advance persistence), updates active-entry application, and persists r, deterministic, and clockSync in serializePlaylist().

Sequence Diagram (high-level)

sequenceDiagram
  participant Frontend
  participant PlaylistManager
  participant TokiTime
  participant Strip
  Frontend->>PlaylistManager: set clockSync / deterministic via plUpdateOptions
  PlaylistManager->>TokiTime: request current cycle (getClockSyncPlaylistCycle)
  TokiTime-->>PlaylistManager: cycle number + elapsed in cycle
  PlaylistManager->>PlaylistManager: mapCycleTimeToPlaylistSlot -> select playlistOrder[index]
  PlaylistManager->>Strip: applyPlaylistEntry(selectedEntry)
Loading

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers:

  • softhack007
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add clocked playlist scheduling' directly and clearly summarizes the main change: introducing optional clock-synchronized playlist mode that derives active entries from wall-clock time rather than stateful progression.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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
Copy Markdown
Contributor

@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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
wled00/playlist.cpp (1)

151-166: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate clocked entry durations at load time.

loadPlaylist() accepts clocked:true with dur == 0, but getClockedPlaylistState() later rejects any zero-duration entry and the clocked branch just returns. That means API callers or edited presets.json can load a clocked playlist that never starts advancing, even though the UI normalizes these values before save.

Suggested ingress-side normalization
-  shuffle = shuffle || playlistObj["r"];
-  if (playlistObj[F("clocked")] | false) playlistOptions |= PL_OPTION_CLOCKED;
+  shuffle = shuffle || playlistObj["r"];
+  const bool clocked = playlistObj[F("clocked")] | false;
+  if (clocked) {
+    for (byte i = 0; i < playlistLen; i++) {
+      if (playlistEntries[i].dur == 0) playlistEntries[i].dur = 10000UL; // match UI default: 10.0s
+    }
+    playlistOptions |= PL_OPTION_CLOCKED;
+  }
   if (shuffle) playlistOptions |= PL_OPTION_SHUFFLE;

As per coding guidelines, "Trust Boundary Model — enforce input-validation and bounds-checking rules ONLY at the first untrusted ingress point."

Also applies to: 198-199

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wled00/playlist.cpp` around lines 151 - 166, loadPlaylist() currently allows
clocked playlists to be loaded with zero-duration entries which later causes
getClockedPlaylistState() to reject the playlist; enforce validation at load
time by normalizing any dur==0 to a safe non-zero fallback (e.g., the default
100 tenths -> 10000 ms) or to the previous valid duration when parsing JsonArray
durations and the single dur field, updating playlistEntries[].dur accordingly;
modify the code paths that parse playlistObj["dur"] and the durations loop (and
the analogous block at the other occurrence around the 198-199 mention) to
replace zero durations with the fallback before storing so clocked playlists are
always valid after load.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@wled00/playlist.cpp`:
- Around line 224-255: The clocked-path returns early and therefore skips the
existing rollover/repeat/end-preset handling; update the PL_OPTION_CLOCKED
branch (the block guarded by playlistOptions & PL_OPTION_CLOCKED and using
getClockedPlaylistState, derivedIndex, entryOffset) so that after syncing
playlistIndex/timebase and applying the new preset it still runs the common
end-of-entry logic that handles playlistRepeat decrement, unloading the
playlist, and applying playlistEndPreset (i.e., do not return before the
existing repeat/end handling), or explicitly invoke that same rollover logic
from here (ensuring nightlightActive, doAdvancePlaylist, playlistRepeat and
playlistEndPreset are processed correctly) rather than bypassing it with an
early return.

---

Outside diff comments:
In `@wled00/playlist.cpp`:
- Around line 151-166: loadPlaylist() currently allows clocked playlists to be
loaded with zero-duration entries which later causes getClockedPlaylistState()
to reject the playlist; enforce validation at load time by normalizing any
dur==0 to a safe non-zero fallback (e.g., the default 100 tenths -> 10000 ms) or
to the previous valid duration when parsing JsonArray durations and the single
dur field, updating playlistEntries[].dur accordingly; modify the code paths
that parse playlistObj["dur"] and the durations loop (and the analogous block at
the other occurrence around the 198-199 mention) to replace zero durations with
the fallback before storing so clocked playlists are always valid after load.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 55ae4f6b-0105-46d5-a155-06c8f1cf88ed

📥 Commits

Reviewing files that changed from the base of the PR and between bd0ebf9 and a8ae705.

📒 Files selected for processing (3)
  • wled00/const.h
  • wled00/data/index.js
  • wled00/playlist.cpp

Comment thread wled00/playlist.cpp Outdated
@softhack007
Copy link
Copy Markdown
Member

A clocked playlist derives its active entry from synchronized Toki time, so multiple WLED controllers with matching playlists and presets can stay aligned without continuously communicating with each other.

@aeoa can you explain how this feature behaves different from time-controlled presets (settings -> time & macros -> Time-Controlled Presets) ?

@softhack007 softhack007 added enhancement AI Partly generated by an AI. Make sure that the contributor fully understands the code! labels Jun 3, 2026
@aeoa
Copy link
Copy Markdown
Author

aeoa commented Jun 3, 2026

A clocked playlist derives its active entry from synchronized Toki time, so multiple WLED controllers with matching playlists and presets can stay aligned without continuously communicating with each other.

@aeoa can you explain how this feature behaves different from time-controlled presets (settings -> time & macros -> Time-Controlled Presets) ?

Time-Controlled Presets trigger a specific preset / playlist at a specific time of day.
Clocked playlists are continuous wall-time-derived playback. The intended use case is multiple controllers with the same playlist: each controller derives the active playlist entry and effect offset from synced time, so they select the same preset within the playlist at the same wall-clock moments, regardless of when the playlist was loaded.

@DedeHai
Copy link
Copy Markdown
Collaborator

DedeHai commented Jun 4, 2026

do I understand correctly that what this tries to solve is time drift of different controllers over long periods of time where a playlist plays? like several hours? how much drift did you observe without this PR?

@aeoa
Copy link
Copy Markdown
Author

aeoa commented Jun 4, 2026

do I understand correctly that what this tries to solve is time drift of different controllers over long periods of time where a playlist plays? like several hours? how much drift did you observe without this PR?

The actual problem I am trying to solve is this: I have several separate mobile LED installations, each with their own controller but with the same playlist preset set to apply on boot. I want those installation to show the same effect at the same time such that they appear as one connected unit. We cannot rely on the WLED sync feature, because there is no reliable WIFI connection, and the controllers may not all be on the same network.

The feature of this PR is that clocked playlists schedule their entries in a deterministic manner relative to wall clock. Say we have a playlist with three presets A, B, and C, each with a duration of 10s. Each controller is started at a different point in time. Until first NTP time fix, the first effect of the playlist is shown as a fallback. Once wall time is available, the playlist determines the correct effect for the current point in time and stays locked to wall time.

Wall clock:      0s        10s        20s        30s        40s
Timeline:        A----------B----------C----------A----------B---- ...

Controller 1:
Before NTP:                   A---
After NTP:                        B----C----------A----------B---- ...

Controller 2:
Before NTP:                                A---
After NTP:                                     C--A----------B---- ...

This also works for shuffled playlists, because the seed for the randomization is also derived from the wall clock.

The actual time synchronization is left to the Toki implementation already present in WLED. With NTP, time is only updated about twice a day. Within that time frame, the controller clocks may drift apart by a couple of seconds. Perhaps another PR could make that NTP update interval configurable.

@DedeHai
Copy link
Copy Markdown
Collaborator

DedeHai commented Jun 4, 2026

ok got it.
while this seems like a useful feature it is very specific - multiple controllers, need sync, controllers on different wifi's - not something that has broad use IMHO. This should be implemented as a usermod.

@softhack007
Copy link
Copy Markdown
Member

softhack007 commented Jun 4, 2026

This also works for shuffled playlists, because the seed for the randomization is also derived from the wall clock.

Did your AI tell you? Because the playlist shuffle code uses the hardware random generator, which is not pseudo-random but a true random number based on entropy. random() does not use any seed that is based on time.

int randomIndex = random(0, currentIndex);

WLED/wled00/fcn_declare.h

Lines 484 to 485 in bd0ebf9

#define random hw_random // replace arduino random()
inline uint32_t hw_random() { return HW_RND_REGISTER; };

@aeoa
Copy link
Copy Markdown
Author

aeoa commented Jun 4, 2026

This also works for shuffled playlists, because the seed for the randomization is also derived from the wall clock.

Did your AI tell you? Because the playlist shuffle code uses the hardware random generator, which is not pseudo-random but a true random number based on entropy. random() does not use any seed that is based on time.

int randomIndex = random(0, currentIndex);

WLED/wled00/fcn_declare.h

Lines 484 to 485 in bd0ebf9

#define random hw_random // replace arduino random()
inline uint32_t hw_random() { return HW_RND_REGISTER; };

Normal playlists still use true randomness through shufflePlaylist(). This function shuffles the playlistEntries in-place. This is stateful and thus cannot be used for clocked playlists.

Instead, a separate set of RNG functions are used for clocked playlists:
https://github.com/aeoa/WLED/blob/5d3853872181f79dd1fb792cb18234abc04d6f8a/wled00/playlist.cpp#L53:L67

The seed is computed from the playlist cycle, which is computed from the wall time and playlist duration:
https://github.com/aeoa/WLED/blob/5d3853872181f79dd1fb792cb18234abc04d6f8a/wled00/playlist.cpp#L97:L100

The seed is then used shuffle an array of playlist entry indices:
https://github.com/aeoa/WLED/blob/5d3853872181f79dd1fb792cb18234abc04d6f8a/wled00/playlist.cpp#L104:L113

This is then used in handlePlaylist().

Comment thread wled00/playlist.cpp Outdated
@softhack007
Copy link
Copy Markdown
Member

while this seems like a useful feature it is very specific - multiple controllers, need sync, controllers on different wifi's - not something that has broad use IMHO. This should be implemented as a usermod.

@DedeHai i share your view about this feature is "just" for a very specific usecase. However it might be very hard to implement it as a usermod, without re-implementing playlists at usermod level. Maybe its better to have the "clocked playlist steps" encapsulated with a feature flag (like WLED_ENABLE_TIME_IN_PLAYLIST), disabled by default?

Any other feature flag name that is not too long should be ok...

As a side-effect, we'd need to test that a "timed playlist step" will be ignored by a normal build.

@willmmiles
Copy link
Copy Markdown
Member

I think there's a valid use case for a feature like this, but I really don't like the implementation by creating a second, independent code path in the playlist logic. This approach will create a maintenance burden for any future playlist features: they'll have to be implemented twice. The fact that you had to consider nightLightActive, handle zero-duration entries and repeats as special cases is an architecture smell. The existence of applyPlaylistEntry as a function is another: if there are multiple paths that need to call it, something has gone wrong with the code design.

(FWIW, this is a common problem with AI-generated code architectures -- it's biased towards copy-and-paste and bolting on new logic as an aside, "minimizing changes to the existing code", vs. rethinking the design to accommodate new goals properly.)

Instead I suggest breaking down the feature into its primitive components:

  • An option to use a synchronizable PRNG for shuffling playlists. I think this is a useful feature in isolation and an easy extension to shufflePlaylist().
  • An option to choose the clock type for playlist calculations (millis() vs Toki::Time -- or CLOCK_MONOTONIC vs CLOCK_REALTIME for my unix friends).
  • "Skip ahead" handling for the CLOCK_REALTIME case. An explicit resync on a change of toki::getTimeSource() might be necessary.

The latter two could be implemented in the context of the existing handlePlaylist() function by augmenting the test of whether or not to change the preset, while keeping the apply handling as-is.

Zero-duration entries should be treated with the existing semantics, ie. treated as terminal when applied. The fallback logic isn't worth the code complexity and weird special cases -- documenting exceptions to the usual behaviour is a huge pain in the butt and makes it hard for people to understand.

On the bikeshed front: I don't love "clocked" as a name, either -- the past-tense form makes me think of hardware devices with clock strobe lines. Maybe "real time" or "wall time" or "absolute time". Thoughts?

@DedeHai
Copy link
Copy Markdown
Collaborator

DedeHai commented Jun 4, 2026

fully agree with @willmmiles
also: the static array is a no no.

re: naming
I also do not like clocked as a name, the UI should be as intuitive as possible, few do RTFM so using a name that has intrinsic meaning is key. I would throw in "clock mode" - it's a pity english does have double meanings for both clock and watch ;)

Copy link
Copy Markdown
Contributor

@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.

🧹 Nitpick comments (1)
wled00/playlist.cpp (1)

190-198: 💤 Low value

Clock-sync without deterministic shuffle may cause controller desync.

When clockSync is enabled but deterministic is false (or r is false), controllers will be on the same cycle position but with different shuffle orders. The PR objectives state "Shuffle for clocked playlists is deterministic per wall-clock playlist cycle" — consider whether enabling clockSync should implicitly enable deterministic shuffle when shuffle is active, or at minimum document this behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wled00/playlist.cpp` around lines 190 - 198, When clockSync is set but
deterministic shuffle isn't, controllers can desync because they share cycle
position but not shuffle order; update the logic in playlist.cpp (around
playlistObj, shuffle, clockSync, deterministicShuffle, and playlistOptions) so
that if clockSync is true and shuffle is active you also set
deterministicShuffle (i.e., ensure PL_OPTION_DETERMINISTIC_SHUFFLE is enabled
whenever PL_OPTION_CLOCK_SYNC and PL_OPTION_SHUFFLE are set), or at minimum add
a comment documenting this implicit behavior; adjust the condition that
currently sets PL_OPTION_DETERMINISTIC_SHUFFLE to check clockSync as well and
set playlistOptions accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@wled00/playlist.cpp`:
- Around line 190-198: When clockSync is set but deterministic shuffle isn't,
controllers can desync because they share cycle position but not shuffle order;
update the logic in playlist.cpp (around playlistObj, shuffle, clockSync,
deterministicShuffle, and playlistOptions) so that if clockSync is true and
shuffle is active you also set deterministicShuffle (i.e., ensure
PL_OPTION_DETERMINISTIC_SHUFFLE is enabled whenever PL_OPTION_CLOCK_SYNC and
PL_OPTION_SHUFFLE are set), or at minimum add a comment documenting this
implicit behavior; adjust the condition that currently sets
PL_OPTION_DETERMINISTIC_SHUFFLE to check clockSync as well and set
playlistOptions accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e74b0c5e-32e5-4d8b-b5ab-58ebdc1555d4

📥 Commits

Reviewing files that changed from the base of the PR and between 884b517 and 4dee167.

📒 Files selected for processing (4)
  • wled00/const.h
  • wled00/data/index.js
  • wled00/fcn_declare.h
  • wled00/playlist.cpp
💤 Files with no reviewable changes (1)
  • wled00/fcn_declare.h

@aeoa
Copy link
Copy Markdown
Author

aeoa commented Jun 5, 2026

Thank you for the good feedback @willmmiles. I have reworked the implementation according to your suggestions.

Changes:

  • I have renamed clocked to clock sync. What do you think?
  • Deterministic shuffle and wall clock sync are now separate features that work independently and have their own checkboxes.
    • Only enabling deterministic shuffle will produce the same pseudo-random order every time the playlist is triggered.
    • Only enabling clock sync (and normal shuffle) causes effects to be switched at the same time, but different ones are chosen on each controller.
  • Finite repetitions and end preset now also work for clock sync mode. The initial sync with wall time will probably cause one repetition to be considered done.
  • Zero-durations are no longer normalized. In clock sync mode, they are currently implicitly skipped / ignored since the function mapCycleTimeToPlaylistSlot will never select them. To me this seems fine, I cannot come up with a good reason to combine infinite entry durations with clock synched playlists. Doing this properly seems complicated.
  • There is only one shuffle function that supports both PRNG and hardware random.
  • There is a now playlistOrder index array that is allocated together with playlistEntries. Shuffling now only changes the order of playlistOrder. This has the advantage that the order can be easily reset, which is needed for deterministic shuffle. Normal shuffle no longer stacks on previous shuffles, the order is always reset, then shuffled. Should not make a difference for truly random shuffles.
  • The handlePlaylist function now has one shared path instead of a completely separate function for clock sync. It is still a bit more complex than I'd like it to be, but there is now only one place for handling rollovers, shuffle, and applying presets.
  • A shared UI handler for Shuffle, Deterministic, Clock sync, Manual advance, Repeat (indefinitely) and End preset to enforce constraints.

I have tested this a bit on two ESP32C3, and it seems to do the right thing. I have not looked into the nightlight.

Does this go in the right direction?

@willmmiles
Copy link
Copy Markdown
Member

Looking much better! I don't have time to do a deep review today, unfortunately - I'll give more detailed feedback next week. Some quick thoughts:

  • The memory cost of storing the shuffle order separately from the playlist order is not insignificant, and we all pay the cost, needed or not. I'd like to think about alternatives before committing to that approach. (I understand your objective is to support late joiners.)
  • Avoid running mapCycleTimeToPlaylistSlot() on every pass -- it's expensive to run an O(n) check every loop(). Instead check the time against the current playlist item duration (or end time), like the "regular" playlist logic. The initial mapping can be done at playlist load, and an explicit remap performed only if toki::getTimeSource() changes (a much faster check). Otherwise it should be safe to assume that any further clock adjustments are small, and we don't need to worry about jumping ahead or backwards very far.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI Partly generated by an AI. Make sure that the contributor fully understands the code! enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants