Skip to content

Quickstart

Ten minutes, no broker, no Docker, no camera.

1. Scaffold

pip install opennvr-app-sdk
opennvr-app new driveway-watch
cd driveway-watch
uv sync

You get a runnable app, a config template, a Dockerfile, a smoke test that already passes, and — with --repo — the CI and publish workflows the App Catalog expects.

2. Watch it fire

uv run opennvr-app dev

dev walks a simulated person across the frame and prints every alert your rule fires, annotating zone entry and exit. A zone your app declares but nobody has drawn yet gets a stand-in polygon across the middle of the frame, so a zone= rule can be seen firing before any operator touches the catalog:

opennvr-app dev — driveway-watch 0.1.0 (perimeter)
  camera cam-1 · person walking left → right · 1 event/s · zones: driveway
  drew a stand-in polygon across the middle of the frame for driveway —
  the operator draws the real one in the App Catalog (--no-zones to skip).

  t=   3.0s  person  conf 0.80  at (0.30, 0.50)
  t=   4.0s  person  conf 0.80  at (0.38, 0.50)  in driveway
  t=   7.0s  ALERT [HIGH] Person loitering on cam-1

Events go through the same code path a real subscription uses, so what fires here fires in production. --still parks the object, --label car changes what it sees, --rate and --count change the pace.

3. Write the rule

The rule is one decorated function:

app.param("dwell_s", float, default=30.0)

@app.on_detection("person", zone="driveway", dwell="$dwell_s", cooldown=60)
def loitering(event):
    event.alert(f"Person loitering on {event.camera}", severity="high")

The filters do what rules used to hand-roll:

Filter Effect
"person", "car" Which labels. None given means every label.
zone="driveway" Only inside a zone the operator drew. Declaring it adds a per-camera polygon param named driveway, which is how the operator knows which zones this app expects.
dwell=30 Only after 30 seconds of continuously satisfying this rule's filters, then once per presence episode. With a zone, that is 30 seconds in the zone.
cooldown=60 At most one alert a minute for the same object.
forget=120 How long an object may go unseen before the episode ends and dwell re-arms. Defaults to max(30s, dwell).
camera="cam-1" One camera, or a list.
min_confidence=0.6 A floor on detector confidence. There is no hidden default — omit it and every detection reaches the rule.

Any numeric filter can read a config value instead of a literal: dwell="$dwell_s" (or dwell=setting("dwell_s")) resolves from the param above when the app starts, so the operator tunes the rule from the catalog without a code change.

The event carries camera, label, confidence, track_id, zone, zones, dwell_s, first_seen, bbox, center, count("car"), detections, config, and remember() / recall(). It is also how you act:

Call Reaches
event.alert(title, …) an operator, in the alert inbox
event.publish(schema, payload) other apps, as a contracted domain event
event.nvr the platform — cameras, snapshots, recordings, timeline, durable state, inference
event.snapshot() the current frame from this event's camera

4. Give it a dashboard

Declare a tile, keep a number, and the catalog renders it — no frontend:

app.metric("alerted", label="Alerts fired")
app.log("recent", label="Recent sightings", limit=20)

@app.on_setup()
def prepare(config):
    app.store.update(alerted=0, recent=[])

app.store is merged into GET /state automatically; @app.state() adds computed keys. @app.action(...) adds an operator button with a generated form, @app.ui() an embedded HTML page, @app.on_license() the licence gate for a paid app, and @app.on_config() / @app.on_shutdown() the rest of the lifecycle. See App surfaces.

5. Test it

uv run pytest -q

The generated smoke test is the parity bar: it fires on the thing, it stays quiet on the near-miss, and the envelope carries what a downstream consumer needs. See Testing.

6. Check it, then ship it

uv run opennvr-app validate .   # what a reviewer would check
uv run opennvr-app spec         # your app's OpenAPI 3.1 document

Then list it: CONTRIBUTING_APPS.md.