Skip to content

feat: DMX (Art-Net) input support for external parameter control#622

Open
livepeer-tessa wants to merge 2 commits intomainfrom
feat/dmx-artnet-input
Open

feat: DMX (Art-Net) input support for external parameter control#622
livepeer-tessa wants to merge 2 commits intomainfrom
feat/dmx-artnet-input

Conversation

@livepeer-tessa
Copy link
Contributor

@livepeer-tessa livepeer-tessa commented Mar 8, 2026

Summary

Adds Art-Net DMX input support to Scope, enabling live show professionals to control pipeline parameters from lighting consoles and DMX software.

Hypothesis: H174 — DMX In/Out support will unlock adoption among live show professionals by making Scope a controllable visual processing layer inside existing lighting pipelines.

Architecture

Follows the existing OSC implementation pattern:

  • dmx_server.py — Art-Net UDP listener on port 6454
  • dmx_docs.py — Self-hosted HTML documentation
  • DmxTab.tsx — Settings UI for managing channel mappings
  • Integration with existing broadcast_parameter_update() path

Key Features

  • ✅ Art-Net protocol support (UDP port 6454, standard Art-Net port)
  • ✅ Multi-universe support
  • ✅ Configurable channel-to-parameter mappings via Settings UI
  • ✅ Value scaling from 0-255 to parameter ranges
  • ✅ Persistent mapping storage in ~/.daydream-scope/dmx_mappings.json
  • ✅ Self-hosted HTML docs at /api/v1/dmx/docs
  • ✅ Rate-limited broadcasts (~60fps max)

How It Works

  1. Configure mappings in Settings → DMX
  2. Map DMX channels to Scope parameters (e.g., Universe 0, Channel 1 → noise_scale)
  3. Send Art-Net DMX data to port 6454
  4. DMX values (0-255) are scaled to parameter ranges and broadcast to the pipeline

Example Usage

from stupidArtnet import StupidArtnet

artnet = StupidArtnet("127.0.0.1", 0)  # Universe 0
artnet.start()

packet = [0] * 512
packet[0] = 128  # Channel 1 at 50%
artnet.set(packet)
artnet.show()

Compatible Software

  • QLC+, MagicQ, grandMA, Chamsys
  • TouchDesigner (Art-Net output)
  • Resolume Arena (Art-Net output)

Testing

  • Manual testing with Art-Net sender
  • Mapping persistence across restarts
  • Parameter updates visible in stream

Screenshots

(Settings → DMX tab shows status and mapping configuration)


Closes #621

/cc @thomshutt

Summary by CodeRabbit

  • New Features
    • DMX (Art‑Net) support: new DMX settings tab to view server status, manage channel mappings, and control active mappings
    • Add/delete DMX mappings with configurable universe, channel and min/max ranges; live mapping count and status shown
    • Built‑in DMX documentation/quick‑start accessible from the settings for parameter mapping and troubleshooting

Adds Art-Net DMX input support to Scope, enabling live show professionals
to control pipeline parameters from lighting consoles and DMX software.

Architecture follows the existing OSC implementation pattern:
- DMXServer: Art-Net UDP listener on port 6454
- Channel-to-parameter mapping system with persistent storage
- Real-time parameter broadcast via WebRTCManager
- DmxTab.tsx: Settings UI for managing mappings

Key features:
- Art-Net protocol support (UDP port 6454)
- Multi-universe support
- Configurable channel-to-parameter mappings
- Value scaling from 0-255 to parameter ranges
- Persistent mapping storage in ~/.daydream-scope/dmx_mappings.json
- Self-hosted HTML docs at /api/v1/dmx/docs

Related: H174 (DMX In/Out support hypothesis)
Closes #621

Signed-off-by: livepeer-robot <robot@livepeer.org>
@livepeer-tessa livepeer-tessa added the enhancement New feature or request label Mar 8, 2026
@coderabbitai
Copy link

coderabbitai bot commented Mar 8, 2026

📝 Walkthrough

Walkthrough

Adds Art‑Net DMX input: a UDP DMX server with mapping storage and scaling logic, new HTTP API endpoints (status, mappings, add, delete, docs), a Settings UI tab for mapping management, and an HTML DMX documentation renderer.

Changes

Cohort / File(s) Summary
Frontend Settings & UI
frontend/src/components/SettingsDialog.tsx, frontend/src/components/settings/DmxTab.tsx
Adds a DMX tab to settings and implements the DmxTab component: status display, mappings list, add‑mapping dialog, CRUD calls to DMX API, and parameter dropdown populated from OSC paths.
Backend DMX Server Implementation
src/scope/server/dmx_server.py
New Art‑Net UDP listener, DMXServer, DMXMapping, and DMXMappingStore with packet parsing, per‑channel mapping, scaling (0‑255 → param range), SSE/WebRTC broadcasting, persistence, and lifecycle methods.
API Endpoints & App Wiring
src/scope/server/app.py
Registers DMX endpoints (/api/v1/dmx/status, /api/v1/dmx/mappings, POST/DELETE mappings, /api/v1/dmx/docs), adds DMXMappingRequest, get_dmx_server() dependency, and starts/stops DMXServer during app lifespan.
DMX Documentation Renderer
src/scope/server/dmx_docs.py
New function render_dmx_docs_html(...) generating a self‑contained HTML docs page with parameter reference, examples, and dynamic DMX port display.
Manifests / package
manifest_file, package.json
Dependency/manifest updates to include new frontend/backend files (lines changed: +11/-1 and +459/-0 reflected across files).

Sequence Diagram(s)

sequenceDiagram
    participant Client as Frontend Client
    participant API as API Server
    participant DMXServer as DMX Server
    participant Store as Mapping Store
    participant WR as WebRTC Manager

    Client->>API: POST /api/v1/dmx/mappings
    API->>DMXServer: add_mapping(DMXMapping)
    DMXServer->>Store: add(mapping)
    Store-->>Store: save to config

    rect rgba(100, 200, 150, 0.5)
        Note over DMXServer: Art‑Net UDP Listener Active
        DMXServer->>DMXServer: _handle_artnet_packet()
        DMXServer->>DMXServer: _process_dmx_frame()
        DMXServer->>WR: broadcast_parameter_update(scaled_values)
    end

    WR-->>Client: Parameter update via WebRTC
Loading
sequenceDiagram
    participant DmxTab as DmxTab Frontend
    participant API as API Server
    participant DMXServer as DMX Server
    participant OSC as OSC Docs

    DmxTab->>API: GET /api/v1/dmx/status
    API->>DMXServer: status()
    DMXServer-->>DmxTab: {listening, port, mapping_count}

    DmxTab->>API: GET /api/v1/dmx/mappings
    API->>DMXServer: get_mappings()
    DMXServer-->>DmxTab: [DMXMapping...]

    DmxTab->>API: GET /api/v1/osc/paths
    API->>OSC: get_osc_paths()
    OSC-->>DmxTab: [param_paths]

    DmxTab->>DmxTab: Populate dropdown & display UI
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 I hopped on port six‑four‑five‑four, ears bright,

DMX bytes danced through the night,
Channels to parameters, a scaled delight,
Mappings saved and lights take flight,
A rabbit cheers — control now alight.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 70.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 accurately summarizes the main change: adding DMX (Art-Net) input support for parameter control, which is the primary objective of the pull request.
Linked Issues check ✅ Passed All Phase 1 MVP requirements from issue #621 are implemented: Art-Net UDP listener, channel mappings, integration with broadcast_parameter_update, and DmxTab status display.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing DMX support as specified in issue #621; no unrelated modifications detected.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/dmx-artnet-input

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


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: 3

Caution

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

⚠️ Outside diff range comments (1)
src/scope/server/app.py (1)

297-302: ⚠️ Potential issue | 🔴 Critical

Missing dmx_server in global declaration causes assignment to create local variable.

The global statement on lines 297-302 doesn't include dmx_server, but line 366 assigns to it. This creates a local variable instead of modifying the module-level global, causing get_dmx_server() to return None.

🐛 Proposed fix
     global \
         webrtc_manager, \
         pipeline_manager, \
         cloud_connection_manager, \
         kafka_publisher, \
-        osc_server
+        osc_server, \
+        dmx_server

Also applies to: 362-368

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/scope/server/app.py` around lines 297 - 302, The function assigns to the
module-level dmx_server but the existing global declaration (webrtc_manager,
pipeline_manager, cloud_connection_manager, kafka_publisher, osc_server) omits
dmx_server, causing a local variable to be created and get_dmx_server() to
return None; add dmx_server to the global statement(s) that appear around the
assignment sites (the same global list that currently mentions webrtc_manager,
pipeline_manager, cloud_connection_manager, kafka_publisher, osc_server) so the
assignment modifies the module-level dmx_server rather than creating a local
variable.
🧹 Nitpick comments (4)
src/scope/server/app.py (1)

805-806: Consider adding universe range validation.

Channel range is validated (1-512), but universe is not validated. Art-Net supports universes 0-32767. Adding validation would provide consistent error handling and match the frontend constraints.

♻️ Proposed addition
     if request.channel < 1 or request.channel > 512:
         raise HTTPException(status_code=400, detail="Channel must be between 1 and 512")
+
+    if request.universe < 0 or request.universe > 32767:
+        raise HTTPException(status_code=400, detail="Universe must be between 0 and 32767")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/scope/server/app.py` around lines 805 - 806, Add validation for the
Art-Net universe in the same place you validate channel: check request.universe
is within 0-32767 and if not raise the same type of HTTPException
(status_code=400, descriptive detail). Update the block that currently checks
request.channel (refer to request.channel and the surrounding handler function
in app.py) to include a universe range check so errors are handled consistently
with the frontend constraints.
src/scope/server/dmx_server.py (1)

276-284: Pending updates may be delayed indefinitely if DMX frames stop arriving.

If DMX data stops arriving after updates have been queued in _pending_updates, those updates won't be broadcast until the next frame. This is likely acceptable for continuous DMX input, but consider flushing pending updates on a timer if exact final values matter.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/scope/server/dmx_server.py` around lines 276 - 284, The current logic
only broadcasts `_pending_updates` when a new DMX frame arrives, so queued
updates can sit indefinitely if frames stop; add a periodic flush that runs on a
timer (e.g., an asyncio task or loop.call_later) which checks `_pending_updates`
and if now - `_last_broadcast_time` >= `_MIN_BROADCAST_INTERVAL` calls
`_webrtc_manager.broadcast_parameter_update(self._pending_updates)`, then clears
`_pending_updates` and updates `_last_broadcast_time`; start this flush task
when the server/component starts and cancel it on shutdown to avoid leaks.
frontend/src/components/settings/DmxTab.tsx (2)

353-368: Channel input allows out-of-range values via direct typing.

The HTML min={1} and max={512} constraints only prevent arrow key/spinner changes and validate on form submit. Users can still type values like "0" or "600" directly. Consider clamping the value in the onChange handler.

♻️ Proposed fix to clamp channel values
               <div className="space-y-2">
                 <Label htmlFor="channel">Channel (1-512)</Label>
                 <Input
                   id="channel"
                   type="number"
                   min={1}
                   max={512}
                   value={newMapping.channel}
                   onChange={e =>
                     setNewMapping(m => ({
                       ...m,
-                      channel: parseInt(e.target.value) || 1,
+                      channel: Math.max(1, Math.min(512, parseInt(e.target.value) || 1)),
                     }))
                   }
                 />
               </div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/settings/DmxTab.tsx` around lines 353 - 368, The
channel number input currently uses min/max attributes but allows typing
out-of-range values; update the Input onChange handler (the setter using
setNewMapping and the newMapping.channel field) to clamp the parsed integer into
the valid range [1, 512] and handle NaN by falling back to 1 before calling
setNewMapping. Ensure the logic runs inside the existing onChange callback so
newMapping.channel is always set to Math.max(1, Math.min(512, parsedValue)) (or
equivalent) rather than the raw parseInt result.

68-79: Silent failure on non-OK responses.

When res.ok is false, the status fetch silently fails without informing the user. Consider showing an error toast for non-OK responses, similar to how handleAddMapping handles errors.

♻️ Proposed improvement
   const fetchStatus = useCallback(async () => {
     setIsLoading(true);
     try {
       const res = await fetch("/api/v1/dmx/status");
       if (res.ok) {
         setStatus(await res.json());
+      } else {
+        console.error("Failed to fetch DMX status:", res.status);
       }
     } catch (err) {
       console.error("Failed to fetch DMX status:", err);
     } finally {
       setIsLoading(false);
     }
   }, []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/settings/DmxTab.tsx` around lines 68 - 79, The
fetchStatus function silently ignores non-OK HTTP responses; update fetchStatus
(used with setIsLoading and setStatus) to handle res.ok === false by parsing the
response error (or status text) and showing an error toast (reuse the same toast
pattern used in handleAddMapping) before returning, and ensure setIsLoading is
still cleared in the finally block; include the response details in the toast
message to aid the user.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/src/components/settings/DmxTab.tsx`:
- Line 1: The import line in DmxTab.tsx is failing CI Prettier formatting; run
Prettier to reformat this file (e.g., run prettier --write on
frontend/src/components/settings/DmxTab.tsx or the repo) or apply the same
formatting rules to the import statement and surrounding code so the file
conforms to project Prettier settings (ensure imports like the line with
useState, useEffect, useCallback are formatted according to the project's
Prettier config).

In `@src/scope/server/dmx_server.py`:
- Around line 310-315: Replace the hardcoded numeric errno check (98) with the
platform-independent constant errno.EADDRINUSE in the DMX server exception
handling block (the except OSError as e: branch that references self._port and
logger) and ensure the module imports errno at the top of the file so the check
reads e.errno == errno.EADDRINUSE; keep the existing warning message and
behavior otherwise.
- Around line 157-158: Add "from __future__ import annotations" at the top of
the module (immediately after the file docstring) and then remove the string
quotes from the runtime-only type annotations for self._pipeline_manager and
self._webrtc_manager so they read using the actual types PipelineManager | None
and WebRTCManager | None; this resolves the UP037 lint errors while keeping the
types available for TYPE_CHECKING without needing runtime imports.

---

Outside diff comments:
In `@src/scope/server/app.py`:
- Around line 297-302: The function assigns to the module-level dmx_server but
the existing global declaration (webrtc_manager, pipeline_manager,
cloud_connection_manager, kafka_publisher, osc_server) omits dmx_server, causing
a local variable to be created and get_dmx_server() to return None; add
dmx_server to the global statement(s) that appear around the assignment sites
(the same global list that currently mentions webrtc_manager, pipeline_manager,
cloud_connection_manager, kafka_publisher, osc_server) so the assignment
modifies the module-level dmx_server rather than creating a local variable.

---

Nitpick comments:
In `@frontend/src/components/settings/DmxTab.tsx`:
- Around line 353-368: The channel number input currently uses min/max
attributes but allows typing out-of-range values; update the Input onChange
handler (the setter using setNewMapping and the newMapping.channel field) to
clamp the parsed integer into the valid range [1, 512] and handle NaN by falling
back to 1 before calling setNewMapping. Ensure the logic runs inside the
existing onChange callback so newMapping.channel is always set to Math.max(1,
Math.min(512, parsedValue)) (or equivalent) rather than the raw parseInt result.
- Around line 68-79: The fetchStatus function silently ignores non-OK HTTP
responses; update fetchStatus (used with setIsLoading and setStatus) to handle
res.ok === false by parsing the response error (or status text) and showing an
error toast (reuse the same toast pattern used in handleAddMapping) before
returning, and ensure setIsLoading is still cleared in the finally block;
include the response details in the toast message to aid the user.

In `@src/scope/server/app.py`:
- Around line 805-806: Add validation for the Art-Net universe in the same place
you validate channel: check request.universe is within 0-32767 and if not raise
the same type of HTTPException (status_code=400, descriptive detail). Update the
block that currently checks request.channel (refer to request.channel and the
surrounding handler function in app.py) to include a universe range check so
errors are handled consistently with the frontend constraints.

In `@src/scope/server/dmx_server.py`:
- Around line 276-284: The current logic only broadcasts `_pending_updates` when
a new DMX frame arrives, so queued updates can sit indefinitely if frames stop;
add a periodic flush that runs on a timer (e.g., an asyncio task or
loop.call_later) which checks `_pending_updates` and if now -
`_last_broadcast_time` >= `_MIN_BROADCAST_INTERVAL` calls
`_webrtc_manager.broadcast_parameter_update(self._pending_updates)`, then clears
`_pending_updates` and updates `_last_broadcast_time`; start this flush task
when the server/component starts and cancel it on shutdown to avoid leaks.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f01968bb-7eea-4d03-9788-fdf49124e939

📥 Commits

Reviewing files that changed from the base of the PR and between 0a52f7c and 9fb55ca.

📒 Files selected for processing (5)
  • frontend/src/components/SettingsDialog.tsx
  • frontend/src/components/settings/DmxTab.tsx
  • src/scope/server/app.py
  • src/scope/server/dmx_docs.py
  • src/scope/server/dmx_server.py

@@ -0,0 +1,450 @@
import { useState, useEffect, useCallback } from "react";
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Address Prettier formatting issue flagged by pipeline.

The CI pipeline reports a code style issue. Run prettier --write to fix formatting.

🧰 Tools
🪛 GitHub Actions: Lint

[warning] 1-1: Code style issues found in DmxTab.tsx. Run 'prettier --write' to fix.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/settings/DmxTab.tsx` at line 1, The import line in
DmxTab.tsx is failing CI Prettier formatting; run Prettier to reformat this file
(e.g., run prettier --write on frontend/src/components/settings/DmxTab.tsx or
the repo) or apply the same formatting rules to the import statement and
surrounding code so the file conforms to project Prettier settings (ensure
imports like the line with useState, useEffect, useCallback are formatted
according to the project's Prettier config).

Comment on lines +157 to +158
self._pipeline_manager: "PipelineManager | None" = None
self._webrtc_manager: "WebRTCManager | None" = None
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix linting errors: Remove quotes from type annotations.

The pipeline reports UP037 errors. Since these are runtime-only type hints (protected by TYPE_CHECKING), they should use string literals. However, the linter suggests removing quotes, which means you should import the types properly or use from __future__ import annotations.

🔧 Proposed fix using future annotations

Add at the top of the file after the docstring:

+from __future__ import annotations
+
 import asyncio

Then remove quotes from type annotations:

-    self._pipeline_manager: "PipelineManager | None" = None
-    self._webrtc_manager: "WebRTCManager | None" = None
+    self._pipeline_manager: PipelineManager | None = None
+    self._webrtc_manager: WebRTCManager | None = None
📝 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
self._pipeline_manager: "PipelineManager | None" = None
self._webrtc_manager: "WebRTCManager | None" = None
self._pipeline_manager: PipelineManager | None = None
self._webrtc_manager: WebRTCManager | None = None
🧰 Tools
🪛 GitHub Actions: Lint

[error] 157-157: UP037 Remove quotes from type annotation.


[error] 158-158: UP037 Remove quotes from type annotation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/scope/server/dmx_server.py` around lines 157 - 158, Add "from __future__
import annotations" at the top of the module (immediately after the file
docstring) and then remove the string quotes from the runtime-only type
annotations for self._pipeline_manager and self._webrtc_manager so they read
using the actual types PipelineManager | None and WebRTCManager | None; this
resolves the UP037 lint errors while keeping the types available for
TYPE_CHECKING without needing runtime imports.

Comment on lines +310 to +315
except OSError as e:
if e.errno == 98: # Address already in use
logger.warning(
f"DMX server: Port {self._port} already in use. "
"Another application may be using Art-Net."
)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Hardcoded errno value is platform-specific.

errno 98 is the Linux value for EADDRINUSE. On Windows, the error code is different (10048). Use errno.EADDRINUSE for cross-platform compatibility.

🔧 Proposed fix
+import errno
+
 ...
 
         except OSError as e:
-            if e.errno == 98:  # Address already in use
+            if e.errno == errno.EADDRINUSE:  # Address already in use
                 logger.warning(
📝 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
except OSError as e:
if e.errno == 98: # Address already in use
logger.warning(
f"DMX server: Port {self._port} already in use. "
"Another application may be using Art-Net."
)
except OSError as e:
if e.errno == errno.EADDRINUSE: # Address already in use
logger.warning(
f"DMX server: Port {self._port} already in use. "
"Another application may be using Art-Net."
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/scope/server/dmx_server.py` around lines 310 - 315, Replace the hardcoded
numeric errno check (98) with the platform-independent constant errno.EADDRINUSE
in the DMX server exception handling block (the except OSError as e: branch that
references self._port and logger) and ensure the module imports errno at the top
of the file so the check reads e.errno == errno.EADDRINUSE; keep the existing
warning message and behavior otherwise.

- Remove non-existent Label component import, use plain <label> elements
- Fix ruff linter warnings (remove quotes from type annotations)

Signed-off-by: livepeer-robot <robot@livepeer.org>
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: 5

♻️ Duplicate comments (1)
src/scope/server/dmx_server.py (1)

310-315: ⚠️ Potential issue | 🟡 Minor

Use errno.EADDRINUSE instead of 98.

98 is Linux-specific. This path will misclassify “address already in use” on other platforms, including Windows.

🔧 Proposed fix
+import errno
+
 ...
-            if e.errno == 98:  # Address already in use
+            if e.errno == errno.EADDRINUSE:  # Address already in use
                 logger.warning(
What does Python's errno.EADDRINUSE represent, and are hardcoded numeric errno values like 98 portable across Linux and Windows for OSError?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/scope/server/dmx_server.py` around lines 310 - 315, Replace the hardcoded
numeric errno check (e.errno == 98) with the portable constant errno.EADDRINUSE:
import errno at the module top and change the except block in the DMX server
code (the handler referencing self._port and logger) to compare e.errno against
errno.EADDRINUSE so the "Address already in use" path works across platforms.
🧹 Nitpick comments (2)
frontend/src/components/settings/DmxTab.tsx (2)

67-79: Silent failure on non-OK response leaves user without feedback.

When res.ok is false (e.g., 4xx/5xx status), the function silently ignores the error. The user sees no indication that the status fetch failed—they just see the loading spinner disappear with no data. Consider showing a toast or setting an error state.

💡 Suggested improvement
   const fetchStatus = useCallback(async () => {
     setIsLoading(true);
     try {
       const res = await fetch("/api/v1/dmx/status");
       if (res.ok) {
         setStatus(await res.json());
+      } else {
+        console.error("DMX status fetch failed:", res.status);
       }
     } catch (err) {
       console.error("Failed to fetch DMX status:", err);
     } finally {
       setIsLoading(false);
     }
   }, []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/settings/DmxTab.tsx` around lines 67 - 79, The
fetchStatus function currently ignores non-OK responses causing silent failures;
update fetchStatus to handle res.ok === false by reading the error payload (or
status/text) and surface it to the UI—either set an error state (e.g., setError)
or call the existing toast/notification helper—then ensure setIsLoading(false)
still runs in finally; keep setStatus only on success and include the response
error details in the error state/toast so the user sees why the status fetch
failed.

122-159: No client-side check for duplicate universe+channel mappings.

Users can create multiple mappings for the same universe and channel combination, which may cause conflicts or unexpected behavior on the server. Consider warning users or preventing duplicate mappings.

💡 Suggested validation
   const handleAddMapping = async () => {
     if (!newMapping.param_key) {
       toast.error("Please select a parameter");
       return;
     }
+
+    const existingMapping = mappings.find(
+      m => m.universe === newMapping.universe && m.channel === newMapping.channel
+    );
+    if (existingMapping) {
+      toast.error(`Channel ${newMapping.channel} in universe ${newMapping.universe} is already mapped`);
+      return;
+    }

     try {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/settings/DmxTab.tsx` around lines 122 - 159,
handleAddMapping currently lets users POST a mapping without checking for
existing mappings with the same universe+channel; update handleAddMapping to
first check the current mappings state/prop (e.g., mappings) for an entry where
mapping.universe === newMapping.universe && mapping.channel ===
newMapping.channel, and if found show a toast.error like "Mapping for that
universe and channel already exists" and return early to prevent the POST;
optionally also disable the Add button when a duplicate exists by computing a
isDuplicate flag from mappings and newMapping so users receive immediate
feedback before submitting.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/src/components/settings/DmxTab.tsx`:
- Around line 434-440: The onChange handler for max_value uses
parseFloat(e.target.value) || 1 which treats 0 as falsy and prevents setting a
legitimate 0; update the setNewMapping call in the max_value handler (and mirror
pattern for min_value) to parse the value, check for NaN (e.g., const v =
parseFloat(...); use isNaN(v) ? 1 : v) or use conditional operator/nullish
checks so only empty/invalid input falls back to 1 while preserving 0 as a valid
value; adjust the handler attached to setNewMapping/newMapping.max_value (and
the similar min_value handler) accordingly.

In `@src/scope/server/dmx_server.py`:
- Around line 1-335: The file fails ruff formatting; run the formatter to
satisfy CI. Reformat this module (symbols to check: DMXServer, DMXMappingStore,
DMXMapping, ArtNetProtocol, _process_dmx_frame) by running `ruff format` (or
`ruff format src/scope/server/dmx_server.py`) and commit the changes; optionally
add ruff to pre-commit hooks or CI config to prevent future format failures.
- Around line 116-119: The add method currently allows multiple mappings for the
same (universe, channel), making later ones unreachable via get_by_channel;
update add(self, mapping: DMXMapping) to enforce one mapping per (universe,
channel) by searching existing entries in self.mappings for any mapping with the
same mapping.universe and mapping.channel, and if found either replace that
entry (remove its key from self.mappings and insert the new mapping under
mapping.id) or raise a ValueError to reject duplicates—ensure you handle the
case where the found mapping has the same id (treat as an update) and always
call self.save() after performing the replace/reject logic so state remains
consistent.
- Around line 243-248: The handler currently trusts the advertised `length` and
slices `data[18:18 + length]`, allowing oversized frames to create out-of-spec
channel counts; before slicing and calling _process_dmx_frame(universe,
dmx_data) validate the advertised `length` (and that 18 + length <= len(data))
and reject any packet where length > 512 (or otherwise invalid) by returning
early. Update the logic that computes `dmx_data` to first check the `length`
bounds and only proceed when 0 < length <= 512 and the buffer contains at least
18 + length bytes, otherwise drop the packet.
- Around line 276-285: The current rate-limit can drop the final pending update
because we only flush opportunistically; modify the logic around
_pending_updates/_last_broadcast_time so that when you add updates but the
minimum interval hasn’t elapsed you schedule a delayed flush via an
asyncio.TimerHandle (store it in self._flush_handle) that will call the same
broadcast code (using self._webrtc_manager.broadcast_parameter_update) after the
remaining interval; create and initialize self._flush_handle:
asyncio.TimerHandle | None = None in __init__, set/replace it when scheduling,
clear it and self._pending_updates after broadcasting, and cancel and nil out
self._flush_handle in stop() to avoid leaks.

---

Duplicate comments:
In `@src/scope/server/dmx_server.py`:
- Around line 310-315: Replace the hardcoded numeric errno check (e.errno == 98)
with the portable constant errno.EADDRINUSE: import errno at the module top and
change the except block in the DMX server code (the handler referencing
self._port and logger) to compare e.errno against errno.EADDRINUSE so the
"Address already in use" path works across platforms.

---

Nitpick comments:
In `@frontend/src/components/settings/DmxTab.tsx`:
- Around line 67-79: The fetchStatus function currently ignores non-OK responses
causing silent failures; update fetchStatus to handle res.ok === false by
reading the error payload (or status/text) and surface it to the UI—either set
an error state (e.g., setError) or call the existing toast/notification
helper—then ensure setIsLoading(false) still runs in finally; keep setStatus
only on success and include the response error details in the error state/toast
so the user sees why the status fetch failed.
- Around line 122-159: handleAddMapping currently lets users POST a mapping
without checking for existing mappings with the same universe+channel; update
handleAddMapping to first check the current mappings state/prop (e.g., mappings)
for an entry where mapping.universe === newMapping.universe && mapping.channel
=== newMapping.channel, and if found show a toast.error like "Mapping for that
universe and channel already exists" and return early to prevent the POST;
optionally also disable the Add button when a duplicate exists by computing a
isDuplicate flag from mappings and newMapping so users receive immediate
feedback before submitting.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f704cd40-3607-433c-9fd7-18230ce4606f

📥 Commits

Reviewing files that changed from the base of the PR and between 9fb55ca and 0344bd4.

📒 Files selected for processing (2)
  • frontend/src/components/settings/DmxTab.tsx
  • src/scope/server/dmx_server.py

Comment on lines +434 to +440
value={newMapping.max_value}
onChange={e =>
setNewMapping(m => ({
...m,
max_value: parseFloat(e.target.value) || 1,
}))
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fallback || 1 prevents setting max_value to 0.

The expression parseFloat(e.target.value) || 1 treats 0 as falsy, so if a user enters 0 for max value, it will be replaced with 1. While uncommon, some parameters might legitimately need a max of 0.

🐛 Proposed fix
                   onChange={e =>
                     setNewMapping(m => ({
                       ...m,
-                      max_value: parseFloat(e.target.value) || 1,
+                      max_value: e.target.value === "" ? 1 : parseFloat(e.target.value),
                     }))
                   }

Similarly, min_value at line 421 has the same pattern with || 0, which happens to work correctly since 0 is the intended fallback. However, consider using consistent explicit empty-string handling for both.

📝 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
value={newMapping.max_value}
onChange={e =>
setNewMapping(m => ({
...m,
max_value: parseFloat(e.target.value) || 1,
}))
}
value={newMapping.max_value}
onChange={e =>
setNewMapping(m => ({
...m,
max_value: e.target.value === "" ? 1 : parseFloat(e.target.value),
}))
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/settings/DmxTab.tsx` around lines 434 - 440, The
onChange handler for max_value uses parseFloat(e.target.value) || 1 which treats
0 as falsy and prevents setting a legitimate 0; update the setNewMapping call in
the max_value handler (and mirror pattern for min_value) to parse the value,
check for NaN (e.g., const v = parseFloat(...); use isNaN(v) ? 1 : v) or use
conditional operator/nullish checks so only empty/invalid input falls back to 1
while preserving 0 as a valid value; adjust the handler attached to
setNewMapping/newMapping.max_value (and the similar min_value handler)
accordingly.

Comment on lines +1 to +335
"""Art-Net DMX UDP server for external parameter control.

Receives Art-Net DMX frames and maps channel values to pipeline parameters.
Follows the same architecture as the OSC server but with channel-to-parameter
mapping since DMX doesn't have named addresses like OSC.

Art-Net uses UDP port 6454 by default. Each universe contains 512 channels
with 8-bit values (0-255).
"""

import asyncio
import json
import logging
import struct
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from .pipeline_manager import PipelineManager
from .webrtc import WebRTCManager

logger = logging.getLogger(__name__)

# Art-Net constants
ARTNET_PORT = 6454
ARTNET_HEADER = b"Art-Net\x00"
ARTNET_OPCODE_DMX = 0x5000

# How often to broadcast updates (rate limiting)
_MIN_BROADCAST_INTERVAL = 0.016 # ~60fps max


@dataclass
class DMXMapping:
"""Maps a DMX channel to a pipeline parameter."""

id: str
universe: int
channel: int # 1-512 (DMX convention, 1-indexed)
param_key: str
min_value: float = 0.0
max_value: float = 1.0
enabled: bool = True

def scale(self, raw: int) -> float:
"""Convert 0-255 DMX value to parameter range."""
normalized = raw / 255.0
return self.min_value + normalized * (self.max_value - self.min_value)

def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"universe": self.universe,
"channel": self.channel,
"param_key": self.param_key,
"min_value": self.min_value,
"max_value": self.max_value,
"enabled": self.enabled,
}

@classmethod
def from_dict(cls, data: dict[str, Any]) -> "DMXMapping":
return cls(
id=data["id"],
universe=data["universe"],
channel=data["channel"],
param_key=data["param_key"],
min_value=data.get("min_value", 0.0),
max_value=data.get("max_value", 1.0),
enabled=data.get("enabled", True),
)


@dataclass
class DMXMappingStore:
"""Persistent storage for DMX channel mappings."""

mappings: dict[str, DMXMapping] = field(default_factory=dict)
_config_path: Path | None = None

@classmethod
def load(cls, config_dir: Path) -> "DMXMappingStore":
"""Load mappings from config file."""
config_path = config_dir / "dmx_mappings.json"
store = cls(_config_path=config_path)

if config_path.exists():
try:
data = json.loads(config_path.read_text())
for mapping_data in data.get("mappings", []):
mapping = DMXMapping.from_dict(mapping_data)
store.mappings[mapping.id] = mapping
logger.info(f"Loaded {len(store.mappings)} DMX mappings from {config_path}")
except Exception as e:
logger.warning(f"Failed to load DMX mappings: {e}")

return store

def save(self) -> None:
"""Save mappings to config file."""
if self._config_path is None:
return

try:
self._config_path.parent.mkdir(parents=True, exist_ok=True)
data = {
"mappings": [m.to_dict() for m in self.mappings.values()]
}
self._config_path.write_text(json.dumps(data, indent=2))
logger.debug(f"Saved {len(self.mappings)} DMX mappings")
except Exception as e:
logger.error(f"Failed to save DMX mappings: {e}")

def add(self, mapping: DMXMapping) -> None:
"""Add or update a mapping."""
self.mappings[mapping.id] = mapping
self.save()

def remove(self, mapping_id: str) -> bool:
"""Remove a mapping by ID."""
if mapping_id in self.mappings:
del self.mappings[mapping_id]
self.save()
return True
return False

def get_by_channel(self, universe: int, channel: int) -> DMXMapping | None:
"""Find mapping for a specific universe/channel."""
for mapping in self.mappings.values():
if mapping.universe == universe and mapping.channel == channel and mapping.enabled:
return mapping
return None


class ArtNetProtocol(asyncio.DatagramProtocol):
"""UDP protocol handler for Art-Net packets."""

def __init__(self, server: "DMXServer"):
self._server = server

def connection_made(self, transport: asyncio.DatagramTransport) -> None:
self._transport = transport

def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
self._server._handle_artnet_packet(data, addr)


class DMXServer:
"""Manages the Art-Net DMX UDP listener with channel-to-parameter mapping."""

def __init__(self, port: int = ARTNET_PORT, config_dir: Path | None = None):
self._port = port
self._transport: asyncio.DatagramTransport | None = None
self._listening = False
self._pipeline_manager: PipelineManager | None = None
self._webrtc_manager: WebRTCManager | None = None

# SSE subscribers for real-time DMX monitoring
self._sse_queues: list[asyncio.Queue] = []

# Channel mappings
if config_dir is None:
config_dir = Path.home() / ".daydream-scope"
self._mapping_store = DMXMappingStore.load(config_dir)

# Rate limiting for broadcasts
self._last_broadcast_time: float = 0.0
self._pending_updates: dict[str, Any] = {}

# Cache last known channel values for monitoring
self._channel_values: dict[tuple[int, int], int] = {}

@property
def port(self) -> int:
return self._port

@property
def listening(self) -> bool:
return self._listening

@property
def mappings(self) -> list[DMXMapping]:
return list(self._mapping_store.mappings.values())

def set_managers(
self,
pipeline_manager: "PipelineManager",
webrtc_manager: "WebRTCManager",
) -> None:
self._pipeline_manager = pipeline_manager
self._webrtc_manager = webrtc_manager

def subscribe(self) -> "asyncio.Queue[dict[str, Any]]":
"""Register a new SSE subscriber and return its event queue."""
q: asyncio.Queue = asyncio.Queue(maxsize=100)
self._sse_queues.append(q)
return q

def unsubscribe(self, q: "asyncio.Queue") -> None:
"""Deregister an SSE subscriber."""
try:
self._sse_queues.remove(q)
except ValueError:
pass

def add_mapping(self, mapping: DMXMapping) -> None:
"""Add or update a channel mapping."""
self._mapping_store.add(mapping)

def remove_mapping(self, mapping_id: str) -> bool:
"""Remove a mapping by ID."""
return self._mapping_store.remove(mapping_id)

def get_mappings(self) -> list[dict[str, Any]]:
"""Get all mappings as dicts."""
return [m.to_dict() for m in self._mapping_store.mappings.values()]

def _handle_artnet_packet(self, data: bytes, addr: tuple[str, int]) -> None:
"""Parse and process an Art-Net packet."""
# Validate Art-Net header
if len(data) < 18 or not data.startswith(ARTNET_HEADER):
return

# Parse opcode (little-endian)
opcode = struct.unpack("<H", data[8:10])[0]

if opcode != ARTNET_OPCODE_DMX:
return # We only care about DMX data packets

# Parse Art-Net DMX packet
# Bytes 10-11: Protocol version (14)
# Byte 12: Sequence
# Byte 13: Physical port
# Bytes 14-15: Universe (little-endian, but Art-Net spec says low byte first)
# Bytes 16-17: Length (big-endian)
# Bytes 18+: DMX data

universe = struct.unpack("<H", data[14:16])[0]
length = struct.unpack(">H", data[16:18])[0]

if len(data) < 18 + length:
return

dmx_data = data[18:18 + length]

self._process_dmx_frame(universe, dmx_data)

def _process_dmx_frame(self, universe: int, dmx_data: bytes) -> None:
"""Process a DMX frame and apply any mapped parameters."""
now = time.monotonic()
updates: dict[str, Any] = {}
changed_channels: list[dict[str, Any]] = []

for i, value in enumerate(dmx_data):
channel = i + 1 # DMX channels are 1-indexed
cache_key = (universe, channel)

# Track if value changed (for SSE monitoring)
old_value = self._channel_values.get(cache_key)
if old_value != value:
self._channel_values[cache_key] = value
changed_channels.append({
"universe": universe,
"channel": channel,
"value": value,
})

# Check for mapping
mapping = self._mapping_store.get_by_channel(universe, channel)
if mapping:
scaled_value = mapping.scale(value)
updates[mapping.param_key] = scaled_value

# Rate-limit broadcasts
if updates:
self._pending_updates.update(updates)

if now - self._last_broadcast_time >= _MIN_BROADCAST_INTERVAL:
if self._webrtc_manager and self._pending_updates:
self._webrtc_manager.broadcast_parameter_update(self._pending_updates)
self._pending_updates = {}
self._last_broadcast_time = now

# Push channel updates to SSE subscribers (for monitoring UI)
if changed_channels:
event = {
"type": "dmx_channels",
"universe": universe,
"channels": changed_channels[:50], # Limit to avoid flooding
}
for q in list(self._sse_queues):
try:
q.put_nowait(event)
except asyncio.QueueFull:
pass

async def start(self) -> None:
"""Start the Art-Net UDP listener."""
try:
loop = asyncio.get_running_loop()
transport, _ = await loop.create_datagram_endpoint(
lambda: ArtNetProtocol(self),
local_addr=("0.0.0.0", self._port),
)
self._transport = transport
self._listening = True
logger.info(f"DMX (Art-Net) server listening on udp://0.0.0.0:{self._port}")
except OSError as e:
if e.errno == 98: # Address already in use
logger.warning(
f"DMX server: Port {self._port} already in use. "
"Another application may be using Art-Net."
)
else:
logger.exception(f"Failed to start DMX server on port {self._port}")
self._listening = False

async def stop(self) -> None:
"""Stop the DMX server."""
if self._transport:
self._transport.close()
self._transport = None
self._listening = False
logger.info("DMX server stopped")

def status(self) -> dict[str, Any]:
"""Return current server status."""
return {
"enabled": True,
"listening": self._listening,
"port": self._port,
"mapping_count": len(self._mapping_store.mappings),
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Run ruff format before merge.

CI is red because ruff format --check wants to reformat this file.

🧰 Tools
🪛 GitHub Actions: Lint

[error] 1-1: Ruff format check failed. 1 file would be reformatted (src/scope/server/dmx_server.py). Run 'ruff format' to fix formatting.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/scope/server/dmx_server.py` around lines 1 - 335, The file fails ruff
formatting; run the formatter to satisfy CI. Reformat this module (symbols to
check: DMXServer, DMXMappingStore, DMXMapping, ArtNetProtocol,
_process_dmx_frame) by running `ruff format` (or `ruff format
src/scope/server/dmx_server.py`) and commit the changes; optionally add ruff to
pre-commit hooks or CI config to prevent future format failures.

Comment on lines +116 to +119
def add(self, mapping: DMXMapping) -> None:
"""Add or update a mapping."""
self.mappings[mapping.id] = mapping
self.save()
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Enforce one mapping per (universe, channel).

get_by_channel() only returns the first enabled match, so saving a second mapping for the same DMX input makes one of them silently unreachable. Reject duplicates here or replace the existing mapping for that channel.

🔧 Proposed fix
 def add(self, mapping: DMXMapping) -> None:
     """Add or update a mapping."""
+    for existing_id, existing in self.mappings.items():
+        if (
+            existing_id != mapping.id
+            and existing.universe == mapping.universe
+            and existing.channel == mapping.channel
+        ):
+            raise ValueError(
+                f"DMX channel {mapping.universe}:{mapping.channel} is already mapped"
+            )
+
     self.mappings[mapping.id] = mapping
     self.save()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/scope/server/dmx_server.py` around lines 116 - 119, The add method
currently allows multiple mappings for the same (universe, channel), making
later ones unreachable via get_by_channel; update add(self, mapping: DMXMapping)
to enforce one mapping per (universe, channel) by searching existing entries in
self.mappings for any mapping with the same mapping.universe and
mapping.channel, and if found either replace that entry (remove its key from
self.mappings and insert the new mapping under mapping.id) or raise a ValueError
to reject duplicates—ensure you handle the case where the found mapping has the
same id (treat as an update) and always call self.save() after performing the
replace/reject logic so state remains consistent.

Comment on lines +243 to +248
if len(data) < 18 + length:
return

dmx_data = data[18:18 + length]

self._process_dmx_frame(universe, dmx_data)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Reject oversized DMX frames.

Art-Net DMX payloads are capped at 512 slots. Accepting any advertised length lets malformed packets create impossible channel numbers and grow _channel_values well past spec.

🔧 Proposed fix
         if len(data) < 18 + length:
             return
+        if length > 512:
+            logger.debug("Ignoring oversized Art-Net DMX frame: %s bytes", length)
+            return
 
         dmx_data = data[18:18 + length]
📝 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
if len(data) < 18 + length:
return
dmx_data = data[18:18 + length]
self._process_dmx_frame(universe, dmx_data)
if len(data) < 18 + length:
return
if length > 512:
logger.debug("Ignoring oversized Art-Net DMX frame: %s bytes", length)
return
dmx_data = data[18:18 + length]
self._process_dmx_frame(universe, dmx_data)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/scope/server/dmx_server.py` around lines 243 - 248, The handler currently
trusts the advertised `length` and slices `data[18:18 + length]`, allowing
oversized frames to create out-of-spec channel counts; before slicing and
calling _process_dmx_frame(universe, dmx_data) validate the advertised `length`
(and that 18 + length <= len(data)) and reject any packet where length > 512 (or
otherwise invalid) by returning early. Update the logic that computes `dmx_data`
to first check the `length` bounds and only proceed when 0 < length <= 512 and
the buffer contains at least 18 + length bytes, otherwise drop the packet.

Comment on lines +276 to +285
# Rate-limit broadcasts
if updates:
self._pending_updates.update(updates)

if now - self._last_broadcast_time >= _MIN_BROADCAST_INTERVAL:
if self._webrtc_manager and self._pending_updates:
self._webrtc_manager.broadcast_parameter_update(self._pending_updates)
self._pending_updates = {}
self._last_broadcast_time = now

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Throttle logic can drop the last parameter update.

If a frame lands inside the 16ms window and the sender stops before the next packet, _pending_updates never flushes. This needs a delayed flush, not just opportunistic flushing on later frames.

🔧 Proposed fix
         if updates:
             self._pending_updates.update(updates)
 
-            if now - self._last_broadcast_time >= _MIN_BROADCAST_INTERVAL:
-                if self._webrtc_manager and self._pending_updates:
-                    self._webrtc_manager.broadcast_parameter_update(self._pending_updates)
-                    self._pending_updates = {}
-                    self._last_broadcast_time = now
+            def flush_pending() -> None:
+                if self._webrtc_manager and self._pending_updates:
+                    self._webrtc_manager.broadcast_parameter_update(
+                        self._pending_updates
+                    )
+                    self._pending_updates = {}
+                    self._last_broadcast_time = time.monotonic()
+                self._flush_handle = None
+
+            if now - self._last_broadcast_time >= _MIN_BROADCAST_INTERVAL:
+                flush_pending()
+            elif self._flush_handle is None:
+                delay = _MIN_BROADCAST_INTERVAL - (
+                    now - self._last_broadcast_time
+                )
+                self._flush_handle = asyncio.get_running_loop().call_later(
+                    delay, flush_pending
+                )

Also initialize self._flush_handle: asyncio.TimerHandle | None = None in __init__ and cancel it in stop().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/scope/server/dmx_server.py` around lines 276 - 285, The current
rate-limit can drop the final pending update because we only flush
opportunistically; modify the logic around _pending_updates/_last_broadcast_time
so that when you add updates but the minimum interval hasn’t elapsed you
schedule a delayed flush via an asyncio.TimerHandle (store it in
self._flush_handle) that will call the same broadcast code (using
self._webrtc_manager.broadcast_parameter_update) after the remaining interval;
create and initialize self._flush_handle: asyncio.TimerHandle | None = None in
__init__, set/replace it when scheduling, clear it and self._pending_updates
after broadcasting, and cancel and nil out self._flush_handle in stop() to avoid
leaks.

@github-actions
Copy link
Contributor

github-actions bot commented Mar 9, 2026

🚀 fal.ai Preview Deployment

App ID daydream/scope-pr-622--preview
WebSocket wss://fal.run/daydream/scope-pr-622--preview/ws
Commit 0344bd4

Testing

Connect to this preview deployment by running this on your branch:

uv run build && SCOPE_CLOUD_APP_ID="daydream/scope-pr-622--preview/ws" uv run daydream-scope

🧪 E2E tests will run automatically against this deployment.

@github-actions
Copy link
Contributor

github-actions bot commented Mar 9, 2026

✅ E2E Tests passed

Status passed
fal App daydream/scope-pr-622--preview
Run View logs

Test Artifacts

Check the workflow run for screenshots.

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

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: DMX (Art-Net) input support for external parameter control

1 participant