Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions GeecsPanels/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,18 @@ account) until the maintainer asks.
geecs_panels/
__main__.py # CLI; applies geecs_core.epics_env BEFORE anything imports aioca
registry.py # native_devices.yaml → Device(name, kind, prefix)
camera.py # the chain contract (PV suffixes) and the freshness rule — pure
camera.py # the chain contract (PV suffixes), the freshness rule and the
# write allow-list (WRITABLE, check_write) — pure
coords.py # image pixels <-> the analysis ROI's output pixels — pure
feed.py # CameraFeed (monitored PVs → snapshot), FrameSource (shared JPEG)
# — transports injected, so tests need no CA library or IOC
ca.py # the real transports: aioca monitors, stream URL, httpx
app.py # routes: /, /camera/{device}, its frame.jpg and events (SSE)
app.py # routes: /, /camera/{device}, its frame.jpg and events (SSE),
# and the writes: put (allow-listed), crosshair colour, to-centroid
templates/, static/ # on the GeecsWebTheme kit; colours through tokens only
# camera.js = the viewer (frame loop, zoom/pan, colour map);
# overlay.js = the crosshair + ROI layer, plugged in through
# window.GeecsCameraView's hooks (draw, grab, hover, snapshot)
```

## Rules this package keeps
Expand All @@ -40,8 +46,14 @@ geecs_panels/
flight) and pauses while hidden.
- **The stream URL is rehosted to the CA address** (`ca.with_ca_host`): the
IOC names itself by hostname, which a laptop cannot resolve.
- **Read-only so far.** A write route must name its PV in an allow-list
(design note §4), and acquisition controls wait for the run lock (§6).
- **Writes are allow-listed** (`camera.WRITABLE`): crosshairs (OVER1:1/2)
and the analysis ROI (ROI1) only — display and analysis state, never
what a scan saves. The page names an allow-listed suffix for a plain
put; anything that needs computing goes through its own route (the colour
route takes r/g/b — channel PV names in the script would also trip the
theme's colour-word guard — and to-centroid converts on the server with
`coords`). `--read-only` refuses every write. Acquisition controls (the driver) wait for the run lock
(design note §6).
- Never imports GeecsBluesky, the scanner, the portal or the logbook
(`.importlinter` `panels-edges`).

Expand Down
11 changes: 10 additions & 1 deletion GeecsPanels/geecs_panels/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ def main() -> None:
default="",
help="URL prefix behind a prefix-preserving reverse proxy",
)
parser.add_argument(
"--read-only",
action="store_true",
help="refuse every write (crosshairs, analysis ROI): a viewer only",
)
parser.add_argument("--log-level", default="INFO", help="Python logging level")
args = parser.parse_args()

Expand Down Expand Up @@ -76,12 +81,16 @@ def main() -> None:
app = create_app(
experiment,
devices,
LiveCameras(min_interval=1.0 / args.max_fps),
# The floor between upstream fetches for ALL viewers of a camera
# (FrameSource.latest). Half the page's poll period, so timer jitter
# never hands a viewer its own previous frame.
LiveCameras(min_interval=0.5 / args.max_fps),
registry_path=path,
registry_error=error,
stale_after=args.stale_after,
max_fps=args.max_fps,
root_path=args.root_path,
writable=not args.read_only,
)
uvicorn.run(app, host=args.host, port=args.port, log_level=args.log_level.lower())

Expand Down
128 changes: 121 additions & 7 deletions GeecsPanels/geecs_panels/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,25 @@

import asyncio
import json
import math
from collections.abc import AsyncIterator, Callable
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
from typing import Optional, Protocol
from typing import Optional, Protocol, Union

from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, Response, StreamingResponse
from pydantic import BaseModel, Field
from geecs_web_theme.web import (
ForwardedPrefixMiddleware,
RevalidatingStaticFiles,
make_templates,
mount_theme,
)

from geecs_panels.camera import READINGS
from geecs_panels.feed import CameraFeed, FrameSource, FrameUnavailable
from geecs_panels.camera import CROSSHAIRS, READINGS, NotWritable, check_write
from geecs_panels.coords import to_image
from geecs_panels.feed import CameraFeed, FrameSource, FrameUnavailable, WriteFailed
from geecs_panels.registry import Device

_PKG = Path(__file__).resolve().parent
Expand Down Expand Up @@ -69,6 +72,24 @@ def feed(self, device: Device) -> CameraFeed:
def frames(self, device: Device) -> FrameSource:
"""The camera's shared frame source."""

async def put(self, device: Device, suffix: str, value: object) -> None:
"""Write one PV under the camera's prefix; :class:`WriteFailed` if the IOC refuses."""


class Colour(BaseModel):
"""A crosshair colour, 0..255 a channel (the IOC stores it per overlay)."""

r: int = Field(ge=0, le=255)
g: int = Field(ge=0, le=255)
b: int = Field(ge=0, le=255)


class PutRequest(BaseModel):
"""One allow-listed write: a PV suffix under the camera's prefix and its value."""

pv: str
value: Union[bool, int, float, str]


def create_app(
experiment: str,
Expand All @@ -80,6 +101,7 @@ def create_app(
stale_after: float = 3.0,
max_fps: float = 10.0,
root_path: str = "",
writable: bool = True,
) -> FastAPI:
"""Build the panels application.

Expand All @@ -101,6 +123,9 @@ def create_app(
The frame rate the page asks for at most.
root_path : str
URL prefix behind a prefix-preserving reverse proxy.
writable : bool
Whether the write routes (crosshairs, analysis ROI) are open; off,
every write is a 403 and the page draws its controls disabled.
"""
app = FastAPI(
title="GEECS Panels",
Expand Down Expand Up @@ -153,10 +178,12 @@ def camera_page(request: Request, name: str) -> HTMLResponse:
"readings": READINGS,
"stale_after": stale_after,
"max_fps": max_fps,
"writable": writable,
},
)

_add_live_routes(app, cameras, camera, stale_after)
_add_write_routes(app, cameras, camera, stale_after, writable)

@app.get("/health")
def health() -> dict[str, object]:
Expand All @@ -177,12 +204,12 @@ def _add_live_routes(
async def frame(name: str) -> Response:
source = cameras.frames(camera(name))
try:
jpeg = await source.latest()
frame = await source.latest()
except FrameUnavailable as exc:
return Response(str(exc), status_code=503, media_type="text/plain")
return Response(
jpeg, media_type="image/jpeg", headers={"Cache-Control": "no-store"}
)
# The page skips decoding a frame it already shows.
headers = {"Cache-Control": "no-store", "X-Frame-Tag": frame.tag}
return Response(frame.jpeg, media_type="image/jpeg", headers=headers)

@app.get("/camera/{name}/events")
async def events(request: Request, name: str, once: int = 0) -> StreamingResponse:
Expand All @@ -199,3 +226,90 @@ async def gen() -> AsyncIterator[str]:
return StreamingResponse(
gen(), media_type="text/event-stream", headers=_SSE_HEADERS
)


def centroid_in_image(feed: CameraFeed, stale_after: float) -> tuple[int, int]:
"""The centroid (STAT's, in ROI pixels) in image pixels; ``LookupError`` if it is not fresh."""
states = {r["key"]: r for r in feed.snapshot(stale_after)["readings"]}
if states["cx"]["state"] != "fresh" or states["cy"]["state"] != "fresh":
raise LookupError("the centroid is not fresh: nothing to move to")
ax, ay = feed.roi_axes()
# Half up, not Python's half-to-even: 120.5 and 121.5 both go up.
return (
math.floor(to_image(ax, float(states["cx"]["value"])) + 0.5),
math.floor(to_image(ay, float(states["cy"]["value"])) + 0.5),
)


class _Writer:
"""What every write route shares: the read-only refusal and the put."""

def __init__(
self, cameras: Cameras, camera: Callable[[str], Device], writable: bool
) -> None:
self.cameras, self.camera, self.writable = cameras, camera, writable

def device(self, name: str, crosshair: Optional[int] = None) -> Device:
"""The camera to write to, refusing a read-only panel or an unknown crosshair."""
device = self.camera(name)
if not self.writable:
raise HTTPException(403, "this panel is read-only (--read-only)")
if crosshair is not None and crosshair not in CROSSHAIRS:
raise HTTPException(404, f"no crosshair {crosshair}")
return device

async def put(self, device: Device, suffix: str, value: object) -> None:
"""One allow-listed write; the IOC refusing it is a 502."""
try:
value = check_write(suffix, value)
except NotWritable as exc:
raise HTTPException(422, str(exc)) from exc
try:
await self.cameras.put(device, suffix, value)
except WriteFailed as exc:
raise HTTPException(502, str(exc)) from exc


def _add_write_routes(
app: FastAPI,
cameras: Cameras,
camera: Callable[[str], Device],
stale_after: float,
writable: bool,
) -> None:
"""The panel's writes: one allow-listed PV at a time (design note §4)."""
w = _Writer(cameras, camera, writable)

@app.post("/camera/{name}/put")
async def put_one(name: str, request: PutRequest) -> dict[str, object]:
device = w.device(name)
await w.put(device, request.pv, request.value)
return {
"ok": True,
"pv": request.pv,
"value": check_write(request.pv, request.value),
}

@app.post("/camera/{name}/crosshair/{n}/colour")
async def colour(name: str, n: int, request: Colour) -> dict[str, object]:
"""Set crosshair *n*'s colour: its three channel PVs, each allow-listed."""
device = w.device(name, n)
for field, value in (
("Red", request.r),
("Green", request.g),
("Blue", request.b),
):
await w.put(device, f"OVER1:{n}:{field}", value)
return {"ok": True}

@app.post("/camera/{name}/crosshair/{n}/to-centroid")
async def to_centroid(name: str, n: int) -> dict[str, object]:
"""Move crosshair *n* to the centroid, converted from ROI to image pixels."""
device = w.device(name, n)
try:
x, y = centroid_in_image(cameras.feed(device), stale_after)
except LookupError as exc:
raise HTTPException(409, str(exc)) from exc
await w.put(device, f"OVER1:{n}:CenterX", x)
await w.put(device, f"OVER1:{n}:CenterY", y)
return {"ok": True, "x": x, "y": y}
27 changes: 24 additions & 3 deletions GeecsPanels/geecs_panels/ca.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,20 @@
import aioca
import httpx

from geecs_panels.camera import JPG_URL_SUFFIX
from geecs_panels.feed import CameraFeed, FrameSource, ResolveUrl, Subscription
from geecs_panels.camera import IMAGE_PLUGIN, JPG_URL_SUFFIX, counter_suffix
from geecs_panels.feed import (
CameraFeed,
FrameSource,
ResolveUrl,
Subscription,
WriteFailed,
)
from geecs_panels.registry import Device

#: Upstream frame timeout: the IOC answers a snapshot in ~50 ms.
FETCH_TIMEOUT_S = 3.0
#: A write waits for the IOC to process the record, at most this long.
PUT_TIMEOUT_S = 3.0


def subscribe(
Expand Down Expand Up @@ -118,11 +126,24 @@ def frames(self, device: Device) -> FrameSource:
"""The camera's frame source."""
source = self._frames.get(device.name)
if source is None:
feed = self.feed(device)
counter = counter_suffix(IMAGE_PLUGIN)
source = self._frames[device.name] = FrameSource(
url_resolver(device.prefix), self._fetch, self._min_interval
url_resolver(device.prefix),
self._fetch,
self._min_interval,
counter=lambda: feed.values.get(counter),
)
return source

async def put(self, device: Device, suffix: str, value: object) -> None:
"""Write one PV and wait for the IOC to process it."""
pv = device.prefix + suffix
try:
await aioca.caput(pv, value, wait=True, timeout=PUT_TIMEOUT_S)
except Exception as exc:
raise WriteFailed(f"{pv}: {exc}") from exc

async def aclose(self) -> None:
"""Drop every subscription and the HTTP client."""
for feed in self._feeds.values():
Expand Down
Loading