The platform¶
Everything an app reads from the running deployment: the camera roster it was assigned, snapshots, recordings, the timeline and its evidence, durable state, and inference. The app's own credential scopes all of it — an app cannot see the whole site by accident.
opennvr_app_sdk.OpenNVR
¶
OpenNVR(
url: str | None = None,
*,
token: str | None = None,
kaic_url: str | None = None,
kaic_api_key: str | None = None,
timeout: float = DEFAULT_TIMEOUT,
client_id: str = "opennvr-app",
)
See the module docstring. All arguments fall back to the
environment the app overlays already set: OPENNVR_URL,
KAIC_URL / OPENNVR_KAIC_URL, OPENNVR_INTERNAL_API_KEY
(bootstrap) and the app key from credentials.py.
Source code in opennvr_app_sdk/client.py
382 383 384 385 386 387 388 389 390 391 392 393 394 395 | |
roster
¶
roster() -> list[Camera] | None
The cameras picked for this app in its configuration.
[] means core answered and nothing is picked: the app should
do nothing. None means core could not be asked (unreachable,
an error, a key it refused) — NOT the same answer. An app that
treats None as "nothing picked" tears its work down on every
core restart; one that treats [] as "keep going" never stops
when an operator unpicks the last camera. Keep what you have on
None, stop on [].
Source code in opennvr_app_sdk/client.py
403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 | |
cameras
¶
cameras() -> list[Camera]
The cameras picked for this app, or [] — which here means
EITHER nothing is picked OR core could not be reached. Use
:meth:roster wherever that difference changes what you do.
Source code in opennvr_app_sdk/client.py
419 420 421 422 423 | |
snapshot
¶
snapshot(camera) -> bytes | None
The camera's current frame as JPEG, or None.
Source code in opennvr_app_sdk/client.py
429 430 431 432 | |
save_evidence
¶
save_evidence(jpeg: bytes) -> str | None
Store a JPEG for an alert to cite; returns its path.
Put photos HERE, then pass the paths as Alert(images=...).
An alert is a NATS message with a 1 MB ceiling, so a base64 crop
inside the alert is not merely wasteful — past the ceiling the
broker drops the publish and the alert never reaches anyone.
None when the upload fails: an app must still be able to
raise its alert without the picture.
Source code in opennvr_app_sdk/client.py
434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 | |
stream_grant
¶
stream_grant(camera) -> dict | None
Core's permission to read this camera's video, plus the URL.
The token in it is scoped to this one camera's path and expires,
so apps sharing a network cannot read each other's cameras.
None when core will not or cannot grant it.
Source code in opennvr_app_sdk/client.py
456 457 458 459 460 461 462 463 464 | |
stream
¶
stream(camera, *, width: int = 640, fps: float = 10.0)
A live frame stream for one camera, started and self-renewing.
For rules about a shape in TIME — a scan sweep, a fall, a queue forming — where a snapshot every few seconds has already missed it. Newest frame wins; a camera reboot reconnects on its own.
with nvr.stream(cam) as video:
for frame in video.frames():
...
The grant is re-fetched on every reconnect, so an expiring token renews itself rather than failing mid-session.
Source code in opennvr_app_sdk/client.py
466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 | |
opennvr_app_sdk.AsyncOpenNVR
¶
AsyncOpenNVR(
url: str | None = None,
*,
token: str | None = None,
kaic_url: str | None = None,
kaic_api_key: str | None = None,
timeout: float = DEFAULT_TIMEOUT,
http_client: AsyncClient | None = None,
client_id: str = "opennvr-app",
)
The async twin of :class:opennvr_app_sdk.OpenNVR; same
arguments and environment fallbacks. Pass http_client to share
one httpx.AsyncClient (a FastAPI app's lifespan pool, a test
transport); the client then does not close it.
Source code in opennvr_app_sdk/aio.py
289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 | |
roster
async
¶
roster() -> list[Camera] | None
Picked cameras; [] = nothing picked, None = core could
not be asked. See :meth:opennvr_app_sdk.client.OpenNVR.roster.
Source code in opennvr_app_sdk/aio.py
311 312 313 314 315 316 317 | |
save_evidence
async
¶
save_evidence(jpeg: bytes) -> str | None
Store a JPEG for an alert to cite; returns its path.
Photos go here, not into the alert: an alert is a NATS message
with a 1 MB ceiling, and past it the broker drops the publish —
the alarm is never seen at all. None when the upload fails,
because an app must still be able to raise its alert.
Source code in opennvr_app_sdk/aio.py
330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 | |
stream_grant
async
¶
stream_grant(camera) -> dict | None
Core's permission to read this camera's video, plus the URL.
Scoped to this one camera's path and short-lived, so apps on a shared network cannot read each other's cameras.
Source code in opennvr_app_sdk/aio.py
349 350 351 352 353 354 355 356 | |
stream
¶
stream(camera, *, width: int = 640, fps: float = 10.0)
A live frame stream for one camera, started and self-renewing.
Deliberately NOT a coroutine: the decoder is a background thread feeding a newest-frame slot, so there is nothing to await. Awaiting frames would only add the queue this design exists to avoid.
Source code in opennvr_app_sdk/aio.py
358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 | |
opennvr_app_sdk.Camera
dataclass
¶
Camera(
id: int,
handle: str,
name: str,
role: str,
frame_url: str,
assignments: list[dict] = list(),
raw: dict = dict(),
)
One camera in the app's roster (what core assigned to this app).
opennvr_app_sdk.PlatformError
¶
Bases: RuntimeError
A write (state, actions) the platform refused or could not take.
opennvr_app_sdk.EventsClient
¶
EventsClient(
core_url: str,
api_key: str | None = None,
*,
http_get: HttpGetH | None = None,
)
Query visits and fetch their evidence photos from core.
Source code in opennvr_app_sdk/events.py
67 68 69 70 71 72 73 74 75 76 | |
search
async
¶
search(
*,
label: str | None = None,
camera_id: int | None = None,
plate: str | None = None,
start: datetime | str | None = None,
end: datetime | str | None = None,
limit: int = 50,
) -> list[StoredEvent] | None
Visits overlapping [start, end), newest first.
Returns [] for a genuinely empty window and None on ANY
failure (transport, auth, or a rejected query) — the caller must be
able to say "nothing came" and "I couldn't check" differently; in a
security product those are different answers.
Source code in opennvr_app_sdk/events.py
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 | |
evidence
async
¶
evidence(event_id: int) -> bytes | None
The visit's best-frame JPEG, or None.
Source code in opennvr_app_sdk/events.py
129 130 131 132 133 134 135 136 | |
recording_frame
async
¶
recording_frame(camera_id: int, at: str) -> bytes | None
One JPEG from recorded footage at instant at (ISO 8601), or None.
Powers the agent's describe_window: sample a few instants across a past
window and caption each. Internal-key authed like evidence.
Source code in opennvr_app_sdk/events.py
138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 | |
opennvr_app_sdk.StoredEvent
dataclass
¶
StoredEvent(
id: int,
camera_id: int,
label: str | None,
score: float | None,
started_at: str | None,
ended_at: str | None,
stationary: bool | None,
plate_text: str | None,
has_evidence: bool,
)
One remembered visit, as the store serves it.
opennvr_app_sdk.KaiCClient
¶
KaiCClient(
base_url: str,
adapter_name: str,
*,
api_key: str | None = None,
timeout_seconds: float = 10.0,
http_client: Client | None = None,
)
Tiny client for KAI-C's POST /api/v1/infer/{adapter}.
Sends the frame as a base64 JSON body (the contract-v1 convenience
path — multipart adds boilerplate without benefit at ~1 fps
polling) and threads X-Correlation-Id so every alert traces
back through KAI-C's audit log and the adapter's logs alike. The
optional API key rides the X-Internal-Api-Key header, matching
the intrusion-detection example and KAI-C's internal-auth scheme.
Source code in opennvr_app_sdk/frame_app.py
98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 | |
infer
¶
infer(
frame_bytes: bytes,
*,
task: str,
camera_id: str | None = None,
params: dict[str, Any] | None = None,
correlation_id: str | None = None,
) -> dict[str, Any]
Send one frame; return the raw §5.1 InferResponse body.
camera_id is optional, as it is for KAI-C itself: with it
the call is attributed to the camera (audit, skill budgets, the
NATS subject); without it KAI-C treats the call as a one-off
probe. Raises :class:KaiCError on transport failure or
non-200; the frame loop catches and decides whether to alert /
skip / abort.
Source code in opennvr_app_sdk/frame_app.py
119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 | |
opennvr_app_sdk.KaiCError
¶
Bases: Exception
Raised when KAI-C is unreachable or returns a non-200. Frame loops treat this as a transient skip — alerts don't fire on a comms failure (the failure itself is visible in KAI-C's audit log via the correlation_id we sent).
opennvr_app_sdk.InferStream
¶
InferStream(
kaic_url: str,
api_key: str | None,
*,
adapter: str,
camera_id: str,
client_id: str = "opennvr-app",
timeout: float = 10.0,
websocket_factory: Callable[
[str, list[tuple[str, str]]], Any
]
| None = None,
)
A streaming inference session against one KAI-C adapter.
KaiCClient.infer is one HTTP round-trip per frame — fine at one
frame every few seconds, wasteful at ten a second. This holds a
WebSocket session open instead, so the model stays warm and every
frame in the session shares one audit correlation_id, which is
what makes a sequence of frames traceable as a single episode.
Use it as a context manager; the session reopens itself after a failure, so a dropped frame costs one frame::
with InferStream(url, key, adapter="yolov8", camera_id="cam1") as s:
for jpeg in frames:
result = s.infer(jpeg)["result"]
nvr.ai.stream(adapter, camera_id=…) builds one from the app's own
credential, which is usually what you want inside an app.
Source code in opennvr_app_sdk/infer_stream.py
54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 | |
open
¶
open(correlation_id: str | None = None) -> 'InferStream'
Connect + §6.1 handshake (idempotent).
Source code in opennvr_app_sdk/infer_stream.py
89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 | |
infer
¶
infer(jpeg: bytes) -> dict[str, Any]
Send one frame, return a §5.1-shaped result. Raises
KaiCError and closes the session on any failure.
Source code in opennvr_app_sdk/infer_stream.py
116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 | |
opennvr_app_sdk.discover_cameras
¶
discover_cameras(
opennvr_url: str,
*,
api_key: str | None = None,
timeout: float = 5.0,
) -> list[dict[str, Any]]
Return OpenNVR's configured cameras, or [] if they can't be read.
Never raises: discovery runs at app startup, and an app that refuses to boot because core was still starting is worse than one that boots with no cameras and says so. Callers should log the empty result.
[] deliberately conflates "core said zero cameras" with "core
could not be reached" — for a boot-time listing they are the same
non-answer. Callers that must tell them apart (assignment, where
the two mean opposite things) use :func:_fetch_cameras.
Source code in opennvr_app_sdk/cameras.py
56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 | |
opennvr_app_sdk.camera_key
¶
camera_key(value: Any) -> int | None
3 / "3" / "cam3" / "cam-3" → 3; anything else → None.
Never raises: it is used to look things up, and an unparseable key should simply match nothing.
Source code in opennvr_app_sdk/cameras.py
125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 | |
opennvr_app_sdk.per_camera_value
¶
per_camera_value(
mapping: Any, camera: Any, default: Any = None
) -> Any
mapping[camera] for a per-camera config value, whichever
spelling of the camera either side used.
Source code in opennvr_app_sdk/cameras.py
145 146 147 148 149 150 151 152 153 154 155 156 | |
opennvr_app_sdk.cameras_for_skill
¶
cameras_for_skill(
opennvr_url: str,
skill: str,
*,
api_key: str | None = None,
timeout: float = 5.0,
) -> list[str] | None
Fetch-and-filter convenience: the camera ids assigned skill.
[] means core answered and no camera carries the skill: watch
nothing. None means core could not be ASKED — unknown, and
unknown must not be acted on in either direction: keep whatever
roster you already had rather than dropping to nothing on a restart
that raced core, or widening to everything on a network blip.
Use :func:filter_cameras_for_skill when you already hold a
:func:discover_cameras result.
Source code in opennvr_app_sdk/cameras.py
204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 | |
opennvr_app_sdk.filter_cameras_for_skill
¶
filter_cameras_for_skill(
cameras: list[dict[str, Any]], skill: str
) -> list[str] | None
Which of cameras (a :func:discover_cameras payload) are
assigned skill.
Returns the camera-id list, EMPTY when no camera carries the skill.
An empty list means watch nothing: the operator has not pointed this
skill at anything yet. It used to return None there, meaning
"watch everything", which is why an unconfigured app saw the fleet.
The return type stays | None for callers that still branch on
it; None now only means "could not ask" (an empty skill name),
never "no restriction".
Pure — feed it the list you already fetched instead of fetching twice (the occupancy example's refresh loop does exactly this).
Source code in opennvr_app_sdk/cameras.py
172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 | |
opennvr_app_sdk.AppCredentials
¶
AppCredentials(explicit: str | None = None)
Resolve, remember and rotate the app's credential.
explicit is the config's opennvr_token (either kind).
Source code in opennvr_app_sdk/credentials.py
166 167 168 169 170 171 172 | |
token
¶
token() -> str | None
What to send: the app key when we have one, else the site key.
Source code in opennvr_app_sdk/credentials.py
196 197 198 199 | |
headers
¶
headers() -> dict[str, str]
Both header shapes core accepts, so one value works whether it is an app key, the site key or a user JWT.
Source code in opennvr_app_sdk/credentials.py
201 202 203 204 205 206 207 | |
adopt
¶
adopt(key: str) -> None
A key just issued by core: use it from now on and persist it.
Source code in opennvr_app_sdk/credentials.py
209 210 211 212 213 | |
invalidate
¶
invalidate() -> None
Core refused our app key (rotated/revoked): fall back to the site key so the next registration can ask for a new one.
Source code in opennvr_app_sdk/credentials.py
215 216 217 218 219 220 221 222 223 | |
opennvr_app_sdk.auth_headers
¶
auth_headers(explicit: str | None = None) -> dict[str, str]
One-shot helper for clients that don't hold an AppCredentials.
Source code in opennvr_app_sdk/credentials.py
226 227 228 | |
opennvr_app_sdk.FrameSource
¶
Bases: Protocol
Anything that can produce the latest frame for a camera.
Returns encoded image bytes (JPEG/PNG — whatever the adapter's
contract accepts) or None when no frame is available right now
(camera offline, snapshot endpoint empty). Raising is also fine —
the poll loop isolates per-camera fetch failures.
opennvr_app_sdk.CameraFrameSource
¶
Bases: Protocol
Anything with fetch() -> bytes and a stable camera_id
is a per-camera frame source.
opennvr_app_sdk.FileFrameSource
¶
FileFrameSource(*, camera_id: str, path: str)
Read a JPEG/PNG from disk. camera_id is operator-supplied.
Path-traversal protected: we resolve the configured path once at init time and reject any subsequent change. (Operators shouldn't be passing user-controlled paths anyway, but the example sets the pattern for future sources.)
Source code in opennvr_app_sdk/frame_sources.py
78 79 80 81 82 83 84 85 | |
opennvr_app_sdk.HttpSnapshotSource
¶
HttpSnapshotSource(
*,
camera_id: str,
url: str,
timeout_seconds: float = 5.0,
verify_tls: bool = True,
)
GET an HTTP snapshot URL. Supports basic-auth via the URL
(http://user:pass@host/snapshot.jpg) — standard pattern for
consumer-grade cameras.
Timeout is intentionally low (default 5s): a slow snapshot in the polling loop blocks every camera. If the camera is consistently slow, the operator should lower the poll interval or move to RTSP.
Source code in opennvr_app_sdk/frame_sources.py
101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 | |
opennvr_app_sdk.CoreSnapshotSource
¶
CoreSnapshotSource(nvr=None)
Frames from OpenNVR itself, for any camera picked for this app.
get_frame("cam3") fetches the camera's current JPEG through core's
app snapshot route, which serves only the app's own picks — so an app
connected to core needs no hand-written frame_url per camera, and
cannot read a camera it wasn't given. Returns None when core has no
frame (camera offline, not picked, core unreachable); the poll loop
simply skips that camera for the tick.
Source code in opennvr_app_sdk/frame_sources.py
214 215 | |
opennvr_app_sdk.DictFrameSource
¶
DictFrameSource(sources: Mapping[str, CameraFrameSource])
Adapt a {camera_id: CameraFrameSource} mapping to the
:class:~.frame_app.FrameSource shape the FrameApp loop polls.
Holds the mapping by reference — swapping an entry (a test stub, a
reconfigured camera) is picked up on the next tick. Unknown
camera ids raise KeyError; the poll loop isolates that like any
other per-camera fetch failure.
Source code in opennvr_app_sdk/frame_sources.py
191 192 | |
opennvr_app_sdk.build_frame_source
¶
build_frame_source(
*, camera_id: str, url: str
) -> CameraFrameSource
Pick the right source class based on the URL scheme. Anything
unrecognised raises FrameSourceError — fail fast at config-load
time rather than mid-loop.
Source code in opennvr_app_sdk/frame_sources.py
148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 | |
opennvr_app_sdk.dict_frame_source
¶
dict_frame_source(
sources: Mapping[str, CameraFrameSource],
) -> FrameSource
Convenience constructor for :class:DictFrameSource.
Source code in opennvr_app_sdk/frame_sources.py
198 199 200 | |
opennvr_app_sdk.FrameSourceError
¶
Bases: Exception
Raised when a frame source cannot produce a frame this cycle. Caller (the detector loop) decides whether to skip or abort — transient failures are normal (network blips, camera offline).
opennvr_app_sdk.RtspFrameStream
¶
RtspFrameStream(
url: str | None = None,
*,
url_factory: Callable[[], str] | None = None,
width: int = DEFAULT_WIDTH,
fps: float = DEFAULT_FPS,
spawn: SpawnFn | None = None,
size: tuple[int, int] | None = None,
name: str = "rtsp",
)
Newest-frame-wins reader for one RTSP URL.
Start it, then either poll :meth:latest or iterate :meth:frames.
Both hand back the most recent decoded frame; neither ever hands
back a backlog.
url_factory is called for each (re)connect, so a caller whose
URL carries a short-lived token — every app reading through the
platform's scoped stream grant — renews simply by returning a fresh
one.
Source code in opennvr_app_sdk/rtsp.py
220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 | |
latest
¶
latest(*, timeout: float | None = None) -> Frame | None
The newest frame, waiting up to timeout for a new one.
Returns None on timeout — which is a fact about the camera, not an error, and the caller decides what it means.
Source code in opennvr_app_sdk/rtsp.py
308 309 310 311 312 313 314 315 316 317 318 | |
frames
¶
frames(*, timeout: float = 5.0) -> Iterator[Frame]
Frames as they arrive, skipping any the caller was too slow to collect. Ends when the stream is closed.
Source code in opennvr_app_sdk/rtsp.py
320 321 322 323 324 325 326 | |
opennvr_app_sdk.RtspStillSource
¶
RtspStillSource(
*,
camera_id: str,
url: str,
width: int = DEFAULT_WIDTH,
fps: float = 4.0,
quality: int = 85,
)
A stream dressed as a snapshot source.
The polling FrameApp asks for "a frame now" every few seconds
and expects encoded bytes. Pointing it at a stream would otherwise
mean spawning ffmpeg per tick (what the camera-agent does, and it
costs a second each time), so this keeps ONE decoder warm and hands
over the newest frame, JPEG-encoded on demand.
Needs opencv for the encode — an app that only wants stills and has no opencv should use the camera's HTTP snapshot URL instead.
Source code in opennvr_app_sdk/rtsp.py
493 494 495 496 497 498 499 | |
fetch
¶
fetch() -> bytes | None
The newest frame as JPEG, or None if the stream has nothing.
Source code in opennvr_app_sdk/rtsp.py
501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 | |
opennvr_app_sdk.Frame
dataclass
¶
Frame(
data: bytes,
width: int,
height: int,
seq: int,
mono_ts: float,
wall_ts: float = 0.0,
restarted: bool = False,
)
One decoded frame: raw BGR bytes plus when it was taken.
to_ndarray
¶
to_ndarray()
The frame as an (h, w, 3) BGR array (needs numpy).
Source code in opennvr_app_sdk/rtsp.py
97 98 99 100 101 102 | |
opennvr_app_sdk.FrameStreamError
¶
Bases: RuntimeError
The stream could not be opened or understood.
opennvr_app_sdk.FrameStreamUnavailable
¶
Bases: PlatformError
Core would not grant a stream for this camera.