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:
@@ -336,32 +336,40 @@
|
||||
/* ── Ports ── */
|
||||
|
||||
.graph-port {
|
||||
stroke: var(--bg-color);
|
||||
stroke-width: 2;
|
||||
opacity: 0.85;
|
||||
transition: r 0.15s, opacity 0.15s;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.graph-node:hover .graph-port {
|
||||
r: 5;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Port output cursor: draggable */
|
||||
.graph-port-out {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.graph-port circle {
|
||||
fill: var(--bg-secondary);
|
||||
stroke: var(--text-muted);
|
||||
stroke-width: 1.5;
|
||||
r: 5;
|
||||
transition: fill 0.15s, stroke 0.15s, r 0.15s;
|
||||
/* Port interaction states during connection drag */
|
||||
.graph-port-compatible {
|
||||
r: 6 !important;
|
||||
opacity: 1 !important;
|
||||
cursor: pointer;
|
||||
filter: drop-shadow(0 0 3px currentColor);
|
||||
}
|
||||
|
||||
.graph-port:hover circle {
|
||||
fill: var(--primary-color);
|
||||
stroke: var(--primary-color);
|
||||
r: 6;
|
||||
.graph-port-incompatible {
|
||||
opacity: 0.15 !important;
|
||||
}
|
||||
|
||||
.graph-port.connected circle {
|
||||
fill: var(--text-secondary);
|
||||
stroke: var(--text-secondary);
|
||||
}
|
||||
|
||||
.graph-port-label {
|
||||
fill: var(--text-muted);
|
||||
font-size: 9px;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
.graph-port-drop-target {
|
||||
r: 7 !important;
|
||||
stroke: var(--primary-color) !important;
|
||||
stroke-width: 3 !important;
|
||||
filter: drop-shadow(0 0 6px var(--primary-color));
|
||||
}
|
||||
|
||||
/* ── Edges ── */
|
||||
@@ -398,6 +406,12 @@
|
||||
opacity: 0.12;
|
||||
}
|
||||
|
||||
/* Nested edges (composite layers, zones) — not drag-editable */
|
||||
.graph-edge-nested {
|
||||
stroke-dasharray: 2 2;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Edge type colors */
|
||||
.graph-edge-picture { stroke: #42A5F5; color: #42A5F5; }
|
||||
.graph-edge-colorstrip { stroke: #66BB6A; color: #66BB6A; }
|
||||
@@ -433,6 +447,47 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Edge context menu ── */
|
||||
|
||||
.graph-edge-menu {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px var(--shadow-color);
|
||||
padding: 4px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.graph-edge-menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-color);
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.graph-edge-menu-item:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.graph-edge-menu-item.danger {
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.graph-edge-menu-item.danger:hover {
|
||||
background: var(--danger-color);
|
||||
color: var(--primary-contrast);
|
||||
}
|
||||
|
||||
/* ── Hover overlay (action buttons) ── */
|
||||
|
||||
.graph-node-overlay {
|
||||
|
||||
133
server/src/wled_controller/static/js/core/graph-connections.js
Normal file
133
server/src/wled_controller/static/js/core/graph-connections.js
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Graph connection editing — maps edge types to API fields and endpoints.
|
||||
* Supports creating, changing, and detaching connections via the graph editor.
|
||||
*/
|
||||
|
||||
import { fetchWithAuth } from './api.js';
|
||||
import {
|
||||
streamsCache, colorStripSourcesCache, valueSourcesCache,
|
||||
audioSourcesCache, outputTargetsCache, automationsCacheObj,
|
||||
} from './state.js';
|
||||
|
||||
/**
|
||||
* Connection map: for each (targetKind, field) pair, defines:
|
||||
* - sourceKind: which entity kind(s) can be the source
|
||||
* - edgeType: the edge type for this connection
|
||||
* - endpoint: the API endpoint pattern (use {id} for target ID)
|
||||
* - cache: the DataCache to invalidate after update
|
||||
* - nested: true if this field is inside a nested structure (not editable via drag)
|
||||
*/
|
||||
const CONNECTION_MAP = [
|
||||
// Picture sources
|
||||
{ targetKind: 'picture_source', field: 'capture_template_id', sourceKind: 'capture_template', edgeType: 'template', endpoint: '/picture-sources/{id}', cache: streamsCache },
|
||||
{ targetKind: 'picture_source', field: 'source_stream_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/picture-sources/{id}', cache: streamsCache },
|
||||
{ targetKind: 'picture_source', field: 'postprocessing_template_id', sourceKind: 'pp_template', edgeType: 'template', endpoint: '/picture-sources/{id}', cache: streamsCache },
|
||||
|
||||
// Audio sources
|
||||
{ targetKind: 'audio_source', field: 'audio_template_id', sourceKind: 'audio_template', edgeType: 'audio', endpoint: '/audio-sources/{id}', cache: audioSourcesCache },
|
||||
{ targetKind: 'audio_source', field: 'audio_source_id', sourceKind: 'audio_source', edgeType: 'audio', endpoint: '/audio-sources/{id}', cache: audioSourcesCache },
|
||||
|
||||
// Value sources
|
||||
{ targetKind: 'value_source', field: 'audio_source_id', sourceKind: 'audio_source', edgeType: 'audio', endpoint: '/value-sources/{id}', cache: valueSourcesCache },
|
||||
{ targetKind: 'value_source', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/value-sources/{id}', cache: valueSourcesCache },
|
||||
|
||||
// Color strip sources
|
||||
{ targetKind: 'color_strip_source', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
|
||||
{ targetKind: 'color_strip_source', field: 'audio_source_id', sourceKind: 'audio_source', edgeType: 'audio', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
|
||||
{ targetKind: 'color_strip_source', field: 'clock_id', sourceKind: 'sync_clock', edgeType: 'clock', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
|
||||
|
||||
// Output targets
|
||||
{ targetKind: 'output_target', field: 'device_id', sourceKind: 'device', edgeType: 'device', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
|
||||
{ targetKind: 'output_target', field: 'color_strip_source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
|
||||
{ targetKind: 'output_target', field: 'brightness_value_source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
|
||||
{ targetKind: 'output_target', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
|
||||
|
||||
// Automations
|
||||
{ targetKind: 'automation', field: 'scene_preset_id', sourceKind: 'scene_preset', edgeType: 'scene', endpoint: '/automations/{id}', cache: automationsCacheObj },
|
||||
{ targetKind: 'automation', field: 'deactivation_scene_preset_id', sourceKind: 'scene_preset', edgeType: 'scene', endpoint: '/automations/{id}', cache: automationsCacheObj },
|
||||
|
||||
// ── Nested fields (not drag-editable in V1) ──
|
||||
{ targetKind: 'color_strip_source', field: 'layer.source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', nested: true },
|
||||
{ targetKind: 'color_strip_source', field: 'layer.brightness_source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
{ targetKind: 'color_strip_source', field: 'zone.source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', nested: true },
|
||||
{ targetKind: 'color_strip_source', field: 'calibration.picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', nested: true },
|
||||
{ targetKind: 'output_target', field: 'settings.pattern_template_id', sourceKind: 'pattern_template', edgeType: 'template', nested: true },
|
||||
{ targetKind: 'output_target', field: 'settings.brightness_value_source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
{ targetKind: 'scene_preset', field: 'target_id', sourceKind: 'output_target', edgeType: 'scene', nested: true },
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if an edge (by field name) is editable via drag-connect.
|
||||
*/
|
||||
export function isEditableEdge(field) {
|
||||
const entry = CONNECTION_MAP.find(c => c.field === field);
|
||||
return entry ? !entry.nested : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the connection mapping for a given target kind and source kind.
|
||||
* Returns the matching entry (or entries) from CONNECTION_MAP.
|
||||
*/
|
||||
export function findConnection(targetKind, sourceKind, edgeType) {
|
||||
return CONNECTION_MAP.filter(c =>
|
||||
!c.nested &&
|
||||
c.targetKind === targetKind &&
|
||||
c.sourceKind === sourceKind &&
|
||||
(!edgeType || c.edgeType === edgeType)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find compatible input port fields for a given source kind.
|
||||
* Returns array of { targetKind, field, edgeType }.
|
||||
*/
|
||||
export function getCompatibleInputs(sourceKind) {
|
||||
return CONNECTION_MAP
|
||||
.filter(c => !c.nested && c.sourceKind === sourceKind)
|
||||
.map(c => ({ targetKind: c.targetKind, field: c.field, edgeType: c.edgeType }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the connection entry for a specific edge (by target kind and field).
|
||||
*/
|
||||
export function getConnectionByField(targetKind, field) {
|
||||
return CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field && !c.nested);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a connection: set the reference field on the target entity.
|
||||
* @param {string} targetId - The target entity's ID
|
||||
* @param {string} targetKind - The target entity's kind
|
||||
* @param {string} field - The field name to update
|
||||
* @param {string|null} newSourceId - New source ID, or '' to detach
|
||||
* @returns {Promise<boolean>} success
|
||||
*/
|
||||
export async function updateConnection(targetId, targetKind, field, newSourceId) {
|
||||
const entry = CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field && !c.nested);
|
||||
if (!entry) return false;
|
||||
|
||||
const url = entry.endpoint.replace('{id}', targetId);
|
||||
const body = { [field]: newSourceId };
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(url, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) return false;
|
||||
// Invalidate the relevant cache so data refreshes
|
||||
if (entry.cache) entry.cache.invalidate();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach a connection (set field to null via empty-string sentinel).
|
||||
*/
|
||||
export async function detachConnection(targetId, targetKind, field) {
|
||||
return updateConnection(targetId, targetKind, field, '');
|
||||
}
|
||||
|
||||
export { CONNECTION_MAP };
|
||||
@@ -51,9 +51,16 @@ function _createArrowMarker(type) {
|
||||
}
|
||||
|
||||
function _renderEdge(edge) {
|
||||
const { from, to, type, points, fromNode, toNode, field } = edge;
|
||||
const cssClass = `graph-edge graph-edge-${type}`;
|
||||
const d = points ? _pointsToPath(points) : _defaultBezier(fromNode, toNode);
|
||||
const { from, to, type, points, fromNode, toNode, field, editable } = edge;
|
||||
const cssClass = `graph-edge graph-edge-${type}${editable === false ? ' graph-edge-nested' : ''}`;
|
||||
let d;
|
||||
if (points) {
|
||||
// Adjust ELK start/end points to match port positions
|
||||
const adjusted = _adjustEndpoints(points, fromNode, toNode, edge.fromPortY, edge.toPortY);
|
||||
d = _pointsToPath(adjusted);
|
||||
} else {
|
||||
d = _defaultBezier(fromNode, toNode, edge.fromPortY, edge.toPortY);
|
||||
}
|
||||
|
||||
const path = svgEl('path', {
|
||||
class: cssClass,
|
||||
@@ -104,13 +111,31 @@ function _pointsToPath(points) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback bezier when no ELK routing is available.
|
||||
* Adjust ELK-routed start/end points to match port Y positions.
|
||||
*/
|
||||
function _defaultBezier(fromNode, toNode) {
|
||||
function _adjustEndpoints(points, fromNode, toNode, fromPortY, toPortY) {
|
||||
if (points.length < 2) return points;
|
||||
const result = points.map(p => ({ ...p }));
|
||||
if (fromPortY != null) {
|
||||
result[0].y = fromNode.y + fromPortY;
|
||||
result[0].x = fromNode.x + fromNode.width;
|
||||
}
|
||||
if (toPortY != null) {
|
||||
result[result.length - 1].y = toNode.y + toPortY;
|
||||
result[result.length - 1].x = toNode.x;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback bezier when no ELK routing is available.
|
||||
* Uses port Y offsets when provided, otherwise centers vertically.
|
||||
*/
|
||||
function _defaultBezier(fromNode, toNode, fromPortY, toPortY) {
|
||||
const x1 = fromNode.x + fromNode.width;
|
||||
const y1 = fromNode.y + fromNode.height / 2;
|
||||
const y1 = fromNode.y + (fromPortY ?? fromNode.height / 2);
|
||||
const x2 = toNode.x;
|
||||
const y2 = toNode.y + toNode.height / 2;
|
||||
const y2 = toNode.y + (toPortY ?? toNode.height / 2);
|
||||
const dx = Math.abs(x2 - x1) * 0.4;
|
||||
return `M ${x1} ${y1} C ${x1 + dx} ${y1} ${x2 - dx} ${y2} ${x2} ${y2}`;
|
||||
}
|
||||
@@ -195,7 +220,7 @@ export function updateEdgesForNode(group, nodeId, nodeMap, edges) {
|
||||
const toNode = nodeMap.get(edge.to);
|
||||
if (!fromNode || !toNode) continue;
|
||||
|
||||
const d = _defaultBezier(fromNode, toNode);
|
||||
const d = _defaultBezier(fromNode, toNode, edge.fromPortY, edge.toPortY);
|
||||
group.querySelectorAll(`.graph-edge[data-from="${edge.from}"][data-to="${edge.to}"]`).forEach(pathEl => {
|
||||
if ((pathEl.getAttribute('data-field') || '') === (edge.field || '')) {
|
||||
pathEl.setAttribute('d', d);
|
||||
|
||||
@@ -168,7 +168,9 @@ function buildGraph(e) {
|
||||
nodes.find(n => n.id === to)?.kind,
|
||||
field
|
||||
);
|
||||
edges.push({ from, to, field, label, type });
|
||||
// Edges with dotted fields are nested (composite layers, zones, etc.) — not drag-editable
|
||||
const editable = !field.includes('.');
|
||||
edges.push({ from, to, field, label, type, editable });
|
||||
}
|
||||
|
||||
// 1. Devices
|
||||
@@ -317,4 +319,66 @@ function buildGraph(e) {
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
/* ── Port computation ── */
|
||||
|
||||
/** Canonical ordering of port types (top → bottom on the node). */
|
||||
const PORT_TYPE_ORDER = ['template', 'picture', 'colorstrip', 'value', 'audio', 'clock', 'scene', 'device', 'default'];
|
||||
|
||||
/**
|
||||
* Compute input/output port positions on every node from the edge list.
|
||||
* Mutates edges (adds fromPortY, toPortY) and nodes (adds inputPorts, outputPorts).
|
||||
*/
|
||||
export function computePorts(nodeMap, edges) {
|
||||
// Collect which port types each node needs (keyed by edge type)
|
||||
const inputTypes = new Map(); // nodeId → Set<edgeType>
|
||||
const outputTypes = new Map(); // nodeId → Set<edgeType>
|
||||
|
||||
for (const edge of edges) {
|
||||
if (!inputTypes.has(edge.to)) inputTypes.set(edge.to, new Set());
|
||||
inputTypes.get(edge.to).add(edge.type);
|
||||
|
||||
if (!outputTypes.has(edge.from)) outputTypes.set(edge.from, new Set());
|
||||
outputTypes.get(edge.from).add(edge.type);
|
||||
}
|
||||
|
||||
// Sort port types and assign vertical positions
|
||||
function assignPorts(typeSet, height) {
|
||||
const types = [...typeSet].sort((a, b) => {
|
||||
const ai = PORT_TYPE_ORDER.indexOf(a);
|
||||
const bi = PORT_TYPE_ORDER.indexOf(b);
|
||||
return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi);
|
||||
});
|
||||
const ports = {};
|
||||
const n = types.length;
|
||||
types.forEach((t, i) => {
|
||||
ports[t] = height * (i + 1) / (n + 1);
|
||||
});
|
||||
return { types, ports };
|
||||
}
|
||||
|
||||
// Build port maps on nodes
|
||||
for (const [id, node] of nodeMap) {
|
||||
const inSet = inputTypes.get(id) || new Set();
|
||||
const outSet = outputTypes.get(id) || new Set();
|
||||
node.inputPorts = assignPorts(inSet, node.height);
|
||||
node.outputPorts = assignPorts(outSet, node.height);
|
||||
}
|
||||
|
||||
// Annotate edges with port Y offsets
|
||||
for (const edge of edges) {
|
||||
const fromNode = nodeMap.get(edge.from);
|
||||
const toNode = nodeMap.get(edge.to);
|
||||
if (fromNode?.outputPorts?.ports) {
|
||||
edge.fromPortY = fromNode.outputPorts.ports[edge.type] ?? fromNode.height / 2;
|
||||
} else {
|
||||
edge.fromPortY = fromNode ? fromNode.height / 2 : 0;
|
||||
}
|
||||
if (toNode?.inputPorts?.ports) {
|
||||
edge.toPortY = toNode.inputPorts.ports[edge.type] ?? toNode.height / 2;
|
||||
} else {
|
||||
edge.toPortY = toNode ? toNode.height / 2 : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { NODE_WIDTH, NODE_HEIGHT };
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* SVG node rendering for the graph editor.
|
||||
*/
|
||||
|
||||
import { ENTITY_COLORS, NODE_WIDTH, NODE_HEIGHT } from './graph-layout.js';
|
||||
import { ENTITY_COLORS, NODE_WIDTH, NODE_HEIGHT, computePorts } from './graph-layout.js';
|
||||
import { EDGE_COLORS } from './graph-edges.js';
|
||||
import * as P from './icon-paths.js';
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
@@ -110,6 +111,46 @@ function renderNode(node, callbacks) {
|
||||
});
|
||||
g.appendChild(barCover);
|
||||
|
||||
// Input ports (left side)
|
||||
if (node.inputPorts?.types) {
|
||||
for (const t of node.inputPorts.types) {
|
||||
const py = node.inputPorts.ports[t];
|
||||
const dot = svgEl('circle', {
|
||||
class: `graph-port graph-port-in graph-port-${t}`,
|
||||
cx: 0, cy: py, r: 4,
|
||||
fill: EDGE_COLORS[t] || EDGE_COLORS.default,
|
||||
'data-node-id': id,
|
||||
'data-node-kind': kind,
|
||||
'data-port-type': t,
|
||||
'data-port-dir': 'in',
|
||||
});
|
||||
const tip = svgEl('title');
|
||||
tip.textContent = t;
|
||||
dot.appendChild(tip);
|
||||
g.appendChild(dot);
|
||||
}
|
||||
}
|
||||
|
||||
// Output ports (right side)
|
||||
if (node.outputPorts?.types) {
|
||||
for (const t of node.outputPorts.types) {
|
||||
const py = node.outputPorts.ports[t];
|
||||
const dot = svgEl('circle', {
|
||||
class: `graph-port graph-port-out graph-port-${t}`,
|
||||
cx: width, cy: py, r: 4,
|
||||
fill: EDGE_COLORS[t] || EDGE_COLORS.default,
|
||||
'data-node-id': id,
|
||||
'data-node-kind': kind,
|
||||
'data-port-type': t,
|
||||
'data-port-dir': 'out',
|
||||
});
|
||||
const tip = svgEl('title');
|
||||
tip.textContent = t;
|
||||
dot.appendChild(tip);
|
||||
g.appendChild(dot);
|
||||
}
|
||||
}
|
||||
|
||||
// Entity icon (right side)
|
||||
const iconPaths = (SUBTYPE_ICONS[kind]?.[subtype]) || KIND_ICONS[kind];
|
||||
if (iconPaths) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { GraphCanvas } from '../core/graph-canvas.js';
|
||||
import { computeLayout, ENTITY_COLORS, ENTITY_LABELS } from '../core/graph-layout.js';
|
||||
import { computeLayout, computePorts, ENTITY_COLORS, ENTITY_LABELS } from '../core/graph-layout.js';
|
||||
import { renderNodes, patchNodeRunning, highlightNode, markOrphans, updateSelection } from '../core/graph-nodes.js';
|
||||
import { renderEdges, highlightChain, clearEdgeHighlights, updateEdgesForNode, renderFlowDots, updateFlowDotsForNode } from '../core/graph-edges.js';
|
||||
import {
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { fetchWithAuth } from '../core/api.js';
|
||||
import { showToast } from '../core/ui.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, isEditableEdge } from '../core/graph-connections.js';
|
||||
|
||||
let _canvas = null;
|
||||
let _nodeMap = null;
|
||||
@@ -42,6 +43,16 @@ let _manualPositions = new Map();
|
||||
let _rubberBand = null;
|
||||
let _rubberBandListenersAdded = false;
|
||||
|
||||
// Port-drag connection state
|
||||
let _connectState = null; // { sourceNodeId, sourceKind, portType, startPos, dragPath }
|
||||
let _connectListenersAdded = false;
|
||||
|
||||
// Edge context menu
|
||||
let _edgeContextMenu = null;
|
||||
|
||||
// Selected edge for Delete key detach
|
||||
let _selectedEdge = null; // { from, to, field, targetKind }
|
||||
|
||||
// Minimap position/size persisted in localStorage
|
||||
const _MM_KEY = 'graph_minimap';
|
||||
function _loadMinimapRect() {
|
||||
@@ -86,6 +97,7 @@ export async function loadGraphEditor() {
|
||||
// Apply manual position overrides from previous drag operations
|
||||
_applyManualPositions(nodes, edges);
|
||||
|
||||
computePorts(nodes, edges);
|
||||
_nodeMap = nodes;
|
||||
_edges = edges;
|
||||
_bounds = _calcBounds(nodes);
|
||||
@@ -229,6 +241,7 @@ function _renderGraph(container) {
|
||||
_initMinimap(container.querySelector('.graph-minimap'));
|
||||
_initToolbarDrag(container.querySelector('.graph-toolbar'));
|
||||
_initNodeDrag(nodeGroup, edgeGroup);
|
||||
_initPortDrag(svgEl, nodeGroup, edgeGroup);
|
||||
_initRubberBand(svgEl);
|
||||
|
||||
// Edge click: select edge and its endpoints
|
||||
@@ -239,6 +252,15 @@ function _renderGraph(container) {
|
||||
_onEdgeClick(edgePath, nodeGroup, edgeGroup);
|
||||
});
|
||||
|
||||
// Edge right-click: detach connection
|
||||
edgeGroup.addEventListener('contextmenu', (e) => {
|
||||
const edgePath = e.target.closest('.graph-edge');
|
||||
if (!edgePath) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
_onEdgeContextMenu(edgePath, e, container);
|
||||
});
|
||||
|
||||
const searchInput = container.querySelector('.graph-search-input');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', (e) => _renderSearchResults(e.target.value));
|
||||
@@ -247,6 +269,7 @@ function _renderGraph(container) {
|
||||
|
||||
// Deselect on click on empty space (not after a pan gesture)
|
||||
svgEl.addEventListener('click', (e) => {
|
||||
_dismissEdgeContextMenu();
|
||||
if (_canvas.wasPanning) return;
|
||||
if (e.shiftKey) return; // Shift+click reserved for rubber-band
|
||||
if (!e.target.closest('.graph-node')) {
|
||||
@@ -274,6 +297,7 @@ function _renderGraph(container) {
|
||||
|
||||
function _deselect(nodeGroup, edgeGroup) {
|
||||
_selectedIds.clear();
|
||||
_selectedEdge = null;
|
||||
if (nodeGroup) {
|
||||
updateSelection(nodeGroup, _selectedIds);
|
||||
nodeGroup.querySelectorAll('.graph-node').forEach(n => n.style.opacity = '1');
|
||||
@@ -766,11 +790,15 @@ function _onKeydown(e) {
|
||||
_deselect(ng, eg);
|
||||
}
|
||||
}
|
||||
// Delete key → delete single selected node
|
||||
if (e.key === 'Delete' && _selectedIds.size === 1) {
|
||||
const nodeId = [..._selectedIds][0];
|
||||
const node = _nodeMap.get(nodeId);
|
||||
if (node) _onDeleteNode(node);
|
||||
// Delete key → detach selected edge or delete single selected node
|
||||
if (e.key === 'Delete') {
|
||||
if (_selectedEdge) {
|
||||
_detachSelectedEdge();
|
||||
} else if (_selectedIds.size === 1) {
|
||||
const nodeId = [..._selectedIds][0];
|
||||
const node = _nodeMap.get(nodeId);
|
||||
if (node) _onDeleteNode(node);
|
||||
}
|
||||
}
|
||||
// Ctrl+A → select all
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
||||
@@ -797,6 +825,15 @@ function _selectAll() {
|
||||
function _onEdgeClick(edgePath, nodeGroup, edgeGroup) {
|
||||
const fromId = edgePath.getAttribute('data-from');
|
||||
const toId = edgePath.getAttribute('data-to');
|
||||
const field = edgePath.getAttribute('data-field') || '';
|
||||
|
||||
// Track selected edge for Delete key detach
|
||||
const toNode = _nodeMap?.get(toId);
|
||||
if (toNode && isEditableEdge(field)) {
|
||||
_selectedEdge = { from: fromId, to: toId, field, targetKind: toNode.kind };
|
||||
} else {
|
||||
_selectedEdge = null;
|
||||
}
|
||||
|
||||
_selectedIds.clear();
|
||||
_selectedIds.add(fromId);
|
||||
@@ -827,6 +864,7 @@ function _initNodeDrag(nodeGroup, edgeGroup) {
|
||||
const nodeEl = e.target.closest('.graph-node');
|
||||
if (!nodeEl) return;
|
||||
if (e.target.closest('.graph-node-overlay-btn')) return;
|
||||
if (e.target.closest('.graph-port-out')) return; // handled by port drag
|
||||
|
||||
const nodeId = nodeEl.getAttribute('data-id');
|
||||
const node = _nodeMap.get(nodeId);
|
||||
@@ -1089,6 +1127,203 @@ function _escHtml(s) {
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
/* ── Port drag (connect/reconnect) ── */
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
function _initPortDrag(svgEl, nodeGroup, edgeGroup) {
|
||||
// Capture-phase on output ports to prevent node drag
|
||||
nodeGroup.addEventListener('pointerdown', (e) => {
|
||||
const port = e.target.closest('.graph-port-out');
|
||||
if (!port || e.button !== 0) return;
|
||||
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const sourceNodeId = port.getAttribute('data-node-id');
|
||||
const sourceKind = port.getAttribute('data-node-kind');
|
||||
const portType = port.getAttribute('data-port-type');
|
||||
const sourceNode = _nodeMap?.get(sourceNodeId);
|
||||
if (!sourceNode) return;
|
||||
|
||||
// Compute start position in graph coords (output port = right side of node)
|
||||
const portY = sourceNode.outputPorts?.ports?.[portType] ?? sourceNode.height / 2;
|
||||
const startX = sourceNode.x + sourceNode.width;
|
||||
const startY = sourceNode.y + portY;
|
||||
|
||||
// Create temporary drag edge in SVG
|
||||
const dragPath = document.createElementNS(SVG_NS, 'path');
|
||||
dragPath.setAttribute('class', 'graph-drag-edge');
|
||||
dragPath.setAttribute('d', `M ${startX} ${startY} L ${startX} ${startY}`);
|
||||
const root = svgEl.querySelector('.graph-root');
|
||||
root.appendChild(dragPath);
|
||||
|
||||
_connectState = { sourceNodeId, sourceKind, portType, startX, startY, dragPath };
|
||||
|
||||
if (_canvas) _canvas.blockPan = true;
|
||||
svgEl.classList.add('connecting');
|
||||
|
||||
// Highlight compatible input ports
|
||||
const compatible = getCompatibleInputs(sourceKind);
|
||||
const compatibleSet = new Set(compatible.map(c => `${c.targetKind}:${c.edgeType}`));
|
||||
|
||||
nodeGroup.querySelectorAll('.graph-port-in').forEach(p => {
|
||||
const nKind = p.getAttribute('data-node-kind');
|
||||
const pType = p.getAttribute('data-port-type');
|
||||
const nId = p.getAttribute('data-node-id');
|
||||
// Don't connect to self
|
||||
if (nId === sourceNodeId) {
|
||||
p.classList.add('graph-port-incompatible');
|
||||
return;
|
||||
}
|
||||
if (compatibleSet.has(`${nKind}:${pType}`)) {
|
||||
p.classList.add('graph-port-compatible');
|
||||
} else {
|
||||
p.classList.add('graph-port-incompatible');
|
||||
}
|
||||
});
|
||||
}, true); // capture phase to beat node drag
|
||||
|
||||
if (!_connectListenersAdded) {
|
||||
window.addEventListener('pointermove', _onConnectPointerMove);
|
||||
window.addEventListener('pointerup', _onConnectPointerUp);
|
||||
_connectListenersAdded = true;
|
||||
}
|
||||
}
|
||||
|
||||
function _onConnectPointerMove(e) {
|
||||
if (!_connectState || !_canvas) return;
|
||||
|
||||
const gp = _canvas.screenToGraph(e.clientX, e.clientY);
|
||||
const { startX, startY, dragPath } = _connectState;
|
||||
const dx = Math.abs(gp.x - startX) * 0.4;
|
||||
dragPath.setAttribute('d',
|
||||
`M ${startX} ${startY} C ${startX + dx} ${startY} ${gp.x - dx} ${gp.y} ${gp.x} ${gp.y}`
|
||||
);
|
||||
|
||||
// Highlight drop target port
|
||||
const svgEl = document.querySelector('.graph-svg');
|
||||
if (!svgEl) return;
|
||||
const elem = document.elementFromPoint(e.clientX, e.clientY);
|
||||
const port = elem?.closest?.('.graph-port-compatible');
|
||||
|
||||
svgEl.querySelectorAll('.graph-port-drop-target').forEach(p => p.classList.remove('graph-port-drop-target'));
|
||||
if (port) port.classList.add('graph-port-drop-target');
|
||||
}
|
||||
|
||||
function _onConnectPointerUp(e) {
|
||||
if (!_connectState) return;
|
||||
|
||||
const { sourceNodeId, sourceKind, portType, dragPath } = _connectState;
|
||||
|
||||
// Clean up drag edge
|
||||
dragPath.remove();
|
||||
const svgEl = document.querySelector('.graph-svg');
|
||||
if (svgEl) svgEl.classList.remove('connecting');
|
||||
if (_canvas) _canvas.blockPan = false;
|
||||
|
||||
// Clean up port highlights
|
||||
const nodeGroup = document.querySelector('.graph-nodes');
|
||||
if (nodeGroup) {
|
||||
nodeGroup.querySelectorAll('.graph-port-compatible, .graph-port-incompatible, .graph-port-drop-target').forEach(p => {
|
||||
p.classList.remove('graph-port-compatible', 'graph-port-incompatible', 'graph-port-drop-target');
|
||||
});
|
||||
}
|
||||
|
||||
// Check if dropped on a compatible input port
|
||||
const elem = document.elementFromPoint(e.clientX, e.clientY);
|
||||
const targetPort = elem?.closest?.('.graph-port-in');
|
||||
if (targetPort) {
|
||||
const targetNodeId = targetPort.getAttribute('data-node-id');
|
||||
const targetKind = targetPort.getAttribute('data-node-kind');
|
||||
const targetPortType = targetPort.getAttribute('data-port-type');
|
||||
|
||||
if (targetNodeId !== sourceNodeId) {
|
||||
// Find the matching connection
|
||||
const matches = findConnection(targetKind, sourceKind, targetPortType);
|
||||
if (matches.length === 1) {
|
||||
_doConnect(targetNodeId, targetKind, matches[0].field, sourceNodeId);
|
||||
} else if (matches.length > 1) {
|
||||
// Multiple possible fields (e.g., template → picture_source could be capture or pp template)
|
||||
// Resolve by source kind
|
||||
const exact = matches.find(m => m.sourceKind === sourceKind);
|
||||
if (exact) {
|
||||
_doConnect(targetNodeId, targetKind, exact.field, sourceNodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_connectState = null;
|
||||
}
|
||||
|
||||
async function _doConnect(targetId, targetKind, field, sourceId) {
|
||||
const ok = await updateConnection(targetId, targetKind, field, sourceId);
|
||||
if (ok) {
|
||||
showToast(t('graph.connection_updated') || 'Connection updated', 'success');
|
||||
await loadGraphEditor();
|
||||
} else {
|
||||
showToast(t('graph.connection_failed') || 'Failed to update connection', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Edge context menu (right-click to detach) ── */
|
||||
|
||||
function _onEdgeContextMenu(edgePath, e, container) {
|
||||
_dismissEdgeContextMenu();
|
||||
|
||||
const field = edgePath.getAttribute('data-field') || '';
|
||||
if (!isEditableEdge(field)) return; // nested fields can't be detached from graph
|
||||
|
||||
const toId = edgePath.getAttribute('data-to');
|
||||
const toNode = _nodeMap?.get(toId);
|
||||
if (!toNode) return;
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'graph-edge-menu';
|
||||
menu.style.left = `${e.clientX - container.getBoundingClientRect().left}px`;
|
||||
menu.style.top = `${e.clientY - container.getBoundingClientRect().top}px`;
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'graph-edge-menu-item danger';
|
||||
btn.textContent = t('graph.disconnect') || 'Disconnect';
|
||||
btn.addEventListener('click', async () => {
|
||||
_dismissEdgeContextMenu();
|
||||
const ok = await detachConnection(toId, toNode.kind, field);
|
||||
if (ok) {
|
||||
showToast(t('graph.connection_removed') || 'Connection removed', 'success');
|
||||
await loadGraphEditor();
|
||||
} else {
|
||||
showToast(t('graph.disconnect_failed') || 'Failed to disconnect', 'error');
|
||||
}
|
||||
});
|
||||
menu.appendChild(btn);
|
||||
|
||||
container.querySelector('.graph-container').appendChild(menu);
|
||||
_edgeContextMenu = menu;
|
||||
}
|
||||
|
||||
function _dismissEdgeContextMenu() {
|
||||
if (_edgeContextMenu) {
|
||||
_edgeContextMenu.remove();
|
||||
_edgeContextMenu = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function _detachSelectedEdge() {
|
||||
if (!_selectedEdge) return;
|
||||
const { to, field, targetKind } = _selectedEdge;
|
||||
_selectedEdge = null;
|
||||
|
||||
const ok = await detachConnection(to, targetKind, field);
|
||||
if (ok) {
|
||||
showToast(t('graph.connection_removed') || 'Connection removed', 'success');
|
||||
await loadGraphEditor();
|
||||
} else {
|
||||
showToast(t('graph.disconnect_failed') || 'Failed to disconnect', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Re-render graph when language changes (toolbar titles, legend, search placeholder use t())
|
||||
document.addEventListener('languageChanged', () => {
|
||||
if (_initialized && _nodeMap) {
|
||||
|
||||
@@ -1397,5 +1397,10 @@
|
||||
"graph.minimap": "Minimap",
|
||||
"graph.relayout": "Re-layout",
|
||||
"graph.empty": "No entities yet",
|
||||
"graph.empty.hint": "Create devices, sources, and targets to see them here."
|
||||
"graph.empty.hint": "Create devices, sources, and targets to see them here.",
|
||||
"graph.disconnect": "Disconnect",
|
||||
"graph.connection_updated": "Connection updated",
|
||||
"graph.connection_failed": "Failed to update connection",
|
||||
"graph.connection_removed": "Connection removed",
|
||||
"graph.disconnect_failed": "Failed to disconnect"
|
||||
}
|
||||
|
||||
@@ -1397,5 +1397,10 @@
|
||||
"graph.minimap": "Миникарта",
|
||||
"graph.relayout": "Перестроить",
|
||||
"graph.empty": "Ещё нет сущностей",
|
||||
"graph.empty.hint": "Создайте устройства, источники и цели, чтобы увидеть их здесь."
|
||||
"graph.empty.hint": "Создайте устройства, источники и цели, чтобы увидеть их здесь.",
|
||||
"graph.disconnect": "Отключить",
|
||||
"graph.connection_updated": "Соединение обновлено",
|
||||
"graph.connection_failed": "Не удалось обновить соединение",
|
||||
"graph.connection_removed": "Соединение удалено",
|
||||
"graph.disconnect_failed": "Не удалось отключить"
|
||||
}
|
||||
|
||||
@@ -1397,5 +1397,10 @@
|
||||
"graph.minimap": "小地图",
|
||||
"graph.relayout": "重新布局",
|
||||
"graph.empty": "暂无实体",
|
||||
"graph.empty.hint": "创建设备、源和目标后即可在此查看。"
|
||||
"graph.empty.hint": "创建设备、源和目标后即可在此查看。",
|
||||
"graph.disconnect": "断开连接",
|
||||
"graph.connection_updated": "连接已更新",
|
||||
"graph.connection_failed": "更新连接失败",
|
||||
"graph.connection_removed": "连接已移除",
|
||||
"graph.disconnect_failed": "断开连接失败"
|
||||
}
|
||||
|
||||
@@ -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