Add interactive graph editor connections: port-based edges, drag-connect, and detach
- Add visible typed ports on graph nodes (colored dots for each edge type) - Route edges to specific port positions instead of node center - Drag from output port to compatible input port to create/change connections - Right-click edge context menu with Disconnect option - Delete key detaches selected edge - Mark nested edges (composite layers, zones) as non-editable with dotted style - Add resolve_ref helper for empty-string sentinel to clear reference fields - Apply resolve_ref across all storage stores for consistent detach support - Add connection mapping module (graph-connections.js) with API field resolution - Add i18n keys for connection operations (en/ru/zh) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ from wled_controller.storage.audio_source import (
|
||||
MultichannelAudioSource,
|
||||
)
|
||||
from wled_controller.storage.base_store import BaseJsonStore
|
||||
from wled_controller.storage.utils import resolve_ref
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -112,15 +113,18 @@ class AudioSourceStore(BaseJsonStore[AudioSource]):
|
||||
if is_loopback is not None:
|
||||
source.is_loopback = bool(is_loopback)
|
||||
if audio_template_id is not None:
|
||||
source.audio_template_id = audio_template_id
|
||||
source.audio_template_id = resolve_ref(audio_template_id, source.audio_template_id)
|
||||
elif isinstance(source, MonoAudioSource):
|
||||
if audio_source_id is not None:
|
||||
parent = self._items.get(audio_source_id)
|
||||
if not parent:
|
||||
raise ValueError(f"Parent audio source not found: {audio_source_id}")
|
||||
if not isinstance(parent, MultichannelAudioSource):
|
||||
raise ValueError("Mono sources must reference a multichannel source")
|
||||
source.audio_source_id = audio_source_id
|
||||
resolved = resolve_ref(audio_source_id, source.audio_source_id)
|
||||
if resolved is not None:
|
||||
# Validate parent exists and is multichannel
|
||||
parent = self._items.get(resolved)
|
||||
if not parent:
|
||||
raise ValueError(f"Parent audio source not found: {resolved}")
|
||||
if not isinstance(parent, MultichannelAudioSource):
|
||||
raise ValueError("Mono sources must reference a multichannel source")
|
||||
source.audio_source_id = resolved
|
||||
if channel is not None:
|
||||
source.channel = channel
|
||||
|
||||
|
||||
@@ -84,11 +84,11 @@ class AutomationStore(BaseJsonStore[Automation]):
|
||||
if conditions is not None:
|
||||
automation.conditions = conditions
|
||||
if scene_preset_id != "__unset__":
|
||||
automation.scene_preset_id = scene_preset_id
|
||||
automation.scene_preset_id = None if scene_preset_id == "" else scene_preset_id
|
||||
if deactivation_mode is not None:
|
||||
automation.deactivation_mode = deactivation_mode
|
||||
if deactivation_scene_preset_id != "__unset__":
|
||||
automation.deactivation_scene_preset_id = deactivation_scene_preset_id
|
||||
automation.deactivation_scene_preset_id = None if deactivation_scene_preset_id == "" else deactivation_scene_preset_id
|
||||
if tags is not None:
|
||||
automation.tags = tags
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import List, Optional
|
||||
|
||||
from wled_controller.core.capture.calibration import CalibrationConfig, calibration_to_dict
|
||||
from wled_controller.storage.base_store import BaseJsonStore
|
||||
from wled_controller.storage.utils import resolve_ref
|
||||
from wled_controller.storage.color_strip_source import (
|
||||
AdvancedPictureColorStripSource,
|
||||
ApiInputColorStripSource,
|
||||
@@ -390,14 +391,14 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]):
|
||||
source.description = description
|
||||
|
||||
if clock_id is not None:
|
||||
source.clock_id = clock_id if clock_id else None
|
||||
source.clock_id = resolve_ref(clock_id, source.clock_id)
|
||||
|
||||
if tags is not None:
|
||||
source.tags = tags
|
||||
|
||||
if isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)):
|
||||
if picture_source_id is not None and isinstance(source, PictureColorStripSource):
|
||||
source.picture_source_id = picture_source_id
|
||||
source.picture_source_id = resolve_ref(picture_source_id, source.picture_source_id)
|
||||
if fps is not None:
|
||||
source.fps = fps
|
||||
if brightness is not None:
|
||||
@@ -447,7 +448,7 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]):
|
||||
if visualization_mode is not None:
|
||||
source.visualization_mode = visualization_mode
|
||||
if audio_source_id is not None:
|
||||
source.audio_source_id = audio_source_id
|
||||
source.audio_source_id = resolve_ref(audio_source_id, source.audio_source_id)
|
||||
if sensitivity is not None:
|
||||
source.sensitivity = float(sensitivity)
|
||||
if smoothing is not None:
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from wled_controller.storage.output_target import OutputTarget
|
||||
from wled_controller.storage.utils import resolve_ref
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -105,7 +106,7 @@ class KeyColorsOutputTarget(OutputTarget):
|
||||
"""Apply mutable field updates for KC targets."""
|
||||
super().update_fields(name=name, description=description, tags=tags)
|
||||
if picture_source_id is not None:
|
||||
self.picture_source_id = picture_source_id
|
||||
self.picture_source_id = resolve_ref(picture_source_id, self.picture_source_id)
|
||||
if key_colors_settings is not None:
|
||||
self.settings = key_colors_settings
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import datetime, timezone
|
||||
from typing import List, Optional, Set
|
||||
|
||||
from wled_controller.storage.base_store import BaseJsonStore
|
||||
from wled_controller.storage.utils import resolve_ref
|
||||
from wled_controller.storage.picture_source import (
|
||||
PictureSource,
|
||||
ProcessedPictureSource,
|
||||
@@ -183,7 +184,8 @@ class PictureSourceStore(BaseJsonStore[PictureSource]):
|
||||
stream = self.get(stream_id)
|
||||
|
||||
# If changing source_stream_id on a processed stream, check for cycles
|
||||
if source_stream_id is not None and isinstance(stream, ProcessedPictureSource):
|
||||
# (skip validation when clearing via empty string)
|
||||
if source_stream_id is not None and source_stream_id != "" and isinstance(stream, ProcessedPictureSource):
|
||||
if source_stream_id not in self._items:
|
||||
raise ValueError(f"Source stream not found: {source_stream_id}")
|
||||
if self._detect_cycle(source_stream_id, exclude_stream_id=stream_id):
|
||||
@@ -201,14 +203,14 @@ class PictureSourceStore(BaseJsonStore[PictureSource]):
|
||||
if display_index is not None:
|
||||
stream.display_index = display_index
|
||||
if capture_template_id is not None:
|
||||
stream.capture_template_id = capture_template_id
|
||||
stream.capture_template_id = resolve_ref(capture_template_id, stream.capture_template_id)
|
||||
if target_fps is not None:
|
||||
stream.target_fps = target_fps
|
||||
elif isinstance(stream, ProcessedPictureSource):
|
||||
if source_stream_id is not None:
|
||||
stream.source_stream_id = source_stream_id
|
||||
stream.source_stream_id = resolve_ref(source_stream_id, stream.source_stream_id)
|
||||
if postprocessing_template_id is not None:
|
||||
stream.postprocessing_template_id = postprocessing_template_id
|
||||
stream.postprocessing_template_id = resolve_ref(postprocessing_template_id, stream.postprocessing_template_id)
|
||||
elif isinstance(stream, StaticImagePictureSource):
|
||||
if image_source is not None:
|
||||
stream.image_source = image_source
|
||||
|
||||
23
server/src/wled_controller/storage/utils.py
Normal file
23
server/src/wled_controller/storage/utils.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Shared utilities for storage layer."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def resolve_ref(new_value: Optional[str], current_value: Optional[str]) -> Optional[str]:
|
||||
"""Resolve a reference field update.
|
||||
|
||||
Handles three cases for nullable reference ID fields:
|
||||
- new_value == '' -> clear to None (detach)
|
||||
- new_value is None -> keep current value (no change)
|
||||
- otherwise -> use new_value
|
||||
|
||||
Args:
|
||||
new_value: The incoming value from the API update request.
|
||||
current_value: The current value stored on the entity.
|
||||
|
||||
Returns:
|
||||
The resolved value to assign to the field.
|
||||
"""
|
||||
if new_value == "":
|
||||
return None
|
||||
return new_value if new_value is not None else current_value
|
||||
@@ -5,6 +5,7 @@ from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from wled_controller.storage.base_store import BaseJsonStore
|
||||
from wled_controller.storage.utils import resolve_ref
|
||||
from wled_controller.storage.value_source import (
|
||||
AdaptiveValueSource,
|
||||
AnimatedValueSource,
|
||||
@@ -179,7 +180,7 @@ class ValueSourceStore(BaseJsonStore[ValueSource]):
|
||||
source.max_value = max_value
|
||||
elif isinstance(source, AudioValueSource):
|
||||
if audio_source_id is not None:
|
||||
source.audio_source_id = audio_source_id
|
||||
source.audio_source_id = resolve_ref(audio_source_id, source.audio_source_id)
|
||||
if mode is not None:
|
||||
source.mode = mode
|
||||
if sensitivity is not None:
|
||||
@@ -198,7 +199,7 @@ class ValueSourceStore(BaseJsonStore[ValueSource]):
|
||||
raise ValueError("Time of day schedule requires at least 2 points")
|
||||
source.schedule = schedule
|
||||
if picture_source_id is not None:
|
||||
source.picture_source_id = picture_source_id
|
||||
source.picture_source_id = resolve_ref(picture_source_id, source.picture_source_id)
|
||||
if scene_behavior is not None:
|
||||
source.scene_behavior = scene_behavior
|
||||
if sensitivity is not None:
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from wled_controller.storage.output_target import OutputTarget
|
||||
from wled_controller.storage.utils import resolve_ref
|
||||
|
||||
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds
|
||||
|
||||
@@ -68,11 +69,11 @@ class WledOutputTarget(OutputTarget):
|
||||
"""Apply mutable field updates for WLED targets."""
|
||||
super().update_fields(name=name, description=description, tags=tags)
|
||||
if device_id is not None:
|
||||
self.device_id = device_id
|
||||
self.device_id = resolve_ref(device_id, self.device_id)
|
||||
if color_strip_source_id is not None:
|
||||
self.color_strip_source_id = color_strip_source_id
|
||||
self.color_strip_source_id = resolve_ref(color_strip_source_id, self.color_strip_source_id)
|
||||
if brightness_value_source_id is not None:
|
||||
self.brightness_value_source_id = brightness_value_source_id
|
||||
self.brightness_value_source_id = resolve_ref(brightness_value_source_id, self.brightness_value_source_id)
|
||||
if fps is not None:
|
||||
self.fps = fps
|
||||
if keepalive_interval is not None:
|
||||
|
||||
Reference in New Issue
Block a user