C4: a device's `viewers` (superusers by default, or members) covers the image and video messages its ports publish. Anyone else gets none of their values on the socket, in the catalogue or a flow's state, 403 for their history, for the frames they hold (the last 8 included) and for TURN credentials, no bytes when asking for them, and `bye: forbidden` for a live view. A device credential stays bounded by its dashboards. The device page sets it in its Camera section, and a tile the viewer may not see says "No access to this camera" and asks the camera for nothing. C3: - A volatile upload is held to MAX_FRAME_BYTES (4 MiB, 413 above it, read without extra copies), and so is a frame on a device's socket. - A frame the engine holds in memory is written to the ring directory before a local worker is handed it, so load_artifact finds it. - A socket the bus dropped events for catches up with a snapshot, demand included, and a device's credential is checked again per frame, cached by the flow store revision and its nonce, so a lost event never keeps an unpaired device's frames coming. - A report is at most 64 KB once its named config_schemas are put in place. - A device away for 60 s has its true bool messages set to false. - Refused frames are logged by fixed keys, a frame is named after its message, `fluksio serve` waits 5 s for open connections, a tile with a stream URL creates no demand, the 8-name cap keeps the newest names. - Every container's log rotates (json-file, 3 x 10 MB). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138N7k5PT4dcR1iEqXaaQ9k
165 lines
5.3 KiB
Python
165 lines
5.3 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.messages import flow_of
|
|
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)
|
|
async 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:
|
|
await run_in_threadpool(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))
|
|
|
|
values = controller.values(flow_of(name))
|
|
current = values.get(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],
|
|
)
|