A self-hosted, event-driven home security platform integrating Reolink cameras, ONVIF events, deterministic incident correlation, presence-aware security modes, trusted people/vehicles, topology-aware severity logic, and optional AI-assisted verification. Runs on an always-on machine on the home network and integrates with an existing network-presence data source for away-mode detection.
This project integrates with Reolink cameras via ONVIF; it is an independent, self-hosted project and is not affiliated with or endorsed by Reolink.
A bare camera alert ("motion detected on Camera 3") carries almost no useful information on its own -- it can't tell you whether that motion matters. This platform exists to add the context a human would actually use to judge that: who's home right now, what security mode is active, which camera saw it and whether that camera's coverage genuinely connects to the last one that fired, which zone on the property it was in, whether a known vehicle or trusted person is already associated with it, whether this is a continuation of an incident already underway, how confident the underlying presence/vehicle/AI evidence actually is, and whether there's hard security evidence (like an unexpected indoor person while the house is confirmed away) that should never be second-guessed by softer context. The goal is a system that explains why something is concerning -- or isn't -- instead of just forwarding raw detections.
These are enforced in code, not just documentation:
- UNKNOWN presence must never silently become AWAY. Insufficient signal is reported as uncertain, never collapsed into a confident "away" that would suppress or misjudge an alert.
- AI may enrich evidence but must not silently override deterministic security rules. A Claude classification of "false positive" can soften a severity score, but it can never pull a result below what hard evidence (e.g. an indoor person while confidently away) already established.
- Security decisions should be explainable. Every severity result carries human-readable reasons and, where relevant, the softer context that was considered and outweighed -- never a black-box score.
- One noisy camera should not create duplicate incidents. Related events across time, camera adjacency, zones, and shared context correlate into a single incident rather than a flood of separate alerts.
- Camera adjacency must reflect real topology, not just camera role. Two cameras sharing a "perimeter" role are not necessarily physically adjacent -- treating them as interchangeable can manufacture a fake continuous approach where none was actually observed (see the Camera Topology section below).
- Automated behavior should fail conservatively. A missed alert is worse than a spurious one in some paths (e.g. an unreachable presence source defaults to "home") and better than one in others (e.g. an unreachable camera during snapshot verification fails open and alerts anyway) -- chosen deliberately per case, never left to chance.
flowchart TD
A[Reolink Cameras] --> B[ONVIF Events]
B --> C[Event Router]
C --> D[Detection / Metadata]
D --> E[Context Layer]
E --> E1[Presence]
E --> E2[Security Mode]
E --> E3[Zones]
E --> E4[Trusted People]
E --> E5[Known Vehicles]
E --> E6[Camera Topology]
E1 & E2 & E3 & E4 & E5 & E6 --> F[Incident Correlation Engine]
F --> G[Severity / Verification]
G --> G1[Deterministic Rules]
G --> G2[Optional AI Verification]
G1 & G2 --> H[Alerts + Dashboard + Incident History]
Every stage above the Alerts box is deterministic, synchronous, and unit-tested without any network, camera hardware, or AI credentials -- the AI verification step is the one narrow exception, and even it is subordinate to the deterministic rules around it (see Design Principles).
- Security modes: HOME / AWAY / NIGHT / GUEST / VACATION, with AUTO (derived from presence) and MANUAL override handling
- Presence tracking: house-level home/away with debounced transitions (to absorb momentary signal loss) plus optional per-person tracking via a pluggable provider architecture
- Camera topology / adjacency: explicit relationship modeling between cameras (continuously-observed adjacency, a known coverage gap, an exterior-to-interior transition, or no established relationship) instead of assuming any two same-role cameras are interchangeable
- Zones: rectangular/polygonal zone definitions per camera with a zone-editor UI, used as one signal (never the only one) for correlation and severity
- Incident lifecycle: OPEN -> QUIET -> RESOLVED, with automatic transitions plus manual acknowledge / resolve / reopen
- Incident correlation: groups related events across time, camera adjacency, zone progression, and shared context into one incident instead of separate alerts
- Trusted people & known vehicles: soft context (never "trusted = safe") that can reduce nuisance severity but can never suppress hard security evidence
- AI-assisted verification: Claude Haiku classification for perimeter camera events (reducing false positives) and a narrow, infrequent second-opinion check before an indoor away+person rule fires an alert
- Dashboard and camera status: live camera tiles, house-state badge, recent-events table, per-camera cooldown/pause state, and a running Claude cost estimate
- Persistent SQLite storage: events, incidents, zones, known vehicles, and trusted people, with snapshot retention/cleanup
- REST API routes: status, events, incidents, zones, vehicles, trusted people, presence, and security-mode endpoints backing the dashboard
- Python 3 / aiohttp (async webhook + dashboard server)
- SQLite (via
aiosqlite) for event/incident/zone/vehicle storage -- this app's own primary datastore - PostgreSQL (via
asyncpg) -- read-only integration used specifically by the WiFi/MAC presence provider, reading device online/offline status from an existing network-presence database; not required if you don't use that provider (e.g. the manual per-person override provider works standalone) - Reolink cameras via
reolink_aio/ ONVIF push events - Anthropic Claude (Haiku) for optional AI-assisted classification
- HTML/CSS/JavaScript dashboard (no frontend framework/build step)
systemdunit files for persistent deploymentpytest/pytest-asynciofor the test suite
- No automatic alarm dispatch, siren activation, or door locking -- this system alerts a human, it does not take physical action
- No facial recognition or biometric identification of any kind -- trusted people are identified only via a linked known vehicle or an explicit manual correction, never from camera imagery
- No BLE presence detection (designed for as a pluggable provider, not built -- no real hardware to build it against yet)
- No guarantee against camera, network, or power outages -- camera health monitoring detects and alerts on connectivity loss, but cannot prevent it
- AI verification is optional and deliberately subordinate to deterministic rules -- it can never override hard security evidence, and the system functions (more conservatively) with no AI credentials configured at all
- All credentials (camera passwords, API keys, database credentials, alert
tokens) are supplied at runtime via environment variables (
.env, git-ignored) -- never hardcoded, never committed - This public repository contains only synthetic example data -- no real camera credentials, network addresses, household identities, or incident history
- Camera streams, snapshots, and credentials should never be committed to
version control;
.gitignoreexcludes runtime data, databases, and snapshot images by default - This project is intended for a trusted, self-hosted home network -- the webhook/dashboard server is meant to stay on your LAN and should not be port-forwarded to the public internet; secure any remote access (VPN, reverse proxy with auth, etc.) appropriately on your own network
- Setting
API_TOKENis recommended -- it gates the endpoints that change state (away-mode override, camera role, etc.) behind a shared secret
Built in stages, with later phases building on earlier ones without breaking them (the full test suite runs after every change). This public repository is a curated, sanitized snapshot of the working project. Development occurred incrementally in the private working repository, while the public release uses a clean history to avoid exposing household-specific deployment data.
Core pipeline:
- 1. Camera connection (
reolink_aio) -- connect, list devices, pull snapshot - 2. ONVIF event subscription -- webhook listener, log raw events
- 3. Presence / away-mode integration
- 4. Event router + SQLite storage
- 5. Claude Haiku classification
- 6. Alerting (ntfy push / Twilio SMS)
- 7. Web dashboard
Context-aware layer (later phases):
- Presence 2.0 -- per-person tracking with pluggable providers
- Security modes -- HOME/AWAY/NIGHT/GUEST/VACATION, kept independent of raw presence
- Camera zones -- persistent zone definitions, matching engine, editor UI
- Zone classification bridge -- AI zone context folded into the existing classification call (no extra AI cost)
- Incident correlation engine -- related events grouped into one incident
- Known vehicles -- soft context, never a presence signal on its own
- Trusted people -- identity only via a linked known vehicle or manual correction, never camera imagery
- Context-aware severity engine -- deterministic HIGH/MEDIUM/etc. scoring with explainable reasons (advisory/shadow mode -- does not change real alert routing)
- Camera topology / coverage-gap correction -- explicit adjacency modeling so same-role cameras aren't assumed interchangeable
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements-dev.txt # includes pytest
cp .env.example .env
# edit .env: fill in real camera IPs/credentials, and later Anthropic/Twilio keysCameras are configured as numbered blocks in .env (CAMERA_1_*,
CAMERA_2_*, ...) so the list is arbitrary length -- add a camera by adding
the next numbered block. Each camera has a ROLE of either:
perimeter-- AI motion events go through the Claude Haiku filterindoor_pet-- Claude is skipped entirely; only a rule-based away-mode "person detected while away" check applies (added in step 3)
See .env.example for the full list of variables and comments.
python scripts/test_camera_connection.pyThis logs into every camera in .env, prints its model/name/ONVIF status,
and saves one snapshot per camera into data/snapshots/ so you can eyeball
that the image is right.
Run this on a machine that's actually on the same LAN as the cameras (the mini PC, or your normal dev machine at home) -- it won't work from an isolated cloud/CI environment with no route to your home network.
Requires WEBHOOK_BASE_URL in .env -- the URL cameras use to reach this
machine, e.g. http://192.168.1.10:8090 (this machine's LAN IP, not
0.0.0.0 or localhost).
python scripts/run_event_listener.pyConnects to every camera, starts a local webhook server, subscribes each camera to ONVIF push events, then logs every raw motion/AI event to the console as it arrives. Trigger motion on a camera (or walk in front of one with AI detection on) and confirm a line shows up. Ctrl+C to stop -- subscriptions are cleanly torn down on shutdown.
Each camera needs ONVIF enabled in the Reolink app (Device Settings -> Network -> ONVIF) for this to work; a camera with ONVIF disabled will log a subscribe failure but won't block the others.
Reads live device status directly from the existing home network monitor
app's Postgres database (same machine, devices table). No network calls --
just a local DB connection. Configure PRESENCE_DB_* and
PRESENCE_DEVICE_MACS in .env (see .env.example); house is "away" only
when every listed MAC shows offline, "home" the moment any of them is
online. A manual override (--set-override home|away) always wins, and if
the presence database can't be reached at all, it defaults to "home" (a
missed alert beats a spammed one). Include every MAC a phone has ever
shown, not just one -- MAC randomization means a phone can present a
different address per network/reconnect.
The presence database resolves a real change within ~20-30s, fast enough
that a momentary WiFi drop (phone sleeps, walks to the edge of range)
could otherwise cause a brief false "away" reading. So going to "away"
is debounced (AWAY_DEBOUNCE_SECONDS, default 90) -- the raw signal has
to hold "away" continuously for that long before it's actually reported;
coming back to "home" is always immediate, no debounce. While a
transition is pending, the dashboard's house badge shows a "confirming
away in Ns" countdown instead of silently sitting on "home."
python scripts/test_away_mode.py
python scripts/test_away_mode.py --set-override away
python scripts/test_away_mode.py --clear-overrideThe house-level home/away logic above is unchanged and still drives every
alerting decision. Optionally, set PERSON_1_NAME/PERSON_1_MACS,
PERSON_2_NAME/PERSON_2_MACS, ... in .env (see .env.example) to also
track individual household members -- the dashboard shows a "Who's Home"
row (e.g. ALEX HOME, JORDAN AWAY) built from app/presence/engine.py.
If you don't set any PERSON_N_NAME, this falls back to a single
"Household" person built from the existing PRESENCE_DEVICE_MACS, so
skipping it changes nothing.
This is a pluggable provider architecture (app/presence/providers.py):
today it has a WiFi/MAC provider (the same presence database as above,
resolved per person) and a manual per-person override
(POST /api/presence/{person}/override). BLE beacon and known-vehicle
providers are designed for but not built -- they need real hardware/data
this deployment doesn't have yet, so nothing fake is exposed until they're
real.
This is the real entry point -- it wires cameras, ONVIF events, the
away-mode checker, the event router, Claude Haiku classification,
alerting, SQLite storage, and the web dashboard together, all on one
aiohttp server (webhook + dashboard share WEBHOOK_PORT). Every event
gets logged; perimeter AI-object events (person/vehicle/animal) get a
snapshot pulled and sent to Claude Haiku for a real/false-positive verdict
- short reason; indoor_pet cameras never call Claude and only alert on the
rule-based "person detected while away" check. Requires
WEBHOOK_BASE_URL, presence config, andANTHROPIC_API_KEYin.env(see.env.example).
A continued presence re-fires ONVIF notifications every second or two --
not a series of distinct events -- so a per-camera cooldown
(ALERT_COOLDOWN_SECONDS, default 300) suppresses repeat Claude calls and
alerts from the same camera within that window of the last one. One
continuous incident gets one alert (and one Claude call) instead of a
flood of both; it resets after the window, so a presence still ongoing
later still triggers a fresh alert.
indoor_pet cameras (never billed -- no Claude call involved) use their
own, much shorter 15-second cooldown just for snapshots, completely
separate from the alert cooldown above: a person walking through the
house and out the door still only gets one alert, but gets a fresh
snapshot every ~15s along the way instead of just one photo of the first
moment they were seen.
When perimeter cameras alert, by presence + time of day: away, and
night (11pm-7am local, regardless of home/away), every perimeter camera
alerts normally as described above. The one exception is home during the
day (7am-11pm): every perimeter camera goes watch-only -- it still
logs an event and takes a snapshot on its own short cooldown (same idea
as indoor_pet's, so a visual record still exists), but skips Claude
entirely and never alerts, shown as "watching" in the events table's
Verdict column. The reasoning: routine daytime activity while you're home
(kids, pets, deliveries, coming and going) doesn't need Claude's opinion.
If some specific camera should be the exception and keep alerting even
during home-daytime hours, set CAMERA_N_ALWAYS_ALERT_WHEN_HOME=true in
.env for it -- but that's an opt-in exception, not the default for any
camera. indoor_pet cameras are
untouched by any of this -- their away+person rule already only fires
when away, which daytime-at-home never is.
Indoor away+person alerts get a second opinion before they text you.
The on-device "person" tag can misfire (a dog, a shadow, a reflection),
and since this is the one alert that fires purely off a rule with no
human judgment involved, a single Claude call double-checks the snapshot
right before the alert would go out -- confirming it's actually a person,
not just relabeling the same tag. This only ever runs in that one narrow
case (away, person tag, outside cooldown -- i.e. right when an alert is
about to fire), so it stays a small, infrequent cost add-on rather than a
new per-event Claude call on every indoor_pet detection. If the camera
can't be reached to pull a snapshot to verify, it fails open and alerts
anyway rather than silently dropping a possibly-real event. A rejected
false alarm shows up in the events table the same as a perimeter
false_positive: claude_verdict=false_positive, no alert sent.
Doorbell button presses are their own signal. A press (visitor in
the events table's raw tags) is a deliberate action by someone at the
door, not ambient motion -- it always gets a snapshot and a shot at a
real Claude classification, even with no person/vehicle/animal AI tag
alongside it, and bypasses the home-daytime watch-only gate above (same
as away/night). It still respects a manual pause and the monthly cost
cap -- those are explicit choices you made, not automatic policy.
Dashboard: open http://<this-machine's-LAN-IP>:<WEBHOOK_PORT>/ from
any browser on the home network. Shows:
-
A house-state badge (home/away + why) with Force Home / Force Away / Auto buttons -- the manual override from step 3, now with a UI. Hover the badge for a per-device breakdown (each tracked MAC's online/offline status from the presence DB) -- useful for spotting exactly which device is keeping the house "home" when you expect "away" (e.g. a VPN session making a phone reappear on the home network). This app only ever reads that status live from the existing network-monitor app's database -- if a device is slow to flip offline, or a VPN connection makes it look present, that's that other app's detection behavior, not a bug here.
-
A camera tile grid: live snapshot per camera (click to refresh, or wait for the 30s auto-refresh), connection status, and a role toggle (perimeter <-> indoor_pet) -- takes effect immediately, no restart, but doesn't persist past a restart (edit
.envfor the permanent default). A tile also shows an amber "cooldown, Ns left" line whenever that camera is currently suppressing repeat Claude calls/alerts (see cooldown above) -- so a quiet camera right after an alert reads as "working as intended" instead of "did it stop seeing anything?" A perimeter camera withALWAYS_ALERT_WHEN_HOME=trueshows an "always alerts" badge so it's clear at a glance which camera(s) stay live during home-daytime hours. -
A recent-events table (auto-refreshes every 8s): a thumbnail of the actual snapshot Claude looked at (click for full size), timestamp, camera, tags, Claude verdict + reason, and whether it alerted -- the raw vs. filtered view per camera. A cooldown-suppressed detection shows "cooldown" (in italic muted text, hover for why) in the Verdict column instead of a bare "-", so it's visibly distinct from an event that just didn't have any relevant tags. Only the latest 50 load automatically; click Load older events at the bottom to page further back (this pauses auto-refresh so it doesn't yank you back to the live tail mid-scroll -- click Back to live to resume).
-
A "Claude: $X.XX this month" badge in the header (hover for today's count/cost too). Computed from the actual input/output token counts each classification call reports -- not a guess -- but it's still an estimate; the Anthropic console is always the source of truth for actual billing. Only perimeter events that reach Claude count toward this -- indoor_pet cameras never call Claude at all, and cooldown-suppressed/motion-only events aren't billed and aren't counted either.
This badge only counts calls made after token tracking was added -- older classify() calls have no captured token count, so right after upgrading it can read "$0.00" even though the Anthropic console shows real spend for the month. It'll start climbing again as soon as the next perimeter detection fires. To also account for older calls this month, run once:
python scripts/backfill_claude_token_estimates.py --dry-run # see how many events would be affected python scripts/backfill_claude_token_estimates.py # apply a flat per-call estimate to them
This fills in a flat per-call token estimate for historical calls, not their real counts (which were never captured) -- it's meant to make the month-to-date figure less misleading, not to match the console exactly. Safe to re-run; it only touches rows still missing a token count.
-
Pause 30m / 1h / 3h buttons, plus a custom hours field (up to 8) for anything longer, in the header for times you're deliberately in a perimeter camera's view and don't want to pay for it -- mowing the lawn, sitting on the porch, working in the driveway. One pause covers the whole job; no need to keep re-pausing it every hour. While paused, perimeter cameras log events as usual but skip Claude entirely (shown as "paused" in the events table's Verdict column, same treatment as a cooldown-suppressed event) -- no snapshot pull, no API call, no alert. Indoor_pet cameras are untouched: pausing doesn't weaken the away+person rule or its verification check (above) either. A paused state shows a countdown and a Resume now button, and auto-expires on its own (capped at 8 hours) so it can't be forgotten and left off indefinitely.
-
Optional hard monthly spending cap (
MONTHLY_COST_CAP_USDin.env, unset by default -- no cap). Once month-to-date estimated cost (the same number on the Claude badge) reaches this amount, perimeter cameras auto-pause -- shown as "cost cap" in the events table's Verdict column -- until the calendar month rolls over or you manually resume. You get one push alert when it trips, not silence. indoor_pet cameras are unaffected either way, same reasoning as the manual pause above.
Meant for your home LAN only -- never port-forward WEBHOOK_PORT to the
internet. Setting API_TOKEN in .env (recommended, blank by default) adds
a shared-secret check to the ONVIF webhook and the dashboard's away-mode
override + camera role endpoints -- without it, anything on your LAN could
POST fake camera events (running up Claude costs) or silently force the
house to "home" to suppress the away+person alert. The dashboard page
itself still loads with no login either way; the token only gates the two
requests that change state. Generate one with:
python3 -c "import secrets; print(secrets.token_urlsafe(24))"Camera health monitoring: a camera going silent (crashed, rebooted, Wi-Fi dropped, ONVIF got disabled in the Reolink app) used to produce no signal at all -- events just stopped showing up for that camera and nobody noticed until they checked. Now:
- At startup, any camera that fails to connect or fails to subscribe to ONVIF events sends an immediate alert ("Camera(s) offline at startup" / "Camera(s) not receiving events").
- Every 5 minutes, each camera's ONVIF subscription is renewed (or re-subscribed if renewal fails). A camera that can't be renewed or re-subscribed sends a "Camera offline" alert -- once per outage, not every 5 minutes, and again if it recovers and then drops a second time.
/api/status(and the dashboard) now shows each camera'ssubscribedstate andlast_event_at(the last time it actually received an ONVIF notification), so you can see at a glance whether a camera that looks "connected" is actually still delivering events.
The SQLite database itself stays small forever -- each event row is a few hundred bytes, so even thousands of events a month only adds up to a few MB a year. Event rows are never deleted, so the full history is always there to page back through in the dashboard.
The real disk usage is the snapshot JPEGs (perimeter events routed to
Claude, and indoor_pet person detections) -- these are real camera images,
roughly 0.5-1.4MB each. scripts/cleanup_snapshots.py deletes snapshot
files older than SNAPSHOT_RETENTION_DAYS (default 30) and clears their
reference in the DB -- the event row itself is kept forever, just without
an image attached once it ages out.
python scripts/cleanup_snapshots.py # uses SNAPSHOT_RETENTION_DAYS from .env
python scripts/cleanup_snapshots.py --days 14 # override for this run
python scripts/cleanup_snapshots.py --dry-run # show what would be deleted, delete nothingRun it daily via systemd timer so you don't have to remember:
sudo cp deploy/context-aware-home-security-cleanup.service deploy/context-aware-home-security-cleanup.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now context-aware-home-security-cleanup.timer
systemctl list-timers context-aware-home-security-cleanup.timer # confirm it's scheduled(Same caveat as the main service: edit WorkingDirectory/ExecStart in
deploy/context-aware-home-security-cleanup.service first if the repo isn't at
/opt/context-aware-home-security.)
Backups: nothing is backed up automatically. data/events.db is a
single file -- sqlite3 data/events.db ".backup /path/to/backup.db" (safe
even while the app is running) or a plain cp while the service is
stopped. Snapshots are just files under data/snapshots/ if you want to
back those up too, though with retention cleanup running they're
short-lived by design.
Alerting picks the first of these that's configured, in order:
- ntfy (
NTFY_TOPIC) -- free push notifications, no account. Install the ntfy app and subscribe to your topic. - Twilio SMS (
TWILIO_ACCOUNT_SID/AUTH_TOKEN/FROM_NUMBER+ALERT_TO_NUMBER) -- real texts, but Twilio is paid after the trial. - Stub -- if neither is configured, alerts just get logged to the console instead of sent anywhere.
All three implement the same interface, so switching later needs no code
changes -- just edit .env.
Every alert text includes a direct link to the snapshot that triggered it
(WEBHOOK_BASE_URL + /snapshots/<file>), so you can see the photo without
having to separately open the dashboard.
If a send actually fails (Twilio rejects it, ntfy.sh is unreachable, etc.), that's no longer just a log line -- the event's row gets marked and shows FAILED (in red) in the dashboard's events table Alert column instead of silently doing nothing, so a delivery outage doesn't go unnoticed.
python scripts/run_pipeline.pyTrigger some events, then Ctrl+C -- it prints a per-camera summary, and you can inspect the full log anytime with:
python scripts/view_events.py # last 20 events
python scripts/view_events.py 100 # last 100scripts/run_event_listener.py from step 2 still works too, if you just
want to watch raw ONVIF events without the routing/storage layer.
Running python scripts/run_pipeline.py directly only lasts as long as
that terminal/SSH session stays open -- closing it sends the process a
hangup signal and kills it. For an always-on setup, install it as a
systemd service instead:
sudo cp deploy/context-aware-home-security.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now context-aware-home-securityThe unit file assumes the repo lives at /opt/context-aware-home-security
with a venv at .venv in that directory -- edit WorkingDirectory and
ExecStart in deploy/context-aware-home-security.service first if either is
different on your machine, then sudo systemctl daemon-reload again.
systemctl status context-aware-home-security # is it running?
journalctl -u context-aware-home-security -f # live logs (same output as running it directly)
sudo systemctl restart context-aware-home-security # e.g. after a git pull, or a .env change
sudo systemctl stop context-aware-home-securityRestart=on-failure means it comes back on its own if it crashes, and
WantedBy=multi-user.target means it starts on boot -- no manual restart
needed after a mini PC reboot.
pytest753 tests, no real network, camera hardware, database, or API credentials needed -- safe to run anywhere. Coverage spans:
- Configuration: env parsing, including phone-number normalization
- ONVIF / event routing: webhook routing and dispatch against fake camera clients, the router's perimeter/indoor_pet decisions against fake cameras/classifier/presence
- Presence engine / providers: the pluggable per-person provider architecture, away-mode logic against a fake presence reader
- Security modes: HOME/AWAY/NIGHT/GUEST/VACATION evaluation and AUTO/MANUAL handling
- Zones: zone storage and AI zone-match sanitization
- Incident correlation, lifecycle, and storage: join/create decisions, OPEN/QUIET/RESOLVED transitions, persistence, and summary/title generation
- Camera topology / adjacency: relationship kinds (observed, coverage-gap, entry-transition, none) and same-role-fallback behavior
- Severity engine: deterministic scoring, the hard-evidence floor, and topology-aware progression quality
- Known vehicles / trusted people: matching, storage, and soft-context behavior
- AI classification / verification: the Haiku classifier's request-building and error handling, and the indoor away+person verification check, all against a mocked Anthropic client
- Alerting: the SMS alert composer, and the ntfy/Twilio senders against a real local test server / mocked Twilio client
- Dashboard / API: status, counts, events, incidents, zones, vehicles, trusted people, presence, and security-mode routes against fake cameras and a real SQLite/away-mode backend
- Persistence / storage: the SQLite storage layer underlying events, incidents, zones, vehicles, and trusted people