Files
ledgrab/server/src/ledgrab/api/graph_schema.py
T
alexei.dolgolyov 15cfb821d3 feat(graph): duplicate a selected subgraph server-side
A secret-safe equivalent of blueprint import: the graph editor's overflow menu
gains "Duplicate selection", which deep-clones the selected value and
colour-strip sources server-side (full config preserved, never crossing the
wire) and rewires references that point within the selection — shared
dependencies (devices, HA sources, …) stay shared.

- graph_schema.remap_refs: write-twin of extract_refs (same dot/list/bindable
  grammar) that rewrites only in-selection ids; 8 unit tests.
- BaseSqliteStore.clone(): faithful deep-copy clone (no schema round-trip, so no
  field is lost), prefix-preserving fresh id; reusable by any store.
- POST /api/v1/graph/duplicate: two-pass clone-then-rewire restricted to value /
  colour-strip sources (no inline secrets), with a safety net flagging any
  unremapped reference; 7 integration tests vs real stores.
- Frontend: duplicateSubgraph (+cache invalidation), graphDuplicateSelection
  (reload + reselect the new cluster), overflow-menu item, i18n (en/ru/zh).
2026-05-29 11:45:55 +03:00

610 lines
23 KiB
Python

"""Authoritative wiring-graph schema and topology engine.
This module is the single source of truth for **which reference fields connect
which entity kinds**. The frontend graph editor historically hard-coded the same
information in two places (``graph-connections.ts`` ``CONNECTION_MAP`` and
``graph-layout.ts`` ``buildGraph``); the ``GET /api/v1/graph/schema`` endpoint
now serves this registry so the client can render ports and edges generically
and the two never drift.
This registry is a *superset* of the current frontend ``buildGraph``: it also
declares real references that ``buildGraph`` does not yet draw (e.g.
``value_source.value_source_id`` chaining and ``value_source.color_strip_source_id``).
The backend is authoritative; the client is expected to converge on it.
Everything in this module is pure (operates on plain dicts), so the topology
build, dependency lookup, cycle and dangling-reference detection are all unit
testable without booting the app or any store.
Field-path grammar (the ``field`` of a :class:`ConnectionField`):
* ``"device_id"`` — a top-level string id.
* ``"brightness.source_id"`` — a nested object; ``brightness`` may be a
plain number (unbound :class:`BindableFloat`) or ``{"value", "source_id"}``.
* ``"settings.pattern_template_id"`` — arbitrarily deep object access.
* ``"layers[].source_id"`` — ``layers`` is a list; read ``source_id``
from every element.
* ``"calibration.lines[].picture_source_id"`` — object → list → field.
"""
from __future__ import annotations
import logging
from dataclasses import asdict, dataclass, is_dataclass
from typing import Any
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ConnectionField:
"""One connectable reference: ``target_kind.field`` points at ``source_kind``."""
target_kind: str
"""Entity kind that *holds* the reference (the consumer / referrer)."""
field: str
"""Dot-path to the reference value (see module docstring grammar)."""
source_kind: str
"""Entity kind being referenced (the producer / source)."""
edge_type: str
"""Edge category, used by the client for colour and port grouping."""
bindable: bool = False
"""True when the slot is a :class:`BindableFloat`/``BindableColor`` value binding."""
nested: bool = False
"""True when the field lives inside a nested object/list (dotted path)."""
@property
def is_list(self) -> bool:
"""True when any path segment iterates a list (``foo[]``)."""
return "[]" in self.field
# ── Entity kinds & their human "type" attribute ────────────────────────────
# Mirrors the frontend buildGraph(): kind → the serialized field that carries
# the entity's subtype (used only for the node label / icon).
NODE_TYPE_FIELD: dict[str, str] = {
"device": "device_type",
"capture_template": "engine_type",
"pp_template": "",
"audio_template": "engine_type",
"pattern_template": "",
"picture_source": "stream_type",
"audio_source": "source_type",
"value_source": "source_type",
"color_strip_source": "source_type",
"sync_clock": "",
"output_target": "target_type",
"scene_preset": "",
"automation": "",
"cspt": "",
}
ENTITY_KINDS: tuple[str, ...] = tuple(NODE_TYPE_FIELD.keys())
# ── The registry ───────────────────────────────────────────────────────────
# NOTE: ``gradient`` and ``ha_source`` reference fields are intentionally
# omitted — they are not first-class graph node kinds, so wiring them would
# only ever produce dangling-reference noise.
CONNECTION_SCHEMA: tuple[ConnectionField, ...] = (
# ── Picture sources ──
ConnectionField("picture_source", "capture_template_id", "capture_template", "template"),
ConnectionField("picture_source", "source_stream_id", "picture_source", "picture"),
ConnectionField("picture_source", "postprocessing_template_id", "pp_template", "template"),
# ── Audio sources ──
ConnectionField("audio_source", "audio_template_id", "audio_template", "audio"),
ConnectionField("audio_source", "audio_source_id", "audio_source", "audio"),
# ── Value sources ──
ConnectionField("value_source", "audio_source_id", "audio_source", "audio"),
ConnectionField("value_source", "picture_source_id", "picture_source", "picture"),
ConnectionField("value_source", "value_source_id", "value_source", "value"),
ConnectionField("value_source", "color_strip_source_id", "color_strip_source", "colorstrip"),
# AnimatedColorValueSource references a sync clock for shared timing.
ConnectionField("value_source", "clock_id", "sync_clock", "clock"),
# ── Color strip sources (top-level) ──
ConnectionField("color_strip_source", "picture_source_id", "picture_source", "picture"),
ConnectionField("color_strip_source", "audio_source_id", "audio_source", "audio"),
ConnectionField("color_strip_source", "clock_id", "sync_clock", "clock"),
ConnectionField("color_strip_source", "input_source_id", "color_strip_source", "colorstrip"),
ConnectionField("color_strip_source", "processing_template_id", "cspt", "template"),
# ── Color strip sources (BindableFloat value bindings) ──
*(
ConnectionField(
"color_strip_source",
f"{prop}.source_id",
"value_source",
"value",
bindable=True,
nested=True,
)
for prop in (
"smoothing",
"sensitivity",
"intensity",
"scale",
"speed",
"wind_strength",
"temperature_influence",
"sound_volume",
"timeout",
"brightness",
)
),
# ── Color strip sources (BindableColor value bindings) ──
# NOTE: `bindable` here is *structural* (these are BindableColor fields). They
# are NOT usefully wireable from the graph: a ValueStream yields a scalar
# (`get_value() -> float`) and every colour consumer reads the static RGB via
# `bcolor()` (source_id ignored at runtime). The graph editor keeps them
# read-only; do not enable them without a colour-producing value source.
*(
ConnectionField(
"color_strip_source",
f"{prop}.source_id",
"value_source",
"value",
bindable=True,
nested=True,
)
for prop in ("color", "color_peak", "fallback_color", "default_color")
),
# ── Color strip sources (composite layers / mapped zones / calibration) ──
ConnectionField(
"color_strip_source", "layers[].source_id", "color_strip_source", "colorstrip", nested=True
),
ConnectionField(
"color_strip_source",
"layers[].brightness_source_id",
"value_source",
"value",
bindable=True,
nested=True,
),
ConnectionField(
"color_strip_source", "layers[].processing_template_id", "cspt", "template", nested=True
),
ConnectionField(
"color_strip_source", "zones[].source_id", "color_strip_source", "colorstrip", nested=True
),
ConnectionField(
"color_strip_source",
"calibration.lines[].picture_source_id",
"picture_source",
"picture",
nested=True,
),
# ── Output targets ──
ConnectionField("output_target", "device_id", "device", "device"),
ConnectionField("output_target", "color_strip_source_id", "color_strip_source", "colorstrip"),
ConnectionField(
"output_target", "brightness.source_id", "value_source", "value", bindable=True, nested=True
),
ConnectionField(
"output_target", "transition.source_id", "value_source", "value", bindable=True, nested=True
),
ConnectionField(
"output_target", "settings.pattern_template_id", "pattern_template", "template", nested=True
),
ConnectionField(
"output_target",
"settings.brightness.source_id",
"value_source",
"value",
bindable=True,
nested=True,
),
# ── Scene presets ──
ConnectionField("scene_preset", "targets[].target_id", "output_target", "scene", nested=True),
# ── Automations ──
ConnectionField("automation", "scene_preset_id", "scene_preset", "scene"),
ConnectionField("automation", "deactivation_scene_preset_id", "scene_preset", "scene"),
# ── Devices ──
ConnectionField("device", "default_css_processing_template_id", "cspt", "template"),
)
def schema_for_kind(kind: str) -> list[ConnectionField]:
"""Every connectable field whose *referrer* is ``kind``."""
return [c for c in CONNECTION_SCHEMA if c.target_kind == kind]
# BindableColor slots are structurally bindable but NOT graph-editable: a
# ValueStream yields a scalar (``get_value() -> float``) and colour consumers
# read the static RGB via ``bcolor()`` (source_id ignored at runtime), so a
# value source cannot drive a colour.
_COLOR_BINDABLE_FIELDS: frozenset[str] = frozenset(
{
"color.source_id",
"color_peak.source_id",
"fallback_color.source_id",
"default_color.source_id",
}
)
def is_editable(cf: ConnectionField) -> bool:
"""Whether a field can be wired from the graph.
Editable = a top-level reference, or a single-level ``BindableFloat`` slot.
List slots (need an element index), double-nested fields, and the dead
colour bindings stay read-only.
"""
if cf.is_list:
return False
if not cf.nested:
return True
return cf.bindable and cf.field.count(".") == 1 and cf.field not in _COLOR_BINDABLE_FIELDS
def schema_as_dicts() -> list[dict[str, Any]]:
"""Serialize the registry for the ``/graph/schema`` endpoint."""
return [
{
"target_kind": c.target_kind,
"field": c.field,
"source_kind": c.source_kind,
"edge_type": c.edge_type,
"bindable": c.bindable,
"nested": c.nested,
"is_list": c.is_list,
"editable": is_editable(c),
}
for c in CONNECTION_SCHEMA
]
# ── Reference extraction ────────────────────────────────────────────────────
def extract_refs(entity: dict[str, Any], field_path: str) -> list[str]:
"""Resolve a (possibly nested/list) ``field_path`` to its referenced ids.
Returns only non-empty string ids. Tolerant of missing keys, ``None``
values and unbound bindables (a plain number where an object was expected).
"""
current: list[Any] = [entity]
for segment in field_path.split("."):
is_list = segment.endswith("[]")
key = segment[:-2] if is_list else segment
nxt: list[Any] = []
for obj in current:
if not isinstance(obj, dict):
continue
val = obj.get(key)
if is_list:
if isinstance(val, list):
nxt.extend(val)
elif val is not None:
nxt.append(val)
current = nxt
return [v for v in current if isinstance(v, str) and v]
def remap_refs(entity: dict[str, Any], field_path: str, id_map: dict[str, str]) -> int:
"""Rewrite referenced ids under ``field_path`` *in place*, using ``id_map``.
The write-twin of :func:`extract_refs`: it walks the same dot/list/bindable
grammar and replaces any leaf id present in ``id_map`` with its mapped value.
Ids absent from ``id_map`` (references to entities outside the remap set) are
left untouched, so a clone keeps sharing its un-cloned dependencies. Unbound
bindables (a plain number where an object was expected) and missing keys are
tolerated. Returns the number of ids rewritten.
"""
segments = field_path.split(".")
# Descend to the container(s) that hold the final key.
parents: list[Any] = [entity]
for segment in segments[:-1]:
is_list = segment.endswith("[]")
key = segment[:-2] if is_list else segment
nxt: list[Any] = []
for obj in parents:
if not isinstance(obj, dict):
continue
val = obj.get(key)
if is_list:
if isinstance(val, list):
nxt.extend(val)
elif isinstance(val, dict):
nxt.append(val)
parents = nxt
last = segments[-1]
last_is_list = last.endswith("[]")
key = last[:-2] if last_is_list else last
count = 0
for obj in parents:
if not isinstance(obj, dict):
continue
val = obj.get(key)
if last_is_list:
if isinstance(val, list):
for i, item in enumerate(val):
if isinstance(item, str) and item in id_map:
val[i] = id_map[item]
count += 1
elif isinstance(val, str) and val in id_map:
obj[key] = id_map[val]
count += 1
return count
def serialize_entity(model: Any) -> dict[str, Any]:
"""Best-effort serialize a storage model to a plain dict for graph use.
Prefers ``dataclasses.asdict`` (pure structural, recurses bindables/lists,
invokes no managers), falling back to ``to_dict()`` then ``{}``.
"""
if is_dataclass(model) and not isinstance(model, type):
try:
return asdict(model)
except Exception as exc: # noqa: BLE001 — defensive: never let one model break the graph
logger.debug("graph: asdict failed for %r: %s", type(model).__name__, exc)
to_dict = getattr(model, "to_dict", None)
if callable(to_dict):
try:
result = to_dict()
if isinstance(result, dict):
return result
except Exception as exc: # noqa: BLE001
logger.debug("graph: to_dict failed for %r: %s", type(model).__name__, exc)
logger.warning(
"graph: could not serialize model %r; excluding from graph", type(model).__name__
)
return {}
def graph_field_roots(kind: str) -> set[str]:
"""Top-level keys the graph needs for ``kind``: ``id``/``name``, the subtype
field, and the root segment of every reference path for that kind."""
roots: set[str] = {"id", "name"}
type_field = NODE_TYPE_FIELD.get(kind, "")
if type_field:
roots.add(type_field)
for cf in CONNECTION_SCHEMA:
if cf.target_kind == kind:
roots.add(cf.field.split(".", 1)[0].removesuffix("[]"))
return roots
def serialize_entity_for_graph(kind: str, model: Any) -> dict[str, Any]:
"""Serialize a model and project it to ONLY the keys the graph needs.
This projection is a **security boundary**: a full ``asdict``/``to_dict``
can carry secrets (webhook tokens, device/HA/MQTT credentials), so every
field except ``id``/``name``, the subtype field and reference-path roots is
dropped before the data reaches the graph API.
"""
full = serialize_entity(model)
roots = graph_field_roots(kind)
return {k: v for k, v in full.items() if k in roots}
# ── Topology / validation ───────────────────────────────────────────────────
def _node_from(kind: str, entity: dict[str, Any]) -> dict[str, Any] | None:
eid = entity.get("id")
if not isinstance(eid, str) or not eid:
return None
type_field = NODE_TYPE_FIELD.get(kind, "")
subtype = entity.get(type_field, "") if type_field else ""
return {
"id": eid,
"kind": kind,
"name": entity.get("name") or eid,
"type": subtype if isinstance(subtype, str) else "",
}
def build_topology(entities_by_kind: dict[str, list[dict[str, Any]]]) -> dict[str, Any]:
"""Build the full wiring graph + a validation report.
Args:
entities_by_kind: ``{kind: [serialized_entity_dict, ...]}``.
Returns a dict with ``nodes``, ``edges`` and ``issues`` (``orphans``,
``broken_refs``, ``cycles``).
"""
nodes: list[dict[str, Any]] = []
node_ids: set[str] = set()
for kind in ENTITY_KINDS:
for entity in entities_by_kind.get(kind, []):
node = _node_from(kind, entity)
if node and node["id"] not in node_ids:
node_ids.add(node["id"])
nodes.append(node)
edges: list[dict[str, Any]] = []
broken_refs: list[dict[str, str]] = []
for cf in CONNECTION_SCHEMA:
for entity in entities_by_kind.get(cf.target_kind, []):
referrer = entity.get("id")
if not isinstance(referrer, str) or not referrer:
continue
for ref in extract_refs(entity, cf.field):
if ref not in node_ids:
broken_refs.append({"ref": ref, "by": referrer, "field": cf.field})
continue
edges.append(
{
"from": ref,
"to": referrer,
"field": cf.field,
"edge_type": cf.edge_type,
"nested": cf.nested,
}
)
connected: set[str] = set()
for e in edges:
connected.add(e["from"])
connected.add(e["to"])
orphans = sorted(nid for nid in node_ids if nid not in connected)
cycles = sorted(detect_cycles(edges))
return {
"nodes": nodes,
"edges": edges,
"issues": {
"orphans": orphans,
"broken_refs": broken_refs,
"cycles": cycles,
},
}
def find_dependents(
entities_by_kind: dict[str, list[dict[str, Any]]], kind: str, entity_id: str
) -> list[dict[str, str]]:
"""Return every entity that references ``(kind, entity_id)``.
``kind`` is the kind of the *referenced* entity; matching schema entries are
those whose ``source_kind == kind``.
"""
name_by_id: dict[str, str] = {}
for k in ENTITY_KINDS:
for entity in entities_by_kind.get(k, []):
eid = entity.get("id")
if isinstance(eid, str):
name_by_id[eid] = entity.get("name") or eid
dependents: list[dict[str, str]] = []
seen: set[tuple[str, str]] = set()
for cf in CONNECTION_SCHEMA:
if cf.source_kind != kind:
continue
for entity in entities_by_kind.get(cf.target_kind, []):
referrer = entity.get("id")
if not isinstance(referrer, str):
continue
if entity_id in extract_refs(entity, cf.field):
key = (referrer, cf.field)
if key in seen:
continue
seen.add(key)
dependents.append(
{
"id": referrer,
"kind": cf.target_kind,
"name": name_by_id.get(referrer, referrer),
"field": cf.field,
}
)
return dependents
def detect_cycles(edges: list[dict[str, Any]]) -> set[str]:
"""Return every node id that participates in a directed cycle (from→to)."""
adj: dict[str, list[str]] = {}
for e in edges:
adj.setdefault(e["from"], []).append(e["to"])
WHITE, GRAY, BLACK = 0, 1, 2
color: dict[str, int] = {}
in_cycle: set[str] = set()
for start in list(adj.keys()):
if color.get(start, WHITE) != WHITE:
continue
stack: list[tuple[str, int]] = [(start, 0)]
path: list[str] = [start]
color[start] = GRAY
while stack:
node, idx = stack[-1]
neighbors = adj.get(node, [])
if idx < len(neighbors):
stack[-1] = (node, idx + 1)
nxt = neighbors[idx]
c = color.get(nxt, WHITE)
if c == GRAY:
if nxt in path:
i = path.index(nxt)
in_cycle.update(path[i:])
elif c == WHITE:
color[nxt] = GRAY
path.append(nxt)
stack.append((nxt, 0))
else:
color[node] = BLACK
if path and path[-1] == node:
path.pop()
stack.pop()
return in_cycle
def _reachable(edges: list[dict[str, Any]], start: str, goal: str) -> bool:
"""True if ``goal`` is reachable from ``start`` following from→to edges."""
if start == goal:
return True
adj: dict[str, list[str]] = {}
for e in edges:
adj.setdefault(e["from"], []).append(e["to"])
seen = {start}
queue = [start]
while queue:
cur = queue.pop()
for nxt in adj.get(cur, []):
if nxt == goal:
return True
if nxt not in seen:
seen.add(nxt)
queue.append(nxt)
return False
def would_create_cycle(edges: list[dict[str, Any]], source_id: str, target_id: str) -> bool:
"""Would wiring ``source_id`` into ``target_id`` (edge source→target) loop?
A cycle forms if ``source_id`` is already reachable from ``target_id`` via
the existing data-flow edges (so the new edge would close the loop), or the
two are the same node.
"""
if source_id == target_id:
return True
return _reachable(edges, target_id, source_id)
def _entity_exists(
entities_by_kind: dict[str, list[dict[str, Any]]], kind: str, entity_id: str
) -> bool:
return any(e.get("id") == entity_id for e in entities_by_kind.get(kind, []))
def validate_connection(
entities_by_kind: dict[str, list[dict[str, Any]]],
target_kind: str,
target_id: str,
field: str,
source_id: str,
) -> tuple[bool, str | None]:
"""Validate a proposed wiring edit before it is persisted.
Checks, in order: the field is a known connectable reference; the target
exists; (when not detaching) the source exists and is of the registry's
expected kind; and the edit would not create a dependency cycle. Returns
``(ok, error_message)``. Detaching (empty ``source_id``) is always allowed.
"""
cf = next(
(c for c in CONNECTION_SCHEMA if c.target_kind == target_kind and c.field == field),
None,
)
if cf is None:
return False, f"Unknown connection field: {target_kind}.{field}"
if not is_editable(cf):
# List slots (need an element index), double-nested fields, and dead
# colour bindings can't be wired from the graph — edit via the entity
# editor instead.
return False, f"Field '{field}' is not editable via the graph"
if not _entity_exists(entities_by_kind, target_kind, target_id):
return False, f"Target entity not found: {target_id}"
if not source_id:
return True, None # detaching a slot is always valid
if not _entity_exists(entities_by_kind, cf.source_kind, source_id):
return False, f"Source {cf.source_kind} not found: {source_id}"
# Cycle check: ignore the edge currently occupying this slot, since the
# write replaces it.
topo = build_topology(entities_by_kind)
edges = [e for e in topo["edges"] if not (e["to"] == target_id and e["field"] == field)]
if would_create_cycle(edges, source_id, target_id):
return False, "Connection would create a dependency cycle"
return True, None