feat: DMX (Art-Net) input support for external parameter control#622
feat: DMX (Art-Net) input support for external parameter control#622livepeer-tessa wants to merge 2 commits intomainfrom
Conversation
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>
📝 WalkthroughWalkthroughAdds 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
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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Tip Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs). Comment |
There was a problem hiding this comment.
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 | 🔴 CriticalMissing
dmx_serverin global declaration causes assignment to create local variable.The
globalstatement on lines 297-302 doesn't includedmx_server, but line 366 assigns to it. This creates a local variable instead of modifying the module-level global, causingget_dmx_server()to returnNone.🐛 Proposed fix
global \ webrtc_manager, \ pipeline_manager, \ cloud_connection_manager, \ kafka_publisher, \ - osc_server + osc_server, \ + dmx_serverAlso 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}andmax={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 theonChangehandler.♻️ 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.okis false, the status fetch silently fails without informing the user. Consider showing an error toast for non-OK responses, similar to howhandleAddMappinghandles 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
📒 Files selected for processing (5)
frontend/src/components/SettingsDialog.tsxfrontend/src/components/settings/DmxTab.tsxsrc/scope/server/app.pysrc/scope/server/dmx_docs.pysrc/scope/server/dmx_server.py
| @@ -0,0 +1,450 @@ | |||
| import { useState, useEffect, useCallback } from "react"; | |||
There was a problem hiding this comment.
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).
src/scope/server/dmx_server.py
Outdated
| self._pipeline_manager: "PipelineManager | None" = None | ||
| self._webrtc_manager: "WebRTCManager | None" = None |
There was a problem hiding this comment.
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 asyncioThen 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.
| 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.
| 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." | ||
| ) |
There was a problem hiding this comment.
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.
| 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>
There was a problem hiding this comment.
Actionable comments posted: 5
♻️ Duplicate comments (1)
src/scope/server/dmx_server.py (1)
310-315:⚠️ Potential issue | 🟡 MinorUse
errno.EADDRINUSEinstead of98.
98is 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.okis 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
📒 Files selected for processing (2)
frontend/src/components/settings/DmxTab.tsxsrc/scope/server/dmx_server.py
| value={newMapping.max_value} | ||
| onChange={e => | ||
| setNewMapping(m => ({ | ||
| ...m, | ||
| max_value: parseFloat(e.target.value) || 1, | ||
| })) | ||
| } |
There was a problem hiding this comment.
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.
| 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.
| """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), | ||
| } |
There was a problem hiding this comment.
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.
| def add(self, mapping: DMXMapping) -> None: | ||
| """Add or update a mapping.""" | ||
| self.mappings[mapping.id] = mapping | ||
| self.save() |
There was a problem hiding this comment.
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.
| if len(data) < 18 + length: | ||
| return | ||
|
|
||
| dmx_data = data[18:18 + length] | ||
|
|
||
| self._process_dmx_frame(universe, dmx_data) |
There was a problem hiding this comment.
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.
| 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.
| # 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 | ||
|
|
There was a problem hiding this comment.
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.
🚀 fal.ai Preview Deployment
TestingConnect to this preview deployment by running this on your branch: 🧪 E2E tests will run automatically against this deployment. |
✅ E2E Tests passed
Test ArtifactsCheck the workflow run for screenshots. |
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 6454dmx_docs.py— Self-hosted HTML documentationDmxTab.tsx— Settings UI for managing channel mappingsbroadcast_parameter_update()pathKey Features
~/.daydream-scope/dmx_mappings.json/api/v1/dmx/docsHow It Works
noise_scale)Example Usage
Compatible Software
Testing
Screenshots
(Settings → DMX tab shows status and mapping configuration)
Closes #621
/cc @thomshutt
Summary by CodeRabbit