feat(graph): make the visual editor a full wiring control surface
Lets users wire the system end-to-end from the graph, and fixes the core bug that made drag-to-wire silently fail. - Fix drag-to-wire 422s across 5 entity kinds: updateConnection() now echoes the target's discriminator (source_type/stream_type/target_type) into the partial PUT, so value/colour-strip/audio/picture sources and output targets all wire correctly. New contract test (54 cases) in test_graph_wiring_contract.py. - Re-wire composite layers / mapped zones from the graph (right-click a layer/zone source edge -> Re-wire). Whole-list write preserves every sibling layer/zone setting, with an optimistic-concurrency guard and undo. - Secret-safe /graph topology: project entities to id/name/subtype + reference roots so the endpoint cannot leak webhook tokens or other credentials. - Carry slot indices on list edges; node custom-icon + schema-drift refinements; rewire i18n keys (en/ru/zh); wiring-control roadmap (TODO.md).
This commit is contained in:
@@ -99,6 +99,8 @@ CONNECTION_SCHEMA: tuple[ConnectionField, ...] = (
|
||||
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"),
|
||||
@@ -129,6 +131,11 @@ CONNECTION_SCHEMA: tuple[ConnectionField, ...] = (
|
||||
)
|
||||
),
|
||||
# ── 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",
|
||||
@@ -168,7 +175,6 @@ CONNECTION_SCHEMA: tuple[ConnectionField, ...] = (
|
||||
# ── Output targets ──
|
||||
ConnectionField("output_target", "device_id", "device", "device"),
|
||||
ConnectionField("output_target", "color_strip_source_id", "color_strip_source", "colorstrip"),
|
||||
ConnectionField("output_target", "picture_source_id", "picture_source", "picture"),
|
||||
ConnectionField(
|
||||
"output_target", "brightness.source_id", "value_source", "value", bindable=True, nested=True
|
||||
),
|
||||
@@ -201,6 +207,34 @@ def schema_for_kind(kind: str) -> list[ConnectionField]:
|
||||
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 [
|
||||
@@ -212,6 +246,7 @@ def schema_as_dicts() -> list[dict[str, Any]]:
|
||||
"bindable": c.bindable,
|
||||
"nested": c.nested,
|
||||
"is_list": c.is_list,
|
||||
"editable": is_editable(c),
|
||||
}
|
||||
for c in CONNECTION_SCHEMA
|
||||
]
|
||||
@@ -269,6 +304,32 @@ def serialize_entity(model: Any) -> dict[str, Any]:
|
||||
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 ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -480,12 +541,11 @@ def validate_connection(
|
||||
)
|
||||
if cf is None:
|
||||
return False, f"Unknown connection field: {target_kind}.{field}"
|
||||
if cf.is_list:
|
||||
# List slots (layers/zones/scene targets) hold many edges sharing the
|
||||
# same (to, field); without an element index this endpoint can't model
|
||||
# which one is being replaced for the cycle check. Edit those via the
|
||||
# entity editor.
|
||||
return False, f"List connection '{field}' must be edited via the entity editor"
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user