ControlForge Video Historian (Camera Capture + Frame Index)
June 2026 | ControlForge v1.0.1069
The video historian is a "dashcam for process data" — a low-rate camera that snapshots a JPEG on a fixed interval, samples a configured list of process tags at the moment of capture, and indexes both in SQLite so you can later fetch the frame nearest any timestamp. Click a point on a trend, get the picture of the machine at that instant, with the tag values that were live in the same frame.
It is built for forensics and proof, not streaming: 1 frame/sec by default, retained for days. When something goes wrong you scrub back and see it, with the data overlaid — the linear-foot count, the temperature, the alarm word that was set on that exact tick.
Capture is driven two ways, and you can mix them:
- YAML config (
video:block) — cameras stand up at boot, travel with deployment overlays. - ST functions (
VIDEO_*) — create/start/stop/burst cameras from running logic, with lazy-init: the firstVIDEO_CAMERA_CREATEstands up the whole engine even if novideo:block exists.
How it works — the capture loop
For each running camera, on its FPS interval:
- The capture tool (
rpicam/ffmpeg/v4l2) grabs one JPEG from the device. - The historian samples the camera's tag list from the live scheduler — the snapshot is the tag values as of this frame.
- The JPEG is written under
storage_dir; a row (id, camera, unix-ms ts, path, size, w/h, flags, tags JSON) is inserted into the index DB. - Retention prunes old frames (by age and by total size) — except frames flagged
event_clip, which survive the prune.
Frames are retrieved by timestamp, not by streaming. GET …/nearest?ts= returns the single closest frame plus its image_url; GET …/frames?from=&to= returns the index for a window. The raw JPEG bytes come from GET /api/video/frames/{id}/image.
The enable model — boot config vs. lazy-init
The engine is a process-wide singleton. It is installed when either:
- the YAML
video.enabled: trueblock is present at boot, or - an ST program calls
VIDEO_CAMERA_CREATEfor the first time — which lazy-starts the engine using default paths (data/video.db+data/video/).
Until one of those happens, every /api/video/* endpoint returns 503 {"error":"video historian not enabled"}, and the VIDEO_* ST functions return FALSE/0. Check the state from ST with VIDEO_HISTORIAN_ENABLED().
// Gate one-time setup so it runs once, not every scan
IF NOT VIDEO_HISTORIAN_ENABLED() THEN
// engine not up yet — the CREATE below will lazy-start it
;
END_IF;
Worked example — create → start → capture → query by timestamp
A single ST program that stands up a camera, snapshots two tags per frame, and (on an event) flags a clip. The pattern uses a setup_done latch so the create logic runs once.
PROGRAM VideoMain
VAR
setup_done : BOOL := FALSE;
ok : BOOL;
frames : LINT;
last_ts : LINT;
fault : BOOL; // some condition you want clipped
fault_edge : R_TRIG;
END_VAR
// --- one-time setup: create the camera + register snapshot tags ---
IF NOT setup_done THEN
// VIDEO_CAMERA_CREATE(name, [tool], [device], [width], [height], [fps], [quality_pct], [title]) : BOOL
// lazy-starts the historian engine on the first call if no video: YAML block exists
ok := VIDEO_CAMERA_CREATE('line1', 'ffmpeg', '/dev/video0', 1280, 720, 1.0, 80, 'Line 1 Infeed');
IF ok THEN
// mjpeg input unlocks higher res on most USB webcams
VIDEO_CAMERA_SET_INPUT_FORMAT('line1', 'mjpeg');
VIDEO_CAMERA_SET_TIMEOUT('line1', 3000); // ms; 0 = default (2000)
// tags sampled INTO each frame's metadata, retrievable with the picture
VIDEO_CAMERA_TAG_ADD('line1', 'Process.linear_feet');
VIDEO_CAMERA_TAG_ADD('line1', 'Process.temp_c');
setup_done := TRUE; // latch: don't re-create every scan
END_IF;
END_IF;
// --- liveness you can read from ST ---
frames := VIDEO_CAMERA_FRAME_COUNT('line1'); // LINT — successful captures since create
last_ts := VIDEO_CAMERA_LAST_TS('line1'); // LINT — unix-ms of newest frame (0 = none yet)
// --- on an event, clip the lookback + burst the post-window ---
// VIDEO_CAMERA_BURST(name, pre_s, post_s, [burst_fps], [event_id]) : BOOL
fault_edge(CLK := fault);
IF fault_edge.Q THEN
// keep the last 15 s past retention, then capture 30 s at 5 fps
ok := VIDEO_CAMERA_BURST('line1', 15.0, 30.0, 5.0, 'jam-A17');
END_IF;
Then retrieve the picture for any moment from the API (or an HMI trend click). The flow: pick a timestamp → nearest → render image_url.
# auth
TOKEN=$(curl -s -X POST http://localhost:8302/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"goplc","password":"goplc"}' | jq -r .token)
# 1) list cameras + live capture stats
curl -s -H "Authorization: Bearer $TOKEN" \
http://localhost:8302/api/video/cameras | jq
# 2) frame index for a window (from/to accept unix ms OR s; defaults: now-1h .. now)
curl -s -H "Authorization: Bearer $TOKEN" \
"http://localhost:8302/api/video/cameras/line1/frames?from=1782100000&to=1782103600&limit=500" | jq
# 3) frame NEAREST a timestamp (default ts = now) — returns the frame + an image_url
curl -s -H "Authorization: Bearer $TOKEN" \
"http://localhost:8302/api/video/cameras/line1/nearest?ts=1782102000000" | jq
# 4) fetch the actual JPEG bytes (frame IDs are global; no camera in the path)
curl -s -H "Authorization: Bearer $TOKEN" \
http://localhost:8302/api/video/frames/4821/image -o frame.jpg
The nearest response looks like:
{
"frame": {
"id": 4821,
"camera": "line1",
"ts": 1782102000123,
"path": "line1/2026/06/21/1782102000123.jpg",
"size_b": 184320,
"width": 1280,
"height": 720,
"flags": 0,
"tags": { "Process.linear_feet": 412.7, "Process.temp_c": 63.4 },
"event_id": ""
},
"image_url": "/api/video/frames/4821/image"
}
flags bit 0 = event clip (survives retention). The per-frame tags map is exactly the snapshot list you registered — that is the historian's whole point: the picture and the numbers came from the same instant.
ST function reference
All 13 functions live in the VIDEO category. Signatures verbatim from the live registry (GET /api/docs/functions?search=VIDEO_). [brackets] mark optional args.
| Function | Signature → return | Notes |
|---|---|---|
VIDEO_CAMERA_CREATE | (name: STRING, [tool: STRING], [device: STRING], [width: DINT], [height: DINT], [fps: REAL], [quality_pct: DINT], [title: STRING]) : BOOL | Create + start a camera. Lazy-starts the engine on first call. Defaults: tool auto, /dev/video0 for ffmpeg, 640×480, 1 fps, 80% quality. Returns FALSE if name empty or already exists. |
VIDEO_CAMERA_START | (name: STRING) : BOOL | Resume a previously stopped camera. |
VIDEO_CAMERA_STOP | (name: STRING) : BOOL | Pause capture without removing the camera. |
VIDEO_CAMERA_DELETE | (name: STRING) : BOOL | Stop + remove the camera (history already on disk is not deleted). |
VIDEO_CAMERA_BURST | (name: STRING, pre_s: REAL, post_s: REAL, [burst_fps: REAL], [event_id: STRING]) : BOOL | Flag lookback frames as event_clip + capture the post-window at burst_fps (default 5× steady). pre_s=0 → no lookback; post_s=0 → lookback-only. |
VIDEO_CAMERA_FRAME_COUNT | (name: STRING) : LINT | Successful captures since create. |
VIDEO_CAMERA_LAST_TS | (name: STRING) : LINT | Unix-ms of most recent successful capture (0 if none). |
VIDEO_CAMERA_SET_INPUT_FORMAT | (name: STRING, fmt: STRING) : BOOL | Set ffmpeg -input_format (e.g. 'mjpeg' unlocks higher res on USB webcams). Empty string clears it. |
VIDEO_CAMERA_SET_TIMEOUT | (name: STRING, ms: DINT) : BOOL | Update capture-command timeout (ms; 0 = default 2000). |
VIDEO_CAMERA_SET_TITLE | (name: STRING, title: STRING) : BOOL | Set the human-facing label rendered in the /video HMI header. |
VIDEO_CAMERA_TAG_ADD | (name: STRING, tag: STRING) : BOOL | Add a variable to the per-frame tag snapshot (de-duplicated). |
VIDEO_CAMERA_TAG_CLEAR | (name: STRING) : BOOL | Clear all snapshot tags for a camera. |
VIDEO_HISTORIAN_ENABLED | () : BOOL | TRUE if the engine is running (from YAML config or lazy-init). |
All VIDEO_* calls return FALSE/0 when the engine isn't running — they never fault the scan.
REST API reference (tag: video)
Endpoints under /api/video/ (verified in /api/openapi.json). JSON responses are dynamic maps; all return 503 {"error":"video historian not enabled"} when the engine is off, and 404 {"error":"unknown camera: …"} for an unknown camera name.
| Method + path | Purpose |
|---|---|
GET /api/video/cameras | List cameras + live capture stats ({cameras, count}). |
POST /api/video/cameras | Live-register a camera. Body = a camera config (below). In-memory only — also add to YAML to persist across restarts. |
DELETE /api/video/cameras/{name} | Stop + forget a camera (history on disk is kept). |
POST /api/video/cameras/{name}/start | (Re)start a camera's capture loop. |
POST /api/video/cameras/{name}/stop | Pause a camera's capture loop. |
GET /api/video/cameras/{name}/frames | Frame index for a window. Query: from, to (unix ms or s; defaults now-1h .. now), limit (default 1000). |
GET /api/video/cameras/{name}/nearest | Frame nearest a timestamp. Query: ts (unix ms or s; default now). Returns {frame, image_url}. |
POST /api/video/cameras/{name}/burst | Flag recent frames as an event clip + raise capture rate. Body fields all optional: pre_seconds, post_seconds, fps, event_id. |
GET /api/video/cameras/{name}/tags | Per-frame snapshot tag list ({camera, tags, count}). |
POST /api/video/cameras/{name}/tags | Append snapshot tags. Body: {"tag":"…"} or {"tags":["…","…"]}. |
DELETE /api/video/cameras/{name}/tags | Drop every snapshot tag from the camera. |
DELETE /api/video/cameras/{name}/tags/{tag} | Drop one snapshot tag. |
GET /api/video/frames/{id}/image | Serve the raw JPEG bytes for a frame. Frame IDs are global (no camera in the path); Cache-Control: immutable. |
POST /api/video/cameras body (a VideoCameraConfig):
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
http://localhost:8302/api/video/cameras \
-d '{
"name": "cam2",
"tool": "ffmpeg",
"device": "/dev/video2",
"input_format": "mjpeg",
"width": 1920, "height": 1080,
"fps": 1,
"jpeg_quality": 85,
"timeout_ms": 3000,
"title": "Outfeed",
"tags": ["Process.linear_feet"]
}'
YAML config — cameras that stand up at boot
For cameras that should exist on every boot and travel with the deployment, declare a top-level video: block. Machine-local fields (storage_dir, database, per-camera device) are stripped from the shared project file and supplied via per-host overlay.
video:
enabled: true # opt-in; absent/false = off
database: data/video.db # SQLite index (default: data/video.db)
storage_dir: data/video # JPEG root (default: data/video)
max_age_days: 14 # prune frames older than this (event clips survive)
max_size_mb: 5000 # prune oldest non-event frames over this total
hmi:
scrubber_range_s: 3600 # /video HMI scrubber lookback (default 1 h)
cameras:
- name: line1
title: Line 1 Infeed
tool: ffmpeg # "auto" (default) | "rpicam" (Pi CSI) | "ffmpeg"/"v4l2" (USB)
device: /dev/video0 # ffmpeg device; rpicam passes it as --camera
input_format: mjpeg # ffmpeg -input_format; unlocks higher res on USB webcams
width: 1280
height: 720
fps: 1.0
jpeg_quality: 80 # 1-100
timeout_ms: 2000
tags: # variables snapshotted into each frame
- Process.linear_feet
- Process.temp_c
Notes & limits
- Off by default — neither path runs until
video.enabled: trueat boot or the firstVIDEO_CAMERA_CREATE. Until then every endpoint is 503 and everyVIDEO_*call returns FALSE/0. - Retrieval is by timestamp, not streaming. This is a historian, not an RTSP server. The design target is forensic lookup: nearest-frame for a trend click, a window index for an event review.
- Frames carry their tags. The whole value is that
frame.tagswas sampled at the same instant as the JPEG — picture and numbers agree by construction. - Event clips survive retention.
VIDEO_CAMERA_BURST(or the/burstendpoint) setsflagsbit 0 on lookback frames; the age/size prune skips those. Use it for "operator saw something weird — keep the last 15 s." POST /api/video/camerasis in-memory only. A camera added live is forgotten on restart unless you also add it to thevideo:YAML.DELETEremoves future captures, not history. Deleting a camera (ST or API) stops capture; frames already on disk and indexed remain queryable.- Latch your
CREATE.VIDEO_CAMERA_CREATEreturns FALSE on "already exists" — gate it behind asetup_donelatch so the POU doesn't call it every scan (the engine de-floods the log, but the latch is the clean pattern). mjpegfor resolution. Most USB webcams cap raw-YUV resolution low;VIDEO_CAMERA_SET_INPUT_FORMAT(name,'mjpeg')(orinput_format: mjpegin YAML) unlocks the higher modes.- Frame IDs are global. The JPEG route is
/api/video/frames/{id}/imagewith no camera name — IDs are unique across all cameras, so the URL stays compact for embedding in HMI<img>tags. - Sibling subsystems: the vision subsystem (
/api/vision/*) runs trigger-based inference (detection/OCR/barcode/gauge) over these same camera devices; the/videoHMI page gives a scrubber over this index. The historian here owns capture + the by-timestamp frame index.