Writing a rule¶
Where the object is, how long it has been there, and what to fire when it matters. Most rules are a zone, a dwell timer and an alert; these are those three, unwrapped.
opennvr_app_sdk.Zone
dataclass
¶
Zone(name: str, polygon: list[Point])
A named polygonal zone in pixel coordinates.
Polygon vertices are pixel coords on the camera frame. The polygon is implicitly closed (we connect the last vertex back to the first). A degenerate zone with < 3 vertices is rejected.
contains
¶
contains(point: Point) -> bool
True if the point lies inside the polygon (ray-casting).
Source code in opennvr_app_sdk/geometry.py
61 62 63 | |
from_config
classmethod
¶
from_config(
name: str, vertices: Sequence[Sequence[float]]
) -> "Zone"
Build a Zone from config-style vertex list [[x, y], [x, y], ...].
Source code in opennvr_app_sdk/geometry.py
65 66 67 68 69 70 71 | |
opennvr_app_sdk.Tripwire
dataclass
¶
Tripwire(
name: str,
a: Point,
b: Point,
count_direction: str = "both",
)
An oriented line segment A→B with a counted direction.
count_direction is one of "a_to_b", "b_to_a", "both".
side
¶
side(p: Point) -> float
Signed side of point p relative to the oriented line A→B.
Positive = left, negative = right, ~0 = on the line.
Source code in opennvr_app_sdk/geometry.py
154 155 156 157 158 159 | |
crossing
¶
crossing(prev: Point, curr: Point) -> str | None
Return the crossing direction ("a_to_b" / "b_to_a") if
the movement prev → curr crosses this tripwire in a counted
direction, else None.
Two conditions must both hold: 1. the segment prev→curr intersects the segment A→B (so the object physically traversed the wire), and 2. the side sign flipped from prev to curr (so it genuinely changed sides, not merely touched the line).
Source code in opennvr_app_sdk/geometry.py
161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 | |
opennvr_app_sdk.Point
dataclass
¶
Point(x: float, y: float)
2D point in pixel coordinates (origin top-left, y-down).
opennvr_app_sdk.bbox_center
¶
bbox_center(
bbox_normalized: dict,
frame_width: int,
frame_height: int,
) -> Point
Convert a §5.1 NormalizedBBox (x/y/w/h in [0, 1]) into a
pixel-space center point given the camera's actual frame size.
Defensive against partial bboxes: missing keys default to 0.
Source code in opennvr_app_sdk/geometry.py
227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 | |
opennvr_app_sdk.full_frame_polygon
¶
full_frame_polygon(
size: int = UNIT_FRAME,
) -> list[list[int]]
The whole-frame zone, in the unit space auto-derived zones use.
Source code in opennvr_app_sdk/cameras.py
110 111 112 | |
opennvr_app_sdk.keyed_state
¶
keyed_state(
ttl: float,
*,
auto_gc: bool = True,
record_factory: Callable[
..., StateRecord
] = StateRecord,
clock: Callable[[], float] = time.time,
) -> KeyedState
Build a :class:KeyedState — TTL + latch + GC per §04 of the
App SDK spec. ttl is in seconds of event time (whatever
timeline you pass to touch(at=...)).
Source code in opennvr_app_sdk/state.py
169 170 171 172 173 174 175 176 177 178 179 | |
opennvr_app_sdk.KeyedState
¶
KeyedState(
ttl: float,
*,
auto_gc: bool = True,
record_factory: Callable[
..., StateRecord
] = StateRecord,
clock: Callable[[], float] = time.time,
)
A TTL-pruned mapping of hashable keys to :class:StateRecord.
Build via :func:keyed_state. Dict-like surface: get / pop
/ items / in / len / [key].
Source code in opennvr_app_sdk/state.py
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 | |
touch
¶
touch(
key: Hashable, at: float | None = None
) -> StateRecord
Record a presence ping for key at time at (defaults
to wall clock). Creates the record on first touch (fresh
first_seen, alerted=False); refreshes last_seen on
subsequent touches. With auto_gc enabled, prunes OTHER
stale keys first — never the touched key itself.
Source code in opennvr_app_sdk/state.py
100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 | |
gc
¶
gc(
now: float | None = None, *, exclude: Any = ()
) -> list[tuple[Hashable, StateRecord]]
Prune every key whose last_seen is more than ttl
seconds before now (strictly older — a record exactly at
the TTL boundary survives, matching the grace-period semantics
the loitering detector shipped with). Keys in exclude are
kept regardless. Returns the pruned (key, record) pairs so
lifecycle apps can emit disappearance events.
Source code in opennvr_app_sdk/state.py
117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 | |
items
¶
items() -> list[tuple[Hashable, StateRecord]]
Snapshot list — safe to pop while iterating.
Source code in opennvr_app_sdk/state.py
149 150 151 | |
opennvr_app_sdk.StateRecord
dataclass
¶
StateRecord(
first_seen: float,
last_seen: float,
alerted: bool = False,
data: dict[str, Any] = dict(),
)
Bookkeeping for one key.
first_seen is the timestamp of the first touch since the record
was created (or re-created after a GC) — loitering reads it as
present_since. last_seen is the most recent touch.
alerted is a caller-settable latch so a threshold-crossing
alert fires once per episode. data is a free-form scratchpad
for app-specific flags (state-machine phase, counters, …);
subclassing and passing record_factory works too when you want
typed fields.
age
property
¶
age: float
Seconds between the first and the most recent touch —
i.e. the dwell time as of the last touch.
opennvr_app_sdk.AlertType
dataclass
¶
AlertType(
name: str,
severity: str = "medium",
description: str = "",
)
One alert kind the app can emit — drives catalog documentation and downstream routing defaults.
opennvr_app_sdk.AlertSource
dataclass
¶
AlertSource(
kind: str = (lambda: _DEFAULT_SOURCE.get()["kind"])(),
name: str = (lambda: _DEFAULT_SOURCE.get()["name"])(),
version: str = (
lambda: _DEFAULT_SOURCE.get()["version"]
)(),
)
The source block of the §11.5 alert envelope.
Field defaults come from the context default set via
:func:set_default_source — one of: kind = kai-c / adapter / app.
opennvr_app_sdk.AlertChannel
¶
Bases: Protocol
Anything that can send(alert) is a channel. Stdout + webhook
are the v1 implementations; future channels (OpenNVR alerts API)
plug in here without touching the detector loop.
opennvr_app_sdk.AlertDispatcher
¶
AlertDispatcher(channels: list[AlertChannel])
Holds an ordered list of channels and fires an alert through all of them, isolating each channel's failures.
fire() returns a per-channel report so the caller can audit
delivery outcomes; the detector loop ignores this in v1 but the
OpenNVR alerts-API integration (planned follow-up) will record it.
Source code in opennvr_app_sdk/alerts.py
563 564 565 566 | |
channels
property
¶
channels: tuple[AlertChannel, ...]
The channels, in delivery order — read-only. Handy in tests
and in a /state view ("where do my alerts go?").
close
¶
close() -> None
Drain + tear down any channel that has a close method.
Stdout and webhook channels are stateless and have no close()
— only NatsAlertChannel does. Wire this into the detector's
SIGINT/SIGTERM finally clause so in-flight NATS publishes get
drained instead of dropped (peer-review H2). Per-channel
failures are logged but never raise, so shutdown stays clean
even if one channel's close hangs.
Source code in opennvr_app_sdk/alerts.py
586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 | |
opennvr_app_sdk.StdoutChannel
¶
Always-on channel: human-readable line to stdout.
opennvr_app_sdk.WebhookChannel
¶
WebhookChannel(url: str, *, timeout_seconds: float = 5.0)
POST the alert's JSON wire shape to an operator-configured URL.
Failures are LOGGED but never raise — a dead webhook should not prevent stdout alerts from firing or crash the detector loop. This matches the §11.2 "audit forwarding failures are themselves audited" pattern (though we don't yet have an audit sink here — that lands when the SDK talks to KAI-C's audit log directly).
Source code in opennvr_app_sdk/alerts.py
293 294 295 | |
opennvr_app_sdk.NatsAlertChannel
¶
NatsAlertChannel(
url: str,
*,
token: str | None = None,
subject_prefix: str = DEFAULT_ALERT_SUBJECT_PREFIX,
connect_timeout_seconds: float = _NATS_CONNECT_TIMEOUT_SECONDS,
publish_timeout_seconds: float = _NATS_PUBLISH_TIMEOUT_SECONDS,
)
Publish each alert as JSON onto a NATS subject derived from the §11.5 source block.
Why this exists¶
The other two channels are point-to-point: stdout goes to one operator, the webhook goes to one URL. NATS is bus-shaped — N subscribers can fan out off the same publish (operator UI inbox, SIEM bridge, Slack bot, audit forwarder) without the publishing app knowing they exist. Same pattern KAI-C uses for inference events under the NATS event bus.
Implementation notes¶
AlertChannel.send is synchronous and called from a detector
loop that may itself be sync (HTTP poll mode) or async (NATS
subscriber / WS streaming mode). To keep the protocol uniform we
run a background daemon thread that owns an asyncio event loop;
send schedules the publish coroutine onto it via
run_coroutine_threadsafe and waits for the result with a hard
timeout. This insulates the dispatcher from the async-ness of
nats-py and keeps publish failures isolated to this channel.
Failures (broker down, slow connect, bad credentials) are LOGGED
but never raise — same contract as WebhookChannel. The detector
loop should never crash because the bus is down.
Source code in opennvr_app_sdk/alerts.py
354 355 356 357 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 392 393 394 395 396 397 398 | |
publish_json
¶
publish_json(subject: str, obj: Any) -> bool
Publish obj as JSON on subject — the channel's
machinery (background loop, lazy connect, hard timeouts,
log-never-raise) for ANY payload. send is now a thin
wrapper; domain_events.DomainEventPublisher composes this
to publish contracted opennvr.events.* envelopes.
Source code in opennvr_app_sdk/alerts.py
404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 | |
close
¶
close() -> None
Drain pending publishes and stop the background thread.
Called from the detector's shutdown path. Safe to call even if
send was never invoked (no thread to clean up). After
close() the channel is re-init safe: the next send()
spins a fresh loop + thread and reconnects from scratch.
Source code in opennvr_app_sdk/alerts.py
430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 | |
opennvr_app_sdk.build_dispatcher
¶
build_dispatcher(
*,
webhook_url: str | None,
nats_alerts_url: str | None = None,
nats_alerts_token: str | None = None,
nats_alerts_subject_prefix: str = DEFAULT_ALERT_SUBJECT_PREFIX,
) -> AlertDispatcher
Convenience factory used by app config loading. stdout is always included; webhook and NATS are independently opt-in via config.
Order matters: stdout fires first (operator-visible immediately),
then webhook (still typically the fastest external sink), then NATS
(bus fan-out for consumers that don't need synchronous delivery).
Each channel's failure is isolated by AlertDispatcher.fire.
Source code in opennvr_app_sdk/alerts.py
609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 | |
opennvr_app_sdk.alert_subject
¶
alert_subject(
alert: "Alert",
*,
prefix: str = DEFAULT_ALERT_SUBJECT_PREFIX,
) -> str
Derive the NATS subject for one alert.
Shape: {prefix}.{source.kind}.{source.name}.{camera_id} with
each segment sanitized so the result is a valid NATS subject (no
spaces, no dots, no NATS reserved * / >).
Sanitization rule: any character outside [A-Za-z0-9_-] becomes
_. Empty-after-sanitization segments fall back to "unknown"
so a malformed Alert can't produce opennvr.alerts...cam-X.
Pulled out as a module-level function so tests can assert subject derivation without spinning up NATS.
Source code in opennvr_app_sdk/alerts.py
251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 | |
opennvr_app_sdk.set_default_source
¶
set_default_source(
*,
kind: str | None = None,
name: str | None = None,
version: str | None = None,
) -> None
Set the default AlertSource identity for the current context.
Call once at app startup for single-app processes. The Detector
base instead scopes its identity around each handler call, so
multiple detectors in one process don't fight. Only affects
AlertSource instances created WITHOUT explicit values.
Source code in opennvr_app_sdk/alerts.py
104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 | |
opennvr_app_sdk.DEFAULT_ALERT_SUBJECT_PREFIX
module-attribute
¶
DEFAULT_ALERT_SUBJECT_PREFIX = 'opennvr.alerts'
opennvr_app_sdk.DETECTION_LABELS
module-attribute
¶
DETECTION_LABELS: tuple[str, ...] = (
"person",
"bicycle",
"car",
"motorcycle",
"bus",
"truck",
"boat",
"train",
"airplane",
"traffic light",
"fire hydrant",
"stop sign",
"bench",
"backpack",
"umbrella",
"handbag",
"suitcase",
"dog",
"cat",
"bird",
"horse",
"sheep",
"cow",
"elephant",
"bear",
"zebra",
"giraffe",
"bottle",
"cup",
"knife",
"laptop",
"cell phone",
"chair",
"couch",
"potted plant",
"bed",
"dining table",
"tv",
"book",
"clock",
)
opennvr_app_sdk.Setting
dataclass
¶
Setting(name: str)
A rule filter that reads an operator-set config value.
Decorator arguments are evaluated at import time, so a literal
dwell=30 can never follow the config form. Wrap the param name
instead — dwell=setting("dwell_s"), or the shorthand
dwell="$dwell_s" — and the value is read from the parsed config
when the app starts.