Skip to main content

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 first VIDEO_CAMERA_CREATE stands up the whole engine even if no video: block exists.

How it works — the capture loop

For each running camera, on its FPS interval:

  1. The capture tool (rpicam/ffmpeg/v4l2) grabs one JPEG from the device.
  2. The historian samples the camera's tag list from the live scheduler — the snapshot is the tag values as of this frame.
  3. 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.
  4. 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: true block is present at boot, or
  • an ST program calls VIDEO_CAMERA_CREATE for 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.

FunctionSignature → returnNotes
VIDEO_CAMERA_CREATE(name: STRING, [tool: STRING], [device: STRING], [width: DINT], [height: DINT], [fps: REAL], [quality_pct: DINT], [title: STRING]) : BOOLCreate + 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) : BOOLResume a previously stopped camera.
VIDEO_CAMERA_STOP(name: STRING) : BOOLPause capture without removing the camera.
VIDEO_CAMERA_DELETE(name: STRING) : BOOLStop + 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]) : BOOLFlag 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) : LINTSuccessful captures since create.
VIDEO_CAMERA_LAST_TS(name: STRING) : LINTUnix-ms of most recent successful capture (0 if none).
VIDEO_CAMERA_SET_INPUT_FORMAT(name: STRING, fmt: STRING) : BOOLSet ffmpeg -input_format (e.g. 'mjpeg' unlocks higher res on USB webcams). Empty string clears it.
VIDEO_CAMERA_SET_TIMEOUT(name: STRING, ms: DINT) : BOOLUpdate capture-command timeout (ms; 0 = default 2000).
VIDEO_CAMERA_SET_TITLE(name: STRING, title: STRING) : BOOLSet the human-facing label rendered in the /video HMI header.
VIDEO_CAMERA_TAG_ADD(name: STRING, tag: STRING) : BOOLAdd a variable to the per-frame tag snapshot (de-duplicated).
VIDEO_CAMERA_TAG_CLEAR(name: STRING) : BOOLClear all snapshot tags for a camera.
VIDEO_HISTORIAN_ENABLED() : BOOLTRUE 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 + pathPurpose
GET /api/video/camerasList cameras + live capture stats ({cameras, count}).
POST /api/video/camerasLive-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}/stopPause a camera's capture loop.
GET /api/video/cameras/{name}/framesFrame index for a window. Query: from, to (unix ms or s; defaults now-1h .. now), limit (default 1000).
GET /api/video/cameras/{name}/nearestFrame nearest a timestamp. Query: ts (unix ms or s; default now). Returns {frame, image_url}.
POST /api/video/cameras/{name}/burstFlag 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}/tagsPer-frame snapshot tag list ({camera, tags, count}).
POST /api/video/cameras/{name}/tagsAppend snapshot tags. Body: {"tag":"…"} or {"tags":["…","…"]}.
DELETE /api/video/cameras/{name}/tagsDrop every snapshot tag from the camera.
DELETE /api/video/cameras/{name}/tags/{tag}Drop one snapshot tag.
GET /api/video/frames/{id}/imageServe 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: true at boot or the first VIDEO_CAMERA_CREATE. Until then every endpoint is 503 and every VIDEO_* 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.tags was sampled at the same instant as the JPEG — picture and numbers agree by construction.
  • Event clips survive retention. VIDEO_CAMERA_BURST (or the /burst endpoint) sets flags bit 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/cameras is in-memory only. A camera added live is forgotten on restart unless you also add it to the video: YAML.
  • DELETE removes 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_CREATE returns FALSE on "already exists" — gate it behind a setup_done latch so the POU doesn't call it every scan (the engine de-floods the log, but the latch is the clean pattern).
  • mjpeg for resolution. Most USB webcams cap raw-YUV resolution low; VIDEO_CAMERA_SET_INPUT_FORMAT(name,'mjpeg') (or input_format: mjpeg in YAML) unlocks the higher modes.
  • Frame IDs are global. The JPEG route is /api/video/frames/{id}/image with 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 /video HMI page gives a scrubber over this index. The historian here owns capture + the by-timestamp frame index.