Repository: LedFx/LedFx
Pull request #1306: Feature/refactored oneshot
Author: login: goodfaiter
Created: 2025-03-14T14:37:09Z | Merged: 2025-03-27T22:19:34Z

Hello!

This PR has:

- Moved oneshot logic into its own class and file
- Updated restAPI with brightness (which I sneaked into this but its a very small change)
- Oneshots are now a list inside virtual 'Virtual', which means we can add multiple oneshots at any time

This PR is a first step towards easier addition of oneshots. Next step (which I have already done for next PR) are:

- Move from PUT to POST (since we are now "adding" rather than modifying existing virtual)
- Rename oneshot to flash everywhere (I kept it as is for now to avoid complicated PRs)
- Add new oneshots such as wave, ripple, firework (firework utilizes both PUT and POST api hence the change)

I have been using this setup along with a midi keyboard for a while to add a manual "extra" to the lights. Figured it would be nice for other people as well so decided to make this PR.

Hope it looks good!

Cheers,

Val.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

## Summary by CodeRabbit

- **New Features**
	- Oneshoot visual effects now support brightness adjustments, offering more nuanced control over lighting.
- **Documentation**
	- API guides have been updated to include instructions on the new brightness parameter for the oneshot tool.
- **Refactor**
	- The effect processing workflow has been streamlined for improved performance and smoother visual transitions, transitioning to an object-oriented approach for managing oneshot effects.
- **Chores**
	- Minor formatting adjustments made to the dependabot configuration file for consistency.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


Diff:
diff --git a/docs/api.md b/docs/api.md
index 4d335ec6c..c1f2ffe1e 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -766,6 +766,7 @@ Repeated oneshot will overwrite the previous oneshot if has not finished
 - ramp: The time in ms over which to ramp the color from zero to full weight over the active  effect
 - hold: The time in ms to hold the color to full weight over the active effect
 - fade: The time in ms to fade the color from full weight to zero over the active effect
+- brightness: The brightness of the oneshot at the beginning. Defaults to 1.0 which is maximum brightness
 
 If all values for ramp, hold and fade are zero, which is default, any
 exisiting oneshot will be cleared
@@ -779,7 +780,8 @@ oneshot that is executing
     "color":"white",
     "ramp":10,
     "hold":200,
-    "fade":2000
+    "fade":2000,
+    "brightness":1
 }
 ```
 
@@ -919,6 +921,7 @@ Repeated oneshot to a virtual will overwrite the previous oneshot if has not fin
 - ramp: The time in ms over which to ramp the color from zero to full weight over the active effect
 - hold: The time in ms to hold the color to full weight over the active effect
 - fade: The time in ms to fade the color from full weight to zero over the active effect
+- brightness: The brightness of the oneshot at the beginning. Defaults to 1.0 which is maximum brightness
 
 If all values for ramp, hold and fade are zero, which is default, any exisiting oneshot will be cleared
 
@@ -930,7 +933,8 @@ A bare call to onsshot will result in a hard disable of any existing oneshot tha
     "color":"white",
     "ramp":10,
     "hold":200,
-    "fade":2000
+    "fade":2000,
+    "brightness":1
 }
 ```
 
diff --git a/ledfx/api/virtual_tools.py b/ledfx/api/virtual_tools.py
index be1983858..54d099e86 100644
--- a/ledfx/api/virtual_tools.py
+++ b/ledfx/api/virtual_tools.py
@@ -5,6 +5,7 @@
 
 from ledfx.api import RestEndpoint
 from ledfx.color import parse_color, validate_color
+from ledfx.effects.oneshots.oneshot import Flash
 
 _LOGGER = logging.getLogger(__name__)
 TOOLS = ["force_color", "oneshot"]
@@ -48,11 +49,11 @@ async def put(self, request: web.Request) -> web.Response:
                     virtual.force_frame(parse_color(validate_color(color)))
 
         if tool == "oneshot":
-            color = data.get("color", "white")
-
+            color = parse_color(validate_color(data.get("color", "white")))
             ramp = data.get("ramp", 0)
             hold = data.get("hold", 0)
             fade = data.get("fade", 0)
+            brightness = data.get("brightness", 1)
 
             # if all values are zero, we will now just ensure any current
             # oneshot are cancelled
@@ -61,8 +62,8 @@ async def put(self, request: web.Request) -> web.Response:
             for virtual_id in self._ledfx.virtuals:
                 virtual = self._ledfx.virtuals.get(virtual_id)
                 if virtual is not None:
-                    virtual.oneshot(
-                        parse_color(validate_color(color)), ramp, hold, fade
+                    virtual.add_oneshot(
+                        Flash(color, ramp, hold, fade, brightness)
                     )
 
         effect_response = {}
diff --git a/ledfx/api/virtuals_tools.py b/ledfx/api/virtuals_tools.py
index 2ce98b254..c8cd8ea79 100644
--- a/ledfx/api/virtuals_tools.py
+++ b/ledfx/api/virtuals_tools.py
@@ -6,6 +6,7 @@
 from ledfx.api import RestEndpoint
 from ledfx.color import parse_color, validate_color
 from ledfx.config import save_config
+from ledfx.effects.oneshots.oneshot import Flash
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -101,18 +102,19 @@ async def put(self, virtual_id, request) -> web.Response:
                 )
 
         if tool == "oneshot":
-            color = data.get("color", "white")
-
+            color = parse_color(validate_color(data.get("color", "white")))
             ramp = data.get("ramp", 0)
             hold = data.get("hold", 0)
             fade = data.get("fade", 0)
+            brightness = data.get("brightness", 1)
 
             # if all values are zero, we will now just ensure any current
             # oneshot are cancelled
 
-            result = virtual.oneshot(
-                parse_color(validate_color(color)), ramp, hold, fade
+            result = virtual.add_oneshot(
+                Flash(color, ramp, hold, fade, brightness)
             )
+
             if result is False:
                 return await self.invalid_request("oneshot failed")
 
diff --git a/ledfx/effects/oneshots/oneshot.py b/ledfx/effects/oneshots/oneshot.py
new file mode 100644
index 000000000..c5b4c3818
--- /dev/null
+++ b/ledfx/effects/oneshots/oneshot.py
@@ -0,0 +1,86 @@
+import timeit
+from abc import ABC, abstractmethod
+
+import numpy as np
+
+
+class Oneshot(ABC):
+    """
+    Base class for all oneshot types.
+
+    Oneshot is an effect that is triggered manually (through, for example, REST API).
+    """
+
+    def __init__(self):
+        self._active: bool = True
+        self._pixel_count: int = 0
+
+    @abstractmethod
+    def init(self):
+        raise NotImplementedError("Please implement this method")
+
+    @abstractmethod
+    def update(self):
+        raise NotImplementedError("Please implement this method")
+
+    @abstractmethod
+    def apply(self, seg, start, stop):
+        raise NotImplementedError("Please implement this method")
+
+    @property
+    def active(self):
+        return self._active
+
+    @property
+    def pixel_count(self):
+        return self._pixel_count
+
+    @pixel_count.setter
+    def pixel_count(self, pixel_count):
+        self._pixel_count = pixel_count
+
+
+class Flash(Oneshot):
+
+    def __init__(self, color, ramp, hold, fade, brightness):
+        """
+        Force all pixels in virtual to color over a time envelope defined in ms.
+
+        Parameters:
+            color of the flash
+            ramp time from in ms
+            hold time in ms
+            fade time in ms
+            brightness of the flash
+        Returns:
+            True if oneshot was activated, False if not
+        """
+        super().__init__()
+        self._color = np.array(color, dtype=float) * brightness
+        self._ramp = ramp / 1000.0
+        self._hold = hold / 1000.0
+        self._fade = fade / 1000.0
+        self._start = timeit.default_timer()
+        self._hold_end = self._ramp + self._hold
+        self._fade_end = self._ramp + self._hold + self._fade
+        self._weight = 0.0
+
+    def init(self):
+        return
+
+    def update(self):
+        passed = timeit.default_timer() - self._start
+        if passed <= self._ramp:
+            self._weight = passed / self._ramp
+        elif passed <= self._hold_end:
+            self._weight = 1.0
+        elif passed <= self._fade_end:
+            self._weight = (self._fade_end - passed) / self._fade
+        else:
+            self._active = False
+            self._weight = 0.0
+
+    def apply(self, seg, start, stop):
+        blend = np.multiply(self._color, self._weight)
+        np.multiply(seg, 1 - self._weight, seg)
+        np.add(seg, blend, seg)
diff --git a/ledfx/virtuals.py b/ledfx/virtuals.py
index 2e5d33ca5..3b5776b21 100644
--- a/ledfx/virtuals.py
+++ b/ledfx/virtuals.py
@@ -20,6 +20,7 @@
     MIN_FREQ_DIFFERENCE,
     FrequencyRange,
 )
+from ledfx.effects.oneshots.oneshot import Oneshot
 from ledfx.events import (
     EffectClearedEvent,
     EffectSetEvent,
@@ -156,6 +157,7 @@ def __init__(self, ledfx, config):
         self._hl_start = 0
         self._hl_end = 0
         self._hl_step = 1
+        self._oneshots = []
         self._os_active = False
         self.lock = threading.Lock()
         self.clear_handle = None
@@ -603,57 +605,6 @@ def _fire_update_event(self, frame=None):
             )
         )
 
-    def oneshot(self, color, ramp, hold, fade):
-        """
-        Force all pixels in virtual to color over a time envelope defined in ms
-        Following calls will override any active one shot
-
-        Parameters:
-            ramp time from in ms
-            hold time in ms
-            fade time in ms
-        Returns:
-            True if oneshot was activated, False if not
-        """
-        if self.active:
-            self._os_color = np.array(color, dtype=float)
-            self._os_ramp = ramp / 1000.0
-            self._os_hold = hold / 1000.0
-            self._os_fade = fade / 1000.0
-            self._os_start = timeit.default_timer()
-            self._os_hold_end = self._os_ramp + self._os_hold
-            self._os_fade_end = self._os_ramp + self._os_hold + self._os_fade
-            self._os_weight = 0.0
-
-            # if all total timings are zero, treat as a hard off
-            if self._os_fade_end == 0:
-                self._os_active = False
-            else:
-                self._os_active = True
-
-            result = True
-        else:
-            result = False
-        return result
-
-    def oneshot_weight(self):
-        passed = timeit.default_timer() - self._os_start
-        if passed <= self._os_ramp:
-            self._os_weight = passed / self._os_ramp
-        elif passed <= self._os_hold_end:
-            self._os_weight = 1.0
-        elif passed <= self._os_fade_end:
-            self._os_weight = (self._os_fade_end - passed) / self._os_fade
-        else:
-            self._os_active = False
-            self._os_weight = 0.0
-        # _LOGGER.info(f"oneshot_state: {passed} {self._os_ramp} {self._os_hold_end} {self._os_fade_end} {self._os_active} {self._os_weight}")
-
-    def oneshot_apply(self, seg):
-        blend = np.multiply(self._os_color, self._os_weight)
-        np.multiply(seg, 1 - self._os_weight, seg)
-        np.add(seg, blend, seg)
-
     def set_calibration(self, calibration):
         self._calibration = calibration
 
@@ -874,8 +825,15 @@ def flush(self, pixels=None):
         if pixels is None:
             pixels = self.assembled_frame
 
-        if self._os_active:
-            self.oneshot_weight()
+        # Where we update oneshots
+        oneshot_index = 0
+        while oneshot_index < len(self._oneshots):
+            oneshot = self._oneshots[oneshot_index]
+            oneshot.update()
+            if not oneshot.active:
+                self._oneshots.remove(oneshot)
+            else:
+                oneshot_index += 1
 
         if self._config["mapping"] == "span":
             # In span mode we can calculate the final pixels once for all segments
@@ -901,8 +859,9 @@ def flush(self, pixels=None):
                             device_end,
                         ) in segments:
                             seg = pixels[start:stop:step]
-                            if self._os_active:
-                                self.oneshot_apply(seg)
+                            # Where we override segment
+                            for oneshot in self._oneshots:
+                                oneshot.apply(seg, start, stop)
                             data.append((seg, device_start, device_end))
                     elif self._config["mapping"] == "copy":
                         for (
@@ -963,6 +922,14 @@ def render_calibration(
             )
             data.append((pattern, self._hl_start, self._hl_end))
 
+    def add_oneshot(self, oneshot: Oneshot):
+        if not self._active:
+            return False
+        oneshot.pixel_count = self.pixel_count
+        oneshot.init()
+        self._oneshots.append(oneshot)
+        return True
+
     @property
     def name(self):
         return self._config["name"]
@@ -997,6 +964,10 @@ def id(self) -> str:
     def segments(self):
         return self._segments
 
+    @property
+    def oneshots(self):
+        return self._oneshots
+
     @cached_property
     def _segments_by_device(self):
         """


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (10)</summary><blockquote>

<details>
<summary>docs/api.md (1)</summary><blockquote>

`782-783`: **Add brief range information for brightness.**  
Consider clarifying in the documentation that `"brightness"` supports values from 0 to 1 (or another acceptable range) to inform users of valid inputs.

</blockquote></details>
<details>
<summary>ledfx/api/virtual_tools.py (1)</summary><blockquote>

`56-56`: **Handle default brightness carefully.**  
Setting brightness to 0 by default is valid, but confirm this aligns with user expectations (i.e., lowest output vs. disabled?).

</blockquote></details>
<details>
<summary>ledfx/api/virtuals_tools.py (1)</summary><blockquote>

`109-109`: **Brightness default to 0.**  
As with the other file, confirm that 0 is an intended safe default and not a misleading value for end users.

</blockquote></details>
<details>
<summary>ledfx/virtuals.py (1)</summary><blockquote>

`829-832`: **Update and cleanup for each oneshot.**  
Iterating over the `_oneshots` and removing inactive ones is concise. However, be mindful of potential iteration pitfalls when removing items from the same list.  

Consider collecting inactive oneshots outside the loop or iterating over a copy to avoid potential skipping issues.

</blockquote></details>
<details>
<summary>ledfx/effects/oneshots/oneshot.py (6)</summary><blockquote>

`6-40`: **Well-structured abstract base class.**

The `Oneshot` class provides a solid foundation with clearly defined abstract methods and property accessors. This design establishes a clean contract for all oneshot effect implementations.

A few suggestions to enhance the implementation:
- Consider adding type hints to method parameters and return values
- The docstring could include examples of how to implement a concrete oneshot effect

---

`43-65`: **Incorrect docstring return value description.**

The docstring states "Returns: True if oneshot was activated, False if not", but constructors don't return values in Python. This appears to be leftover documentation from a function that was refactored.

Also, consider validating the brightness parameter to ensure it's within a valid range (typically 0-1).


```diff
-        Returns:
-            True if oneshot was activated, False if not
+        Note:
+            Creates a new Flash effect instance
```

---

`66-68`: **Empty method implementation.**

The `init` method is implemented but doesn't do anything. Consider either:
1. Adding a docstring explaining why this empty implementation exists
2. Removing it if it's not needed in this concrete class (the abstract method would still force subclasses to implement it)

---

`69-80`: **Time-based state management looks good.**

The update method handles the time-based envelope logic effectively, with proper transitions between ramp, hold, and fade phases.

Consider adding an early return if the oneshot is already inactive to avoid unnecessary calculations:


```diff
def update(self):
+    if not self._active:
+        return
    passed = timeit.default_timer() - self._start
    if passed <= self._ramp:
        self._weight = passed / self._ramp
    elif passed <= self._hold_end:
        self._weight = 1.0
    elif passed <= self._fade_end:
        self._weight = (self._fade_end - passed) / self._fade
    else:
        self._active = False
        self._weight = 0.0
```

---

`81-85`: **Efficient color blending implementation.**

The NumPy operations for blending colors are efficient. However, there are a few considerations:

1. There's no validation that `seg` is a NumPy array of the correct shape
2. The method modifies `seg` in-place, which should be documented
3. Consider ensuring `self._weight` is clamped between 0 and 1 before applying to prevent unexpected behavior


```diff
def apply(self, seg, start, stop):
+    # Ensure weight is between 0 and 1
+    weight = max(0.0, min(1.0, self._weight))
-    blend = np.multiply(self._color, self._weight)
-    np.multiply(seg, 1 - self._weight, seg)
+    blend = np.multiply(self._color, weight)
+    np.multiply(seg, 1 - weight, seg)
    np.add(seg, blend, seg)
```

---

`86-86`: **Missing newline at end of file.**

The file should end with a newline character to comply with standard conventions.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between a4c0ab892d160c39f26bb3cff582b833c58db37c and 61882e71268f4714f53818b402cdf7d10069c532.

</details>

<details>
<summary>📒 Files selected for processing (5)</summary>

* `docs/api.md` (2 hunks)
* `ledfx/api/virtual_tools.py` (3 hunks)
* `ledfx/api/virtuals_tools.py` (2 hunks)
* `ledfx/effects/oneshots/oneshot.py` (1 hunks)
* `ledfx/virtuals.py` (6 hunks)

</details>

<details>
<summary>🔇 Additional comments (13)</summary><blockquote>

<details>
<summary>docs/api.md (1)</summary>

`934-935`: **Ensure consistent brightness validation.**  
The repeated `"brightness"` property is helpful. Make sure all API endpoints and the backend consistently validate brightness input to avoid unexpected behavior.

</details>
<details>
<summary>ledfx/api/virtual_tools.py (3)</summary>

`8-8`: **New import for Flash.**  
This import is correct and aligns with the refactor to use a class-based oneshot approach.

---

`52-52`: **Color parsing logic is appropriate.**  
Directly parsing and validating user color input helps to avoid errors downstream.

---

`65-65`: **Creating the Flash object is a clean approach.**  
Constructing the oneshot effect with all relevant parameters is tidy and enhances readability.

</details>
<details>
<summary>ledfx/api/virtuals_tools.py (3)</summary>

`9-9`: **Flash import aligns with the new oneshot system.**  
Import declaration is correct and follows from the refactor.

---

`105-105`: **Color parameter handling is well-structured.**  
Using sensible defaults and validating user input is good practice.

---

`114-114`: **add_oneshot usage is consistent.**  
Integrating the new Flash effect via `virtual.add_oneshot()` ensures uniform effect handling across the system.

</details>
<details>
<summary>ledfx/virtuals.py (5)</summary>

`8-8`: **Adding Optional and List is suitable.**  
The use of type annotations improves clarity in the codebase.

---

`16-16`: **Oneshot import.**  
Importing the abstract base class is logical for typed references.

---

`160-160`: **Introduce self._oneshots list.**  
Maintaining active oneshots in a separate list nicely modularizes the effect management.

---

`921-928`: **add_oneshot method.**  
Attaching the effect to the virtual if active is a clean design. Returning boolean also helps calling code decide how to handle inactivate states.

---

`964-966`: **oneshots property.**  
Exposing `_oneshots` via a property is beneficial for monitoring and testing.

</details>
<details>
<summary>ledfx/effects/oneshots/oneshot.py (1)</summary>

`1-4`: **Appropriate imports for the functionality.**

The imports are well-chosen for the requirements of this file, utilizing ABC for abstract class definition, NumPy for efficient array operations, and timeit for precise timing measurements.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T14:41:49Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 1**

<details>
<summary>🧹 Nitpick comments (4)</summary><blockquote>

<details>
<summary>ledfx/virtuals.py (4)</summary><blockquote>

`8-8`: **Remove the unused `List` import.**

The code uses native type hints like `list[Oneshot]` instead of `List[Oneshot]`. Since `List` is not used, it should be removed to avoid lint warnings and keep imports clean.


Apply this diff to remove the unused import:
```diff
-from typing import List, Optional
+from typing import Optional
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 Ruff (0.8.2)</summary>

8-8: `typing.List` imported but unused

Remove unused import: `typing.List`

(F401)

</details>

</details>

---

`859-860`: **Ensure thread-safety when applying oneshot effects.**

The flush method does not appear to use a lock, but it mutates and iterates over `_oneshots`. If this method can be called concurrently, consider adding a lock or ensuring single-threaded usage to prevent race conditions.

---

`921-928`: **Add locking to guard insertion of new oneshots.**

When adding a new oneshot, no lock is acquired. If other parts of the code modify `_oneshots` in a concurrent context, this may cause race conditions.


Consider wrapping this method body in a thread lock to ensure thread-safety, for example:

```diff
 def add_oneshot(self, oneshot):
+    with self.lock:
         if not self._active:
             return False
         oneshot.pixel_count = self.pixel_count
         oneshot.init()
         self._oneshots.append(oneshot)
         return True
```

---

`963-966`: **Return a safe copy of the `_oneshots` list.**

Exposing the internal list directly may allow external code to modify it without proper synchronization. Consider returning an immutable view or a copy of this list.


```diff
@property
def oneshots(self):
-    return self._oneshots
+    return list(self._oneshots)
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 61882e71268f4714f53818b402cdf7d10069c532 and fc901be848e710c1f9d9d83827507c26723dbc78.

</details>

<details>
<summary>📒 Files selected for processing (5)</summary>

* `.github/dependabot.yml` (1 hunks)
* `ledfx/api/virtual_tools.py` (3 hunks)
* `ledfx/api/virtuals_tools.py` (2 hunks)
* `ledfx/effects/oneshots/oneshot.py` (1 hunks)
* `ledfx/virtuals.py` (7 hunks)

</details>

<details>
<summary>✅ Files skipped from review due to trivial changes (1)</summary>

* .github/dependabot.yml

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (2)</summary>

* ledfx/api/virtual_tools.py
* ledfx/api/virtuals_tools.py

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🪛 Ruff (0.8.2)</summary>

<details>
<summary>ledfx/virtuals.py</summary>

8-8: `typing.List` imported but unused

Remove unused import: `typing.List`

(F401)

</details>

</details>

</details>

<details>
<summary>🔇 Additional comments (6)</summary><blockquote>

<details>
<summary>ledfx/virtuals.py (2)</summary>

`23-23`: **No concerns with importing `Oneshot`.**

---

`160-160`: **Good addition for managing multiple oneshot effects.**

</details>
<details>
<summary>ledfx/effects/oneshots/oneshot.py (4)</summary>

`1-3`: **Imports and abstract-method usage look good.**

---

`4-6`: **NumPy import is appropriate for vector operations.**

---

`7-42`: **Solid abstract base class design.**

The `Oneshot` class properly follows the ABC pattern, declares abstract methods, and maintains key properties. No issues found.

---

`43-87`: **Well-structured `Flash` effect implementation.**

The time-based ramp/hold/fade logic is clear. Applying the color blend via NumPy operations is concise. Clamping is handled downstream in `virtuals.py`, so no changes required.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T14:51:44Z
user: login: goodfaiter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T15:08:48Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T15:09:19Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (3)</summary><blockquote>

<details>
<summary>ledfx/virtuals.py (3)</summary><blockquote>

`925-931`: **Consider adding thread safety to the add_oneshot method.**

While the method correctly initializes the oneshot with the pixel count, it doesn't use locks when adding to the `_oneshots` list, which could potentially lead to thread safety issues if multiple threads access this method simultaneously.

Consider wrapping the method with the existing lock:

```diff
def add_oneshot(self, oneshot: Oneshot):
+   with self.lock:
        if not self._active:
            return False
        oneshot.pixel_count = self.pixel_count
        oneshot.init()
        self._oneshots.append(oneshot)
        return True
```

---

`925-931`: **Add documentation for the new method.**

The new `add_oneshot` method would benefit from a docstring explaining its purpose, parameters, and return value.

Add documentation like this:

```diff
def add_oneshot(self, oneshot: Oneshot):
+   """
+   Adds a oneshot effect to the virtual device.
+   
+   Args:
+       oneshot (Oneshot): The oneshot effect to add
+       
+   Returns:
+       bool: True if the oneshot was added successfully, False otherwise
+   """
    if not self._active:
        return False
    oneshot.pixel_count = self.pixel_count
    oneshot.init()
    self._oneshots.append(oneshot)
    return True
```

---

`967-969`: **Add documentation for the new property.**

The new `oneshots` property would benefit from a docstring explaining what it exposes.

Add documentation like this:

```diff
@property
def oneshots(self):
+   """
+   Returns the list of active oneshot effects.
+   
+   Returns:
+       List[Oneshot]: List of active oneshot effects
+   """
    return self._oneshots
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between c20ef32199ac0d97d33f8d807e2224f39bc2d343 and ba8e74211801cd3d62898188ed2289b9a16ed91f.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `ledfx/virtuals.py` (6 hunks)

</details>

<details>
<summary>🔇 Additional comments (4)</summary><blockquote>

<details>
<summary>ledfx/virtuals.py (4)</summary>

`23-23`: **Added import for the new Oneshot class.**

This import supports the refactored oneshot functionality which has been moved into its own dedicated class as mentioned in the PR objectives.

---

`160-160`: **Good initialization of the oneshots list.**

This change aligns with the PR objective of storing oneshots in a list within a virtual entity, enabling multiple oneshots to be added simultaneously.

---

`828-836`: **Well-implemented solution for iterating and removing oneshots.**

The manual index management approach correctly handles the removal of inactive oneshots during iteration without skipping elements - exactly as agreed upon in the previous review discussion.

---

`863-864`: **Properly applied oneshots to each segment.**

This implementation correctly iterates through all oneshots and applies them to the appropriate segment, replacing the previous oneshot application logic.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T15:23:43Z
user: login: bigredfrog
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-15T13:50:01Z
user: login: bigredfrog
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-15T13:52:51Z
user: login: goodfaiter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-15T18:38:42Z
user: login: goodfaiter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-15T18:39:32Z
user: login: bigredfrog
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-15T18:59:34Z
user: login: bigredfrog
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-15T19:00:09Z
user: login: bigredfrog
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-15T19:01:20Z
user: login: bigredfrog
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-15T19:05:56Z
user: login: bigredfrog
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-16T15:59:30Z
user: login: bigredfrog
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-16T16:00:50Z
user: login: bigredfrog
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-16T16:04:30Z
user: login: bigredfrog
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-16T16:06:50Z
user: login: goodfaiter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T15:23:12Z
user: login: goodfaiter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T15:23:45Z
user: login: goodfaiter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T15:25:35Z
user: login: goodfaiter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T15:33:40Z
user: login: bigredfrog
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T22:53:18Z
user: login: bigredfrog
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-23T21:53:55Z
user: login: bigredfrog
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-23T21:55:22Z
user: login: bigredfrog
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-23T22:04:00Z
user: login: bigredfrog
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-23T23:03:09Z
user: login: goodfaiter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T16:31:55Z

Review comments:
diff_hunk: @@ -874,8 +825,11 @@ def flush(self, pixels=None):
         if pixels is None:
             pixels = self.assembled_frame
 
-        if self._os_active:
-            self.oneshot_weight()
+        # Where we update oneshots
+        for oneshot in self._oneshots:
+            oneshot.update()
+            if not oneshot.active:
+                self._oneshots.remove(oneshot)
path: ledfx/virtuals.py
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Fix removal of elements from list during iteration.**

Removing items from the list within the same iteration can skip checks for subsequent elements. Consider iterating over a copy of `_oneshots` or deferring removal until after the loop.


Apply this diff to avoid skipping items:
```diff
-for oneshot in self._oneshots:
-    oneshot.update()
-    if not oneshot.active:
-        self._oneshots.remove(oneshot)
+for oneshot in self._oneshots[:]:
+    oneshot.update()
+    if not oneshot.active:
+        self._oneshots.remove(oneshot)
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **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.

`````suggestion
        # Where we update oneshots
        for oneshot in self._oneshots[:]:
            oneshot.update()
            if not oneshot.active:
                self._oneshots.remove(oneshot)
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-03-14T14:51:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -874,8 +825,11 @@ def flush(self, pixels=None):
         if pixels is None:
             pixels = self.assembled_frame
 
-        if self._os_active:
-            self.oneshot_weight()
+        # Where we update oneshots
+        for oneshot in self._oneshots:
+            oneshot.update()
+            if not oneshot.active:
+                self._oneshots.remove(oneshot)
path: ledfx/virtuals.py
user: login: goodfaiter
body: What about something like this:

```
        oneshot_index = 0
        while oneshot_index < len(self._oneshots):
            oneshot = self._oneshots[oneshot_index]
            oneshot.update()
            if not oneshot.active:
                self._oneshots.remove(oneshot)
            else:
                oneshot_index += 1
```
created_at: 2025-03-14T15:08:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -874,8 +825,11 @@ def flush(self, pixels=None):
         if pixels is None:
             pixels = self.assembled_frame
 
-        if self._os_active:
-            self.oneshot_weight()
+        # Where we update oneshots
+        for oneshot in self._oneshots:
+            oneshot.update()
+            if not oneshot.active:
+                self._oneshots.remove(oneshot)
path: ledfx/virtuals.py
user: login: coderabbitai[bot]
body: @goodfaiter Yes, your solution is valid and would work correctly! The while loop with manual index management properly handles the deletion case by only incrementing the index when an item isn't removed.

Your approach has the advantage of being slightly more memory efficient since it doesn't create a temporary copy of the list. It's particularly beneficial if the list could be large.

Both solutions are common patterns in Python for safely modifying a list during iteration. The slice copy (`[:]`) approach is more idiomatic Python, while your approach gives more explicit control over the iteration process.

Either solution will fix the issue - it's up to your preference. Your version is good to go!

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-03-14T15:09:19Z
author_association: CONTRIBUTOR
path: .github/dependabot.yml
user: login: bigredfrog
body: Seems odd to have an unrelated non human readable change, is there a reason this is necassary, otherwise revert the file.
created_at: 2025-03-15T13:50:01Z
author_association: CONTRIBUTOR
path: docs/api.md
user: login: bigredfrog
body: Will look at the code as well, but for extensively purposes we need to make sure new params are optional. So I would mention that in the change to the docs here.
created_at: 2025-03-15T13:52:51Z
author_association: CONTRIBUTOR
path: .github/dependabot.yml
user: login: goodfaiter
body: Can do. This change was not done by me but by pre-commit.ci and I think it will overwrite my change. How do I go about this?
created_at: 2025-03-15T18:38:42Z
author_association: CONTRIBUTOR
path: docs/api.md
user: login: goodfaiter
body: I believe it is optional but I can double check. You mean that it should be explicitly stated in the docs?
created_at: 2025-03-15T18:39:32Z
author_association: CONTRIBUTOR
path: .github/dependabot.yml
user: login: bigredfrog
body: There should be a way you remove that file from your PR, i am never sure though if I am deleting a change or deleting a file! So be careful!
created_at: 2025-03-15T18:59:34Z
author_association: CONTRIBUTOR
path: docs/api.md
user: login: bigredfrog
body: Yeah, be explicit int he docs, basically anything new has to be optional / backwards compatible.
created_at: 2025-03-15T19:00:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -48,11 +49,11 @@ async def put(self, request: web.Request) -> web.Response:
                     virtual.force_frame(parse_color(validate_color(color)))
 
         if tool == "oneshot":
-            color = data.get("color", "white")
-
+            color = parse_color(validate_color(data.get("color", "white")))
             ramp = data.get("ramp", 0)
             hold = data.get("hold", 0)
             fade = data.get("fade", 0)
+            brightness = data.get("brightness", 0)
path: ledfx/api/virtual_tools.py
user: login: bigredfrog
body: Should brightness default to 1, not zero? If old api call has ko brightness, then I am assuming we want full?

Woll peruse more code, but still phone only.
created_at: 2025-03-15T19:01:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -874,8 +825,15 @@ def flush(self, pixels=None):
         if pixels is None:
             pixels = self.assembled_frame
 
-        if self._os_active:
-            self.oneshot_weight()
+        # Where we update oneshots
path: ledfx/virtuals.py
user: login: bigredfrog
body: Would this be better as

for oneshot in self.oneshot:

Oh it's got remove in the loop, you have to be VERY careful with that, can't remember what the safest way to deal with that is...
created_at: 2025-03-15T19:05:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -61,8 +62,8 @@ async def put(self, request: web.Request) -> web.Response:
             for virtual_id in self._ledfx.virtuals:
                 virtual = self._ledfx.virtuals.get(virtual_id)
                 if virtual is not None:
-                    virtual.oneshot(
-                        parse_color(validate_color(color)), ramp, hold, fade
+                    virtual.add_oneshot(
path: ledfx/api/virtual_tools.py
user: login: bigredfrog
body: I do like that you are improving the code by moving things like validate closer to the color substantiation, makes me happy!
created_at: 2025-03-16T15:59:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -101,18 +102,19 @@ async def put(self, virtual_id, request) -> web.Response:
                 )
 
         if tool == "oneshot":
-            color = data.get("color", "white")
-
+            color = parse_color(validate_color(data.get("color", "white")))
             ramp = data.get("ramp", 0)
             hold = data.get("hold", 0)
             fade = data.get("fade", 0)
+            brightness = data.get("brightness", 0)
path: ledfx/api/virtuals_tools.py
user: login: bigredfrog
body: Same question, should this be default 1, which makes it fully optional

created_at: 2025-03-16T16:00:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,86 @@
+import timeit
+from abc import ABC, abstractmethod
+
+import numpy as np
+
+
+class Oneshot(ABC):
+    """
+    Base class for all oneshot types.
+
+    Oneshot is an effect that is triggered manually (through, for example, REST API).
+    """
+
+    def __init__(self):
+        self._active: bool = True
+        self._pixel_count: int = 0
+
+    @abstractmethod
path: ledfx/effects/oneshots/oneshot.py
user: login: bigredfrog
body: You can probably teach me a lot about proper use of decorators, my python is needs based, rather than educated. Please stick around and throw rocks at my code!
created_at: 2025-03-16T16:04:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,86 @@
+import timeit
+from abc import ABC, abstractmethod
+
+import numpy as np
+
+
+class Oneshot(ABC):
+    """
+    Base class for all oneshot types.
+
+    Oneshot is an effect that is triggered manually (through, for example, REST API).
+    """
+
+    def __init__(self):
+        self._active: bool = True
+        self._pixel_count: int = 0
+
+    @abstractmethod
path: ledfx/effects/oneshots/oneshot.py
user: login: bigredfrog
body: Also your use of private and type hinting :-)
created_at: 2025-03-16T16:06:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -874,8 +825,15 @@ def flush(self, pixels=None):
         if pixels is None:
             pixels = self.assembled_frame
 
-        if self._os_active:
-            self.oneshot_weight()
+        # Where we update oneshots
path: ledfx/virtuals.py
user: login: goodfaiter
body: Yep I followed the standard way of removing from a list why iterating through it found here:

https://www.geeksforgeeks.org/remove-items-from-a-list-while-iterating-python/

So it should be all good. This was also one of the ways coderabbitai suggested to do it.
created_at: 2025-03-18T15:23:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -48,11 +49,11 @@ async def put(self, request: web.Request) -> web.Response:
                     virtual.force_frame(parse_color(validate_color(color)))
 
         if tool == "oneshot":
-            color = data.get("color", "white")
-
+            color = parse_color(validate_color(data.get("color", "white")))
             ramp = data.get("ramp", 0)
             hold = data.get("hold", 0)
             fade = data.get("fade", 0)
+            brightness = data.get("brightness", 0)
path: ledfx/api/virtual_tools.py
user: login: goodfaiter
body: Yes! Fixed.
created_at: 2025-03-18T15:23:45Z
author_association: CONTRIBUTOR
path: docs/api.md
user: login: goodfaiter
body: Updated.
created_at: 2025-03-18T15:25:34Z
author_association: CONTRIBUTOR
path: .github/dependabot.yml
user: login: goodfaiter
body: Done.
created_at: 2025-03-18T15:33:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -874,8 +825,15 @@ def flush(self, pixels=None):
         if pixels is None:
             pixels = self.assembled_frame
 
-        if self._os_active:
-            self.oneshot_weight()
+        # Where we update oneshots
path: ledfx/virtuals.py
user: login: bigredfrog
body: ok, if you are following a pattern, great, it is so easy to pull the rug out from the structure when removing otherwise
created_at: 2025-03-18T22:53:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -603,57 +605,6 @@ def _fire_update_event(self, frame=None):
             )
         )
 
-    def oneshot(self, color, ramp, hold, fade):
-        """
-        Force all pixels in virtual to color over a time envelope defined in ms
-        Following calls will override any active one shot
-
-        Parameters:
-            ramp time from in ms
-            hold time in ms
-            fade time in ms
-        Returns:
-            True if oneshot was activated, False if not
-        """
-        if self.active:
-            self._os_color = np.array(color, dtype=float)
-            self._os_ramp = ramp / 1000.0
-            self._os_hold = hold / 1000.0
-            self._os_fade = fade / 1000.0
-            self._os_start = timeit.default_timer()
-            self._os_hold_end = self._os_ramp + self._os_hold
-            self._os_fade_end = self._os_ramp + self._os_hold + self._os_fade
-            self._os_weight = 0.0
-
-            # if all total timings are zero, treat as a hard off
-            if self._os_fade_end == 0:
path: ledfx/virtuals.py
user: login: bigredfrog
body: This hard off code seems to be missing now in the reworked version, is that intentional?
created_at: 2025-03-23T21:53:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -101,18 +102,19 @@ async def put(self, virtual_id, request) -> web.Response:
                 )
 
         if tool == "oneshot":
-            color = data.get("color", "white")
-
+            color = parse_color(validate_color(data.get("color", "white")))
             ramp = data.get("ramp", 0)
             hold = data.get("hold", 0)
             fade = data.get("fade", 0)
+            brightness = data.get("brightness", 1)
path: ledfx/api/virtuals_tools.py
user: login: bigredfrog
body: Please cap brightness between 0 and 1
created_at: 2025-03-23T21:55:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -603,57 +605,6 @@ def _fire_update_event(self, frame=None):
             )
         )
 
-    def oneshot(self, color, ramp, hold, fade):
-        """
-        Force all pixels in virtual to color over a time envelope defined in ms
-        Following calls will override any active one shot
-
-        Parameters:
-            ramp time from in ms
-            hold time in ms
-            fade time in ms
-        Returns:
-            True if oneshot was activated, False if not
-        """
-        if self.active:
-            self._os_color = np.array(color, dtype=float)
-            self._os_ramp = ramp / 1000.0
-            self._os_hold = hold / 1000.0
-            self._os_fade = fade / 1000.0
-            self._os_start = timeit.default_timer()
-            self._os_hold_end = self._os_ramp + self._os_hold
-            self._os_fade_end = self._os_ramp + self._os_hold + self._os_fade
-            self._os_weight = 0.0
-
-            # if all total timings are zero, treat as a hard off
-            if self._os_fade_end == 0:
path: ledfx/virtuals.py
user: login: bigredfrog
body: Tested on PR, no hard off behaviour.

Tested on main, hard off works 

I will raise a PR against my very basic test script

created_at: 2025-03-23T22:04:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -101,18 +102,19 @@ async def put(self, virtual_id, request) -> web.Response:
                 )
 
         if tool == "oneshot":
-            color = data.get("color", "white")
-
+            color = parse_color(validate_color(data.get("color", "white")))
             ramp = data.get("ramp", 0)
             hold = data.get("hold", 0)
             fade = data.get("fade", 0)
+            brightness = data.get("brightness", 1)
path: ledfx/api/virtuals_tools.py
user: login: bigredfrog
body: Leads to some odd wrap around behaviours otherwise.
created_at: 2025-03-23T23:03:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -603,57 +605,6 @@ def _fire_update_event(self, frame=None):
             )
         )
 
-    def oneshot(self, color, ramp, hold, fade):
-        """
-        Force all pixels in virtual to color over a time envelope defined in ms
-        Following calls will override any active one shot
-
-        Parameters:
-            ramp time from in ms
-            hold time in ms
-            fade time in ms
-        Returns:
-            True if oneshot was activated, False if not
-        """
-        if self.active:
-            self._os_color = np.array(color, dtype=float)
-            self._os_ramp = ramp / 1000.0
-            self._os_hold = hold / 1000.0
-            self._os_fade = fade / 1000.0
-            self._os_start = timeit.default_timer()
-            self._os_hold_end = self._os_ramp + self._os_hold
-            self._os_fade_end = self._os_ramp + self._os_hold + self._os_fade
-            self._os_weight = 0.0
-
-            # if all total timings are zero, treat as a hard off
-            if self._os_fade_end == 0:
path: ledfx/virtuals.py
user: login: goodfaiter
body: Hey @bigredfrog, sorry for a late reply. I see what you mean!

I see how this can be cleanly address in the next PR. Coming soon!
created_at: 2025-03-30T16:31:55Z
author_association: CONTRIBUTOR

PR comments:
user: login: coderabbitai[bot]
created_at: 2025-03-14T14:37:18Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

## Walkthrough
This change introduces a new `"brightness"` property for the oneshot tool. The API documentation now reflects this additional parameter, and the backend endpoints have been updated to parse and validate color values while incorporating brightness. The effect handling has been refactored: the previous oneshot methods have been replaced with a new object-oriented approach that uses a list of oneshot effects, including a new `Flash` class (derived from an abstract `Oneshot` class) which handles brightness alongside existing parameters.

## Changes

| File(s)                                    | Change Summary |
|--------------------------------------------|----------------|
| `docs/api.md`                              | Added `"brightness": 1` to the JSON objects (both request and response) for the `oneshot` tool in the API documentation. |
| `ledfx/api/virtual_tools.py`<br>`ledfx/api/virtuals_tools.py` | Updated the `put` methods to parse and validate color, include a `brightness` parameter (defaulting to 1 if missing), and invoke `add_oneshot(Flash(...))` instead of the previous oneshot call. |
| `ledfx/effects/oneshots/oneshot.py`       | New file defining an abstract `Oneshot` class and a concrete `Flash` implementation with methods `init`, `update`, and `apply` that now factor in brightness. |
| `ledfx/virtuals.py`                         | Removed individual oneshot handling methods. Introduced a `_oneshots` list with an `add_oneshot` method and a corresponding property; updated the `flush` method to iterate over and apply the effects of active oneshots. |
| `.github/dependabot.yml`                   | Adjusted whitespace around the `directory` key for the "uv" package ecosystem. |

## Sequence Diagram(s)
```mermaid
sequenceDiagram
    participant C as Client
    participant E as API Endpoint
    participant V as Virtual Device
    participant F as Flash (Oneshot)
    
    C->>E: Sends oneshot request (color, brightness, fade, etc.)
    E->>V: Calls add_oneshot(Flash(...))
    V->>V: Append Flash instance to _oneshots list
    Note over V: flush() is periodically invoked
    V->>F: For each oneshot, call Flash.update()
    F->>V: Return current effect state
    V->>F: Call Flash.apply() on pixel segments
```

## Possibly related PRs
- **LedFx/LedFx#1154**: The changes in the main PR, which add a `"brightness"` property to the `oneshot` tool's JSON objects, are related to the retrieved PR that introduces a `ui_brightness_boost` key for controlling brightness settings, as both involve enhancements to brightness control in the API.
- **LedFx/LedFx#1069**: The changes in the main PR, which introduce a new `"brightness"` property for the `oneshot` tool, are related to the retrieved PR that modifies the handling of the `color`, `ramp`, `hold`, and `fade` parameters, as both involve enhancements to the `oneshot` functionality in the API.

## Poem
> I'm a rabbit with a hop so bright,  
> Coding changes shine like stars at night.  
> New brightness added, a flash of art,  
> With oneshot effects that now play their part.  
> I nibble on code like a crunchy delight,  
> Celebrating bright fixes in every byte!

<!-- walkthrough_end -->

<!-- announcements_start -->

> [!TIP]
> <details>
> <summary>⚡🧪 Multi-step agentic review comment chat (experimental)</summary>
> 
> - We're introducing multi-step agentic chat in review comments. This experimental feature enhances review discussions with the CodeRabbit agentic chat by enabling advanced interactions, including the ability to create pull requests directly from comments.
> 						- To enable this feature, set early_access to true under in the settings.
> 
> </details>

<!-- announcements_end -->

---

<details>
<summary>📜 Recent review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between fc6b1eb3fcc42a91335edf2a57d855fe2198366e and e86ce7128605cfb4f71359de20f68345c00778be.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `docs/api.md` (4 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (1)</summary>

* docs/api.md

</details>

</details>
<!-- internal state start -->


<!-- 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= -->

<!-- internal state end -->
<!-- tips_start -->

---



<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=LedFx/LedFx&utm_content=1306):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: CONTRIBUTOR
user: login: sentry[bot]
created_at: 2025-03-14T14:37:20Z
body: ## 🔍 Existing Issues For Review
Your pull request is modifying functions with the following pre-existing issues:

📄 File: **ledfx/virtuals.py**

| Function | Unhandled Issue |
| :------- | :----- |
| **`flush`** | [**ValueError: byte must be in range(0, 256)**](https://ledfx-org.sentry.io/issues/6381745338/?referrer=github-open-pr-bot) mls.dev... <br> `Event Count:` **6** |
---

<sub>Did you find this useful? React with a 👍 or 👎</sub>
author_association: NONE
user: login: bigredfrog
created_at: 2025-03-20T19:34:39Z
body: Will mangle my test script tonight. 

Are you able to rebase now that the dependancy stuff has been fixed?
author_association: CONTRIBUTOR
user: login: goodfaiter
created_at: 2025-03-21T11:03:38Z
body: > Will mangle my test script tonight.
> 
> Are you able to rebase now that the dependancy stuff has been fixed?

@bigredfrog this branch is fully up to date with main, I think there was some odd CI/CD fluke that is probably resolved by rerunning. I can't find the button to re-trigger the pipeline though, do you know where it is?
author_association: CONTRIBUTOR
user: login: bigredfrog
created_at: 2025-03-23T23:04:24Z
body: We will ignore the checks and force merge, but leds address brightness range and hard off legacy behaviour

My test script has been added via

https://github.com/LedFx/LedFx/pull/1320
author_association: CONTRIBUTOR