"""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], )