Flow canvas draws panel I/O ports as panel:<id>:<port> endpoints that open their panel in the Panels dialog

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
This commit is contained in:
2026-10-15 16:31:33 +02:00
co-authored by Claude Opus 5.5
parent d825acb768
commit af796f7a02
12 changed files with 132 additions and 31 deletions
+30 -7
View File
@@ -38,7 +38,7 @@ from fluksio.flow.controller import NODE_TYPES, FlowController
from fluksio.flow.dashboards import DashboardStore
from fluksio.flow.events import event_bus
from fluksio.flow.messages import flow_of, qualify
from fluksio.flow.panels import messages_for
from fluksio.flow.panels import messages_for, read_config
from fluksio.flow.pipeline import ValidationIssue
from fluksio.flow.runs import RunRejected
from fluksio.flow.schemas import (
@@ -78,18 +78,19 @@ ws_router = APIRouter(prefix="/flows", tags=["flows"])
class Endpoint(BaseModel):
"""Something wired into this flow that is not a node in it.
A dashboard control setting one of its messages, a tile showing one, or a
node in another flow on the far side of a dotted name. The canvas draws
these so a value never appears to come from nowhere — or worse, appears to
come from whichever node happens to be drawn as a producer.
A dashboard control setting one of its messages, a tile showing one, a
port on a panel's own hardware, or a node in another flow on the far side
of a dotted name. The canvas draws these so a value never appears to come
from nowhere — or worse, appears to come from whichever node happens to be
drawn as a producer.
"""
#: dashboard or flow.
#: dashboard, panel or flow.
kind: str
#: Stable within its kind, and used as the canvas node id.
id: str
label: str
#: What sort of widget, or the node type in the other flow.
#: What sort of widget, the panel, or the node type in the other flow.
detail: str = ""
#: Messages of this flow it publishes, and ones it reads.
provides: list[str] = []
@@ -155,6 +156,28 @@ def _endpoints(controller: FlowController, definition: FlowDef) -> list[Endpoint
requires=binding["requires"],
)
# A panel's own hardware: an ``in`` port publishes like a control, an
# ``out`` port reads like a tile. The id is the source id the device
# publishes under, so its edges pulse for its values.
prefix = f"{flow}."
for panel in read_config().panels:
for port in panel.io:
if not port.message.startswith(prefix):
continue
key = f"panel:{panel.id}:{port.port}"
endpoint = found.setdefault(
key,
Endpoint(
kind="panel",
id=key,
label=port.port,
detail=panel.title or panel.id,
),
)
side = endpoint.provides if port.direction == "in" else endpoint.requires
if port.message not in side:
side.append(port.message)
def _flow_endpoint(key: str, detail: str) -> Endpoint:
"""One label per node on the far side, however many names reach it."""
return found.setdefault(
+34
View File
@@ -10,8 +10,10 @@ from pathlib import Path
import pytest
from fluksio.api.routes.flows import _endpoints
from fluksio.core.config import settings
from fluksio.flow.controller import FlowController
from fluksio.flow.messages import DType, MessageSpec
from fluksio.flow.panels import PanelDef, PanelPort, PanelsConfig, write_config
from fluksio.flow.schemas import FlowDef, NodeDef
from fluksio.flow.store import FlowStore
@@ -109,3 +111,35 @@ def test_the_wiring_index_is_read_once_per_store_revision(controller):
controller.store.write_flow(FlowDef(name="shed", nodes=[]))
_endpoints(controller, controller.store.read_flow("house"))
assert reads == 2
def test_a_panel_port_is_drawn_on_the_side_it_wires(controller, tmp_path, monkeypatch):
monkeypatch.setattr(settings, "PANELS_FILE", tmp_path / "panels.json")
write_config(
PanelsConfig(
panels=[
PanelDef(
id="hall",
title="Hall",
io=[
PanelPort(port="gpio.4", message="house.temp", direction="in"),
PanelPort(
port="backlight", message="house.temp", direction="out"
),
PanelPort(port="mic", message="garage.open", direction="in"),
],
)
]
)
)
endpoints = {
one.id: one
for one in _endpoints(controller, controller.store.read_flow("house"))
}
# The id is what the device publishes under, so its edges pulse for it.
assert endpoints["panel:hall:gpio.4"].provides == ["house.temp"]
assert endpoints["panel:hall:backlight"].requires == ["house.temp"]
assert endpoints["panel:hall:backlight"].detail == "Hall"
assert "panel:hall:mic" not in endpoints