Files
app/backend/fluksio/api/routes/messages.py
T
stroblme 75b3a93094
Docs / docs (push) Successful in 33s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m37s
Playwright Tests / test-playwright (2, 2) (push) Failing after 27s
pre-commit / pre-commit (push) Failing after 2m37s
Test Backend / test-backend (3.12) (push) Successful in 5m7s
Test Backend / test-backend (3.14) (push) Successful in 5m2s
Compose Smoke Test / test-compose (push) Failing after 28s
Playwright Tests / merge-reports (push) Failing after 2m29s
Fix work recovery and reduce engine and chart overhead
2026-10-29 14:24:17 +01:00

163 lines
5.2 KiB
Python

"""Messages: what a dashboard binds to, across every flow.
The flow API is scoped to one flow, which is the wrong shape here — a wall
panel shows the heating alongside the solar. These endpoints are the whole
namespace at once: what exists, what it last was, and a way to put a value
into it without owning a node.
"""
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel
from fluksio.api.deps import (
CAMERA_IS_SUPERUSERS,
FlowControllerDep,
HiddenDep,
get_current_user,
)
from fluksio.flow.devices import DeviceDef, DeviceStore
from fluksio.flow.pipeline import ValueSource
from fluksio.flow.state import as_number
router = APIRouter(
prefix="/messages", tags=["messages"], dependencies=[Depends(get_current_user)]
)
class MessageInfo(BaseModel):
"""One message, as something to bind a widget to."""
name: str
flow: str
dtype: str
#: Nodes publishing it. Empty means the flow declares it as an input.
providers: list[str] = []
#: Whether a dashboard may publish to it — that is, whether it is declared.
writable: bool = True
numeric: bool = False
value: Any = None
ts: float | None = None
class MessagesPublic(BaseModel):
data: list[MessageInfo]
count: int
class PublishRequest(BaseModel):
value: Any
#: Where this came from, so the canvas can show it arriving from outside
#: rather than crediting whichever node is drawn as a producer.
source_kind: str = "api"
source_id: str = ""
source_label: str = ""
source_detail: str = ""
class MessageValue(BaseModel):
name: str
value: Any
ts: float | None = None
class MessagePoints(BaseModel):
message: str
numeric: bool
points: list[dict[str, float]]
@router.get("/", response_model=MessagesPublic)
async def read_messages(controller: FlowControllerDep, hidden: HiddenDep) -> Any:
"""Every message any published flow declares, with its last value.
A camera the caller may not see is listed without its value, so a tile
can still be bound to it.
"""
infos = await run_in_threadpool(controller.message_catalog)
if hidden:
infos = [
info.model_copy(update={"value": None, "ts": None})
if info.name in hidden
else info
for info in infos
]
return MessagesPublic(data=infos, count=len(infos))
def port_source(
devices: DeviceStore, device: DeviceDef, name: str
) -> ValueSource | None:
"""The device node a device's own publish on a bound ``in`` port is from.
Said by the server rather than by the device: the node is what the canvas
draws the value leaving from, and the device does not know which node in
which flow holds its port. Also what a frame on the device's socket is
credited to.
"""
for bound in devices.bound(device.id):
if bound.direction == "in" and bound.message == name:
return ValueSource(
kind="node",
id=f"{bound.flow}.{bound.node}",
label=device.title or device.id,
detail=bound.port,
)
return None
def _port_source(request: Request, name: str) -> ValueSource | None:
device: DeviceDef | None = getattr(request.state, "device", None)
devices: DeviceStore | None = getattr(request.app.state, "device_store", None)
if device is None or devices is None:
return None
return port_source(devices, device, name)
@router.post("/{name}", response_model=MessageValue)
def publish_message(
name: str, body: PublishRequest, controller: FlowControllerDep, request: Request
) -> Any:
"""Put a value into the graph, as a dashboard control does.
Only a message some flow declares can be published to: flows own the
namespace, and a dashboard is a client of it rather than a second author.
"""
source = _port_source(request, name) or ValueSource(
kind=body.source_kind,
id=body.source_id,
label=body.source_label or body.source_id or "API",
detail=body.source_detail,
)
try:
controller.publish_message(name, body.value, source)
except KeyError:
raise HTTPException(
status_code=404, detail=f"No flow declares a message named '{name}'"
)
except TypeError as exc:
raise HTTPException(status_code=422, detail=str(exc))
current = controller.value(name)
return MessageValue(name=name, value=current.get("value"), ts=current.get("ts"))
@router.get("/{name}/history", response_model=MessagePoints)
def read_message_history(
name: str, controller: FlowControllerDep, hidden: HiddenDep
) -> Any:
"""The series behind a chart. Numbers and flags — nothing else plots."""
if name in hidden:
raise HTTPException(status_code=403, detail=CAMERA_IS_SUPERUSERS)
if controller.pipeline is None:
return MessagePoints(message=name, numeric=False, points=[])
series = controller.state.history(name)
numeric = as_number(controller.state.get(name)) is not None
return MessagePoints(
message=name,
numeric=numeric or bool(series),
points=[{"ts": ts, "value": value} for ts, value in series],
)