Compare commits
23 Commits
965dc3b13d
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d17186f466 | ||
|
|
e657f2bfbc | ||
|
|
1633e8b34e | ||
|
|
f02dbaad8c | ||
|
|
e38a0dc174 | ||
|
|
68f9454a7c | ||
|
|
5e0d6c8320 | ||
|
|
c4d119310a | ||
|
|
07f5f341e6 | ||
|
|
58622722c7 | ||
|
|
843daf9c0b | ||
|
|
d3db384c35 | ||
|
|
62696e919c | ||
|
|
87d2df1446 | ||
|
|
c1779dfdb8 | ||
|
|
226a473d4d | ||
|
|
67b8dd672c | ||
|
|
c8d8421112 | ||
|
|
484235f74c | ||
|
|
d38b0c4e25 | ||
|
|
0e4e2c1ca7 | ||
|
|
1fd80ad31c | ||
|
|
4dc2db00e0 |
131
README.md
Normal file
131
README.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# Vigilar
|
||||||
|
|
||||||
|
> DIY offline-first home security. Your cameras, your house, your data.
|
||||||
|
|
||||||
|
Vigilar runs on a small mini PC in your home, records from your IP cameras,
|
||||||
|
detects motion, and pushes notifications to your phone — with no cloud, no
|
||||||
|
account, and no external service in the critical path.
|
||||||
|
|
||||||
|
<!-- Screenshot placeholder: replace with a real UI grid shot when available. -->
|
||||||
|

|
||||||
|
|
||||||
|
## Why Vigilar
|
||||||
|
|
||||||
|
- **Offline-first.** No cloud dependency. No external calls in the hot path.
|
||||||
|
- **Multi-camera live view.** HLS grid for bandwidth efficiency, MJPEG single
|
||||||
|
view for low-latency focus.
|
||||||
|
- **On-device motion detection.** OpenCV MOG2 with a 5-second pre-motion ring
|
||||||
|
buffer, so the run-up to a trigger is captured.
|
||||||
|
- **Adaptive FPS.** Cameras idle at 2 FPS and jump to 30 FPS on motion.
|
||||||
|
- **Event timeline and highlight reels.** Plus timelapses and visitor, pet,
|
||||||
|
and wildlife tracking as separate views.
|
||||||
|
- **Phone push notifications.** PWA + VAPID web push. No Firebase, no
|
||||||
|
Google Cloud Messaging.
|
||||||
|
- **Encrypted recordings at rest.** AES-256-CTR `.vge` files, key stays on
|
||||||
|
your box. (Confidential, not tamper-evident — see the
|
||||||
|
[Operator Guide](docs/operator-guide.md) for the details of the threat
|
||||||
|
model.)
|
||||||
|
- **UPS aware.** NUT integration, graceful shutdown on critical battery.
|
||||||
|
- **Runs on a cheap mini PC.** 4 GB RAM, 128 GB SSD, any modern x86_64 box.
|
||||||
|
|
||||||
|
## Quick paths
|
||||||
|
|
||||||
|
| I want to… | Start here |
|
||||||
|
|----------------------------------------------|--------------------------------------------------------------|
|
||||||
|
| Set it up at home on a mini PC | [Home User Guide](docs/home-user-guide.md) |
|
||||||
|
| Run it as a self-hoster / sysadmin | [Operator Guide](docs/operator-guide.md) |
|
||||||
|
| Understand how it works internally | [Architecture Overview](docs/architecture/overview.md) |
|
||||||
|
| Pick cameras that work well with it | [Camera Hardware Guide](docs/camera-hardware-guide.md) |
|
||||||
|
|
||||||
|
## 60-second overview
|
||||||
|
|
||||||
|
```
|
||||||
|
[IP cameras] ──RTSP──▶ [mini PC running Vigilar] ──LAN──▶ [phone / browser]
|
||||||
|
│
|
||||||
|
└── optional nightly ──▶ [NAS]
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Language:** Python 3.11+
|
||||||
|
- **Web:** Flask + Bootstrap 5 dark + `hls.js`
|
||||||
|
- **Storage:** SQLite (WAL) via SQLAlchemy Core
|
||||||
|
- **Bus:** Local Mosquitto on `127.0.0.1:1883`
|
||||||
|
- **Video:** OpenCV (capture + motion), FFmpeg subprocess (recording, HLS)
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**Alpha.** Works on the author's hardware. Expect rough edges and breaking
|
||||||
|
changes. Not recommended for anyone who cannot read a stack trace.
|
||||||
|
|
||||||
|
Known limitations worth calling out up front:
|
||||||
|
|
||||||
|
- The in-browser event timeline does not yet update live. Push
|
||||||
|
notifications to your phone work in real time; the timeline itself
|
||||||
|
needs a page refresh.
|
||||||
|
- Recording encryption is AES-256-CTR: confidential at rest, but not
|
||||||
|
tamper-evident.
|
||||||
|
- There is no automated database migration framework yet. Schema changes
|
||||||
|
are forward-only.
|
||||||
|
|
||||||
|
See the [Operator Guide](docs/operator-guide.md) for the full list.
|
||||||
|
|
||||||
|
## Installation (TL;DR)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo URL> vigilar
|
||||||
|
cd vigilar
|
||||||
|
sudo ./scripts/install.sh
|
||||||
|
sudo systemctl enable --now vigilar
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://<mini-pc-ip>:49735` on your LAN.
|
||||||
|
|
||||||
|
For the full walkthrough (OS install, camera setup, PIN, push
|
||||||
|
notifications, NAS backup), see the
|
||||||
|
[Home User Guide](docs/home-user-guide.md).
|
||||||
|
|
||||||
|
For configuration, CLI, secrets, backups, and upgrades, see the
|
||||||
|
[Operator Guide](docs/operator-guide.md).
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- **User guides**
|
||||||
|
- [Home User Guide](docs/home-user-guide.md) — mini PC to working cameras,
|
||||||
|
step by step.
|
||||||
|
- [Operator Guide](docs/operator-guide.md) — configuration, CLI, backups,
|
||||||
|
security, upgrades.
|
||||||
|
- [Camera Hardware Guide](docs/camera-hardware-guide.md) — picking and
|
||||||
|
wiring up cameras.
|
||||||
|
- **Architecture**
|
||||||
|
- [Overview](docs/architecture/overview.md) — process model, MQTT bus,
|
||||||
|
data flow, storage layout.
|
||||||
|
- [Conventions](docs/architecture/conventions.md) — coding rules for
|
||||||
|
contributors.
|
||||||
|
- [Subsystems](docs/architecture/subsystems/) — one short reference per
|
||||||
|
subsystem: [camera](docs/architecture/subsystems/camera.md),
|
||||||
|
[detection](docs/architecture/subsystems/detection.md),
|
||||||
|
[events](docs/architecture/subsystems/events.md),
|
||||||
|
[alerts](docs/architecture/subsystems/alerts.md),
|
||||||
|
[sensors](docs/architecture/subsystems/sensors.md),
|
||||||
|
[ups](docs/architecture/subsystems/ups.md),
|
||||||
|
[storage](docs/architecture/subsystems/storage.md),
|
||||||
|
[highlights](docs/architecture/subsystems/highlights.md),
|
||||||
|
[presence](docs/architecture/subsystems/presence.md),
|
||||||
|
[pets](docs/architecture/subsystems/pets.md),
|
||||||
|
[health](docs/architecture/subsystems/health.md),
|
||||||
|
[web](docs/architecture/subsystems/web.md).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Vigilar is distributed under the **GNU General Public License v3.0**. See
|
||||||
|
`LICENSE` for the full text. (If the `LICENSE` file is not yet in the tree,
|
||||||
|
it will be added in a follow-up.)
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Issues and pull requests are welcome. Before sending a PR:
|
||||||
|
|
||||||
|
- Read [`CLAUDE.md`](CLAUDE.md) for the project's code conventions, or the
|
||||||
|
human-friendly distillation at
|
||||||
|
[`docs/architecture/conventions.md`](docs/architecture/conventions.md).
|
||||||
|
- Run `ruff check vigilar/` and `pytest` — both must pass.
|
||||||
|
- Keep commits small and focused.
|
||||||
67
docs/architecture/conventions.md
Normal file
67
docs/architecture/conventions.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Coding Conventions
|
||||||
|
|
||||||
|
These are the rules a contributor needs in their head before touching Vigilar.
|
||||||
|
They are distilled from `CLAUDE.md` and from the patterns already in the codebase.
|
||||||
|
|
||||||
|
## Language and style
|
||||||
|
|
||||||
|
- Python 3.11+.
|
||||||
|
- Ruff for linting. Line length 100 (see `pyproject.toml`).
|
||||||
|
- Type hints on all public functions. Internal helpers may omit them when the
|
||||||
|
types are obvious from context.
|
||||||
|
- No docstrings unless the logic is non-obvious. Short, well-named functions
|
||||||
|
should speak for themselves.
|
||||||
|
|
||||||
|
## String constants
|
||||||
|
|
||||||
|
All string constants referenced in more than one place live in
|
||||||
|
`vigilar/constants.py`. The file uses `StrEnum` for enumerated values —
|
||||||
|
`ArmState`, `Severity`, `EventType`, `SensorType`, `AlertChannel`,
|
||||||
|
`RecordingTrigger`, and friends — and a `Topics` class for MQTT topic builders.
|
||||||
|
|
||||||
|
If you are about to write `"MOTION_START"` or `"vigilar/camera/..."` in a second
|
||||||
|
place, stop and add it to the appropriate enum or the `Topics` class instead.
|
||||||
|
Defaults like `DEFAULT_IDLE_FPS` also live in this module.
|
||||||
|
|
||||||
|
## Database access
|
||||||
|
|
||||||
|
- SQLite in WAL mode, opened through `vigilar/storage/db.py`.
|
||||||
|
- SQLAlchemy Core expressions only — no mapped classes, no ORM session.
|
||||||
|
- Schema lives in `vigilar/storage/schema.py`. Named query helpers live in
|
||||||
|
`vigilar/storage/queries.py`.
|
||||||
|
- New tables go in `schema.py`. New query helpers go in `queries.py`. Do not
|
||||||
|
scatter ad-hoc SQL across subsystem code.
|
||||||
|
|
||||||
|
## Processes and the MQTT bus
|
||||||
|
|
||||||
|
- Each subsystem runs in its own `multiprocessing.Process`, spawned by the
|
||||||
|
supervisor in `vigilar/main.py` via the `SubsystemProcess` wrapper. Cameras
|
||||||
|
are the exception: `CameraManager` owns one child process per camera
|
||||||
|
directly.
|
||||||
|
- Subsystems communicate only through the local MQTT broker at
|
||||||
|
`127.0.0.1:1883`. No direct imports or function calls across subsystem
|
||||||
|
boundaries.
|
||||||
|
- Topic naming: `vigilar/<domain>/<id>/<event>`. Every topic comes from the
|
||||||
|
`Topics` class in `vigilar/constants.py` — do not construct topic strings
|
||||||
|
ad hoc.
|
||||||
|
- If you are tempted to reach into another subsystem directly, publish a
|
||||||
|
topic instead.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
- `config/vigilar.toml` is the only config file the app reads at runtime.
|
||||||
|
- It is validated by Pydantic v2 models in `vigilar/config.py`. Add new fields
|
||||||
|
to the Pydantic models, not just to the TOML.
|
||||||
|
- Secrets are file paths, not inline values. Never put a key, password, or
|
||||||
|
token directly in the TOML.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `pytest` from the repo root.
|
||||||
|
- Tests live under `tests/`.
|
||||||
|
|
||||||
|
## Committing
|
||||||
|
|
||||||
|
- `ruff check vigilar/` must pass.
|
||||||
|
- `pytest` must pass.
|
||||||
|
- One logical change per commit. Commit often.
|
||||||
170
docs/architecture/overview.md
Normal file
170
docs/architecture/overview.md
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
# Vigilar Architecture Overview
|
||||||
|
|
||||||
|
This document explains how Vigilar is put together for someone reading the
|
||||||
|
codebase for the first time. It is short on purpose — per-subsystem details
|
||||||
|
live under `subsystems/`.
|
||||||
|
|
||||||
|
## Design principles
|
||||||
|
|
||||||
|
- **Offline-first.** No external calls in the critical path. Cloud integrations,
|
||||||
|
if any, are opt-in and off the hot path.
|
||||||
|
- **Subsystem isolation.** Each subsystem runs in its own process. A crash in
|
||||||
|
one subsystem cannot take down another — the supervisor in `vigilar/main.py`
|
||||||
|
restarts crashed children with exponential backoff.
|
||||||
|
- **Loose coupling via MQTT.** Subsystems do not call each other directly.
|
||||||
|
They publish and subscribe to a local Mosquitto broker on `127.0.0.1:1883`.
|
||||||
|
- **SQLite (WAL) is the single durable store.** Access goes through
|
||||||
|
SQLAlchemy Core expressions (`vigilar/storage/schema.py`), not ORM mapped
|
||||||
|
classes. WAL mode and `synchronous=NORMAL` are set on every connection in
|
||||||
|
`vigilar/storage/db.py`.
|
||||||
|
- **Adaptive cost.** Cameras idle at 2 FPS and jump to 30 FPS on motion, with
|
||||||
|
a 5-second ring buffer so the moment leading up to the trigger is kept.
|
||||||
|
- **Configuration is typed.** `config/vigilar.toml` is loaded and validated
|
||||||
|
by Pydantic v2. Secrets are never inline — they are file paths under
|
||||||
|
`/etc/vigilar/secrets/`.
|
||||||
|
|
||||||
|
## Process topology
|
||||||
|
|
||||||
|
`vigilar start` loads config and calls `run_supervisor()` in `vigilar/main.py`.
|
||||||
|
The supervisor spawns every subsystem as a `multiprocessing.Process` (via the
|
||||||
|
`SubsystemProcess` wrapper) and monitors them in a 2-second restart loop.
|
||||||
|
Cameras are managed separately by `CameraManager`, which owns one child
|
||||||
|
process per configured camera.
|
||||||
|
|
||||||
|
```
|
||||||
|
systemd (vigilar.service)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
vigilar start (supervisor, main.py)
|
||||||
|
|
|
||||||
|
+--------+-----------+-----------+-----------+----------+
|
||||||
|
| | | | | |
|
||||||
|
v v v v v v
|
||||||
|
web event- sensor- ups- presence- health-
|
||||||
|
(Flask) processor bridge monitor monitor monitor
|
||||||
|
|
||||||
|
CameraManager --> camera worker (front_door)
|
||||||
|
--> camera worker (backyard)
|
||||||
|
--> camera worker (side_yard)
|
||||||
|
--> camera worker (garage)
|
||||||
|
|
||||||
|
^ ^
|
||||||
|
| MQTT |
|
||||||
|
v v
|
||||||
|
mosquitto (127.0.0.1:1883, loopback only)
|
||||||
|
```
|
||||||
|
|
||||||
|
Every arrow touching the broker is a local TCP connection to loopback. The
|
||||||
|
`web` process is a Flask server (`vigilar/web/app.py:create_app`) with one
|
||||||
|
Blueprint per feature area under `vigilar/web/blueprints/`.
|
||||||
|
|
||||||
|
## The MQTT bus
|
||||||
|
|
||||||
|
- Broker: Mosquitto, bound to loopback only (see `systemd/vigilar-mosquitto.conf`:
|
||||||
|
`listener 1883 127.0.0.1`, `allow_anonymous true`, `persistence false`).
|
||||||
|
- Topic convention: every topic starts with `vigilar/` and is defined in
|
||||||
|
`vigilar/constants.py` via the `Topics` class (either static strings or
|
||||||
|
builder functions taking an ID). Real examples:
|
||||||
|
- `vigilar/camera/{camera_id}/motion/start`
|
||||||
|
- `vigilar/camera/{camera_id}/motion/end`
|
||||||
|
- `vigilar/camera/{camera_id}/heartbeat`
|
||||||
|
- `vigilar/sensor/{sensor_id}/{event_type}`
|
||||||
|
- `vigilar/ups/status`, `vigilar/ups/power_loss`, `vigilar/ups/low_battery`
|
||||||
|
- `vigilar/system/arm_state`, `vigilar/system/alert`
|
||||||
|
- Wildcard subscriptions: `vigilar/#`, `vigilar/camera/#`, `vigilar/sensor/#`
|
||||||
|
- Payloads are JSON dicts. Publishers use `bus.publish_event(topic, **kwargs)`
|
||||||
|
from `vigilar/bus.py`; new fields are callers' responsibility.
|
||||||
|
- Why MQTT rather than an in-process queue: crash isolation, introspection with
|
||||||
|
`mosquitto_sub`, and the option to move subsystems to separate hosts later
|
||||||
|
without changing the wire format.
|
||||||
|
|
||||||
|
## Data flow: from motion to phone notification
|
||||||
|
|
||||||
|
1. `vigilar/camera/worker.py:run_camera_worker` — opens the RTSP stream via
|
||||||
|
`cv2.VideoCapture(..., cv2.CAP_FFMPEG)` with reconnect/backoff, pushes every
|
||||||
|
frame into a ring buffer, and drives the capture loop.
|
||||||
|
2. `vigilar/camera/motion.py:MotionDetector.detect` — MOG2 background
|
||||||
|
subtraction on a downscaled frame; when a new motion edge is found,
|
||||||
|
`worker.py` publishes `vigilar/camera/{camera_id}/motion/start` with
|
||||||
|
confidence and zone count.
|
||||||
|
3. `vigilar/camera/recorder.py:AdaptiveRecorder.start_motion_recording` —
|
||||||
|
stops any idle recording, launches a fresh FFmpeg subprocess at
|
||||||
|
`motion_fps` (default 30), and writes the flushed ring-buffer frames
|
||||||
|
(default 5s of pre-roll) before the live frames. On stop, if
|
||||||
|
`VIGILAR_ENCRYPTION_KEY` is set, the MP4 is re-encrypted in place to
|
||||||
|
`.vge` via `vigilar/storage/encryption.py:encrypt_file` (AES-256-CTR).
|
||||||
|
4. `vigilar/events/processor.py:EventProcessor._handle_event` — subscribes
|
||||||
|
to `vigilar/#`, classifies the topic into an `EventType`/`Severity`, and
|
||||||
|
writes a row to the `events` table via
|
||||||
|
`vigilar/storage/queries.py:insert_event`. Wildlife and pet sightings also
|
||||||
|
get rows in `wildlife_sightings` / `pet_sightings`.
|
||||||
|
5. `vigilar/events/rules.py:RuleEngine.evaluate` — matches the event against
|
||||||
|
configured `[[rules]]` from `vigilar.toml` (AND/OR on arm state, sensor
|
||||||
|
event, camera motion, time window), honours per-rule cooldowns, and returns
|
||||||
|
a list of actions.
|
||||||
|
6. `vigilar/alerts/sender.py:send_alert` — for `alert_all` / `push_and_record`
|
||||||
|
actions, builds a notification from the `_CONTENT_MAP` table, loads the
|
||||||
|
VAPID key from `[alerts.web_push].vapid_private_key_file`, and calls
|
||||||
|
`pywebpush.webpush` for every row in `push_subscriptions`. Successes and
|
||||||
|
failures are recorded in `alert_log`; endpoints returning `410 Gone` are
|
||||||
|
pruned.
|
||||||
|
7. Web UI — the browser holds an open SSE connection to a handler in
|
||||||
|
`vigilar/web/blueprints/events.py` (`mimetype="text/event-stream"`), which
|
||||||
|
tails new event rows and pushes them to the timeline live.
|
||||||
|
|
||||||
|
## Storage layout
|
||||||
|
|
||||||
|
- `vigilar.db` under `[system] data_dir` (default `/var/vigilar/data`), SQLite
|
||||||
|
in WAL mode. Tables defined in `vigilar/storage/schema.py`:
|
||||||
|
`cameras`, `sensors`, `sensor_states`, `events`, `recordings`,
|
||||||
|
`system_events`, `arm_state_log`, `alert_log`, `push_subscriptions`,
|
||||||
|
`pets`, `pet_sightings`, `wildlife_sightings`, `package_events`,
|
||||||
|
`pet_training_images`, `pet_rules`, `face_profiles`, `face_embeddings`,
|
||||||
|
`visits`, `timelapse_schedules`.
|
||||||
|
- Recordings: `.vge` files under `[system] recordings_dir` (default
|
||||||
|
`/var/vigilar/recordings`), AES-256-CTR with a random 16-byte IV prefixed
|
||||||
|
to each file. Key at `/etc/vigilar/secrets/storage.key`. **Losing the key
|
||||||
|
means losing the recordings** — there is no recovery path.
|
||||||
|
- HLS: rolling segments under `[system] hls_dir` (default `/var/vigilar/hls`),
|
||||||
|
written by the per-camera `HLSStreamer` in `vigilar/camera/hls.py`.
|
||||||
|
- Backups: DB + `/etc/vigilar` tarball via `scripts/backup.sh`.
|
||||||
|
|
||||||
|
## Configuration and secrets
|
||||||
|
|
||||||
|
- `config/vigilar.toml` is the only configuration file the app reads
|
||||||
|
(systemd points `VIGILAR_CONFIG` at `/etc/vigilar/vigilar.toml` in
|
||||||
|
production).
|
||||||
|
- Validated by Pydantic v2 at startup (`vigilar/config.py`).
|
||||||
|
- Secrets never live in the TOML; they are file paths under
|
||||||
|
`/etc/vigilar/secrets/` (`storage.key`, `vapid_private.pem`).
|
||||||
|
- The arm PIN and admin password are hashed; comparisons are constant-time
|
||||||
|
(see `vigilar/alerts/pin.py`). The PIN hash is written into the TOML via
|
||||||
|
`vigilar config set-pin`, never typed by hand.
|
||||||
|
|
||||||
|
## The web tier
|
||||||
|
|
||||||
|
- Flask with Blueprints, one per feature area under
|
||||||
|
`vigilar/web/blueprints/`: `cameras`, `events`, `kiosk`, `pets`,
|
||||||
|
`recordings`, `sensors`, `system`, `visitors`, `wildlife`. All registered
|
||||||
|
in `vigilar/web/app.py:create_app`.
|
||||||
|
- Jinja2 templates under `vigilar/web/templates/`, Bootstrap 5 dark theme,
|
||||||
|
static assets under `vigilar/web/static/`.
|
||||||
|
- Live view: `hls.js` grid for bandwidth efficiency, MJPEG single view for
|
||||||
|
low latency.
|
||||||
|
- Live timeline updates via Server-Sent Events from
|
||||||
|
`vigilar/web/blueprints/events.py`.
|
||||||
|
- PWA with VAPID web push — no Firebase, no Google Cloud Messaging. Service
|
||||||
|
worker at `vigilar/web/static/sw.js`.
|
||||||
|
|
||||||
|
## What is NOT in the critical path
|
||||||
|
|
||||||
|
- Remote access (`[remote]` section) — optional, bandwidth-shaped HLS over
|
||||||
|
a WireGuard tunnel.
|
||||||
|
- Email alerts (`[alerts.email]`) and webhook alerts (`[alerts.webhook]`)
|
||||||
|
— optional, off by default.
|
||||||
|
- Any cloud service — never.
|
||||||
|
|
||||||
|
## Where to go next
|
||||||
|
|
||||||
|
- [Conventions](conventions.md) — coding rules distilled for contributors.
|
||||||
|
- [Subsystems](subsystems/) — per-subsystem references.
|
||||||
35
docs/architecture/subsystems/alerts.md
Normal file
35
docs/architecture/subsystems/alerts.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# alerts
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Delivers user-facing notifications for events the rule engine has flagged. Provides Web Push (VAPID) delivery to registered browsers and phones, PIN hashing/verification for arm/disarm flows, and a smart-profile matcher that selects recipients based on household state and time-of-day windows. Every alert attempt is logged through `vigilar.alerts` logger; the main supervisor attaches a dedicated syslog handler to that logger so alerts leave an OS-level audit trail.
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
|
||||||
|
- `vigilar/alerts/sender.py` — `send_alert`, `build_notification`, VAPID Web Push dispatcher; reads `push_subscriptions` and writes `alert_log`
|
||||||
|
- `vigilar/alerts/profiles.py` — smart alert profile matcher (household state + time windows)
|
||||||
|
- `vigilar/alerts/pin.py` — PBKDF2-SHA256 PIN hashing and constant-time verification
|
||||||
|
|
||||||
|
## MQTT topics
|
||||||
|
|
||||||
|
**Subscribes:** none directly. `alerts` is not a standalone subsystem process; it is invoked synchronously from the events processor when a rule action fires.
|
||||||
|
**Publishes:** No MQTT publishers found at time of writing. The events processor publishes `vigilar/system/alert` on behalf of alert actions.
|
||||||
|
|
||||||
|
## Database tables
|
||||||
|
|
||||||
|
- `alert_log` — one row per delivery attempt (channel + status + error)
|
||||||
|
- `push_subscriptions` — VAPID Web Push subscriptions, read to fan out a notification; stale entries are removed via `delete_push_subscription`
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- `events` — `send_alert` is called from `EventProcessor._execute_action` for `alert_all` / `push_and_record`
|
||||||
|
- `storage` — reads `push_subscriptions`, writes `alert_log`
|
||||||
|
|
||||||
|
## Consumed by
|
||||||
|
|
||||||
|
- Phones and browsers — receive Web Push notifications via VAPID
|
||||||
|
- `web` — the `system` blueprint manages push subscription registration and PIN verification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
The `vigilar.alerts` logger gets a syslog handler installed by the supervisor (`vigilar/main.py`) so that every alert emission is mirrored into the host's syslog as an out-of-band audit trail, independent of SQLite. PIN verification uses `hmac.compare_digest` to avoid timing leaks. The smart-profile matcher honours `household_state` and HH:MM time windows (including windows that wrap past midnight).
|
||||||
44
docs/architecture/subsystems/camera.md
Normal file
44
docs/architecture/subsystems/camera.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# camera
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Owns all per-camera capture, motion detection, recording, and live streaming. One worker process per configured camera pulls RTSP frames with OpenCV, runs MOG2 background-subtraction motion detection plus optional YOLO object detection, writes H.264 segments via FFmpeg, and exposes HLS for the web grid.
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
|
||||||
|
- `vigilar/camera/manager.py` — `CameraManager`, spawns and supervises one `multiprocessing.Process` per camera; polled by main supervisor via `check_and_restart()`
|
||||||
|
- `vigilar/camera/worker.py` — per-camera capture loop: RTSP read, ring buffer push, motion detect, adaptive-FPS record, YOLO classification, MQTT publish
|
||||||
|
- `vigilar/camera/motion.py` — MOG2-based `MotionDetector`
|
||||||
|
- `vigilar/camera/recorder.py` — FFmpeg-subprocess recorder with idle and motion modes; calls `vigilar.storage.encryption.encrypt_file` to produce `.vge` output
|
||||||
|
- `vigilar/camera/ring_buffer.py` — 5-second pre-motion frame buffer
|
||||||
|
- `vigilar/camera/hls.py` — HLS segment writer for the web grid view
|
||||||
|
|
||||||
|
## MQTT topics
|
||||||
|
|
||||||
|
**Subscribes:** `vigilar/camera/{id}/config` (runtime threshold updates)
|
||||||
|
**Publishes:**
|
||||||
|
- `vigilar/camera/{id}/motion/start` (MOG2 trigger, and YOLO person/vehicle detections)
|
||||||
|
- `vigilar/camera/{id}/motion/end`
|
||||||
|
- `vigilar/camera/{id}/heartbeat`
|
||||||
|
- `vigilar/camera/{id}/error`
|
||||||
|
- `vigilar/camera/{id}/pet/detected`
|
||||||
|
- `vigilar/camera/{id}/wildlife/detected`
|
||||||
|
- `vigilar/pets/{pet_name}/location` (when a known pet is identified)
|
||||||
|
|
||||||
|
## Database tables
|
||||||
|
|
||||||
|
The camera subsystem does not write to SQLite directly; recording metadata and events are persisted by the `events` subsystem after consuming motion topics. Encrypted clips are written to disk by `recorder.py`.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- `storage` — uses `vigilar.storage.encryption.encrypt_file` to seal recordings as `.vge`
|
||||||
|
- `detection` — optional YOLO detector, pet classifier, and wildlife threat classifier are loaded into the worker process
|
||||||
|
|
||||||
|
## Consumed by
|
||||||
|
|
||||||
|
- `events` — subscribes to the whole bus and classifies motion/heartbeat/error topics into rows in `events` and `recordings`
|
||||||
|
- `web` — reads HLS segments for the camera grid and decrypts recordings for playback
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
`CameraManager` is the one subsystem NOT wrapped in `SubsystemProcess`; it owns its own child processes and is polled directly from the main supervisor loop. Adaptive FPS runs at 2 FPS idle and 30 FPS while motion is active, with a 5-second ring buffer flushed into the front of each motion clip for pre-event context. YOLO person/vehicle detections are published as extra `motion/start` messages with an additional `detection` field rather than on dedicated topics.
|
||||||
44
docs/architecture/subsystems/detection.md
Normal file
44
docs/architecture/subsystems/detection.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# detection
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Library of computer-vision models and classifiers that the camera worker loads in-process. Provides YOLO/SSD object detection, pet identification, face recognition, wildlife threat scoring, package-delivery state machine, vehicle fingerprinting, activity heatmaps, and sunset-aware scheduling. It is not itself a supervised subsystem; there is no `detection` process and no direct MQTT involvement — the camera worker invokes these modules and publishes on their behalf.
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
|
||||||
|
- `vigilar/detection/yolo.py` — YOLO object detector and category classifier (person/vehicle/pet/wildlife)
|
||||||
|
- `vigilar/detection/person.py` — MobileNet-SSD v2 detector for person/car/truck
|
||||||
|
- `vigilar/detection/pet_id.py` — MobileNetV3-Small pet identification classifier
|
||||||
|
- `vigilar/detection/trainer.py` — transfer-learning trainer for pet-ID model
|
||||||
|
- `vigilar/detection/face.py` — dlib/`face_recognition`-based face matcher against `face_profiles`/`face_embeddings`
|
||||||
|
- `vigilar/detection/wildlife.py` — wildlife species + threat-level classifier
|
||||||
|
- `vigilar/detection/package.py` — package-delivery state machine (`IDLE → PRESENT → REMINDED → COLLECTED`)
|
||||||
|
- `vigilar/detection/vehicle.py` — dominant-color and size fingerprinting for vehicles
|
||||||
|
- `vigilar/detection/zones.py` — polygon zones and intersection tests
|
||||||
|
- `vigilar/detection/heatmap.py` — accumulates detection bboxes into a grid for activity heatmaps
|
||||||
|
- `vigilar/detection/crop_manager.py` — saves detection crops into staging and training directories
|
||||||
|
- `vigilar/detection/solar.py` — stdlib NOAA sunset calculator for twilight-aware logic
|
||||||
|
|
||||||
|
## MQTT topics
|
||||||
|
|
||||||
|
**Subscribes:** none. Detection modules are library calls inside the camera worker.
|
||||||
|
**Publishes:** none directly. The camera worker publishes detection results on `vigilar/camera/{id}/motion/start` (person/vehicle, with an extra `detection` field), `vigilar/camera/{id}/pet/detected`, `vigilar/camera/{id}/wildlife/detected`, and `vigilar/pets/{pet_name}/location`.
|
||||||
|
|
||||||
|
## Database tables
|
||||||
|
|
||||||
|
No direct writes. `face.py` reads `face_profiles` and `face_embeddings` via `vigilar.storage.queries`.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- `storage` — `face.py` loads face profiles and embeddings
|
||||||
|
- `camera` — invoked as a library inside the camera worker process
|
||||||
|
|
||||||
|
## Consumed by
|
||||||
|
|
||||||
|
- `camera` — worker imports and calls these modules per frame
|
||||||
|
- `pets` — downstream consumer of pet-ID results (via MQTT topics published by camera)
|
||||||
|
- `events` — consumes the resulting detection topics off the bus
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Person and vehicle detections are piggybacked on `motion/start` rather than published on their own topics; the events processor currently collapses both back to `MOTION_START`. The package state machine in `package.py` is driven by the camera worker and uses `solar.get_sunset` to decide when a package has been "abandoned" after dusk.
|
||||||
43
docs/architecture/subsystems/events.md
Normal file
43
docs/architecture/subsystems/events.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# events
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Central classifier and persistence layer for everything that happens on the MQTT bus. Subscribes to the whole bus, parses topic paths into `EventType`/`Severity`/`source_id`, writes rows into `events`, evaluates the rule engine against the arm-state FSM, and executes resulting actions (send alert, broadcast system-alert, command all cameras to record).
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
|
||||||
|
- `vigilar/events/processor.py` — `EventProcessor`: subscribes via `bus.subscribe_all`, classifies topics in `_classify_event` (a big if-chain, no routing table), writes via `insert_event`, executes rule actions
|
||||||
|
- `vigilar/events/state.py` — `ArmStateFSM` (HOME/AWAY/NIGHT/DISARMED), publishes `Topics.SYSTEM_ARM_STATE`, persists to `arm_state_log`
|
||||||
|
- `vigilar/events/rules.py` — `RuleEngine` evaluating TOML-configured rules against topic/payload/arm-state
|
||||||
|
- `vigilar/events/history.py` — thin wrapper around `get_events`/`acknowledge_event`
|
||||||
|
|
||||||
|
## MQTT topics
|
||||||
|
|
||||||
|
**Subscribes:** `vigilar/#` (wildcard via `bus.subscribe_all`)
|
||||||
|
**Publishes:**
|
||||||
|
- `vigilar/system/arm_state` (from `ArmStateFSM`)
|
||||||
|
- `vigilar/system/alert` (when a rule fires `alert_all`/`push_and_record`)
|
||||||
|
- `vigilar/camera/{id}/command/record` (per-camera record command for `push_and_record`/`record_all_cameras`)
|
||||||
|
|
||||||
|
## Database tables
|
||||||
|
|
||||||
|
- `events` — canonical event log; every classified message becomes a row
|
||||||
|
- `arm_state_log` — arm-state transitions written by the FSM
|
||||||
|
- `pet_sightings` — inserted when classification produces `PET_DETECTED`, `PET_ESCAPE`, or `UNKNOWN_ANIMAL`
|
||||||
|
- `wildlife_sightings` — inserted for `WILDLIFE_PREDATOR`/`NUISANCE`/`PASSIVE`
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- Every publisher on the bus (camera, sensors, ups, presence, detection, health, highlights, etc.)
|
||||||
|
- `storage` — uses `vigilar.storage.queries` for all inserts
|
||||||
|
- `alerts` — calls `send_alert` from `vigilar.alerts.sender` when a rule requests it
|
||||||
|
|
||||||
|
## Consumed by
|
||||||
|
|
||||||
|
- `alerts` — triggered indirectly via rule actions
|
||||||
|
- `web` — reads `events` table through `vigilar.storage.queries` for the timeline and SSE stream
|
||||||
|
- `highlights` — consumes the `events` table for reel scoring
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Classification is a hand-written if-chain keyed on the topic path. Camera `heartbeat` and `error` topics are intentionally ignored (return `None`). Person and vehicle YOLO detections ride on `camera/{id}/motion/start` and currently collapse back to a plain `MOTION_START` event — a known artefact of the publishing quirk in the camera worker. Arm-state change messages on `vigilar/system/*` are also deliberately ignored by the classifier to avoid feedback loops.
|
||||||
39
docs/architecture/subsystems/health.md
Normal file
39
docs/architecture/subsystems/health.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# health
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Self-monitoring and housekeeping. Periodically checks disk usage and MQTT reachability, publishes an aggregated system-health snapshot, auto-prunes old recordings when the disk gets full, builds a daily "what happened today" digest, and drives the scheduled firing of highlight-reel and timelapse jobs.
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
|
||||||
|
- `vigilar/health/monitor.py` — `HealthMonitor` subsystem loop: disk check every 5 min, MQTT port check every 30 s, status publish every 10 s, daily reel trigger, per-minute timelapse schedule check
|
||||||
|
- `vigilar/health/pruner.py` — `auto_prune` / `find_prunable_recordings`; deletes oldest unstarred recordings (and their thumbnails) until usage is back under target
|
||||||
|
- `vigilar/health/digest.py` — `build_digest`: counts person/vehicle events, pet and wildlife sightings, and recording totals over a time window for notification digests
|
||||||
|
- `vigilar/health/__init__.py`
|
||||||
|
|
||||||
|
## MQTT topics
|
||||||
|
|
||||||
|
**Subscribes:** none
|
||||||
|
**Publishes:**
|
||||||
|
- `vigilar/system/health` — rolled-up health status with per-check breakdown
|
||||||
|
|
||||||
|
## Database tables
|
||||||
|
|
||||||
|
- `recordings` — read in pruner (`starred = 0`, oldest first) and deleted row-by-row once the file is unlinked
|
||||||
|
- `events`, `pet_sightings`, `wildlife_sightings`, `recordings` — read in `digest.py`
|
||||||
|
- `timelapse_schedules` — read indirectly via `vigilar.highlights.timelapse.check_schedules`
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- `storage` — all DB access
|
||||||
|
- `highlights` — calls `generate_daily_reel` and `check_schedules` on schedule
|
||||||
|
- Filesystem — `shutil.disk_usage` on `system.data_dir`
|
||||||
|
|
||||||
|
## Consumed by
|
||||||
|
|
||||||
|
- `web` — surfaces `vigilar/system/health` and the digest output in the system blueprint
|
||||||
|
- `events` — ingests `vigilar/system/health` off the bus (subscribes to everything)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
The pruner is keyed on percentages, not gigabytes: it checks `config.health.disk_warn_pct` / `disk_critical_pct` for alerting and prunes toward `config.health.auto_prune_target_pct`. It never deletes a recording whose `starred` flag is set, and it unlinks the thumbnail alongside the video. The monitor owns the scheduling clock for both `generate_daily_reel` (fires when wall-clock matches `highlights.generate_time`) and timelapse generation (polls `check_schedules` every 60 s), so neither `highlights/` nor its cron is wired directly into the supervisor.
|
||||||
35
docs/architecture/subsystems/highlights.md
Normal file
35
docs/architecture/subsystems/highlights.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# highlights
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Generates derived video products from recorded footage: a daily highlight reel (events scored and assembled into a short FFmpeg-concatenated clip) and scheduled per-camera timelapses. These are offline/batch jobs that read the `events` and `recordings` tables and write new rows into `recordings` tagged with a synthetic trigger.
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
|
||||||
|
- `vigilar/highlights/reel.py` — event scoring map (`PET_ESCAPE=10`, `WILDLIFE_PREDATOR=9`, ...), clip selection, FFmpeg concat assembly, inserts resulting file via `insert_recording`
|
||||||
|
- `vigilar/highlights/timelapse.py` — per-camera timelapse generator; reads `timelapse_schedules`, walks recordings within the configured hour window, stitches with FFmpeg, inserts a new `recordings` row
|
||||||
|
- `vigilar/highlights/__init__.py`
|
||||||
|
|
||||||
|
## MQTT topics
|
||||||
|
|
||||||
|
**Subscribes:** No MQTT subscribers found at time of writing.
|
||||||
|
**Publishes:** No MQTT publishers found at time of writing.
|
||||||
|
|
||||||
|
## Database tables
|
||||||
|
|
||||||
|
- `events` — read-only, source for reel scoring
|
||||||
|
- `recordings` — read (source clips) and write (new reel / timelapse entries via `insert_recording`)
|
||||||
|
- `timelapse_schedules` — read by `timelapse.py` to determine which cameras / hours / generation times are active
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- `storage` — all table access via `vigilar.storage.queries` and `vigilar.storage.schema`
|
||||||
|
- FFmpeg subprocess for clip concatenation and timelapse assembly
|
||||||
|
|
||||||
|
## Consumed by
|
||||||
|
|
||||||
|
- `web` — the `recordings` blueprint surfaces reels and timelapses alongside regular recordings
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Scoring is a hand-tuned dict in `reel.py`. The top-scored events of the day are pulled, their surrounding recordings are concatenated via FFmpeg, and the result is persisted as a new `recordings` row with a `RecordingTrigger` tag so the UI can distinguish generated from captured clips. Timelapse generation is driven by `timelapse_schedules` rows — each row defines a camera, an hour window, and a `generate_time` at which the day's frames are stitched.
|
||||||
34
docs/architecture/subsystems/pets.md
Normal file
34
docs/architecture/subsystems/pets.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# pets
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Pet rule engine. Defines composable conditions (detected-in-zone, not-seen-for-N-minutes, detected-without-person, time-of-day, etc.) and per-pet state tracking that downstream code uses to trigger pet-specific alerts and automations. The on-camera pet identification itself lives in `vigilar/detection/pet_id.py`; this package is the behavioural layer on top of those sightings.
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
|
||||||
|
- `vigilar/pets/rules.py` — `PetState` dataclass, `evaluate_condition`, rule evaluator; reads `pet_rules` via `get_all_enabled_rules`
|
||||||
|
- `vigilar/pets/__init__.py` (empty)
|
||||||
|
|
||||||
|
## MQTT topics
|
||||||
|
|
||||||
|
**Subscribes:** No MQTT subscribers found at time of writing. The module is invoked as a library — typically from the events processor or an evaluator that already has topic payloads in hand.
|
||||||
|
**Publishes:** No MQTT publishers found at time of writing.
|
||||||
|
|
||||||
|
## Database tables
|
||||||
|
|
||||||
|
- `pet_rules` — loaded via `vigilar.storage.queries.get_all_enabled_rules`
|
||||||
|
- `pets`, `pet_sightings`, `pet_training_images` — owned schema-wise by this feature area but written from elsewhere (events processor for sightings, detection/trainer for training images, web for pets CRUD)
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- `storage` — reads `pet_rules`
|
||||||
|
- `alerts.profiles` — reuses `is_in_time_window` for time-of-day conditions
|
||||||
|
|
||||||
|
## Consumed by
|
||||||
|
|
||||||
|
- `events` / rule evaluator — drives pet-specific alert actions
|
||||||
|
- `web` — the `pets` blueprint manages pets, training images, and rules
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
`PetState` is per-pet, in-memory, and tracks last-seen camera and time, current zone with entry time, and whether a person is currently present alongside the pet. This makes rules like "cat in kitchen for more than 5 minutes while nobody is home" possible without joining SQLite on every frame. The condition schema is a plain dict with a `type` discriminator, so new condition kinds can be added without schema changes.
|
||||||
36
docs/architecture/subsystems/presence.md
Normal file
36
docs/architecture/subsystems/presence.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# presence
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Tracks whether household members are home by ICMP-pinging their phones on the LAN at a fixed interval. Derives an overall household state (EMPTY, OCCUPIED, etc.) from per-member presence and publishes both to MQTT so the rule engine and arm-state FSM can react.
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
|
||||||
|
- `vigilar/presence/monitor.py` — `PresenceMonitor`: ping loop, per-member state machine with `departure_delay_m` grace period, publishes per-member and household-level topics
|
||||||
|
- `vigilar/presence/models.py` — `MemberPresence` dataclass and `derive_household_state` aggregator
|
||||||
|
- `vigilar/presence/__init__.py`
|
||||||
|
|
||||||
|
## MQTT topics
|
||||||
|
|
||||||
|
**Subscribes:** none
|
||||||
|
**Publishes:**
|
||||||
|
- `vigilar/presence/{name}` — per-member status (`HOME` or `AWAY`, plus role)
|
||||||
|
- `vigilar/presence/status` — aggregated household state and a `{name: HOME|AWAY}` map
|
||||||
|
|
||||||
|
## Database tables
|
||||||
|
|
||||||
|
none — presence state lives only in memory and on the bus.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- LAN reachability of configured `[presence.members]` IPs
|
||||||
|
- `ping` binary on the host
|
||||||
|
|
||||||
|
## Consumed by
|
||||||
|
|
||||||
|
- `events` — rules and the arm-state FSM react to household state changes
|
||||||
|
- `alerts` — smart alert profile matcher in `vigilar/alerts/profiles.py` branches on `HouseholdState`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
A member is marked HOME the moment a ping succeeds, but only marked AWAY after `departure_delay_m` minutes without a successful ping — this prevents a single dropped reply from flipping the household state. The monitor uses `time.monotonic()` for the grace window, so it is immune to wall-clock jumps. Per-member and household topics are republished every poll, not only on transitions, so late subscribers always get current state.
|
||||||
38
docs/architecture/subsystems/sensors.md
Normal file
38
docs/architecture/subsystems/sensors.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# sensors
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Ingests events from physical sensors (Zigbee door/window contacts, motion PIRs, leak sensors, GPIO-attached hardware) and republishes them onto the internal Vigilar bus in a normalized form. Maintains the current sensor state in SQLite so the web UI and rule engine always know whether a contact is open/closed, a leak is active, etc.
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
|
||||||
|
- `vigilar/sensors/bridge.py` — `SensorBridge`: subscribes to `zigbee2mqtt/#`, normalizes payloads via `normalize_zigbee_payload`, republishes to `vigilar/sensor/{id}/{event_type}`
|
||||||
|
- `vigilar/sensors/gpio_handler.py` — direct GPIO input handling; publishes the same `vigilar/sensor/{id}/{event_type}` topics
|
||||||
|
- `vigilar/sensors/registry.py` — loads configured sensors, maps Zigbee friendly names to sensor IDs
|
||||||
|
- `vigilar/sensors/models.py` — `SensorEvent` dataclass and protocol enum
|
||||||
|
- `vigilar/sensors/__init__.py`
|
||||||
|
|
||||||
|
## MQTT topics
|
||||||
|
|
||||||
|
**Subscribes:** `{zigbee2mqtt.mqtt_topic_prefix}/#` (external Zigbee2MQTT broker, usually `zigbee2mqtt/#`)
|
||||||
|
**Publishes:**
|
||||||
|
- `vigilar/sensor/{id}/{event_type}` — normalized sensor events (contact_open, contact_closed, motion, leak, etc.)
|
||||||
|
|
||||||
|
## Database tables
|
||||||
|
|
||||||
|
- `sensors` — static sensor registry (read by `SensorRegistry`)
|
||||||
|
- `sensor_states` — current-state table, kept up to date via `upsert_sensor_state`
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- External Zigbee2MQTT broker (same Mosquitto instance, `zigbee2mqtt/...` topic prefix configurable via `[zigbee2mqtt]` TOML section)
|
||||||
|
- `storage` — uses `vigilar.storage.queries.upsert_sensor_state`
|
||||||
|
|
||||||
|
## Consumed by
|
||||||
|
|
||||||
|
- `events` — classifies `vigilar/sensor/#` messages into `CONTACT_OPEN`, `CONTACT_CLOSED`, `MOTION_START`, etc. and persists them to the `events` table
|
||||||
|
- `web` — the `sensors` blueprint reads `sensors` / `sensor_states` for the dashboard
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
`SensorBridge` deliberately skips `zigbee2mqtt/bridge/...` status messages and ignores unknown friendly names with a debug log. Both the Zigbee bridge and the GPIO handler produce identical `vigilar/sensor/{id}/{event_type}` topics so the rest of the system cannot tell a Zigbee door contact from a GPIO-wired one — that's intentional.
|
||||||
51
docs/architecture/subsystems/storage.md
Normal file
51
docs/architecture/subsystems/storage.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# storage
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
The persistence layer. Defines every SQLite table, opens the WAL-mode database, exposes hand-written SQLAlchemy Core queries for the rest of the codebase, and provides the AES-256-CTR file encryption used to seal recordings as `.vge`. It is a library package, not a supervised subsystem process.
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
|
||||||
|
- `vigilar/storage/schema.py` — SQLAlchemy Core `MetaData` and 19 `Table` definitions
|
||||||
|
- `vigilar/storage/db.py` — engine creation, WAL pragmas, `init_db`, `get_db_path`
|
||||||
|
- `vigilar/storage/queries.py` — all `insert_*`/`get_*`/`update_*`/`delete_*` helpers
|
||||||
|
- `vigilar/storage/encryption.py` — AES-256-CTR `encrypt_file` / `decrypt_stream` for `.vge` recordings
|
||||||
|
|
||||||
|
## MQTT topics
|
||||||
|
|
||||||
|
**Subscribes:** none
|
||||||
|
**Publishes:** none
|
||||||
|
|
||||||
|
## Database tables
|
||||||
|
|
||||||
|
- `cameras` — configured camera registry
|
||||||
|
- `sensors` — configured sensor registry
|
||||||
|
- `sensor_states` — current key/value state per sensor
|
||||||
|
- `events` — canonical event log written by the events processor
|
||||||
|
- `recordings` — video clip metadata (path, duration, trigger, event_id, starred)
|
||||||
|
- `system_events` — operator-facing component log (info/alert/critical)
|
||||||
|
- `arm_state_log` — arm-state FSM transitions
|
||||||
|
- `alert_log` — one row per alert delivery attempt and outcome
|
||||||
|
- `push_subscriptions` — VAPID Web Push subscription endpoints
|
||||||
|
- `pets` — known pets (name, species, breed, training count)
|
||||||
|
- `pet_sightings` — per-frame pet observations with confidence and crop path
|
||||||
|
- `wildlife_sightings` — wildlife observations with species, threat level, weather context
|
||||||
|
- `package_events` — package delivery state machine rows (detected/reminded/collected)
|
||||||
|
- `pet_training_images` — images staged for pet-ID model retraining
|
||||||
|
- `pet_rules` — per-pet rule definitions with cooldown and priority
|
||||||
|
- `face_profiles` — known faces, household flag, visit count
|
||||||
|
- `face_embeddings` — serialized face vectors linked to a profile
|
||||||
|
- `visits` — per-visit records for recognized faces
|
||||||
|
- `timelapse_schedules` — per-camera timelapse generation schedules
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- Filesystem: SQLite database under `system.data_dir`, AES key at `/etc/vigilar/secrets/storage.key`
|
||||||
|
|
||||||
|
## Consumed by
|
||||||
|
|
||||||
|
- Every other subsystem. Camera encrypts recordings through it, events/alerts/sensors/ups/pets/highlights/presence/web all import `vigilar.storage.queries`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Encryption is AES-256 in **CTR mode** (no GCM), so `.vge` files are confidential but **not tamper-evident** — there is no authentication tag and a bit-flip in the ciphertext produces a corresponding bit-flip in the plaintext without detection. The 16-byte IV is prepended to each file and the plaintext is unlinked after encryption. The codebase uses SQLAlchemy Core exclusively — there are no mapped ORM classes. The database is opened in WAL mode so the web process can read while the events processor writes.
|
||||||
42
docs/architecture/subsystems/ups.md
Normal file
42
docs/architecture/subsystems/ups.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# ups
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Polls a local NUT (Network UPS Tools) daemon for battery status and publishes power state transitions onto the MQTT bus. When the UPS runs below a configured critical-runtime threshold, triggers a graceful shutdown of the host.
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
|
||||||
|
- `vigilar/ups/monitor.py` — `UPSMonitor`: NUT client, status parser, transition detection, MQTT publisher
|
||||||
|
- `vigilar/ups/shutdown.py` — `ShutdownSequence`: publishes `SYSTEM_SHUTDOWN` and executes the OS shutdown
|
||||||
|
- `vigilar/ups/__init__.py`
|
||||||
|
|
||||||
|
## MQTT topics
|
||||||
|
|
||||||
|
**Subscribes:** none
|
||||||
|
**Publishes:**
|
||||||
|
- `vigilar/ups/status` (every poll; status, battery charge %, runtime seconds, input voltage, load %)
|
||||||
|
- `vigilar/ups/power_loss` (transition `OL -> OB`/`LB`)
|
||||||
|
- `vigilar/ups/restored` (transition back to `OL`)
|
||||||
|
- `vigilar/ups/low_battery` (charge below `low_battery_threshold_pct`)
|
||||||
|
- `vigilar/ups/critical` (runtime below `critical_runtime_threshold_s`, triggers shutdown)
|
||||||
|
- `vigilar/system/shutdown` (published by `ShutdownSequence`)
|
||||||
|
|
||||||
|
## Database tables
|
||||||
|
|
||||||
|
- `events` — writes `POWER_LOSS`, `POWER_RESTORED`, `LOW_BATTERY` rows via `insert_event`
|
||||||
|
- `system_events` — writes operator-facing notices (`"Power loss detected — running on battery"`, critical shutdown messages)
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- External NUT daemon reachable at `[ups] nut_host:nut_port` via the `pynut2` client
|
||||||
|
- `storage` — `insert_event`, `insert_system_event`
|
||||||
|
|
||||||
|
## Consumed by
|
||||||
|
|
||||||
|
- `events` — classifies `vigilar/ups/*` into `POWER_LOSS` / `LOW_BATTERY` / `POWER_RESTORED`
|
||||||
|
- `alerts` — receives those classified events through rule actions
|
||||||
|
- `main` supervisor — reacts to `SYSTEM_SHUTDOWN` so all subsystems can drain cleanly
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
The critical shutdown trigger fires when the UPS is not online AND `battery.runtime < ups.critical_runtime_threshold_s` AND no shutdown has already been triggered. The low-battery alert fires when `battery.charge < ups.low_battery_threshold_pct` while off mains. NUT reconnection uses exponential backoff capped at 120 seconds, so the monitor survives a temporary NUT restart without the supervisor having to restart it.
|
||||||
43
docs/architecture/subsystems/web.md
Normal file
43
docs/architecture/subsystems/web.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# web
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Flask application that exposes everything to humans: camera grid and single-camera views, event timeline, recordings browser, sensor dashboard, system/arm controls, kiosk mode, and the pets / visitors / wildlife journals. The web process runs as one `SubsystemProcess` supervised by `vigilar/main.py`, reads state directly from SQLite, and publishes a handful of config/control MQTT messages on behalf of the user.
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
|
||||||
|
- `vigilar/web/app.py` — `create_app(cfg)` factory (note: `vigilar/web/__init__.py` is empty; the factory lives here)
|
||||||
|
- `vigilar/web/blueprints/cameras.py` — HLS streams, snapshots, per-camera view
|
||||||
|
- `vigilar/web/blueprints/events.py` — event log list, acknowledgement, and `text/event-stream` SSE endpoint for live updates
|
||||||
|
- `vigilar/web/blueprints/recordings.py` — browse, download, delete recordings; serves decrypted `.vge` content
|
||||||
|
- `vigilar/web/blueprints/sensors.py` — sensor status and history
|
||||||
|
- `vigilar/web/blueprints/system.py` — health, arm/disarm, UPS, admin settings, Web Push subscription registration, PIN management; publishes `vigilar/camera/{id}/config` for threshold updates
|
||||||
|
- `vigilar/web/blueprints/kiosk.py` — fullscreen 2x2 ambient grid
|
||||||
|
- `vigilar/web/blueprints/pets.py` — pet registration, sightings, labeling, training-image upload
|
||||||
|
- `vigilar/web/blueprints/visitors.py` — face profiles, visits, labeling, privacy controls
|
||||||
|
- `vigilar/web/blueprints/wildlife.py` — wildlife sightings journal, stats, CSV export
|
||||||
|
|
||||||
|
## MQTT topics
|
||||||
|
|
||||||
|
**Subscribes:** none. The web process does not subscribe to the bus at import time; the SSE endpoint accumulates messages in an in-process queue but nothing wires the MQTT bus into that queue today (see Notes).
|
||||||
|
**Publishes:**
|
||||||
|
- `vigilar/camera/{camera_id}/config` — runtime camera config updates (sensitivity / motion thresholds) from the system settings page
|
||||||
|
|
||||||
|
## Database tables
|
||||||
|
|
||||||
|
All access is read-mostly via `vigilar.storage.queries`. Touches essentially every table: `cameras`, `events`, `recordings`, `sensors`/`sensor_states`, `system_events`, `arm_state_log`, `alert_log`, `push_subscriptions`, `pets`/`pet_sightings`/`pet_training_images`/`pet_rules`, `face_profiles`/`face_embeddings`/`visits`, `wildlife_sightings`, `timelapse_schedules`.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- `storage` — engine passed via `app.config["DB_ENGINE"]` / `VIGILAR_CONFIG`
|
||||||
|
- `camera` — reads HLS segments from `cfg.system.hls_dir` and decrypts recordings via `vigilar.storage.encryption`
|
||||||
|
- `alerts` — uses `vigilar.alerts.pin.hash_pin`/`verify_pin` and manages `push_subscriptions`
|
||||||
|
- `config_writer` — persists camera/alert config changes back to TOML
|
||||||
|
|
||||||
|
## Consumed by
|
||||||
|
|
||||||
|
- Browsers and phones — HTML dashboard, PWA with Web Push (VAPID), HLS grid via `hls.js`, MJPEG fallback for single-camera low-latency view, SSE for the live event timeline
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Templates use Jinja2 with a Bootstrap 5 dark theme; the camera grid uses `hls.js` for multi-camera HLS playback with an MJPEG fallback on the single-camera page. The app is a PWA (service worker + manifest) with VAPID Web Push for mobile notifications. The event-timeline SSE endpoint lives around line 93 of `blueprints/events.py` and holds a per-client `queue.Queue` fed by a module-level `broadcast_sse_event` function. A dedicated MQTT → SSE bridge (`vigilar/web/sse_bridge.py`) runs inside the web process, subscribes to `Topics.EVENTS_PUBLISHED`, and calls `broadcast_sse_event` for every classified event emitted by the events subsystem — so the in-browser event timeline updates live without a page refresh.
|
||||||
337
docs/home-user-guide.md
Normal file
337
docs/home-user-guide.md
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
# Vigilar Home User Guide
|
||||||
|
|
||||||
|
This guide takes you from a bare mini PC to working cameras on your phone.
|
||||||
|
It assumes you are comfortable with a Linux command line and can SSH into
|
||||||
|
a machine on your network.
|
||||||
|
|
||||||
|
If you are running Vigilar on a server you already administer, see the
|
||||||
|
[Operator Guide](operator-guide.md) instead.
|
||||||
|
|
||||||
|
## What you will end up with
|
||||||
|
|
||||||
|
- A small always-on box in your house that records from your IP cameras.
|
||||||
|
- A web UI at `http://<mini-pc-ip>:49735` on your LAN.
|
||||||
|
- Push notifications on your phone when motion is detected.
|
||||||
|
- Optional nightly config + database backup to a NAS share.
|
||||||
|
|
||||||
|
```
|
||||||
|
[IP cameras] --RTSP--> [mini PC running Vigilar] --LAN--> [your phone/browser]
|
||||||
|
|
|
||||||
|
+-- optional nightly --> [NAS]
|
||||||
|
```
|
||||||
|
|
||||||
|
## What you need
|
||||||
|
|
||||||
|
- A mini PC. x86_64, 4 GB RAM minimum, 128 GB SSD minimum. Any modern
|
||||||
|
Intel NUC, Beelink, MinisForum, or similar will do.
|
||||||
|
- A USB stick (8 GB or larger) for installing Linux.
|
||||||
|
- One or more RTSP IP cameras on your LAN. See the
|
||||||
|
[Camera Hardware Guide](camera-hardware-guide.md) for picks.
|
||||||
|
- A phone for push notifications.
|
||||||
|
- Optional: a NAS on your LAN for backups.
|
||||||
|
|
||||||
|
## Step 1 — Install Linux on the mini PC
|
||||||
|
|
||||||
|
Install Debian 12 or Ubuntu Server 24.04 (or later). The official installers
|
||||||
|
walk you through it. During install:
|
||||||
|
|
||||||
|
- Choose **OpenSSH server** when asked for tasks/components.
|
||||||
|
- Set a hostname you will remember (e.g. `vigilar`).
|
||||||
|
- Create a user with `sudo`.
|
||||||
|
|
||||||
|
After install, note the LAN IP (`ip addr` on the box) and confirm you can
|
||||||
|
SSH in from your laptop.
|
||||||
|
|
||||||
|
Arch Linux works too — the installer supports both `apt` and `pacman` —
|
||||||
|
but the rest of this guide assumes a Debian/Ubuntu system.
|
||||||
|
|
||||||
|
## Step 2 — Install Vigilar
|
||||||
|
|
||||||
|
SSH into the mini PC, clone the repository, and run the installer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo URL> vigilar
|
||||||
|
cd vigilar
|
||||||
|
sudo ./scripts/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`install.sh` will:
|
||||||
|
|
||||||
|
- Install system packages (`ffmpeg`, `mosquitto`, `python3`, `nut-client`),
|
||||||
|
create a locked-down `vigilar` system user, and lay out `/opt/vigilar`,
|
||||||
|
`/var/vigilar`, and `/etc/vigilar` with the right permissions.
|
||||||
|
- Build a Python virtualenv at `/opt/vigilar/venv`, install the `vigilar`
|
||||||
|
package into it, generate a 32-byte storage encryption key at
|
||||||
|
`/etc/vigilar/secrets/storage.key`, and drop a sample config at
|
||||||
|
`/etc/vigilar/vigilar.toml`.
|
||||||
|
- Install the `vigilar.service` systemd unit and a Mosquitto config that
|
||||||
|
binds the internal MQTT broker to localhost only, then restart Mosquitto.
|
||||||
|
|
||||||
|
Review the `[INFO]` and `[OK]` output. Anything marked `[FAIL]` needs
|
||||||
|
attention before you continue.
|
||||||
|
|
||||||
|
The installer finishes by printing a short list of follow-up scripts it
|
||||||
|
does **not** run for you. The ones you want now are:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./scripts/gen_vapid_keys.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
That generates the VAPID keypair used for phone push notifications. If
|
||||||
|
you skip it, cameras and the web UI will still work, but phone push
|
||||||
|
will not.
|
||||||
|
|
||||||
|
The other optional follow-ups are `gen_cert.sh` (TLS certificates for
|
||||||
|
HTTPS on the LAN) and `setup_nut.sh` (UPS battery monitoring). You can
|
||||||
|
come back to those later.
|
||||||
|
|
||||||
|
## Step 3 — Edit the config and set a PIN
|
||||||
|
|
||||||
|
The installer drops a sample config at `/etc/vigilar/vigilar.toml`. Open
|
||||||
|
it in your editor:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudoedit /etc/vigilar/vigilar.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll add cameras from the web UI later, so for now you only need to
|
||||||
|
set two things: the arm/disarm PIN and the web UI password.
|
||||||
|
|
||||||
|
### PIN
|
||||||
|
|
||||||
|
Vigilar stores the arm/disarm PIN as a keyed hash — there is no recovery.
|
||||||
|
Generate the hash with the CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u vigilar /opt/vigilar/venv/bin/vigilar config set-pin
|
||||||
|
```
|
||||||
|
|
||||||
|
Enter a PIN when prompted (twice). The command prints a line like:
|
||||||
|
|
||||||
|
```
|
||||||
|
arm_pin_hash = "…:…"
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy that line into the `[system]` section of `/etc/vigilar/vigilar.toml`.
|
||||||
|
|
||||||
|
### Web UI password
|
||||||
|
|
||||||
|
Same idea for the password that protects the web UI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u vigilar /opt/vigilar/venv/bin/vigilar config set-password
|
||||||
|
```
|
||||||
|
|
||||||
|
Paste the printed `password_hash = "…"` line into the `[web]` section.
|
||||||
|
|
||||||
|
### Validate
|
||||||
|
|
||||||
|
Before starting the service, make sure the config parses:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u vigilar /opt/vigilar/venv/bin/vigilar config validate
|
||||||
|
```
|
||||||
|
|
||||||
|
It should print `Config OK` and a short summary. If it prints
|
||||||
|
`Config INVALID`, fix the file and try again.
|
||||||
|
|
||||||
|
## Step 4 — Start it up
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable --now vigilar
|
||||||
|
sudo systemctl status vigilar
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see `active (running)`. Now open a browser on any LAN device:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://<mini-pc-ip>:49735
|
||||||
|
```
|
||||||
|
|
||||||
|
Log in with the password you set in Step 3. The web port is configurable
|
||||||
|
under `[web] port` in `/etc/vigilar/vigilar.toml` — the default is
|
||||||
|
`49735`.
|
||||||
|
|
||||||
|
> Screenshot placeholder: `docs/images/first-run-login.png`
|
||||||
|
|
||||||
|
## Step 5 — Add your first camera
|
||||||
|
|
||||||
|
From the web UI, go to **Cameras → Add camera**. You'll need:
|
||||||
|
|
||||||
|
- A name (e.g. "Front door").
|
||||||
|
- The camera's RTSP URL — see the
|
||||||
|
[Camera Hardware Guide](camera-hardware-guide.md) for the format your
|
||||||
|
brand uses.
|
||||||
|
- Username and password for the RTSP stream (often separate from the
|
||||||
|
camera's admin login).
|
||||||
|
|
||||||
|
Click **Test**, then **Save**. The camera appears in the grid. It idles
|
||||||
|
at 2 FPS and jumps to 30 FPS when motion is detected, with a 5-second
|
||||||
|
pre-motion ring buffer so you don't lose the run-up to an event.
|
||||||
|
|
||||||
|
> Screenshot placeholder: `docs/images/add-camera.png`
|
||||||
|
|
||||||
|
Repeat for each camera. The multi-camera grid uses HLS so a handful of
|
||||||
|
streams are comfortable over LAN Wi-Fi; the single-camera view falls
|
||||||
|
back to MJPEG for lower latency.
|
||||||
|
|
||||||
|
## Step 6 — Phone push notifications
|
||||||
|
|
||||||
|
Vigilar is a Progressive Web App, so your phone installs it straight
|
||||||
|
from the browser:
|
||||||
|
|
||||||
|
1. Open `http://<mini-pc-ip>:49735` in your phone's browser on the
|
||||||
|
same Wi-Fi.
|
||||||
|
2. Log in.
|
||||||
|
3. Use your browser's "Add to Home Screen" option (Safari: share sheet;
|
||||||
|
Chrome: three-dot menu).
|
||||||
|
4. Open the installed app from your home screen.
|
||||||
|
5. When prompted, allow notifications.
|
||||||
|
|
||||||
|
As long as `gen_vapid_keys.sh` was run back in Step 2, you do not need
|
||||||
|
to configure anything else. The next motion event will push a
|
||||||
|
notification to your phone.
|
||||||
|
|
||||||
|
**Note on the event timeline:** the in-browser event list does not
|
||||||
|
update live today — you need to refresh the page to see new events.
|
||||||
|
Push notifications to your phone work independently and do arrive in
|
||||||
|
real time. Fixing live timeline updates in the browser is on the
|
||||||
|
roadmap.
|
||||||
|
|
||||||
|
## Step 7 — Optional: NAS backup
|
||||||
|
|
||||||
|
Vigilar ships a `backup.sh` script that tars the SQLite database and
|
||||||
|
the contents of `/etc/vigilar` (config + secrets) once a day. It does
|
||||||
|
**not** back up recordings — those stay on the mini PC's local disk.
|
||||||
|
Recording backup is planned as a later improvement.
|
||||||
|
|
||||||
|
### Mount the NAS share
|
||||||
|
|
||||||
|
NFS example (swap in SMB if that's what your NAS speaks):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt-get install -y nfs-common
|
||||||
|
sudo mkdir -p /mnt/nas/vigilar-backups
|
||||||
|
echo "nas.lan:/volume1/vigilar-backups /mnt/nas/vigilar-backups nfs defaults,_netdev 0 0" | sudo tee -a /etc/fstab
|
||||||
|
sudo mount -a
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `nas.lan:/volume1/vigilar-backups` with your NAS's actual host
|
||||||
|
and export path. Confirm it mounted:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mount | grep vigilar-backups
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schedule `backup.sh`
|
||||||
|
|
||||||
|
Create `/etc/systemd/system/vigilar-backup.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Vigilar nightly backup
|
||||||
|
After=vigilar.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
Environment=VIGILAR_BACKUP_DIR=/mnt/nas/vigilar-backups
|
||||||
|
Environment=VIGILAR_BACKUP_RETENTION_DAYS=30
|
||||||
|
ExecStart=/opt/vigilar/scripts/backup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `/etc/systemd/system/vigilar-backup.timer`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Run vigilar backup nightly
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=daily
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable the timer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now vigilar-backup.timer
|
||||||
|
sudo systemctl list-timers | grep vigilar
|
||||||
|
```
|
||||||
|
|
||||||
|
Test it immediately without waiting for midnight:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl start vigilar-backup.service
|
||||||
|
ls -lh /mnt/nas/vigilar-backups/
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see a `vigilar-backup-<timestamp>.tar.gz` file. Thirty days
|
||||||
|
of these will rotate automatically thanks to
|
||||||
|
`VIGILAR_BACKUP_RETENTION_DAYS`.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### The web UI will not load
|
||||||
|
|
||||||
|
- `sudo systemctl status vigilar` — is it running?
|
||||||
|
- `journalctl -u vigilar -e` — what does the last error say?
|
||||||
|
- Port 49735 blocked or bound elsewhere?
|
||||||
|
`sudo ss -tlnp | grep 49735`.
|
||||||
|
- Wrong address? On the mini PC, `ip addr` shows its LAN IP.
|
||||||
|
|
||||||
|
### A camera will not connect
|
||||||
|
|
||||||
|
- Try the RTSP URL in VLC from your laptop (Media → Open Network
|
||||||
|
Stream). If VLC cannot play it either, it is a camera or network
|
||||||
|
problem, not Vigilar.
|
||||||
|
- Double-check credentials — many cameras have a separate RTSP login
|
||||||
|
distinct from the admin web login.
|
||||||
|
- Some cameras need a specific stream path (e.g. `/stream1`,
|
||||||
|
`/cam/realmonitor?channel=1`). See the
|
||||||
|
[Camera Hardware Guide](camera-hardware-guide.md).
|
||||||
|
|
||||||
|
### No push notifications on my phone
|
||||||
|
|
||||||
|
- Did you run `sudo ./scripts/gen_vapid_keys.sh`? `install.sh` does
|
||||||
|
not do this for you.
|
||||||
|
- Open the PWA from your home screen, not a browser tab. iOS in
|
||||||
|
particular only delivers web push to installed PWAs.
|
||||||
|
- Check the browser's notification permission for the site.
|
||||||
|
- Remember: the in-browser event timeline is not live. Refresh the
|
||||||
|
page to see events land. Push is the real-time channel.
|
||||||
|
|
||||||
|
### Service keeps restarting
|
||||||
|
|
||||||
|
- `journalctl -u vigilar -n 100` and look at the last stack trace.
|
||||||
|
- Common cause: invalid `vigilar.toml`. Validate it:
|
||||||
|
```bash
|
||||||
|
sudo -u vigilar /opt/vigilar/venv/bin/vigilar config validate
|
||||||
|
```
|
||||||
|
- The supervisor will give up after ten consecutive restarts of the
|
||||||
|
same subsystem, so a persistent crash loop eventually stops on its
|
||||||
|
own — check the logs and fix the underlying cause.
|
||||||
|
|
||||||
|
### I forgot my PIN
|
||||||
|
|
||||||
|
There is no recovery — the PIN is only stored as a hash. Generate a new
|
||||||
|
one and replace the line in `/etc/vigilar/vigilar.toml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u vigilar /opt/vigilar/venv/bin/vigilar config set-pin
|
||||||
|
sudoedit /etc/vigilar/vigilar.toml # replace arm_pin_hash in [system]
|
||||||
|
sudo systemctl restart vigilar
|
||||||
|
```
|
||||||
|
|
||||||
|
The same trick works for a forgotten web UI password — use
|
||||||
|
`config set-password` instead and update `password_hash` in `[web]`.
|
||||||
|
|
||||||
|
## Where to go next
|
||||||
|
|
||||||
|
- [Operator Guide](operator-guide.md) — configuration reference, backups, upgrades, security.
|
||||||
|
- [Camera Hardware Guide](camera-hardware-guide.md) — buying advice and
|
||||||
|
RTSP URL formats per brand.
|
||||||
|
- [Architecture Overview](architecture/overview.md) — how Vigilar is put
|
||||||
|
together under the hood.
|
||||||
660
docs/operator-guide.md
Normal file
660
docs/operator-guide.md
Normal file
@@ -0,0 +1,660 @@
|
|||||||
|
# Vigilar Operator Guide
|
||||||
|
|
||||||
|
## Audience and scope
|
||||||
|
|
||||||
|
This guide is for administrators installing and operating Vigilar on a
|
||||||
|
server they already manage. It is a reference for the on-disk layout,
|
||||||
|
configuration keys, CLI, systemd integration, secrets, UPS integration,
|
||||||
|
backups, upgrades, health and pruning, remote access, and the current
|
||||||
|
set of known limitations.
|
||||||
|
|
||||||
|
If you are setting up a home system from a bare mini PC for the first
|
||||||
|
time, start with the [Home User Guide](home-user-guide.md) and return
|
||||||
|
here when you need reference-level detail.
|
||||||
|
|
||||||
|
## Layout on disk
|
||||||
|
|
||||||
|
`scripts/install.sh` lays the system out as follows. The paths are
|
||||||
|
fixed in the installer; the configuration keys that reference them are
|
||||||
|
shown in parentheses.
|
||||||
|
|
||||||
|
| Path | Owner | Mode | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/opt/vigilar/` | `vigilar:vigilar` | 0755 | Install root and home of the service user |
|
||||||
|
| `/opt/vigilar/venv/` | `vigilar:vigilar` | 0755 | Python virtual environment with the `vigilar` entry point |
|
||||||
|
| `/etc/vigilar/` | `root:root` | 0755 | Configuration root |
|
||||||
|
| `/etc/vigilar/vigilar.toml` | `root:vigilar` | 0644 | Main config file (`VIGILAR_CONFIG`) |
|
||||||
|
| `/etc/vigilar/secrets/` | `root:root` | 0700 | Storage key, VAPID private key |
|
||||||
|
| `/etc/vigilar/secrets/storage.key` | `root:root` | 0600 | 32-byte AES-256 key for recording encryption |
|
||||||
|
| `/etc/vigilar/secrets/vapid_private.pem` | `root:root` | 0600 | VAPID signing key for Web Push |
|
||||||
|
| `/etc/vigilar/secrets/vapid_public.txt` | `root:vigilar` | 0644 | VAPID public key (base64url) |
|
||||||
|
| `/etc/vigilar/certs/` | `root:vigilar` | 0750 | TLS material |
|
||||||
|
| `/etc/vigilar/certs/cert.pem` | `root:vigilar` | 0644 | TLS certificate (`[web] tls_cert`) |
|
||||||
|
| `/etc/vigilar/certs/key.pem` | `root:vigilar` | 0640 | TLS private key (`[web] tls_key`) |
|
||||||
|
| `/var/vigilar/` | `vigilar:vigilar` | 0750 | Runtime data root |
|
||||||
|
| `/var/vigilar/data/` | `vigilar:vigilar` | 0750 | SQLite database and supporting files (`[system] data_dir`) |
|
||||||
|
| `/var/vigilar/data/vigilar.db` | `vigilar:vigilar` | 0640 | Main SQLite database (WAL mode) |
|
||||||
|
| `/var/vigilar/recordings/` | `vigilar:vigilar` | 0750 | `.vge` encrypted recordings and thumbnails (`[system] recordings_dir`) |
|
||||||
|
| `/var/vigilar/hls/` | `vigilar:vigilar` | 0750 | HLS segments and playlists (`[system] hls_dir`) |
|
||||||
|
| `/etc/systemd/system/vigilar.service` | `root:root` | 0644 | Systemd unit |
|
||||||
|
| `/etc/mosquitto/conf.d/vigilar.conf` | `root:root` | 0644 | Localhost-only MQTT config |
|
||||||
|
|
||||||
|
The `VIGILAR_CONFIG` environment variable is read by the CLI and the
|
||||||
|
web blueprints to locate `vigilar.toml`; the systemd unit sets it to
|
||||||
|
`/etc/vigilar/vigilar.toml`.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
`scripts/install.sh` is idempotent and supports Debian/Ubuntu (apt) and
|
||||||
|
Arch Linux (pacman). It performs eight phases:
|
||||||
|
|
||||||
|
1. **System dependencies.** On apt: `ffmpeg mosquitto python3
|
||||||
|
python3-venv python3-pip nut-client`. On pacman: `ffmpeg mosquitto
|
||||||
|
python python-virtualenv nut`.
|
||||||
|
2. **System user.** Creates the `vigilar` system user and group with
|
||||||
|
`/opt/vigilar` as the home directory and `/usr/sbin/nologin` as the
|
||||||
|
shell.
|
||||||
|
3. **Directories and permissions.** Creates `/var/vigilar/{data,
|
||||||
|
recordings,hls}` owned by `vigilar:vigilar` at 0750, plus
|
||||||
|
`/etc/vigilar/{secrets,certs}` with the modes shown above.
|
||||||
|
4. **Python venv.** Creates `/opt/vigilar/venv` as the `vigilar` user
|
||||||
|
and installs the project in place with `pip install "${PROJECT_DIR}"`.
|
||||||
|
5. **Storage encryption key.** Writes 32 random bytes from
|
||||||
|
`/dev/urandom` to `/etc/vigilar/secrets/storage.key` if it does not
|
||||||
|
already exist. This file is never rewritten by the installer.
|
||||||
|
6. **Sample config.** Copies the repository's `config/vigilar.toml` to
|
||||||
|
`/etc/vigilar/vigilar.toml` if a config does not already exist.
|
||||||
|
7. **Systemd unit.** Installs and enables `vigilar.service`.
|
||||||
|
8. **Mosquitto.** Installs `systemd/vigilar-mosquitto.conf` to
|
||||||
|
`/etc/mosquitto/conf.d/vigilar.conf` and restarts `mosquitto.service`.
|
||||||
|
|
||||||
|
The installer prints recommended follow-up steps: edit the TOML, then
|
||||||
|
run `gen_cert.sh`, `gen_vapid_keys.sh`, and `setup_nut.sh`, then start
|
||||||
|
the service.
|
||||||
|
|
||||||
|
### Systemd unit
|
||||||
|
|
||||||
|
`vigilar.service` runs `/opt/vigilar/venv/bin/vigilar start --config
|
||||||
|
/etc/vigilar/vigilar.toml` as `vigilar:vigilar` with
|
||||||
|
`VIGILAR_CONFIG=/etc/vigilar/vigilar.toml`. It requires
|
||||||
|
`mosquitto.service`, wants `nut-monitor.service`, and uses
|
||||||
|
`Restart=on-failure`, `RestartSec=10`, and `WatchdogSec=120`. The unit
|
||||||
|
applies `ProtectSystem=strict`, `ProtectHome`, `PrivateTmp`,
|
||||||
|
`PrivateDevices`, `NoNewPrivileges`, and a `@system-service` syscall
|
||||||
|
filter. `ReadWritePaths` is limited to `/var/vigilar/{data,recordings,
|
||||||
|
hls}`; `/etc/vigilar` is mounted read-only. Output goes to the journal
|
||||||
|
with `SyslogIdentifier=vigilar`.
|
||||||
|
|
||||||
|
### Mosquitto configuration
|
||||||
|
|
||||||
|
`vigilar-mosquitto.conf` binds a single listener on `127.0.0.1:1883`,
|
||||||
|
allows anonymous connections (localhost only), disables persistence
|
||||||
|
(all state lives in SQLite), and logs errors, warnings, notices, and
|
||||||
|
connection events to syslog. Vigilar never authenticates to the broker
|
||||||
|
and never exposes it beyond loopback.
|
||||||
|
|
||||||
|
## Configuration reference
|
||||||
|
|
||||||
|
`config/vigilar.toml` is parsed by `tomllib`, then validated by the
|
||||||
|
Pydantic models in `vigilar/config.py`. The models are the source of
|
||||||
|
truth: any unknown key is rejected, and each section has a default so
|
||||||
|
omitted sections behave sensibly.
|
||||||
|
|
||||||
|
### `[system]`
|
||||||
|
|
||||||
|
- `name` (default `"Vigilar Home Security"`): display name used in
|
||||||
|
logs and the web UI.
|
||||||
|
- `timezone` (default `"UTC"`; sample ships as
|
||||||
|
`"America/New_York"`): used for daily digests, highlight scheduling,
|
||||||
|
and timestamped file paths.
|
||||||
|
- `data_dir` (default `/var/vigilar/data`): SQLite database and
|
||||||
|
derived state.
|
||||||
|
- `recordings_dir` (default `/var/vigilar/recordings`): encrypted
|
||||||
|
`.vge` files.
|
||||||
|
- `hls_dir` (default `/var/vigilar/hls`): HLS segment output.
|
||||||
|
- `log_level` (default `"INFO"`): one of DEBUG, INFO, WARNING, ERROR.
|
||||||
|
- `arm_pin_hash` (default `""`): commented out in the sample; set via
|
||||||
|
`vigilar config set-pin`.
|
||||||
|
|
||||||
|
### `[mqtt]`
|
||||||
|
|
||||||
|
- `host` (default `127.0.0.1`) and `port` (default `1883`): broker
|
||||||
|
address. Leave on loopback unless you deliberately run a shared
|
||||||
|
broker.
|
||||||
|
- `username`, `password` (default `""`): unused by the shipped
|
||||||
|
mosquitto config, present for operators who run their own broker.
|
||||||
|
|
||||||
|
### `[web]`
|
||||||
|
|
||||||
|
- `host` (default `0.0.0.0`) and `port` (default `49735`): Flask
|
||||||
|
listener. Change `host` to `127.0.0.1` if you front with a reverse
|
||||||
|
proxy.
|
||||||
|
- `tls_cert`, `tls_key` (default `""`): PEM paths. `gen_cert.sh`
|
||||||
|
fills these in.
|
||||||
|
- `username` (default `"admin"`): web UI login name.
|
||||||
|
- `password_hash` (default `""`): scrypt hash set via `vigilar config
|
||||||
|
set-password`.
|
||||||
|
- `session_timeout` (default `3600` seconds).
|
||||||
|
|
||||||
|
### `[zigbee2mqtt]`
|
||||||
|
|
||||||
|
- `mqtt_topic_prefix` (default `"zigbee2mqtt"`): used when subscribing
|
||||||
|
to sensor topics from an external Zigbee2MQTT bridge.
|
||||||
|
|
||||||
|
### `[ups]`
|
||||||
|
|
||||||
|
See also the UPS/NUT section below.
|
||||||
|
|
||||||
|
- `enabled` (default `true`).
|
||||||
|
- `nut_host` (default `127.0.0.1`), `nut_port` (default `3493`),
|
||||||
|
`ups_name` (default `"ups"`): matches the `[ups]` block generated by
|
||||||
|
`setup_nut.sh`.
|
||||||
|
- `poll_interval_s` (default `30`).
|
||||||
|
- `low_battery_threshold_pct` (default `20`, range 5–95).
|
||||||
|
- `critical_runtime_threshold_s` (default `300`).
|
||||||
|
- `shutdown_delay_s` (default `60`).
|
||||||
|
|
||||||
|
### `[storage]`
|
||||||
|
|
||||||
|
- `encrypt_recordings` (default `true`): toggles AES-256-CTR
|
||||||
|
encryption of new `.vge` files. Changing this does not re-encrypt
|
||||||
|
existing recordings.
|
||||||
|
- `key_file` (default `/etc/vigilar/secrets/storage.key`): 32-byte
|
||||||
|
raw key.
|
||||||
|
- `max_disk_usage_gb` (default `200`) and `free_space_floor_gb`
|
||||||
|
(default `10`): **legacy keys**. They are defined on the Pydantic
|
||||||
|
model and exposed in the settings UI, but no pruning or recording
|
||||||
|
code currently reads them. The real disk ceiling is the
|
||||||
|
percentage-based pruner in `[health]`. Do not rely on these two
|
||||||
|
fields to cap disk usage today.
|
||||||
|
|
||||||
|
### `[remote]`
|
||||||
|
|
||||||
|
- `enabled` (default `false`): turns on the remote-access bridge.
|
||||||
|
- `upload_bandwidth_mbps` (default `22.0`): informational ceiling.
|
||||||
|
- `remote_hls_resolution` (default `[426, 240]`), `remote_hls_fps`
|
||||||
|
(default `10`), `remote_hls_bitrate_kbps` (default `500`): quality
|
||||||
|
profile for HLS served over the tunnel.
|
||||||
|
- `max_remote_viewers` (default `4`; `0` = unlimited).
|
||||||
|
- `tunnel_ip` (default `"10.99.0.2"`): WireGuard address of the home
|
||||||
|
server, for display only.
|
||||||
|
|
||||||
|
### `[alerts.local]`
|
||||||
|
|
||||||
|
- `enabled` (default `true`).
|
||||||
|
- `syslog` (default `true`): the supervisor installs a `SysLogHandler`
|
||||||
|
on the `vigilar.alerts` logger when this is true.
|
||||||
|
- `desktop_notify` (default `false`): `notify-send` fallback for
|
||||||
|
operator-console deployments.
|
||||||
|
|
||||||
|
### `[alerts.web_push]`
|
||||||
|
|
||||||
|
- `enabled` (default `true`).
|
||||||
|
- `vapid_private_key_file` (default
|
||||||
|
`/etc/vigilar/secrets/vapid_private.pem`).
|
||||||
|
- `vapid_claim_email` (default `"mailto:admin@vigilar.local"`): used
|
||||||
|
as the VAPID `sub` claim.
|
||||||
|
|
||||||
|
### `[alerts.email]`
|
||||||
|
|
||||||
|
- `enabled` (default `false`).
|
||||||
|
- `smtp_host`, `smtp_port` (default `587`), `from_addr`, `to_addr`,
|
||||||
|
`use_tls` (default `true`).
|
||||||
|
|
||||||
|
### `[alerts.webhook]`
|
||||||
|
|
||||||
|
- `enabled` (default `false`).
|
||||||
|
- `url`, `secret`: HMAC secret signs outbound webhook bodies.
|
||||||
|
|
||||||
|
### `[[cameras]]` (array of tables)
|
||||||
|
|
||||||
|
One block per camera. Keys:
|
||||||
|
|
||||||
|
- `id`, `display_name`, `rtsp_url`: required.
|
||||||
|
- `enabled` (default `true`).
|
||||||
|
- `record_continuous` (default `false`), `record_on_motion` (default
|
||||||
|
`true`).
|
||||||
|
- `motion_sensitivity` (default `0.7`, range 0.0–1.0) and
|
||||||
|
`motion_min_area_px` (default `500`).
|
||||||
|
- `motion_zones`, `zones`: polygon and named-zone overrides.
|
||||||
|
- `pre_motion_buffer_s` (default `5`) and `post_motion_buffer_s`
|
||||||
|
(default `30`).
|
||||||
|
- `idle_fps` (default `2`, range 1–30) and `motion_fps` (default
|
||||||
|
`30`, range 1–60): the adaptive FPS pair.
|
||||||
|
- `retention_days` (default `30`).
|
||||||
|
- `resolution_capture` (default `[1920, 1080]`) and
|
||||||
|
`resolution_motion` (default `[640, 360]`): capture size and the
|
||||||
|
downscale used for MOG2 motion detection.
|
||||||
|
- `location` (default `INTERIOR`): `CameraLocation` enum, used for
|
||||||
|
alert profiles.
|
||||||
|
|
||||||
|
Camera IDs must be unique; the Pydantic root validator rejects
|
||||||
|
duplicates.
|
||||||
|
|
||||||
|
### `[[sensors]]` and `[sensors.gpio]`
|
||||||
|
|
||||||
|
Each `[[sensors]]` block has `id`, `display_name`, `type` (e.g.
|
||||||
|
`CONTACT`, `MOTION`, `TEMPERATURE`), `protocol` (`ZIGBEE`, `ZWAVE`,
|
||||||
|
`GPIO`), `device_address`, `location`, and `enabled` (default
|
||||||
|
`true`). `[sensors.gpio] bounce_time_ms` (default `50`) applies to all
|
||||||
|
GPIO sensors. Sensor IDs must also be unique.
|
||||||
|
|
||||||
|
### `[[rules]]`
|
||||||
|
|
||||||
|
Each rule has `id`, `description`, `conditions` (list of `{type,
|
||||||
|
value, sensor_id, event}` maps), `logic` (`AND` or `OR`, default
|
||||||
|
`AND`), `actions` (list of action names like `alert_all` or
|
||||||
|
`record_all_cameras`), and `cooldown_s` (default `60`).
|
||||||
|
|
||||||
|
### `[detection]` and `[vehicles]`
|
||||||
|
|
||||||
|
- `[detection] person_detection` (default `false`), `model_path`,
|
||||||
|
`model_config_path`, `confidence_threshold` (default `0.5`),
|
||||||
|
`cameras` (empty list means all cameras).
|
||||||
|
- `[[vehicles.known]]` entries define recognised vehicles with
|
||||||
|
`name`, `color_profile`, `size_class`, `calibration_file`.
|
||||||
|
|
||||||
|
### `[presence]`
|
||||||
|
|
||||||
|
- `enabled` (default `false`).
|
||||||
|
- `ping_interval_s` (default `30`) and `departure_delay_m` (default
|
||||||
|
`10`).
|
||||||
|
- `method`: `icmp` or `arping`.
|
||||||
|
- `[[presence.members]]` entries with `name`, `ip`, and `role`
|
||||||
|
(`adult` or `child`).
|
||||||
|
- `actions`: mapping of states (`EMPTY`, `ADULTS_HOME`, `KIDS_HOME`,
|
||||||
|
`ALL_HOME`) to arm states.
|
||||||
|
|
||||||
|
### `[health]`
|
||||||
|
|
||||||
|
This is where pruning actually lives.
|
||||||
|
|
||||||
|
- `enabled` (default `true`).
|
||||||
|
- `disk_warn_pct` (default `85`): warning threshold on the partition
|
||||||
|
hosting `data_dir`.
|
||||||
|
- `disk_critical_pct` (default `95`): critical threshold. When crossed
|
||||||
|
and `auto_prune` is true, the health monitor runs the pruner.
|
||||||
|
- `auto_prune` (default `true`).
|
||||||
|
- `auto_prune_target_pct` (default `80`): pruner deletes the oldest
|
||||||
|
non-starred recordings until disk usage drops below this percentage.
|
||||||
|
- `daily_digest` (default `true`) and `daily_digest_time` (default
|
||||||
|
`"08:00"`).
|
||||||
|
|
||||||
|
### `[pets]`, `[visitors]`, `[highlights]`, `[kiosk]`
|
||||||
|
|
||||||
|
Subsystem-specific toggles. See the subsystem references under
|
||||||
|
`docs/architecture/` for per-key behaviour. Notable defaults: `[pets]
|
||||||
|
enabled = false`, `[visitors] enabled = false`, `[highlights] enabled
|
||||||
|
= true`, `[kiosk] ambient_enabled = true`.
|
||||||
|
|
||||||
|
### `[location]` and `[security]`
|
||||||
|
|
||||||
|
- `[location] latitude`, `longitude` (default `0.0`): used for sunrise
|
||||||
|
and sunset lookups.
|
||||||
|
- `[security] pin_hash` and `recovery_passphrase_hash`: populated by
|
||||||
|
`vigilar config set-pin` (the same hash is also stored under
|
||||||
|
`[system] arm_pin_hash` on the `system` model; both fields exist
|
||||||
|
because the web UI uses `[security]` while the CLI helper prints a
|
||||||
|
`[system]` line — pick one location and stick with it).
|
||||||
|
|
||||||
|
## CLI reference
|
||||||
|
|
||||||
|
The entry point is `/opt/vigilar/venv/bin/vigilar`. All commands
|
||||||
|
accept `--version`. In production, run subcommands as the service user
|
||||||
|
so file ownership and venv paths line up:
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo -u vigilar /opt/vigilar/venv/bin/vigilar <subcommand>
|
||||||
|
```
|
||||||
|
|
||||||
|
The CLI exposes exactly two top-level commands: `start` and `config`.
|
||||||
|
|
||||||
|
### `vigilar start`
|
||||||
|
|
||||||
|
Starts all services under the supervisor.
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo -u vigilar /opt/vigilar/venv/bin/vigilar start \
|
||||||
|
--config /etc/vigilar/vigilar.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Options: `--config/-c PATH` (defaults to `$VIGILAR_CONFIG` then
|
||||||
|
`config/vigilar.toml`); `--log-level {DEBUG,INFO,WARNING,ERROR}`
|
||||||
|
(overrides `[system] log_level`). On invocation it loads and validates
|
||||||
|
the config, configures a console log formatter, prints a startup
|
||||||
|
summary (camera count, sensor count, UPS state), then hands off to
|
||||||
|
`vigilar.main.run_supervisor`.
|
||||||
|
|
||||||
|
### `vigilar config validate`
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo -u vigilar /opt/vigilar/venv/bin/vigilar config validate \
|
||||||
|
-c /etc/vigilar/vigilar.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Parses and validates the TOML against the Pydantic models and prints
|
||||||
|
a summary. Exits non-zero if validation fails. Run this after every
|
||||||
|
edit before restarting the service.
|
||||||
|
|
||||||
|
### `vigilar config show`
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo -u vigilar /opt/vigilar/venv/bin/vigilar config show \
|
||||||
|
-c /etc/vigilar/vigilar.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Dumps the parsed config as JSON with `web.password_hash`,
|
||||||
|
`system.arm_pin_hash`, and `alerts.webhook.secret` redacted. Useful
|
||||||
|
for confirming which defaults Pydantic applied for keys you did not
|
||||||
|
set.
|
||||||
|
|
||||||
|
### `vigilar config set-password`
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo -u vigilar /opt/vigilar/venv/bin/vigilar config set-password
|
||||||
|
```
|
||||||
|
|
||||||
|
Prompts for a web UI password (hidden, confirmed), derives a scrypt
|
||||||
|
hash (`n=16384, r=8, p=1`, random 16-byte salt, 32-byte output), and
|
||||||
|
prints a `password_hash = "salt_hex:key_hex"` line to paste into
|
||||||
|
`[web]`. It does not write the file.
|
||||||
|
|
||||||
|
### `vigilar config set-pin`
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo -u vigilar /opt/vigilar/venv/bin/vigilar config set-pin
|
||||||
|
```
|
||||||
|
|
||||||
|
Prompts for an arm/disarm PIN, generates a random 32-byte HMAC key,
|
||||||
|
computes `HMAC-SHA256(key, pin)`, and prints an `arm_pin_hash =
|
||||||
|
"secret_hex:mac_hex"` line to paste into `[system]`. Again, no file
|
||||||
|
write.
|
||||||
|
|
||||||
|
## Secrets and security
|
||||||
|
|
||||||
|
- `/etc/vigilar/secrets/` is `root:root` mode `0700`. The `vigilar`
|
||||||
|
user cannot list it. Individual files the service needs (for
|
||||||
|
example `vapid_public.txt`) are readable by group `vigilar`.
|
||||||
|
- The storage encryption key is `/etc/vigilar/secrets/storage.key`:
|
||||||
|
32 raw bytes. **If this file is lost, every existing `.vge`
|
||||||
|
recording becomes unrecoverable.** Back it up separately (and
|
||||||
|
offline) from your tar archive whenever you take the system into
|
||||||
|
production.
|
||||||
|
- Recordings use **AES-256-CTR** (see `vigilar/storage/encryption.py`).
|
||||||
|
CTR provides confidentiality but no authentication: `.vge` files
|
||||||
|
are confidential but not tamper-evident. An attacker with write
|
||||||
|
access to the recordings directory can flip bits in a ciphertext
|
||||||
|
without detection. If tamper-evidence matters, keep the recordings
|
||||||
|
volume on integrity-verified storage (dm-integrity, ZFS with
|
||||||
|
checksums) or mirror to write-once media.
|
||||||
|
- The web UI password is a scrypt hash set by `vigilar config
|
||||||
|
set-password` and stored at `[web] password_hash`. The arm PIN is
|
||||||
|
an HMAC stored at `[system] arm_pin_hash` (and/or `[security]
|
||||||
|
pin_hash`).
|
||||||
|
- TLS: `gen_cert.sh` uses `mkcert` if present, otherwise an `openssl`
|
||||||
|
ECDSA P-256 self-signed certificate valid for 3650 days with SANs
|
||||||
|
for `vigilar.local`, `localhost`, `127.0.0.1`, and the detected LAN
|
||||||
|
IP. It patches `[web] tls_cert`/`tls_key` into the config.
|
||||||
|
- VAPID: `gen_vapid_keys.sh` writes
|
||||||
|
`/etc/vigilar/secrets/vapid_private.pem` (mode 0600) and
|
||||||
|
`/etc/vigilar/secrets/vapid_public.txt` (the browser-side key).
|
||||||
|
- Firewall stance: the mosquitto broker and NUT daemon bind only to
|
||||||
|
`127.0.0.1`. The only port Vigilar exposes on the LAN is the web UI
|
||||||
|
port (default `49735`). Open that port only on the interface that
|
||||||
|
serves your LAN, and keep WAN exposure behind the WireGuard tunnel
|
||||||
|
described under `[remote]`.
|
||||||
|
|
||||||
|
## UPS and NUT integration
|
||||||
|
|
||||||
|
`scripts/setup_nut.sh` installs NUT, attempts to detect a USB UPS
|
||||||
|
(using `nut-scanner` first, then a short list of vendor IDs as a
|
||||||
|
fallback), and writes a standalone configuration:
|
||||||
|
|
||||||
|
- `/etc/nut/nut.conf` with `MODE=standalone`.
|
||||||
|
- `/etc/nut/ups.conf` with `[ups] driver=usbhid-ups port=auto` (the
|
||||||
|
block name `ups` matches the default `[ups] ups_name`).
|
||||||
|
- `/etc/nut/upsd.conf` with `LISTEN 127.0.0.1 3493` — loopback only.
|
||||||
|
- `/etc/nut/upsd.users` with a `vigilar` local monitoring user.
|
||||||
|
- `/etc/nut/upsmon.conf` pointing at `ups@localhost`.
|
||||||
|
|
||||||
|
It then enables `nut-driver`, `nut-server`, and `nut-monitor` (or
|
||||||
|
`upsd`/`upsmon` on distros that ship the old unit names). Test with
|
||||||
|
`upsc ups@localhost`. The Vigilar UPS subsystem polls this daemon
|
||||||
|
using the keys under `[ups]`.
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
`scripts/backup.sh` produces
|
||||||
|
`${VIGILAR_BACKUP_DIR:-/var/vigilar/backups}/vigilar-backup-YYYYMMDD-HHMMSS.tar.gz`
|
||||||
|
and includes:
|
||||||
|
|
||||||
|
- A consistent SQLite snapshot produced with `sqlite3 … .backup` (or
|
||||||
|
a direct file copy if `sqlite3` is not available), plus any
|
||||||
|
`-wal`/`-shm` files.
|
||||||
|
- The entire `/etc/vigilar/` tree (config, secrets, certs).
|
||||||
|
|
||||||
|
It does **not** include `/var/vigilar/recordings` or
|
||||||
|
`/var/vigilar/hls`. Video is assumed to be either expendable or
|
||||||
|
handled by a separate storage tier.
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
|
||||||
|
- `VIGILAR_BACKUP_DIR` — destination directory (default
|
||||||
|
`/var/vigilar/backups`).
|
||||||
|
- `VIGILAR_BACKUP_RETENTION_DAYS` — age in days after which old
|
||||||
|
archives are pruned; set to `0` to keep forever (default `30`).
|
||||||
|
|
||||||
|
The archive is `chmod 0600 root:root` because it contains secrets.
|
||||||
|
|
||||||
|
### Scheduling
|
||||||
|
|
||||||
|
You can run it from cron, as the script comment suggests
|
||||||
|
(`0 3 * * * /opt/vigilar/scripts/backup.sh`), or via a dedicated
|
||||||
|
systemd timer. A minimal pair of units, kept in your local systemd
|
||||||
|
directory (not in the repo):
|
||||||
|
|
||||||
|
```
|
||||||
|
# /etc/systemd/system/vigilar-backup.service
|
||||||
|
[Unit]
|
||||||
|
Description=Vigilar nightly backup
|
||||||
|
After=vigilar.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
Environment=VIGILAR_BACKUP_DIR=/srv/backups/vigilar
|
||||||
|
ExecStart=/opt/vigilar/scripts/backup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
# /etc/systemd/system/vigilar-backup.timer
|
||||||
|
[Unit]
|
||||||
|
Description=Run Vigilar backup nightly
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 03:00:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable with `sudo systemctl enable --now vigilar-backup.timer`.
|
||||||
|
|
||||||
|
### Restore
|
||||||
|
|
||||||
|
1. `sudo systemctl stop vigilar.service`.
|
||||||
|
2. Extract the archive to a staging directory.
|
||||||
|
3. Copy `etc/vigilar/` back into `/etc/vigilar/`, preserving
|
||||||
|
permissions. Double-check `/etc/vigilar/secrets/storage.key` is
|
||||||
|
`root:root 0600`.
|
||||||
|
4. Copy the database snapshot to `/var/vigilar/data/vigilar.db` and
|
||||||
|
remove any stale `vigilar.db-wal`/`vigilar.db-shm` files.
|
||||||
|
5. `sudo chown -R vigilar:vigilar /var/vigilar/data`.
|
||||||
|
6. `sudo -u vigilar /opt/vigilar/venv/bin/vigilar config validate
|
||||||
|
-c /etc/vigilar/vigilar.toml`.
|
||||||
|
7. `sudo systemctl start vigilar.service` and watch the journal.
|
||||||
|
|
||||||
|
## Upgrades
|
||||||
|
|
||||||
|
1. `sudo systemctl stop vigilar.service`.
|
||||||
|
2. `cd /path/to/vigilar && git pull`.
|
||||||
|
3. `sudo -u vigilar /opt/vigilar/venv/bin/pip install --upgrade .`
|
||||||
|
4. Diff the shipped `config/vigilar.toml` against `/etc/vigilar/
|
||||||
|
vigilar.toml` and merge any new keys by hand; Pydantic will reject
|
||||||
|
unknown keys but is tolerant of missing keys that have defaults.
|
||||||
|
5. `sudo -u vigilar /opt/vigilar/venv/bin/vigilar config validate
|
||||||
|
-c /etc/vigilar/vigilar.toml`.
|
||||||
|
6. `sudo systemctl start vigilar.service`.
|
||||||
|
|
||||||
|
**Schema migrations:** there is no migration framework. Vigilar does
|
||||||
|
not ship Alembic; `vigilar/storage/schema.py` defines the tables
|
||||||
|
(`cameras`, `sensors`, `sensor_states`, `events`, `recordings`,
|
||||||
|
`system_events`, `arm_state_log`, `alert_log`, `push_subscriptions`,
|
||||||
|
`pets`, `pet_sightings`, `wildlife_sightings`, `package_events`,
|
||||||
|
`pet_training_images`, `pet_rules`, `face_profiles`, `face_embeddings`,
|
||||||
|
`visits`, `timelapse_schedules`) and new columns are added by code
|
||||||
|
path at startup or not at all. Take a backup before every upgrade so
|
||||||
|
you can roll back if a column assumption changes.
|
||||||
|
|
||||||
|
## Logs and health
|
||||||
|
|
||||||
|
All subsystem output goes to the journal under the `vigilar` syslog
|
||||||
|
identifier:
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo journalctl -u vigilar.service -f
|
||||||
|
sudo journalctl -u vigilar.service --since "1 hour ago"
|
||||||
|
sudo journalctl -u vigilar.service -p warning
|
||||||
|
```
|
||||||
|
|
||||||
|
The alerts subsystem additionally mirrors messages to syslog via the
|
||||||
|
`vigilar.alerts` logger when `[alerts.local] syslog = true`, which is
|
||||||
|
the default; the supervisor installs the handler at startup.
|
||||||
|
|
||||||
|
Set `[system] log_level = "DEBUG"` (or pass `--log-level DEBUG` to
|
||||||
|
`vigilar start`) to trace MQTT traffic, motion scoring, and FFmpeg
|
||||||
|
invocations. Expect a significant volume increase; revert to `INFO`
|
||||||
|
once you have the evidence you need.
|
||||||
|
|
||||||
|
The only HTTP endpoint currently exposing health is
|
||||||
|
`GET /system/status` on the web UI, which returns a JSON blob with
|
||||||
|
arm state, camera counts, and sensor counts. The richer health data
|
||||||
|
(disk percentage, MQTT reachability) is published to the
|
||||||
|
`vigilar/system/health` MQTT topic by `HealthMonitor` every ten
|
||||||
|
seconds and is not yet surfaced as a REST endpoint.
|
||||||
|
|
||||||
|
## Pruning and disk management
|
||||||
|
|
||||||
|
`vigilar/health/monitor.py` runs a disk check every five minutes
|
||||||
|
against `[system] data_dir` using `shutil.disk_usage`. When usage
|
||||||
|
crosses `[health] disk_critical_pct` and `[health] auto_prune` is
|
||||||
|
true, it calls `vigilar.health.pruner.auto_prune`:
|
||||||
|
|
||||||
|
- Selects up to 20 unstarred recordings at a time, ordered oldest
|
||||||
|
first.
|
||||||
|
- Deletes the file on disk, any thumbnail, and the row from the
|
||||||
|
`recordings` table.
|
||||||
|
- Loops until disk usage drops below `[health] auto_prune_target_pct`
|
||||||
|
or no more candidates exist.
|
||||||
|
|
||||||
|
Starred recordings (`recordings.starred = 1`) are never auto-pruned.
|
||||||
|
Per-camera `retention_days` is enforced separately by the camera
|
||||||
|
subsystem. There is no hard byte ceiling; the pruner is entirely
|
||||||
|
percentage-driven. The `[storage] max_disk_usage_gb` and
|
||||||
|
`[storage] free_space_floor_gb` keys described above are not
|
||||||
|
consulted by the pruner.
|
||||||
|
|
||||||
|
## Remote access
|
||||||
|
|
||||||
|
`[remote]` controls the lower-bitrate HLS profile that Vigilar serves
|
||||||
|
through a WireGuard tunnel. The tunnel itself is not set up by this
|
||||||
|
project — you are expected to bring your own WireGuard server and
|
||||||
|
peer configuration. Once the tunnel is up:
|
||||||
|
|
||||||
|
- `enabled = true` turns on the remote bridge.
|
||||||
|
- `tunnel_ip` is the home server's address inside the tunnel (default
|
||||||
|
`10.99.0.2`), shown in the UI for reference.
|
||||||
|
- `upload_bandwidth_mbps` caps the advertised upstream.
|
||||||
|
- `remote_hls_resolution`, `remote_hls_fps`, `remote_hls_bitrate_kbps`
|
||||||
|
define the transcode profile used when a client connects through
|
||||||
|
the tunnel instead of the LAN.
|
||||||
|
- `max_remote_viewers` bounds concurrent remote sessions; set to `0`
|
||||||
|
for unlimited.
|
||||||
|
|
||||||
|
Do not expose port `49735` directly on the WAN; require the tunnel.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- **Recording integrity is not authenticated.** AES-256-CTR gives you
|
||||||
|
confidentiality, not tamper-evidence. If an attacker reaches the
|
||||||
|
recordings directory they can modify ciphertext unnoticed. See the
|
||||||
|
security section.
|
||||||
|
- **Camera supervision is asymmetric.** Most subsystems run under
|
||||||
|
`SubsystemProcess` in `vigilar/main.py`, which polls every two
|
||||||
|
seconds and applies an exponential backoff up to `max_restarts=10`.
|
||||||
|
Cameras do not: `CameraManager` in `vigilar/camera/manager.py`
|
||||||
|
owns its own per-camera child processes outside that supervisor.
|
||||||
|
A repeatedly crashing camera may thrash differently from, say, a
|
||||||
|
crashing UPS poller. Watch the journal for per-camera restart
|
||||||
|
messages independently from the top-level supervisor log.
|
||||||
|
- **Legacy storage keys.** `[storage] max_disk_usage_gb` and
|
||||||
|
`[storage] free_space_floor_gb` are editable but do nothing. Use
|
||||||
|
`[health]` for real disk policy.
|
||||||
|
- **No schema migrations.** There is no Alembic (or equivalent) in
|
||||||
|
the tree. Rollbacks rely on your backup discipline.
|
||||||
|
- **Duplicate PIN fields.** `vigilar config set-pin` writes to
|
||||||
|
`[system] arm_pin_hash`, while the web arm/disarm flow reads from
|
||||||
|
`[security] pin_hash`. Both models exist. If you set one and the
|
||||||
|
other side does not behave as expected, mirror the value manually.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Supervisor crash loops.** `journalctl -u vigilar.service` will show
|
||||||
|
a subsystem crashing and the supervisor attempting to restart it. If
|
||||||
|
the same subsystem exceeds ten restarts, the supervisor gives up on
|
||||||
|
that subsystem and logs `exceeded max restarts, giving up`. Fix the
|
||||||
|
root cause (bad config, missing secret, missing model file for
|
||||||
|
detection) and restart the unit.
|
||||||
|
|
||||||
|
**Mosquitto will not start.** Confirm that
|
||||||
|
`/etc/mosquitto/conf.d/vigilar.conf` is present and that no other
|
||||||
|
listener is bound to `127.0.0.1:1883`. Run `sudo systemctl status
|
||||||
|
mosquitto.service` and `sudo journalctl -u mosquitto.service`. The
|
||||||
|
Vigilar unit `Requires=mosquitto.service`, so Vigilar will refuse to
|
||||||
|
start until mosquitto is healthy.
|
||||||
|
|
||||||
|
**Camera thrashing.** Because cameras are not under the main
|
||||||
|
supervisor's backoff, a camera whose RTSP URL is wrong or whose
|
||||||
|
remote end is rebooting can respawn quickly. Look for repeated
|
||||||
|
`camera <id>` messages in the journal. Disable the camera in the
|
||||||
|
config (`enabled = false`) while you fix the upstream, then
|
||||||
|
re-enable.
|
||||||
|
|
||||||
|
**Disk full.** Check `[health] disk_critical_pct` and confirm
|
||||||
|
`auto_prune` is on. If the partition is already past the target
|
||||||
|
percentage and nothing is being deleted, there are no unstarred
|
||||||
|
recordings left to prune — unstar something or lower retention. The
|
||||||
|
legacy `[storage]` keys will not help here; see the pruning section.
|
||||||
|
|
||||||
|
**HLS stalls.** The HLS directory lives at `[system] hls_dir`
|
||||||
|
(default `/var/vigilar/hls`) and is mounted `ReadWritePath` in the
|
||||||
|
systemd unit. Stalls usually mean FFmpeg has died on a camera;
|
||||||
|
check the journal for FFmpeg stderr and verify the RTSP URL is still
|
||||||
|
reachable from the server with `ffprobe`.
|
||||||
|
|
||||||
|
**Config validation fails.** Run `sudo -u vigilar
|
||||||
|
/opt/vigilar/venv/bin/vigilar config validate -c
|
||||||
|
/etc/vigilar/vigilar.toml`. Pydantic error messages include the
|
||||||
|
section, key, and reason. The two common traps are duplicate camera
|
||||||
|
or sensor IDs (root validator rejects them) and a TOML table that
|
||||||
|
should be an array of tables (`[cameras]` instead of `[[cameras]]`).
|
||||||
|
|
||||||
|
**Forgotten arm PIN.** Run `vigilar config set-pin` to mint a new
|
||||||
|
hash and paste it in; restart the service. If you also forgot the
|
||||||
|
recovery passphrase set up through the UI, the web
|
||||||
|
`/system/api/reset-pin` endpoint cannot help you — fall back to the
|
||||||
|
CLI.
|
||||||
|
|
||||||
|
**Forgotten web password.** Run `vigilar config set-password` and
|
||||||
|
paste the new hash into `[web] password_hash`, then restart. No
|
||||||
|
database state needs to change.
|
||||||
1720
docs/superpowers/plans/2026-04-05-project-documentation.md
Normal file
1720
docs/superpowers/plans/2026-04-05-project-documentation.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,202 @@
|
|||||||
|
# Vigilar Project Documentation — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-04-05
|
||||||
|
**Status:** Approved, ready for implementation plan
|
||||||
|
**Scope:** Create user-facing and architectural documentation for Vigilar, plus a polished top-level `README.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
Vigilar currently has no top-level `README.md`, no user guide, and no architectural reference. Contributors and would-be home users have to read source code to understand what the project is or how to run it. This effort closes that gap with a single coordinated documentation pass.
|
||||||
|
|
||||||
|
The docs must:
|
||||||
|
|
||||||
|
- Give a homeowner with a mini PC a clear linear path from bare hardware to working cameras on their phone.
|
||||||
|
- Give a self-hoster a reference for config, CLI, secrets, backups, upgrades, and troubleshooting.
|
||||||
|
- Give a contributor enough architectural context to navigate the codebase without reading every file.
|
||||||
|
- Match the project's ethos: plain, no bloat, no cloud, no extra tooling.
|
||||||
|
|
||||||
|
## 2. Non-goals
|
||||||
|
|
||||||
|
- No doc site / MkDocs build (files are organized *as if* MkDocs-ready, but no tooling is added).
|
||||||
|
- No rewrites of existing code or config.
|
||||||
|
- No changes to `docs/camera-hardware-guide.md` (already exists, untouched).
|
||||||
|
- No changes to anything under `docs/superpowers/` other than this spec.
|
||||||
|
- No CI doc-linting, no link-checker automation beyond a one-time manual verification pass.
|
||||||
|
- No recording-backup-to-NAS feature work. Docs describe only what exists today; future backup improvements are noted as planned, not documented as present.
|
||||||
|
|
||||||
|
## 3. Audience & doc layout (Approach 3 — Hybrid)
|
||||||
|
|
||||||
|
Three organizing principles, one tree:
|
||||||
|
|
||||||
|
- **User-facing guides** are monolithic linear narratives (humans read top to bottom once).
|
||||||
|
- **Architecture docs** are split reference material (contributors jump to the subsystem they're touching).
|
||||||
|
- **README** is the front door that ties everything together.
|
||||||
|
|
||||||
|
Final tree:
|
||||||
|
|
||||||
|
```
|
||||||
|
README.md # NEW — top-level front door
|
||||||
|
docs/
|
||||||
|
├── home-user-guide.md # NEW — monolithic, linear
|
||||||
|
├── operator-guide.md # NEW — monolithic, reference
|
||||||
|
├── architecture/
|
||||||
|
│ ├── overview.md # NEW — process model, bus, data flow
|
||||||
|
│ ├── conventions.md # NEW — coding conventions distilled
|
||||||
|
│ └── subsystems/
|
||||||
|
│ ├── camera.md # NEW
|
||||||
|
│ ├── detection.md # NEW
|
||||||
|
│ ├── events.md # NEW
|
||||||
|
│ ├── alerts.md # NEW
|
||||||
|
│ ├── sensors.md # NEW
|
||||||
|
│ ├── ups.md # NEW
|
||||||
|
│ ├── storage.md # NEW
|
||||||
|
│ ├── highlights.md # NEW
|
||||||
|
│ ├── presence.md # NEW
|
||||||
|
│ ├── pets.md # NEW
|
||||||
|
│ ├── health.md # NEW
|
||||||
|
│ └── web.md # NEW
|
||||||
|
└── camera-hardware-guide.md # EXISTING — untouched
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. `README.md` (top-level)
|
||||||
|
|
||||||
|
Sells the project, orients newcomers, links into the doc tree.
|
||||||
|
|
||||||
|
**Required sections, in order:**
|
||||||
|
|
||||||
|
1. **Title + tagline** — "Vigilar — DIY offline-first home security"
|
||||||
|
2. **One-line pitch** (~20 words)
|
||||||
|
3. **Hero image placeholder** — `` with a comment noting it's a placeholder. Do not create the image.
|
||||||
|
4. **Why Vigilar** — bullet list of differentiators: offline-first, HLS grid + MJPEG single, OpenCV MOG2 motion with 5-sec pre-motion ring buffer, event timeline + highlight reels + timelapses, visitor/pet/wildlife tracking, PWA with VAPID push (no Firebase), AES-256 encrypted recordings, NUT UPS monitoring, runs on cheap mini PC.
|
||||||
|
5. **Quick paths table** — 4 rows mapping intent → doc: home user guide, operator guide, architecture overview, camera hardware guide.
|
||||||
|
6. **60-second overview** — small ASCII diagram (cameras → mini PC → phone/browser) + 4 tech-stack bullets (Python 3.11, Flask+Bootstrap 5, SQLite WAL, MQTT, FFmpeg).
|
||||||
|
7. **Status** — "Alpha. Works on the author's hardware. Expect rough edges and breaking changes."
|
||||||
|
8. **Installation TL;DR** — 3-line fenced block (`git clone`, `sudo ./scripts/install.sh`, `sudo systemctl start vigilar`), followed by links to the full home-user and operator guides.
|
||||||
|
9. **Documentation** — nested bullet list mirroring the `docs/` tree, each entry with a one-line description.
|
||||||
|
10. **License** — GPL-3.0. If no `LICENSE` file exists, the README states the intent and notes a `LICENSE` file will follow; we do **not** create the license file as part of this effort unless asked.
|
||||||
|
11. **Contributing** — short stub: "Issues and PRs welcome. See `CLAUDE.md` for code conventions."
|
||||||
|
|
||||||
|
## 5. `docs/home-user-guide.md`
|
||||||
|
|
||||||
|
Monolithic, linear, ~1500–2500 words. Target reader: homeowner with a mini PC, some Linux comfort, wants cameras on phone.
|
||||||
|
|
||||||
|
**Required sections, in order:**
|
||||||
|
|
||||||
|
1. **What you'll end up with** — outcome in 2 sentences plus a small ASCII diagram.
|
||||||
|
2. **What you need** — hardware checklist: mini PC (x86_64, 4GB+ RAM, 128GB+ SSD), USB stick for OS install, one or more RTSP cameras, phone, optional NAS. Link to `camera-hardware-guide.md` for camera picks.
|
||||||
|
3. **Step 1 — Install Debian/Ubuntu Server on the mini PC** — brief, points at upstream installer docs, tells the user to enable SSH. No hand-holding on the OS install itself.
|
||||||
|
4. **Step 2 — Get Vigilar onto the box** — `git clone`, `sudo ./scripts/install.sh`, plus 3 bullets summarizing what `install.sh` does (read `scripts/install.sh` at write-time to ground these bullets).
|
||||||
|
5. **Step 3 — First boot** — `sudo systemctl enable --now vigilar`, then open `http://<mini-pc-ip>:49735` in a browser on the same LAN. Mention the port is configurable under `[web]` in `vigilar.toml`.
|
||||||
|
6. **Step 4 — Set your PIN** — UI walkthrough, 2–3 sentences, screenshot placeholder.
|
||||||
|
7. **Step 5 — Add your first camera** — UI walkthrough: RTSP URL, credentials, test stream, save. Point at `camera-hardware-guide.md` for URL formats.
|
||||||
|
8. **Step 6 — Phone push notifications (PWA)** — open web UI on phone, "Add to Home Screen", allow notifications. Under-the-hood note: VAPID keys already generated by `install.sh`.
|
||||||
|
9. **Step 7 — Optional: NAS backup of config + database** — mount NAS share at `/mnt/nas/vigilar-backups`, set `VIGILAR_BACKUP_DIR`, and set up a nightly run of `scripts/backup.sh`. The guide provides a copy-pasteable systemd service + timer snippet inline (no new units are shipped in the repo as part of this effort). **Explicitly state** that this backs up DB + `/etc/vigilar` (config + secrets) only, and that **recordings stay local** — point at a "planned" note for recording backup.
|
||||||
|
10. **Troubleshooting** — camera won't connect, no push notifications, service won't start (`journalctl -u vigilar`), motion detection too sensitive, how to reset PIN.
|
||||||
|
11. **Where to go next** — links to Operator Guide and Architecture Overview.
|
||||||
|
|
||||||
|
**Grounding rule:** every shell command must correspond to a real file in `scripts/` or a real `vigilar` CLI subcommand. Verify before writing.
|
||||||
|
|
||||||
|
## 6. `docs/operator-guide.md`
|
||||||
|
|
||||||
|
Monolithic, reference-oriented, ~2500–4000 words. Target reader: self-hoster tuning, upgrading, securing.
|
||||||
|
|
||||||
|
**Required sections, in order:**
|
||||||
|
|
||||||
|
1. **Audience & scope** — for admins, not first-time home users. Points at home-user-guide.md for initial setup.
|
||||||
|
2. **Layout on disk** — table of `/opt/vigilar`, `/etc/vigilar/{vigilar.toml, certs/, secrets/}`, `/var/vigilar/{data/vigilar.db, recordings/, hls/, backups/}`.
|
||||||
|
3. **Installation** — what `scripts/install.sh` does, `systemd/vigilar.service` summary, `systemd/vigilar-mosquitto.conf` summary, system dependencies (ffmpeg, mosquitto, sqlite3, Python 3.11+).
|
||||||
|
4. **Configuration reference (`vigilar.toml`)** — one subsection per `[section]` in the default TOML. Each key: default, what it controls, when to change. Sections to cover (from current `config/vigilar.toml`): `system`, `mqtt`, `web`, `zigbee2mqtt`, `ups`, `storage`, `remote`, `alerts.local`, `alerts.web_push`, `alerts.email`, plus any additional sections discovered by re-reading the TOML at write-time.
|
||||||
|
5. **CLI reference (`vigilar ...`)** — enumerated at write-time by reading `vigilar/cli/`. One subsection per top-level command. Do not guess commands.
|
||||||
|
6. **Secrets & security** — `/etc/vigilar/secrets/` layout and permissions; `vigilar config set-pin`; `vigilar config set-password`; TLS via `scripts/gen_cert.sh` → `[web] tls_cert/tls_key`; VAPID via `scripts/gen_vapid_keys.sh`; storage encryption key (`storage.key`) — **explicit warning: do not lose it, recordings are unrecoverable without it**; recommended firewall stance (LAN-only by default).
|
||||||
|
7. **UPS / NUT integration** — `scripts/setup_nut.sh`, `[ups]` options, shutdown behavior, low-battery thresholds.
|
||||||
|
8. **Backups** — what `scripts/backup.sh` captures (DB + `/etc/vigilar`) and what it does **not** (recordings); `VIGILAR_BACKUP_DIR` and `VIGILAR_BACKUP_RETENTION_DAYS`; copy-pasteable systemd service + timer snippet (inline in the doc; no new unit files added to the repo); restore procedure.
|
||||||
|
9. **Upgrades** — `git pull` + `pip install -e .` + `systemctl restart vigilar`; rollback by restoring a backup tarball. If DB migrations exist, note how they're applied; if they don't, say so.
|
||||||
|
10. **Logs & health** — `journalctl -u vigilar`, `log_level` in `[system]`, health endpoints (enumerated at write-time by reading `vigilar/web/blueprints/system.py` and `vigilar/health/`).
|
||||||
|
11. **Remote access** — `[remote]` section, tunnel-based remote HLS, bandwidth-shaped downscaled streams, reiterated not-a-cloud.
|
||||||
|
12. **Troubleshooting** — service crash loops, MQTT broker won't start, camera worker thrashing, disk full / `free_space_floor_gb` triggered, HLS stalling.
|
||||||
|
|
||||||
|
**Grounding rule:** every TOML key, every CLI command, every file path, every endpoint must be verified against the current code before writing. Any that can't be verified must be omitted, not guessed.
|
||||||
|
|
||||||
|
## 7. `docs/architecture/overview.md`
|
||||||
|
|
||||||
|
~1000–1500 words. Target reader: contributor new to the codebase.
|
||||||
|
|
||||||
|
**Required sections, in order:**
|
||||||
|
|
||||||
|
1. **Design principles** — offline-first, subsystem isolation via multiprocessing, loose coupling via local MQTT bus, SQLite WAL as single durable store, SQLAlchemy Core (not ORM), adaptive FPS (2 idle / 30 on motion) with ring buffer.
|
||||||
|
2. **Process topology** — ASCII or Mermaid diagram showing parent supervisor + N subsystem processes + mosquitto + Flask web.
|
||||||
|
3. **The MQTT bus** — broker location, topic naming convention `vigilar/<subsystem>/<entity>/<event>`, retention/QoS notes (verify at write-time), rationale for MQTT over an in-process queue.
|
||||||
|
4. **Data flow: the motion → alert path** — numbered sequence from RTSP capture through motion detection, recording, event creation, highlight scoring, push notification, and UI update. Each step names the actual file/function where it happens (verify at write-time).
|
||||||
|
5. **Storage layout** — SQLite table summary (enumerate at write-time by reading `vigilar/storage/`), recordings (`.vge`, AES-256-GCM, key path), HLS segments, backups.
|
||||||
|
6. **Configuration & secrets** — TOML → Pydantic v2 validation, secrets as file paths (never inline), PIN & password hashing with constant-time compare.
|
||||||
|
7. **The web tier** — Flask + Blueprints, Jinja2 + Bootstrap 5 dark, HLS grid + MJPEG single view rationale, PWA + VAPID.
|
||||||
|
8. **What's NOT in the critical path** — remote access (optional), email alerts (optional), cloud (never).
|
||||||
|
|
||||||
|
## 8. `docs/architecture/conventions.md`
|
||||||
|
|
||||||
|
~400 words. Distilled from `CLAUDE.md` but written for human contributors, not the AI. Covers: StrEnum for string constants (`vigilar/constants.py`), SQLAlchemy Core only (no mapped ORM classes), type hints on public functions, no docstrings unless logic is non-obvious, Ruff line-length 100, multiprocessing-per-subsystem rule, MQTT topic naming, Pydantic-validated TOML config, secrets-as-file-paths.
|
||||||
|
|
||||||
|
## 9. `docs/architecture/subsystems/*.md` (12 files)
|
||||||
|
|
||||||
|
One file per subdirectory under `vigilar/`: `camera`, `detection`, `events`, `alerts`, `sensors`, `ups`, `storage`, `highlights`, `presence`, `pets`, `health`, `web`.
|
||||||
|
|
||||||
|
**Uniform template** (≈150–400 words each):
|
||||||
|
|
||||||
|
```
|
||||||
|
# <Subsystem name>
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
One paragraph — what this subsystem is responsible for.
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
- `vigilar/<sub>/foo.py` — role
|
||||||
|
- ...
|
||||||
|
|
||||||
|
## MQTT topics
|
||||||
|
**Subscribes:** `vigilar/...`
|
||||||
|
**Publishes:** `vigilar/...`
|
||||||
|
|
||||||
|
## Database tables
|
||||||
|
`table_name` — what it holds. Or "none."
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
- sister subsystem X (via topic Y)
|
||||||
|
|
||||||
|
## Consumed by
|
||||||
|
- sister subsystem Z (via topic W)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
Gotchas or perf notes, only if any.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Grounding rule (hard):** every topic name, every table name, every file role must come from reading the actual code. If a topic cannot be found, the doc must say "no MQTT publishers found at time of writing" — not invent one. This rule is the most important verification step in the plan.
|
||||||
|
|
||||||
|
## 10. Verification checklist (before completion)
|
||||||
|
|
||||||
|
1. **Link check** — every relative link in every new file resolves to a real path.
|
||||||
|
2. **Command check** — every shell command in the user guides exists as a real script under `scripts/` or a real `vigilar` CLI subcommand.
|
||||||
|
3. **Grounding check** — every topic name, table name, file path, and endpoint is verified against code, or omitted. Nothing guessed.
|
||||||
|
4. **TOML coverage check** — every `[section]` in `config/vigilar.toml` is covered in the operator guide's configuration reference.
|
||||||
|
5. **Subsystem coverage check** — every subdirectory in `vigilar/` (matching the 12-file list) has a corresponding subsystem doc.
|
||||||
|
6. **Read-through pass** — tone and terminology consistent across all files.
|
||||||
|
7. **README link check** — all doc tree links in `README.md` resolve.
|
||||||
|
|
||||||
|
## 11. Out of scope (explicit)
|
||||||
|
|
||||||
|
- `LICENSE` file creation (the README declares GPL-3.0; creating the file is a separate request).
|
||||||
|
- Screenshot/image creation (placeholders only).
|
||||||
|
- MkDocs configuration.
|
||||||
|
- Any code changes.
|
||||||
|
- Any changes to `docs/camera-hardware-guide.md`.
|
||||||
|
- Any doc-linting CI.
|
||||||
|
- Recording-backup-to-NAS feature or docs beyond the "planned" note.
|
||||||
|
- Migration documentation beyond noting whether migrations exist.
|
||||||
|
|
||||||
|
## 12. Success criteria
|
||||||
|
|
||||||
|
- A new homeowner can go from a bare mini PC to working cameras on their phone using only `README.md` + `docs/home-user-guide.md`.
|
||||||
|
- A self-hoster can answer any "how do I configure / back up / upgrade / troubleshoot" question from `docs/operator-guide.md` alone.
|
||||||
|
- A new contributor can identify which subsystem owns a given behavior within 5 minutes using `docs/architecture/overview.md` + the subsystem files.
|
||||||
|
- Every claim in every doc is either verified against current code or explicitly flagged.
|
||||||
@@ -11,6 +11,28 @@ from vigilar.config import VigilarConfig, load_config
|
|||||||
from vigilar.storage.schema import metadata
|
from vigilar.storage.schema import metadata
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True, scope="session")
|
||||||
|
def _isolate_vigilar_config(tmp_path_factory):
|
||||||
|
"""Prevent tests from writing to the real config/vigilar.toml.
|
||||||
|
|
||||||
|
Web endpoint handlers that call `_save_and_reload()` read the target
|
||||||
|
path from the VIGILAR_CONFIG env var, falling back to the relative
|
||||||
|
`"config/vigilar.toml"`. Without this fixture, any test that exercises
|
||||||
|
such an endpoint rewrites the repo's committed config file via a
|
||||||
|
Pydantic round-trip, stripping comments and non-default fields.
|
||||||
|
"""
|
||||||
|
tmp_config = tmp_path_factory.mktemp("vigilar-config") / "vigilar.toml"
|
||||||
|
prev = os.environ.get("VIGILAR_CONFIG")
|
||||||
|
os.environ["VIGILAR_CONFIG"] = str(tmp_config)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
if prev is None:
|
||||||
|
os.environ.pop("VIGILAR_CONFIG", None)
|
||||||
|
else:
|
||||||
|
os.environ["VIGILAR_CONFIG"] = prev
|
||||||
|
|
||||||
|
|
||||||
def _create_test_engine(db_path: Path):
|
def _create_test_engine(db_path: Path):
|
||||||
"""Create a fresh engine for testing (bypasses the global singleton)."""
|
"""Create a fresh engine for testing (bypasses the global singleton)."""
|
||||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
@@ -429,3 +429,85 @@ class TestPetEventClassification:
|
|||||||
assert etype == EventType.WILDLIFE_PASSIVE
|
assert etype == EventType.WILDLIFE_PASSIVE
|
||||||
assert sev == Severity.INFO
|
assert sev == Severity.INFO
|
||||||
assert source == "front"
|
assert source == "front"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Classified-event broadcast (for SSE bridge)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _RecordingBus:
|
||||||
|
"""Minimal bus fake that records publishes instead of sending them."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.published: list[tuple[str, dict]] = []
|
||||||
|
|
||||||
|
def publish(self, topic: str, payload: dict, qos: int = 1) -> None:
|
||||||
|
self.published.append((topic, payload))
|
||||||
|
|
||||||
|
def publish_event(self, topic: str, **kwargs) -> None:
|
||||||
|
self.publish(topic, kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class _StubFSM:
|
||||||
|
state = ArmState.DISARMED
|
||||||
|
|
||||||
|
|
||||||
|
class _StubRuleEngine:
|
||||||
|
def evaluate(self, topic, payload, state):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventsPublishedBroadcast:
|
||||||
|
"""EventProcessor should publish a classified-event summary to
|
||||||
|
Topics.EVENTS_PUBLISHED after inserting, so the Flask SSE bridge
|
||||||
|
can forward it to browser clients."""
|
||||||
|
|
||||||
|
def test_handle_event_publishes_classified_payload(self, test_db):
|
||||||
|
from vigilar.events.processor import EventProcessor
|
||||||
|
from vigilar.constants import Topics
|
||||||
|
|
||||||
|
processor = EventProcessor.__new__(EventProcessor)
|
||||||
|
bus = _RecordingBus()
|
||||||
|
|
||||||
|
processor._handle_event(
|
||||||
|
topic="vigilar/camera/cam1/motion/start",
|
||||||
|
payload={"ts": 12345, "detail": "x"},
|
||||||
|
engine=test_db,
|
||||||
|
fsm=_StubFSM(),
|
||||||
|
rule_engine=_StubRuleEngine(),
|
||||||
|
bus=bus,
|
||||||
|
)
|
||||||
|
|
||||||
|
published_on_bridge_topic = [
|
||||||
|
p for t, p in bus.published if t == Topics.EVENTS_PUBLISHED
|
||||||
|
]
|
||||||
|
assert len(published_on_bridge_topic) == 1, (
|
||||||
|
f"expected exactly one publish to {Topics.EVENTS_PUBLISHED}, "
|
||||||
|
f"got publishes: {bus.published}"
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = published_on_bridge_topic[0]
|
||||||
|
assert msg["type"] == EventType.MOTION_START
|
||||||
|
assert msg["severity"] == Severity.WARNING
|
||||||
|
assert msg["source_id"] == "cam1"
|
||||||
|
assert "ts" in msg
|
||||||
|
assert "id" in msg and isinstance(msg["id"], int)
|
||||||
|
|
||||||
|
def test_unclassified_topic_does_not_publish(self, test_db):
|
||||||
|
"""Heartbeats and other non-event topics must not be forwarded."""
|
||||||
|
from vigilar.events.processor import EventProcessor
|
||||||
|
from vigilar.constants import Topics
|
||||||
|
|
||||||
|
processor = EventProcessor.__new__(EventProcessor)
|
||||||
|
bus = _RecordingBus()
|
||||||
|
|
||||||
|
processor._handle_event(
|
||||||
|
topic="vigilar/camera/cam1/heartbeat",
|
||||||
|
payload={"ts": 12345},
|
||||||
|
engine=test_db,
|
||||||
|
fsm=_StubFSM(),
|
||||||
|
rule_engine=_StubRuleEngine(),
|
||||||
|
bus=bus,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not any(t == Topics.EVENTS_PUBLISHED for t, _ in bus.published)
|
||||||
|
|||||||
62
tests/unit/test_sse_bridge.py
Normal file
62
tests/unit/test_sse_bridge.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""Tests for the MQTT → Server-Sent Events bridge used by the web process."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import queue
|
||||||
|
|
||||||
|
|
||||||
|
def test_forward_event_delivers_payload_to_sse_subscribers():
|
||||||
|
"""forward_event should hand the payload to every connected SSE client."""
|
||||||
|
from vigilar.web.blueprints import events as events_bp
|
||||||
|
from vigilar.web.sse_bridge import forward_event
|
||||||
|
|
||||||
|
q: queue.Queue = queue.Queue(maxsize=10)
|
||||||
|
events_bp._sse_subscribers.append(q)
|
||||||
|
try:
|
||||||
|
payload = {
|
||||||
|
"type": "MOTION_START",
|
||||||
|
"source_id": "cam1",
|
||||||
|
"severity": "WARNING",
|
||||||
|
"ts": 1234,
|
||||||
|
}
|
||||||
|
forward_event("vigilar/events/published", payload)
|
||||||
|
|
||||||
|
raw = q.get_nowait()
|
||||||
|
assert raw.startswith("data: ")
|
||||||
|
# Strip "data: " prefix and trailing double newline
|
||||||
|
data = json.loads(raw[len("data: "):].rstrip())
|
||||||
|
assert data["type"] == "MOTION_START"
|
||||||
|
assert data["source_id"] == "cam1"
|
||||||
|
finally:
|
||||||
|
if q in events_bp._sse_subscribers:
|
||||||
|
events_bp._sse_subscribers.remove(q)
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_sse_bridge_subscribes_to_events_published(monkeypatch):
|
||||||
|
"""start_sse_bridge must connect a bus and subscribe forward_event
|
||||||
|
to Topics.EVENTS_PUBLISHED — that is the single entry point for the
|
||||||
|
web process to observe classified events from the events subsystem."""
|
||||||
|
from vigilar.config import VigilarConfig
|
||||||
|
from vigilar.constants import Topics
|
||||||
|
from vigilar.web import sse_bridge
|
||||||
|
|
||||||
|
class FakeBus:
|
||||||
|
def __init__(self, config, client_id):
|
||||||
|
self.client_id = client_id
|
||||||
|
self.subscriptions: list[tuple[str, object]] = []
|
||||||
|
self.connected = False
|
||||||
|
|
||||||
|
def subscribe(self, topic, handler):
|
||||||
|
self.subscriptions.append((topic, handler))
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
self.connected = True
|
||||||
|
|
||||||
|
monkeypatch.setattr(sse_bridge, "MessageBus", FakeBus)
|
||||||
|
|
||||||
|
bus = sse_bridge.start_sse_bridge(VigilarConfig())
|
||||||
|
|
||||||
|
assert bus.connected is True
|
||||||
|
assert len(bus.subscriptions) == 1
|
||||||
|
topic, handler = bus.subscriptions[0]
|
||||||
|
assert topic == Topics.EVENTS_PUBLISHED
|
||||||
|
assert handler is sse_bridge.forward_event
|
||||||
@@ -211,6 +211,10 @@ class Topics:
|
|||||||
SYSTEM_SHUTDOWN = "vigilar/system/shutdown"
|
SYSTEM_SHUTDOWN = "vigilar/system/shutdown"
|
||||||
SYSTEM_RULES_UPDATED = "vigilar/system/rules_updated"
|
SYSTEM_RULES_UPDATED = "vigilar/system/rules_updated"
|
||||||
|
|
||||||
|
# Classified events forwarded to the web SSE bridge (see events.processor
|
||||||
|
# and web.sse_bridge).
|
||||||
|
EVENTS_PUBLISHED = "vigilar/events/published"
|
||||||
|
|
||||||
# Wildcard subscriptions
|
# Wildcard subscriptions
|
||||||
ALL = "vigilar/#"
|
ALL = "vigilar/#"
|
||||||
ALL_CAMERAS = "vigilar/camera/#"
|
ALL_CAMERAS = "vigilar/camera/#"
|
||||||
|
|||||||
@@ -103,6 +103,22 @@ class EventProcessor:
|
|||||||
payload=payload,
|
payload=payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Broadcast a classified summary for the web SSE bridge. This is
|
||||||
|
# independent of rule actions and DB storage — the web process
|
||||||
|
# subscribes to Topics.EVENTS_PUBLISHED and forwards each message
|
||||||
|
# to connected browser clients.
|
||||||
|
bus.publish(
|
||||||
|
Topics.EVENTS_PUBLISHED,
|
||||||
|
{
|
||||||
|
"id": event_id,
|
||||||
|
"ts": int(time.time() * 1000),
|
||||||
|
"type": event_type,
|
||||||
|
"severity": severity,
|
||||||
|
"source_id": source_id,
|
||||||
|
"payload": payload,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# Insert pet/wildlife sightings
|
# Insert pet/wildlife sightings
|
||||||
if event_type in (
|
if event_type in (
|
||||||
EventType.PET_DETECTED, EventType.PET_ESCAPE, EventType.UNKNOWN_ANIMAL
|
EventType.PET_DETECTED, EventType.PET_ESCAPE, EventType.UNKNOWN_ANIMAL
|
||||||
|
|||||||
@@ -67,9 +67,27 @@ class SubsystemProcess:
|
|||||||
def _run_web(cfg: VigilarConfig) -> None:
|
def _run_web(cfg: VigilarConfig) -> None:
|
||||||
"""Run the Flask web server in a subprocess."""
|
"""Run the Flask web server in a subprocess."""
|
||||||
from vigilar.web.app import create_app
|
from vigilar.web.app import create_app
|
||||||
|
from vigilar.web.sse_bridge import start_sse_bridge
|
||||||
|
|
||||||
app = create_app(cfg)
|
app = create_app(cfg)
|
||||||
|
|
||||||
|
# Forward classified events from MQTT to browser SSE clients. Failure
|
||||||
|
# here must not kill the web process — the UI still works without
|
||||||
|
# live updates, it just requires a page refresh.
|
||||||
|
sse_bus = None
|
||||||
|
try:
|
||||||
|
sse_bus = start_sse_bridge(cfg)
|
||||||
|
except Exception:
|
||||||
|
log.exception("Failed to start SSE bridge; live event timeline will not update")
|
||||||
|
|
||||||
|
try:
|
||||||
app.run(host=cfg.web.host, port=cfg.web.port, debug=False, use_reloader=False)
|
app.run(host=cfg.web.host, port=cfg.web.port, debug=False, use_reloader=False)
|
||||||
|
finally:
|
||||||
|
if sse_bus is not None:
|
||||||
|
try:
|
||||||
|
sse_bus.disconnect()
|
||||||
|
except Exception:
|
||||||
|
log.exception("Error disconnecting SSE bridge bus")
|
||||||
|
|
||||||
|
|
||||||
def _run_event_processor(cfg: VigilarConfig) -> None:
|
def _run_event_processor(cfg: VigilarConfig) -> None:
|
||||||
|
|||||||
36
vigilar/web/sse_bridge.py
Normal file
36
vigilar/web/sse_bridge.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""Bridge classified events from MQTT to Server-Sent Events subscribers.
|
||||||
|
|
||||||
|
The events subsystem (`vigilar.events.processor.EventProcessor`) publishes
|
||||||
|
classified events to `Topics.EVENTS_PUBLISHED`. The Flask web process runs
|
||||||
|
in its own OS process, so to make the in-browser event timeline update
|
||||||
|
live it must subscribe to that topic via its own `MessageBus` client and
|
||||||
|
forward every message to `broadcast_sse_event`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from vigilar.bus import MessageBus
|
||||||
|
from vigilar.config import VigilarConfig
|
||||||
|
from vigilar.constants import Topics
|
||||||
|
from vigilar.web.blueprints.events import broadcast_sse_event
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def forward_event(topic: str, payload: dict[str, Any]) -> None:
|
||||||
|
"""MQTT handler: forward a classified event to SSE subscribers."""
|
||||||
|
broadcast_sse_event(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def start_sse_bridge(cfg: VigilarConfig) -> MessageBus:
|
||||||
|
"""Create an MQTT client that forwards classified events to SSE clients.
|
||||||
|
|
||||||
|
Returns the connected `MessageBus` so the caller can disconnect it on
|
||||||
|
shutdown.
|
||||||
|
"""
|
||||||
|
bus = MessageBus(cfg.mqtt, client_id="vigilar-web-sse-bridge")
|
||||||
|
bus.subscribe(Topics.EVENTS_PUBLISHED, forward_event)
|
||||||
|
bus.connect()
|
||||||
|
log.info("SSE bridge started: subscribed to %s", Topics.EVENTS_PUBLISHED)
|
||||||
|
return bus
|
||||||
Reference in New Issue
Block a user