Skip to content

The event bus

Domain events

opennvr.events.<domain>.<event>.v<N>.<camera_id> — versioned in the subject, so a v2 can run beside v1 during a migration and every subscriber picks explicitly. The envelopes are normative and CI-enforced: EVENT_CONTRACTS.md.

From a facade rule, publishing is one call — the envelope, the producer (app:<id>), the camera and the correlation id are already known:

app.publishes("occupancy.changed.v1")        # so it appears in the spec

@app.on_detection("person")
def count(event):
    event.publish("occupancy.changed.v1",
                  {"count": event.count("person"), "level": "normal"})

Underneath, and from a base class, it is DomainEventPublisher. Publish the typed payload where you can, not a dict: the class carries the contract's required fields, so a malformed event fails at publish time rather than in someone else's app.

def publish_a_plate(publisher: DomainEventPublisher, camera_id: str) -> None:
    """The typed route — preferred. The payload class carries the
    contract's required fields, so this cannot publish a malformed
    event; the subject and envelope are derived for you."""
    publisher.publish_typed(
        PlateRecognized(
            plate_text="MH12AB1234",
            confidence=0.94,
            vehicle_label="car",
            observed_at="2026-09-10T08:15:00Z",
        ),
        camera_id=camera_id,
        correlation_id="corr-abc123",       # thread it, never mint a new one
    )

Consuming is the mirror image — name the schemas, not the subjects:

class Gate(DomainEventSubscriber):
    subscriptions = ["plate.recognized.v1"]

    def on_event(self, event):
        plate = event.typed          # -> PlateRecognized | None

Scopes

Some domain events carry PII. A plate read is one. Consuming those is a declared capability: name it in requires_scopes, and it is granted at install, audited, visible in the App Catalog, and published in your app's AsyncAPI document. Without the scope the bus does not deliver the event.

requires_scopes=["events:plate.recognized"]

Alerts

An alert is for a human; a domain event is for another app. The alert subject mirrors the alert's own source block, so subscribers filter without parsing the body:

opennvr.alerts.>                       every alert
opennvr.alerts.app.>                   every app-emitted alert
opennvr.alerts.*.*.cam-front-door      one camera
opennvr.alerts.app.loitering.>         one app

Prefer opennvr.alerts.app.> over opennvr.alerts.app.*.*: > matches one or more tokens, so it survives a future contract revision that adds a fifth segment.

Tier-0

The always-on detector. snapshot_from_event reduces a Tier-0 payload to what apps ask of it — counts per label, a speakable phrase, and which tracks have a fetchable best frame. Or set consume_tier0 = True on a Detector and Tier-0 tracks arrive as ordinary detections, so one rule serves both sources.

It is off by default on purpose: an app also subscribed to a heavy adapter would otherwise see the same object twice and alert twice.

Full examples: 05_domain_event_subscriber.py, 12_domain_event_publisher.py, 11_tier0.py.