commit 845a85d618386f51cbffb8116957246ebc278efa Author: Aaron D. Lee Date: Thu Apr 2 23:11:27 2026 -0400 Initial commit: Vigilar DIY home security system Phase 1 (Foundation): project skeleton, TOML config + Pydantic validation, MQTT bus wrapper, SQLite schema (9 tables), Click CLI, process supervisor. Phase 2 (Camera): RTSP capture via OpenCV, MOG2 motion detection with configurable sensitivity/zones, adaptive FPS recording (2fps idle/30fps motion) via FFmpeg subprocess, HLS live streaming, pre-motion ring buffer. Phase 3 (Web UI): Flask + Bootstrap 5 dark theme, 6 blueprints, Jinja2 templates (dashboard, kiosk 2x2 grid, events, sensors, recordings, settings), PWA with service worker + Web Push, full admin settings UI with config persistence. Remote Access: WireGuard tunnel configs, nginx reverse proxy with HLS caching + rate limiting, bandwidth-optimized remote HLS stream (426x240 @ 500kbps), DO droplet setup script, certbot TLS. 29 tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..93df929 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.eggs/ +*.egg + +# Virtual environment +.venv/ +venv/ +env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.mypy_cache/ + +# Runtime data +*.db +*.db-wal +*.db-shm +/var/ + +# Config backups +config/*.bak.* + +# HLS segments (generated at runtime) +*.ts +*.m3u8 + +# Secrets (never commit) +*.key +*.pem +secrets/ + +# OS +.DS_Store +Thumbs.db + +# WireGuard keys (generated per-host) +remote/wireguard/*.key diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9c87547 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,39 @@ +# Vigilar — Project Conventions + +## What is this? +DIY offline-first home security system. Python 3.11+, Flask + Bootstrap 5 dark theme, SQLite, MQTT internal bus, FFmpeg recording, HLS streaming. + +## Stack +- **Web**: Flask, Jinja2 templates, Bootstrap 5 (dark), hls.js for camera streams +- **Backend**: Python 3.11+, multiprocessing for subsystem isolation, paho-mqtt for internal bus +- **Database**: SQLite WAL mode via SQLAlchemy Core (not ORM) +- **Camera**: OpenCV (RTSP capture, MOG2 motion detection), FFmpeg subprocess (recording, HLS) +- **Config**: TOML (`config/vigilar.toml`) validated by Pydantic v2 +- **CLI**: Click + +## Code Style +- Ruff for linting, line length 100 +- Type hints on all public functions +- No docstrings unless logic is non-obvious +- StrEnum for all string constants (see `vigilar/constants.py`) +- SQLAlchemy Core expressions, not ORM — no mapped classes + +## Architecture +- Each subsystem (camera, sensors, ups, events, alerts) runs in its own `multiprocessing.Process` +- All inter-process communication via local Mosquitto MQTT on 127.0.0.1:1883 +- Web UI is Flask with Blueprints in `vigilar/web/blueprints/` +- Templates in `vigilar/web/templates/`, static assets in `vigilar/web/static/` + +## Key Design Decisions +- Adaptive FPS: 2 FPS idle, 30 FPS on motion, 5-second ring buffer for pre-motion context +- HLS for multi-camera grid (bandwidth efficient), MJPEG fallback for single-camera low-latency view +- PWA with Web Push (VAPID) for mobile notifications +- AES-256 encrypted recordings (.vge files), key in /etc/vigilar/secrets/ +- No cloud dependency, no external service calls in critical path + +## Commands +- `pip install -e ".[dev]"` — install with dev deps +- `vigilar start` — launch all services +- `vigilar config validate` — check config +- `pytest` — run tests +- `ruff check vigilar/` — lint diff --git a/config/vigilar.toml b/config/vigilar.toml new file mode 100644 index 0000000..8853188 --- /dev/null +++ b/config/vigilar.toml @@ -0,0 +1,165 @@ +[system] +name = "Vigilar Home Security" +timezone = "America/New_York" +data_dir = "/var/vigilar/data" +recordings_dir = "/var/vigilar/recordings" +hls_dir = "/var/vigilar/hls" +log_level = "INFO" +# arm_pin_hash = "" # Set via: vigilar config set-pin + +[mqtt] +host = "127.0.0.1" +port = 1883 + +[web] +host = "0.0.0.0" +port = 49735 +# tls_cert = "/etc/vigilar/certs/cert.pem" +# tls_key = "/etc/vigilar/certs/key.pem" +username = "admin" +# password_hash = "" # Set via: vigilar config set-password + +[zigbee2mqtt] +mqtt_topic_prefix = "zigbee2mqtt" + +[ups] +enabled = true +nut_host = "127.0.0.1" +nut_port = 3493 +ups_name = "ups" +poll_interval_s = 30 +low_battery_threshold_pct = 20 +critical_runtime_threshold_s = 300 +shutdown_delay_s = 60 + +[storage] +encrypt_recordings = true +key_file = "/etc/vigilar/secrets/storage.key" +max_disk_usage_gb = 200 +free_space_floor_gb = 10 + +[remote] +enabled = false +upload_bandwidth_mbps = 22.0 +remote_hls_resolution = [426, 240] +remote_hls_fps = 10 +remote_hls_bitrate_kbps = 500 +max_remote_viewers = 4 +tunnel_ip = "10.99.0.2" + +[alerts.local] +enabled = true +syslog = true +desktop_notify = false + +[alerts.web_push] +enabled = true +vapid_private_key_file = "/etc/vigilar/secrets/vapid_private.pem" +vapid_claim_email = "mailto:admin@vigilar.local" + +[alerts.email] +enabled = false +# smtp_host = "mail.lan" +# smtp_port = 587 +# from_addr = "vigilar@home.lan" +# to_addr = "admin@home.lan" +# use_tls = true + +[alerts.webhook] +enabled = false +# url = "" +# secret = "" + +# --- Cameras --- +# Add one [[cameras]] block per camera. + +[[cameras]] +id = "front_door" +display_name = "Front Door" +rtsp_url = "rtsp://192.168.1.101:554/stream1" +enabled = true +record_continuous = false +record_on_motion = true +motion_sensitivity = 0.7 +motion_min_area_px = 500 +pre_motion_buffer_s = 5 +post_motion_buffer_s = 30 +idle_fps = 2 +motion_fps = 30 +retention_days = 30 +resolution_capture = [1920, 1080] +resolution_motion = [640, 360] + +[[cameras]] +id = "backyard" +display_name = "Backyard" +rtsp_url = "rtsp://192.168.1.102:554/stream1" +enabled = true +record_continuous = false +record_on_motion = true +motion_sensitivity = 0.7 +retention_days = 30 + +[[cameras]] +id = "side_yard" +display_name = "Side Yard" +rtsp_url = "rtsp://192.168.1.103:554/stream1" +enabled = true +record_on_motion = true +motion_sensitivity = 0.7 +retention_days = 30 + +[[cameras]] +id = "garage" +display_name = "Garage" +rtsp_url = "rtsp://192.168.1.104:554/stream1" +enabled = true +record_on_motion = true +motion_sensitivity = 0.7 +retention_days = 30 + +# --- Sensors --- +# Add one [[sensors]] block per sensor. + +[[sensors]] +id = "front_door_contact" +display_name = "Front Door Contact" +type = "CONTACT" +protocol = "ZIGBEE" +device_address = "0x00158d0001a2b3c4" +location = "Front Door" + +[[sensors]] +id = "pir_living_room" +display_name = "Living Room PIR" +type = "MOTION" +protocol = "ZIGBEE" +device_address = "0x00158d0001deadbe" +location = "Living Room" + +[sensors.gpio] +bounce_time_ms = 50 + +# --- Rules --- + +[[rules]] +id = "intruder_alert" +description = "Motion detected when armed away" +conditions = [ + {type = "arm_state", value = "ARMED_AWAY"}, + {type = "sensor_event", sensor_id = "pir_living_room", event = "MOTION_ACTIVE"}, +] +logic = "AND" +actions = ["alert_all", "record_all_cameras"] +cooldown_s = 120 + +[[rules]] +id = "door_opened_armed" +description = "Door contact opened while armed" +conditions = [ + {type = "arm_state", value = "ARMED_AWAY"}, + {type = "sensor_event", sensor_id = "front_door_contact", event = "CONTACT_OPEN"}, +] +logic = "AND" +actions = ["alert_all"] +cooldown_s = 60 diff --git a/docs/camera-hardware-guide.md b/docs/camera-hardware-guide.md new file mode 100644 index 0000000..1f4add7 --- /dev/null +++ b/docs/camera-hardware-guide.md @@ -0,0 +1,711 @@ +# Vigilar Camera Hardware Guide + +Practical guide for selecting, purchasing, and installing cameras for the Vigilar +DIY home security system. All recommendations are for PoE IP cameras with native +RTSP support -- no cloud dependency, no subscription fees. + +Last updated: April 2026 + +--- + +## 1. Camera Recommendations + +### Selection Criteria + +Every camera below meets these requirements: +- Native RTSP stream output (works directly with OpenCV + FFmpeg) +- PoE (IEEE 802.3af) -- single cable for power and data +- ONVIF Profile S compliance (interoperability, auto-discovery) +- H.265 or H.264 encoding +- No cloud account required for local operation +- MicroSD slot for on-camera fallback recording + +### Recommended Models + +#### Reolink RLC-810A -- Best Overall Outdoor ($90) + +| Spec | Value | +|------|-------| +| Resolution | 3840x2160 (8MP/4K) @ 25fps | +| Lens | 2.8mm fixed, 101deg horizontal FOV | +| Night vision | 100ft / 30m IR (18x 850nm LEDs) | +| Weatherproofing | IP67 | +| AI detection | Person / Vehicle / Pet | +| Encoding | H.265 / H.264 | +| Power draw | ~12W max (PoE 802.3af) | +| ONVIF | Yes (Profile S) | +| Audio | Built-in microphone | +| Storage | MicroSD up to 512GB | + +**Why this camera:** 4K resolution at a sub-$100 price point. The 8MP sensor +provides enough detail for facial identification at 15-20ft and license plate +reading at 25-30ft. Person/vehicle AI detection reduces false alerts from animals, +swaying branches, etc. Widely used with Frigate, ZoneMinder, Blue Iris, and +Home Assistant -- enormous community support for troubleshooting. + +**RTSP URLs:** +``` +Main stream (4K): rtsp://admin:@/Preview_01_main +Sub stream (D1): rtsp://admin:@/Preview_01_sub +``` + +Default port is 554. The sub stream is typically 640x360 and ideal for +Vigilar's motion detection pipeline (`resolution_motion` setting). + +**Vigilar config:** +```toml +[[cameras]] +id = "front_door" +display_name = "Front Door" +rtsp_url = "rtsp://admin:yourpassword@192.168.1.101:554/Preview_01_main" +resolution_capture = [1920, 1080] # downscale from 4K to save storage +resolution_motion = [640, 360] +motion_fps = 25 +idle_fps = 2 +``` + +Note: You can capture at the native 4K (3840x2160) if storage allows, but +1080p is the sweet spot for Vigilar's HLS streaming and storage constraints. + +--- + +#### Reolink RLC-520A -- Best Budget Dome Outdoor ($55) + +| Spec | Value | +|------|-------| +| Resolution | 2560x1920 (5MP) @ 25fps | +| Lens | 4.0mm fixed, 80deg horizontal FOV | +| Night vision | 100ft / 30m IR (18x 850nm LEDs) | +| Weatherproofing | IP67 | +| AI detection | Person / Vehicle | +| Encoding | H.265 / H.264 | +| Power draw | ~10W max (PoE 802.3af) | +| ONVIF | Yes (Profile S) | +| Audio | Built-in microphone | +| Storage | MicroSD up to 512GB | + +**Why this camera:** The dome form factor is more discreet and vandal-resistant +than bullet cameras. At $55, it is the cheapest option that still has confirmed +RTSP/ONVIF support and AI-based person/vehicle detection. The 5MP resolution is +more than sufficient for most positions. Slightly narrower FOV than the 810A +(80deg vs 101deg), which makes it better suited for focused areas like a side +gate or driveway where you want more zoom. + +**RTSP URLs:** Same format as all Reolink PoE cameras: +``` +Main stream (5MP): rtsp://admin:@/Preview_01_main +Sub stream: rtsp://admin:@/Preview_01_sub +``` + +--- + +#### Amcrest IP5M-T1179EW-AI-V3 -- Best Alternative Outdoor ($50-60) + +| Spec | Value | +|------|-------| +| Resolution | 2592x1944 (5MP) @ 20fps | +| Lens | 2.8mm fixed, 132deg horizontal FOV | +| Night vision | 98ft / 30m IR | +| Weatherproofing | IP67 | +| AI detection | Person / Vehicle | +| Encoding | H.265 / H.264 | +| Power draw | ~9W max (PoE 802.3af) | +| ONVIF | Yes (Profile S) | +| Audio | Built-in microphone | +| Storage | MicroSD up to 256GB | + +**Why this camera:** Widest FOV of any camera in this price range at 132 degrees. +Officially recommended by the Frigate NVR project. Amcrest has more granular +RTSP configuration options than Reolink via their web interface. Confirmed +working with Home Assistant ONVIF integration, Frigate, and any RTSP consumer. + +**RTSP URLs:** +``` +Main stream: rtsp://admin:@:554/cam/realmonitor?channel=1&subtype=0 +Sub stream: rtsp://admin:@:554/cam/realmonitor?channel=1&subtype=1 +``` + +Alternative simplified URL (also works): +``` +rtsp://admin:@:554/live +``` + +--- + +#### Reolink E1 Outdoor Pro (or RLC-520A indoors) -- Best Indoor ($55-80) + +For indoor use, you have two good options: + +**Option A: Reolink RLC-520A used indoors ($55)** +The same dome camera listed above works perfectly indoors. Mount it in a corner +near the ceiling. The dome form factor looks clean in a living space. The 100ft +IR range is overkill for a room but means the IR LEDs run at low power, reducing +that red-glow annoyance. + +**Option B: Reolink E1 Outdoor Pro (~$80)** +If you want pan-tilt for a large open area (living room, basement, warehouse): +- 4MP with 355deg pan / 50deg tilt +- Auto-tracking follows detected people +- Works as fixed camera when tracking is disabled +- Same RTSP URL format as other Reolink cameras + +For most Vigilar setups, a fixed dome (Option A) is simpler and more reliable -- +no moving parts, no auto-tracking logic to debug. + +--- + +### Models to AVOID + +| Model | Reason | +|-------|--------| +| Reolink Argus / Go series | Battery-powered, no RTSP/ONVIF support | +| Any Reolink E1 (non-Pro) | Requires Home Hub for RTSP; not standalone | +| Wyze Cam v3/v4 | No native RTSP (requires firmware hack, breaks easily) | +| Ring / Nest / Arlo | Cloud-locked, no RTSP access | +| TP-Link Tapo C200/C310 | RTSP added via firmware but flaky; ONVIF missing | +| Generic Aliexpress "4K" | Often fake resolution, unstable RTSP, no firmware updates | + +--- + +## 2. PoE Network Setup + +### PoE Switch Selection + +For 4 cameras you need a switch with at least 5 PoE ports (4 cameras + headroom). +An 8-port switch is the right buy -- room to grow. + +#### Recommended: NETGEAR GS308PP ($70) + +| Spec | Value | +|------|-------| +| Ports | 8x Gigabit (all PoE+) | +| PoE budget | 83W (FlexPoE upgradeable to 123W) | +| Management | Unmanaged (plug and play) | +| Noise | Fanless (silent) | +| Warranty | Lifetime with next-business-day replacement | + +**Why:** 83W is more than enough for 4 cameras drawing ~10-12W each (total ~48W +with headroom). Fanless means zero noise in a utility closet. Unmanaged means +zero configuration -- plug in cables and it works. + +**Power budget math:** +``` +4 cameras x 12W each = 48W +Switch headroom: 83W - 48W = 35W remaining + (enough for 2-3 more cameras later) +``` + +#### Budget alternative: TP-Link TL-SG1005P ($35) + +5 ports (4 PoE + 1 uplink). 65W budget. Sufficient for exactly 4 cameras with +no room to grow. Only buy this if you are certain you will never add a 5th camera. + +#### If you want management: TP-Link TL-SG108PE ($60) + +8 ports (4 PoE), 64W budget, basic web management with VLAN support. Useful if +you want to isolate cameras on their own VLAN for security (recommended but not +required for Vigilar). + +### Cable Types + +| Scenario | Cable Type | Why | +|----------|-----------|-----| +| Indoor runs (attic, walls, basement) | Cat6 UTP CMR-rated | Standard indoor riser-rated cable | +| Outdoor exposed runs | Cat6 UTP outdoor-rated (UV/water resistant) | PE jacket resists sun and moisture | +| Underground burial | Cat6 direct-burial rated | Gel-filled, no conduit needed | +| Short patch runs (<10ft) | Pre-made Cat6 patch cables | Not worth crimping for short runs | + +**Maximum cable run:** 328 feet (100 meters) per the Ethernet spec. Beyond that +you need a PoE extender or switch relay. + +### Cable Routing Tips + +**Through exterior walls:** +1. Drill from inside out at a slight downward angle (5-10 degrees) so water + runs away from the interior +2. Use a 3/4" drill bit -- large enough for an RJ45 connector to pass through +3. After running cable, fill the hole with silicone caulk or use a weatherproof + cable gland/grommet +4. Consider a weatherproof junction box on the exterior to make the connection + point maintainable + +**Through attic:** +1. The cleanest install routes cable up into the attic, across the attic, and + down through the soffit to the camera +2. Use a flex drill bit (also called an installer bit, 3/4" x 54") to drill + through top plates from inside the attic +3. Attach cables to joists/rafters with cable staples (not tight -- leave slack) +4. Keep ethernet cables 12+ inches away from electrical wiring to avoid + interference + +**Through basement/crawlspace:** +1. Route along floor joists using J-hooks or cable staples +2. Drill up through the sole plate where you need to reach an exterior wall +3. Use fire-stop caulk when penetrating between floors (code requirement) + +**Tools needed for cable routing:** +- Stud finder +- 3/4" installer flex bit (54" long) for drilling through top/sole plates +- Fish tape or glow rods for pulling cable through walls +- Drywall saw (for cutting low-voltage wall plate holes) +- Cable staple gun or J-hooks +- Headlamp (for attic work) + +--- + +## 3. Mounting Guide + +### Outdoor Mounting + +#### Soffit Mount (Recommended for most positions) + +This is the cleanest outdoor install. The camera mounts under the roof overhang, +protected from direct rain and sun. + +1. Hold the camera mounting plate against the soffit and mark screw holes +2. If soffit is vinyl: drill through vinyl into the wood backing behind it. + Use toggle bolts if there is no solid backing +3. If soffit is aluminum: use self-tapping sheet metal screws into the wood + behind +4. Route cable up through the soffit (drill a 3/4" hole) into the attic space +5. Apply silicone sealant around the cable entry point +6. Attach camera to mounting plate + +**Advantages:** Weather protection from the overhang, clean hidden cable routing, +difficult for intruders to reach/tamper with. + +#### Wall Mount + +1. Use a junction box mounted to the exterior wall with Tapcon concrete screws + (for brick/stucco) or lag screws (for wood siding) +2. Route cable through the wall behind the junction box +3. Make the wall penetration inside the junction box so the box covers and + weatherproofs it +4. Seal all entry points with silicone +5. Mount camera to junction box + +#### Fascia/Eave Mount + +Similar to soffit but on the vertical face of the eave. Use an angled mounting +bracket (15-30 degrees) to tilt the camera downward. This is ideal for getting +a wider view of a yard or driveway. + +### Mounting Heights and Angles + +| Position | Recommended height | Tilt angle | Why | +|----------|-------------------|------------|-----| +| Front door | 8-10 ft | 15-20deg down | Captures faces of people approaching | +| Driveway/yard | 10-12 ft | 20-30deg down | Wide area coverage, hard to tamper | +| Side gate | 7-9 ft | 10-15deg down | Narrow passage, closer subjects | +| Indoor room | 7-8 ft (corner, near ceiling) | 10-15deg down | Covers most of the room diagonally | + +**Key principles:** +- Higher = harder to tamper with, but faces become less identifiable +- 8-10ft is the sweet spot for face identification +- Aim cameras so the horizon line is in the upper third of the frame +- Avoid pointing cameras directly at the sky or strong light sources +- For night vision: the IR range (100ft) is the max, but usable identification + distance is more like 30-50ft depending on subject reflectivity + +### Indoor Mounting + +**Corner ceiling mount** (best coverage): +- Mount in the corner diagonally opposite the main entry point to the room +- This maximizes the visible area and captures faces of anyone entering +- Use the dome camera's built-in mount or a small L-bracket +- Cable can run up through the ceiling or along the wall with paintable cable + raceway + +**Shelf/furniture placement** (no drilling): +- Place camera on top of a bookshelf or cabinet +- Angle slightly downward +- Run cable behind furniture to a wall plate or directly to a switch +- Less ideal than ceiling corner mount but zero damage to walls + +### Weatherproofing Cable Entry Points + +For every exterior cable penetration: + +1. **Cable gland** (best): Threaded waterproof fitting, ~$2 each. Drill the + exact hole size, thread the gland, pass cable through, tighten the + compression nut. Watertight seal. + +2. **Silicone caulk** (good enough): Fill the hole around the cable with + exterior-grade silicone. Cheap and effective but harder to service later. + +3. **Weatherproof junction box** (most maintainable): Mount a small outdoor + junction box over the wall penetration. Cable enters the box from inside the + wall and connects to the outdoor cable run inside the protected box. Easiest + to service but adds cost and visual bulk. + +Always ensure the drill hole angles slightly downward toward the exterior so +gravity pulls water away from the interior. + +--- + +## 4. Four-Camera Placement Strategy + +### The Four Positions + +For a typical single-family home, these four positions provide the best coverage +with no blind spots on approach routes: + +``` + STREET + ____________ + | | + [CAM 2]---->| FRONT |<----[CAM 1] + Side gate | DOOR | Front door / porch + | | + | | + | HOUSE | + | | + | | + [CAM 3]---->| BACK |<----[CAM 4] + Side yard | YARD | Backyard / patio + |____________| + + BACKYARD +``` + +#### Camera 1: Front Door / Porch (PRIORITY 1) +- **Model:** Reolink RLC-810A (4K for face identification) +- **Mount:** Soffit above front door, 8-9ft high +- **Aim:** Straight out from the door, capturing anyone approaching +- **FOV needed:** ~100deg horizontal catches porch and walkway +- **Why 4K here:** This is your primary identification camera. Police need clear + face shots. The 8MP sensor lets Vigilar crop and zoom in recordings. + +#### Camera 2: Side Gate / Driveway (PRIORITY 2) +- **Model:** Reolink RLC-520A (5MP dome, discreet) +- **Mount:** Soffit or wall mount at the corner, 8-10ft high +- **Aim:** Along the side of the house toward the street +- **FOV needed:** 80deg is sufficient for a narrow side passage +- **Why dome here:** Vandal-resistant dome is harder to knock off or spray-paint. + Side gates are common break-in approach routes. + +#### Camera 3: Back Door / Patio (PRIORITY 3) +- **Model:** Amcrest IP5M-T1179EW (132deg wide angle) +- **Mount:** Eave or soffit at rear of house, 10-12ft high +- **Aim:** Across the full backyard +- **FOV needed:** The 132deg wide angle covers the most yard area with a single + camera +- **Why wide angle here:** Backyards are large. The Amcrest's 132deg FOV covers + more area than the Reolink's 80-101deg, reducing blind spots. + +#### Camera 4: Garage / Interior (PRIORITY 4) +- **Model:** Reolink RLC-520A (5MP dome) used indoors +- **Mount:** Corner ceiling mount in garage, or inside a high-value room +- **Aim:** Diagonal across the space toward the main entry point +- **Why indoors:** If someone gets past the exterior cameras, this catches them + inside. Garage is the most common interior break-in entry after doors. + +### Coverage Analysis + +With this layout: +- **100% of approach routes are covered**: front, both sides, rear +- **Front door gets 4K identification**: the most important camera position +- **Backyard gets wide-angle**: maximum area with one camera +- **Interior fallback**: catches intruders who bypass or disable exterior cameras +- **Overlap between cameras 1 and 2** at the front corner provides redundancy + on the most common approach + +### Night Vision Considerations + +All recommended cameras have 100ft (30m) IR range. In practice: + +| Distance | Identification quality | +|----------|----------------------| +| 0-15ft | Excellent (clear facial features, text on clothing) | +| 15-30ft | Good (face recognizable, body details visible) | +| 30-50ft | Fair (person detectable, gender/build visible, no face detail) | +| 50-100ft | Movement detection only (shape visible, no identification) | + +**Tips for better night performance:** +- Mount cameras where existing outdoor lights provide supplemental illumination +- Avoid aiming cameras at reflective surfaces (white walls, parked cars with + chrome) as IR reflection causes glare/washout +- If possible, add a dusk-to-dawn LED floodlight near cameras 3 and 4 (backyard) + -- this enables color night vision on cameras that support it and dramatically + improves identification range +- Keep camera lenses clean; dust and spider webs scatter IR light and create haze + +--- + +## 5. Complete Shopping List + +### Cameras + +| Item | Model | Qty | Unit Price | Total | +|------|-------|-----|-----------|-------| +| Front door camera | Reolink RLC-810A (4K) | 1 | $90 | $90 | +| Side gate camera | Reolink RLC-520A (5MP dome) | 1 | $55 | $55 | +| Backyard camera | Amcrest IP5M-T1179EW-AI-V3 (5MP wide) | 1 | $55 | $55 | +| Garage/indoor camera | Reolink RLC-520A (5MP dome) | 1 | $55 | $55 | +| | | | **Subtotal** | **$255** | + +**Budget alternative:** Replace the RLC-810A with a third RLC-520A ($55) and save +$35. You lose 4K on the front door but 5MP is still decent. + +**All-same-brand alternative:** 4x Reolink RLC-810A at $90 each = $360. Simpler +to manage (one RTSP URL format, one firmware update process, one web UI). + +### Networking + +| Item | Model / Description | Qty | Unit Price | Total | +|------|-------------------|-----|-----------|-------| +| PoE switch | NETGEAR GS308PP (8-port, 83W) | 1 | $70 | $70 | +| Bulk ethernet cable | Cat6 UTP CMR 23AWG 500ft pull box | 1 | $55-75 | $65 | +| RJ45 connectors | Cat6 pass-through connectors (50-pack) | 1 | $12 | $12 | +| Crimping tool | RJ45 pass-through crimper with stripper | 1 | $25 | $25 | +| Cable tester | Basic RJ45 continuity tester | 1 | $12 | $12 | +| Short patch cables | Cat6 1ft patch cable (for switch end) | 4 | $3 | $12 | +| | | | **Subtotal** | **$196** | + +**Note on bulk cable vs pre-made:** For runs over 25ft through walls/attic, bulk +cable + crimping is far cheaper and you can make exact-length cables. For +anything under 10ft (switch to patch panel), buy pre-made. + +**Bulk cable brands:** Monoprice, Cable Matters, or trueCABLE are all reliable +for Cat6 CMR-rated cable. Expect ~$55-75 for a 500ft box. A 500ft box is +sufficient for a typical home with 4 cameras (average run 50-80ft each, plus +waste from mistakes and test pulls). + +### Mounting Hardware + +| Item | Description | Qty | Unit Price | Total | +|------|------------|-----|-----------|-------| +| Junction boxes | Weatherproof outdoor camera junction box | 3 | $8 | $24 | +| Cable glands | PG9 waterproof cable glands | 6 | $1 | $6 | +| Tapcon screws | 3/16" x 1-3/4" (for brick/stucco) | 1 box | $8 | $8 | +| Lag screws | #10 x 2" stainless (for wood) | 1 box | $6 | $6 | +| Wall anchors | Plastic expansion anchors (assorted) | 1 pack | $5 | $5 | +| Silicone sealant | GE outdoor silicone caulk | 1 | $7 | $7 | +| | | | **Subtotal** | **$56** | + +### Cable Routing Supplies + +| Item | Description | Qty | Unit Price | Total | +|------|------------|-----|-----------|-------| +| Flex drill bit | 3/4" x 54" installer bit | 1 | $18 | $18 | +| Cable staples | Coax/ethernet cable staples (100pk) | 1 | $6 | $6 | +| Fish tape | 25ft steel fish tape | 1 | $15 | $15 | +| Low-voltage wall plates | 1-gang brush-style pass-through | 4 | $3 | $12 | +| Cable raceway | Paintable adhesive raceway 1" (for indoor runs) | 2 packs | $8 | $16 | +| Velcro cable ties | Reusable 8" ties (50-pack) | 1 | $6 | $6 | +| Electrical tape | For marking cable ends by camera | 1 | $3 | $3 | +| | | | **Subtotal** | **$76** | + +### Optional but Recommended + +| Item | Description | Qty | Unit Price | Total | +|------|------------|-----|-----------|-------| +| UPS for switch | APC BE425M (small UPS for PoE switch) | 1 | $45 | $45 | +| Keystone jacks | Cat6 keystone jacks (for wall plates) | 8 | $3 | $24 | +| Keystone wall plate | 1-gang single-port plate | 4 | $2 | $8 | +| Punch-down tool | 110 punch-down (for keystone jacks) | 1 | $10 | $10 | +| Label maker | Brother P-Touch for cable labeling | 1 | $20 | $20 | +| | | | **Subtotal** | **$107** | + +### Grand Total + +| Category | Cost | +|----------|------| +| Cameras | $255 | +| Networking | $196 | +| Mounting hardware | $56 | +| Cable routing | $76 | +| **Required total** | **$583** | +| Optional extras | $107 | +| **Total with all options** | **$690** | + +--- + +## 6. RTSP URL Quick Reference + +For Vigilar's `config/vigilar.toml`, use these RTSP URLs: + +### Reolink Cameras (RLC-810A, RLC-520A) + +``` +# Main stream (full resolution, for recording) +rtsp://admin:@:554/Preview_01_main + +# Sub stream (low resolution, for motion detection) +rtsp://admin:@:554/Preview_01_sub +``` + +- Default username: `admin` +- Default password: set during initial camera setup via Reolink app or web UI +- Default RTSP port: 554 +- ONVIF port: 8000 (for auto-discovery) +- Web UI: `http://` (port 80) + +**Important:** Do not use special characters in the RTSP password. Stick to +alphanumeric characters. Special characters in URLs cause encoding issues with +some RTSP clients including OpenCV. + +### Amcrest Cameras (IP5M-T1179EW) + +``` +# Main stream (full resolution, for recording) +rtsp://admin:@:554/cam/realmonitor?channel=1&subtype=0 + +# Sub stream (low resolution, for motion detection) +rtsp://admin:@:554/cam/realmonitor?channel=1&subtype=1 +``` + +- Default username: `admin` +- Default password: set during initial setup via Amcrest web UI +- Default RTSP port: 554 +- ONVIF port: 80 +- Web UI: `http://` (port 80, requires IE/Edge or Amcrest app for full features) + +### Vigilar Configuration Example (all 4 cameras) + +```toml +[[cameras]] +id = "front_door" +display_name = "Front Door" +rtsp_url = "rtsp://admin:SecurePass1@192.168.1.101:554/Preview_01_main" +enabled = true +record_on_motion = true +motion_sensitivity = 0.7 +motion_min_area_px = 500 +pre_motion_buffer_s = 5 +post_motion_buffer_s = 30 +idle_fps = 2 +motion_fps = 25 +retention_days = 30 +resolution_capture = [1920, 1080] +resolution_motion = [640, 360] + +[[cameras]] +id = "side_gate" +display_name = "Side Gate" +rtsp_url = "rtsp://admin:SecurePass1@192.168.1.102:554/Preview_01_main" +enabled = true +record_on_motion = true +motion_sensitivity = 0.7 +retention_days = 30 +resolution_capture = [1920, 1080] +resolution_motion = [640, 360] + +[[cameras]] +id = "backyard" +display_name = "Backyard" +rtsp_url = "rtsp://admin:SecurePass2@192.168.1.103:554/cam/realmonitor?channel=1&subtype=0" +enabled = true +record_on_motion = true +motion_sensitivity = 0.6 +retention_days = 30 +resolution_capture = [1920, 1080] +resolution_motion = [640, 360] + +[[cameras]] +id = "garage" +display_name = "Garage" +rtsp_url = "rtsp://admin:SecurePass1@192.168.1.104:554/Preview_01_main" +enabled = true +record_on_motion = true +motion_sensitivity = 0.8 +retention_days = 30 +resolution_capture = [1920, 1080] +resolution_motion = [640, 360] +``` + +--- + +## 7. Bandwidth Planning + +With your 22 Mbps upload, here is how the 4-camera system fits: + +### Local Network (LAN) + +Each camera at 1080p 25fps H.265 generates approximately 4-6 Mbps. Four cameras += ~20-24 Mbps total ingest. A Gigabit PoE switch handles this trivially. + +### Remote Viewing (WAN) + +Vigilar's remote config in `vigilar.toml` is already tuned for your upload: + +```toml +[remote] +upload_bandwidth_mbps = 22.0 +remote_hls_resolution = [426, 240] +remote_hls_fps = 10 +remote_hls_bitrate_kbps = 500 +max_remote_viewers = 4 +``` + +At 500 kbps per remote HLS stream, 4 simultaneous viewers = 2 Mbps. Even if +all 4 cameras stream remotely at once (4 x 500 kbps = 2 Mbps per viewer, 4 +viewers = 8 Mbps), you stay well within the 22 Mbps upload limit. + +### Storage Estimates + +At 1080p H.265 with motion-only recording (typical 2-4 hours of actual motion +per day per camera): + +| Retention | Storage per camera | Total (4 cameras) | +|-----------|-------------------|-------------------| +| 7 days | ~15-25 GB | ~60-100 GB | +| 30 days | ~60-100 GB | ~240-400 GB | +| 90 days | ~180-300 GB | ~720 GB - 1.2 TB | + +The `max_disk_usage_gb = 200` default in vigilar.toml will hold approximately +30 days of motion-triggered recordings from 4 cameras. Increase to 500 GB for +comfortable 30-day retention with buffer, or 1 TB for 90 days. + +--- + +## 8. Initial Camera Setup Checklist + +After mounting cameras and connecting cables: + +1. **Assign static IPs** to each camera via your router's DHCP reservation + (use the camera's MAC address). Suggested range: 192.168.1.101-104. + +2. **Access each camera's web UI** at `http://` and: + - Change the default password to something strong (alphanumeric only) + - Set the time zone and enable NTP sync + - Disable cloud features / P2P (saves bandwidth, improves security) + - Enable RTSP (usually under Network > Advanced > RTSP) + - Set the main stream to 1920x1080 @ 25fps H.265 (or 4K if you prefer) + - Set the sub stream to 640x360 @ 15fps H.264 + +3. **Test RTSP with VLC** before configuring Vigilar: + ```bash + vlc rtsp://admin:password@192.168.1.101:554/Preview_01_main + ``` + If VLC shows video, the stream works and Vigilar will pick it up. + +4. **Test RTSP with ffprobe** for detailed stream info: + ```bash + ffprobe -rtsp_transport tcp rtsp://admin:password@192.168.1.101:554/Preview_01_main + ``` + +5. **Update Vigilar config** (`config/vigilar.toml`) with the RTSP URLs. + +6. **Start Vigilar** and verify all cameras appear in the web UI: + ```bash + vigilar start + ``` + +--- + +## Sources + +- [Reolink RLC-810A Product Page](https://reolink.com/product/rlc-810a/) +- [Reolink RLC-520A Product Page](https://reolink.com/product/rlc-520a/) +- [Amcrest IP5M-T1179EW-AI-V3 Product Page](https://amcrest.com/5mp-poe-camera-turret-ip5m-t1179ew-ai-v3.html) +- [Reolink RTSP/ONVIF Support](https://support.reolink.com/articles/900000617826-Which-Reolink-Products-Support-CGI-RTSP-ONVIF/) +- [Reolink RTSP URL Format (VLC Guide)](https://support.reolink.com/articles/360007010473-How-to-Live-View-Reolink-Cameras-via-VLC-Media-Player/) +- [Amcrest RTSP URL Format](https://support.amcrest.com/hc/en-us/articles/360052688931-Accessing-Amcrest-Products-Using-RTSP) +- [Reolink PoE Tier List (The Hook Up)](https://www.thesmarthomehookup.com/reolink-poe-tier-list-testing-every-reolink-wired-security-camera/) +- [Amcrest IP5M-T1179EW Frigate Config](https://github.com/blakeblackshear/frigate/discussions/15938) +- [Best Low-Cost PoE Switches (Data Wire Solutions)](https://datawiresolutions.com/blog/best-low-cost-poe-switches) +- [Reolink vs Amcrest Comparison](https://webcam.org/blog/reolink-vs-amcrest.html) +- [Amcrest IP5M-T1179EW on Amazon](https://www.amazon.com/Amcrest-5-Megapixel-NightVision-Weatherproof-IP5M-T1179EW-28MM/dp/B083G9KT4C) +- [Reolink RLC-810A on Amazon](https://www.amazon.com/REOLINK-Detection-Timelapse-Recording-RLC-810A/dp/B07K74GWX5) +- [NETGEAR GS308PP PoE Switch](https://www.amazon.com/s?k=NETGEAR+GS308PP) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..01a392c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,61 @@ +[build-system] +requires = ["setuptools>=69.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "vigilar" +version = "0.1.0" +description = "DIY offline-first home security system" +requires-python = ">=3.11" +license = {text = "MIT"} +dependencies = [ + "opencv-python-headless>=4.9.0", + "paho-mqtt>=2.1.0", + "flask>=3.0.0", + "gunicorn>=22.0.0", + "sqlalchemy>=2.0.0", + "alembic>=1.13.0", + "pydantic>=2.7.0", + "pillow>=10.3.0", + "cryptography>=42.0.0", + "click>=8.1.7", + "pynut2>=0.1.0", + "pywebpush>=2.0.0", + "py-vapid>=1.9.0", +] + +[project.optional-dependencies] +gpio = [ + "gpiozero>=2.0.1", + "RPi.GPIO>=0.7.1", +] +dev = [ + "pytest>=8.2.0", + "pytest-cov>=5.0.0", + "mypy>=1.10.0", + "ruff>=0.4.0", + "httpx>=0.27.0", +] + +[project.scripts] +vigilar = "vigilar.cli.main:cli" + +[tool.setuptools.packages.find] +where = ["."] +include = ["vigilar*"] + +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] + +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_configs = true diff --git a/remote/nginx/vigilar.conf b/remote/nginx/vigilar.conf new file mode 100644 index 0000000..ad30607 --- /dev/null +++ b/remote/nginx/vigilar.conf @@ -0,0 +1,165 @@ +# Nginx reverse proxy config for Digital Ocean droplet +# Proxies HTTPS traffic to Vigilar at home via WireGuard tunnel +# +# Install: cp vigilar.conf /etc/nginx/sites-available/vigilar +# ln -s /etc/nginx/sites-available/vigilar /etc/nginx/sites-enabled/ +# nginx -t && systemctl reload nginx +# +# TLS: certbot --nginx -d vigilar.yourdomain.com + +# Rate limiting zones +limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s; +limit_req_zone $binary_remote_addr zone=stream:10m rate=5r/s; +limit_conn_zone $binary_remote_addr zone=connlimit:10m; + +# HLS segment cache — reduces repeat requests hitting the home uplink +proxy_cache_path /var/cache/nginx/vigilar_hls + levels=1:2 + keys_zone=hls_cache:10m + max_size=256m + inactive=30s + use_temp_path=off; + +# Upstream: Vigilar on home server via WireGuard tunnel +upstream vigilar_home { + server 10.99.0.2:49735; + # If home server goes down, fail fast + keepalive 4; +} + +# Redirect HTTP → HTTPS +server { + listen 80; + server_name vigilar.yourdomain.com; + return 301 https://$server_name$request_uri; +} + +server { + listen 443 ssl http2; + server_name vigilar.yourdomain.com; + + # TLS (managed by certbot) + ssl_certificate /etc/letsencrypt/live/vigilar.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/vigilar.yourdomain.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + + # Security headers + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + add_header X-Frame-Options DENY always; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + + # Connection limits — protect 22 Mbps home uplink + # Max 10 simultaneous connections per IP + limit_conn connlimit 10; + + # --- HLS streams (bandwidth-critical path) --- + # Cache .ts segments on the droplet to avoid re-fetching from home + # when multiple remote viewers request the same segment + location ~ ^/cameras/.+/hls/.+\.ts$ { + proxy_pass http://vigilar_home; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Cache segments for 10s — they're 2s segments, so this covers + # multiple viewers watching the same feed without re-fetching + proxy_cache hls_cache; + proxy_cache_valid 200 10s; + proxy_cache_key $uri; + add_header X-Cache-Status $upstream_cache_status; + + # Rate limit: 5 segment requests/sec per IP + limit_req zone=stream burst=20 nodelay; + } + + # HLS playlists — don't cache (they update every segment) + location ~ ^/cameras/.+/hls/.+\.m3u8$ { + proxy_pass http://vigilar_home; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # No cache — playlists must be fresh + proxy_cache off; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + + # --- SSE event stream --- + location /events/stream { + proxy_pass http://vigilar_home; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # SSE: disable buffering, long timeout + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + chunked_transfer_encoding on; + proxy_set_header Connection ''; + proxy_http_version 1.1; + } + + # --- API endpoints --- + location ~ ^/system/api/ { + proxy_pass http://vigilar_home; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + limit_req zone=api burst=10 nodelay; + } + + # --- Static assets (cache aggressively on droplet) --- + location /static/ { + proxy_pass http://vigilar_home; + proxy_set_header Host $host; + + proxy_cache hls_cache; + proxy_cache_valid 200 1h; + add_header X-Cache-Status $upstream_cache_status; + } + + # --- Service worker (must not be cached stale) --- + location = /static/sw.js { + proxy_pass http://vigilar_home; + proxy_set_header Host $host; + proxy_cache off; + add_header Cache-Control "no-cache"; + } + + # --- Everything else (pages, PWA manifest, etc.) --- + location / { + proxy_pass http://vigilar_home; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_http_version 1.1; + + limit_req zone=api burst=20 nodelay; + } + + # Deny access to config/sensitive paths + location ~ ^/(config|migrations|scripts|tests) { + deny all; + } + + # Max upload size (for config changes, etc.) + client_max_body_size 1m; + + # Logging + access_log /var/log/nginx/vigilar_access.log; + error_log /var/log/nginx/vigilar_error.log; +} diff --git a/remote/setup_droplet.sh b/remote/setup_droplet.sh new file mode 100755 index 0000000..5d86fa7 --- /dev/null +++ b/remote/setup_droplet.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# Full setup script for Digital Ocean droplet as Vigilar reverse proxy +# Run this on a fresh Ubuntu 24.04 LTS droplet. +# +# What it does: +# 1. Installs WireGuard, nginx, certbot +# 2. Configures WireGuard tunnel (interactive key exchange) +# 3. Deploys nginx reverse proxy config +# 4. Sets up TLS with Let's Encrypt +# 5. Configures firewall + +set -euo pipefail + +echo "============================================" +echo " Vigilar — Droplet Reverse Proxy Setup" +echo "============================================" +echo "" + +# Require root +if [[ $EUID -ne 0 ]]; then + echo "Run as root: sudo bash setup_droplet.sh" + exit 1 +fi + +# --- Step 1: Install packages --- +echo "[1/6] Installing packages..." +apt update +apt install -y wireguard nginx certbot python3-certbot-nginx ufw + +# --- Step 2: WireGuard --- +echo "" +echo "[2/6] Setting up WireGuard..." + +if [[ -f /etc/wireguard/wg0.conf ]]; then + echo " WireGuard already configured. Skipping." +else + # Generate keys + PRIV_KEY=$(wg genkey) + PUB_KEY=$(echo "$PRIV_KEY" | wg pubkey) + + echo "" + echo " Droplet PUBLIC key (give this to your home server):" + echo " $PUB_KEY" + echo "" + read -p " Enter home server's PUBLIC key: " HOME_PUB_KEY + + cat > /etc/wireguard/wg0.conf < "$NGINX_CONF" +else + echo " ERROR: nginx/vigilar.conf not found in $SCRIPT_DIR" + echo " Copy it manually to /etc/nginx/sites-available/vigilar" + exit 1 +fi + +# Enable site +ln -sf "$NGINX_CONF" /etc/nginx/sites-enabled/vigilar +rm -f /etc/nginx/sites-enabled/default + +# Test config (will fail on TLS certs — that's OK, certbot fixes it) +echo " Testing nginx config (cert errors expected before certbot)..." +nginx -t 2>/dev/null || true + +# --- Step 5: TLS with Let's Encrypt --- +echo "" +echo "[5/6] Setting up TLS..." +echo " Running certbot for $DOMAIN" +echo " Note: DNS must already point $DOMAIN to this droplet's IP." +echo "" + +# Temporarily remove SSL directives so nginx starts +# Create a minimal config for certbot +cat > /etc/nginx/sites-available/vigilar-temp </dev/null; then + echo " [OK] WireGuard interface wg0 is up" +else + echo " [!!] WireGuard not running" +fi + +# Check nginx +if systemctl is-active --quiet nginx; then + echo " [OK] nginx is running" +else + echo " [!!] nginx is not running" +fi + +# Check cert +if [[ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ]]; then + echo " [OK] TLS certificate present" +else + echo " [!!] TLS certificate missing — run certbot manually" +fi + +echo "" +echo "============================================" +echo " Setup complete!" +echo "" +echo " Droplet WireGuard IP: 10.99.0.1" +echo " Home server should be: 10.99.0.2" +echo " Web URL: https://$DOMAIN" +echo "" +echo " Next steps:" +echo " 1. Configure WireGuard on your home server" +echo " 2. Test: ping 10.99.0.2 (from this droplet)" +echo " 3. Start Vigilar on home server" +echo " 4. Access https://$DOMAIN" +echo "============================================" diff --git a/remote/wireguard/setup_wireguard.sh b/remote/wireguard/setup_wireguard.sh new file mode 100755 index 0000000..e1d684d --- /dev/null +++ b/remote/wireguard/setup_wireguard.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# WireGuard key generation and setup helper +# Run this on BOTH the home server and the droplet to generate keys. +# Then copy the public keys into the appropriate config files. + +set -euo pipefail + +echo "=== Vigilar WireGuard Setup ===" +echo "" + +# Check if WireGuard is installed +if ! command -v wg &>/dev/null; then + echo "Installing WireGuard..." + if command -v apt &>/dev/null; then + sudo apt update && sudo apt install -y wireguard + elif command -v pacman &>/dev/null; then + sudo pacman -S --noconfirm wireguard-tools + else + echo "ERROR: Install WireGuard manually for your OS" + exit 1 + fi +fi + +echo "Generating WireGuard keys..." +PRIV_KEY=$(wg genkey) +PUB_KEY=$(echo "$PRIV_KEY" | wg pubkey) + +echo "" +echo "Private Key: $PRIV_KEY" +echo "Public Key: $PUB_KEY" +echo "" +echo "Save the private key in /etc/wireguard/ and share the PUBLIC key" +echo "with the other end of the tunnel." +echo "" + +# Detect if this is the home server or droplet +read -p "Is this the (h)ome server or (d)roplet? [h/d]: " ROLE + +if [[ "$ROLE" == "d" ]]; then + echo "" + echo "=== DROPLET SETUP ===" + echo "" + + # Save keys + sudo mkdir -p /etc/wireguard + echo "$PRIV_KEY" | sudo tee /etc/wireguard/droplet_private.key > /dev/null + echo "$PUB_KEY" | sudo tee /etc/wireguard/droplet_public.key > /dev/null + sudo chmod 600 /etc/wireguard/droplet_private.key + + read -p "Home server's PUBLIC key: " HOME_PUB_KEY + + # Generate config + sudo tee /etc/wireguard/wg0.conf > /dev/null </dev/null; then + sudo ufw allow 51820/udp + sudo ufw allow 443/tcp + sudo ufw allow 80/tcp + echo "Firewall rules added (51820/udp, 80/tcp, 443/tcp)" + fi + + # Enable and start + sudo systemctl enable --now wg-quick@wg0 + echo "" + echo "WireGuard started on droplet." + echo "Droplet tunnel IP: 10.99.0.1" + echo "" + echo "Share this public key with your home server: $PUB_KEY" + +elif [[ "$ROLE" == "h" ]]; then + echo "" + echo "=== HOME SERVER SETUP ===" + echo "" + + # Save keys + sudo mkdir -p /etc/wireguard + echo "$PRIV_KEY" | sudo tee /etc/wireguard/home_private.key > /dev/null + echo "$PUB_KEY" | sudo tee /etc/wireguard/home_public.key > /dev/null + sudo chmod 600 /etc/wireguard/home_private.key + + read -p "Droplet's PUBLIC key: " DROPLET_PUB_KEY + read -p "Droplet's public IP address: " DROPLET_IP + + # Generate config + sudo tee /etc/wireguard/wg0.conf > /dev/null < /etc/wireguard/droplet_public.key +PrivateKey = + +[Peer] +# Home server +PublicKey = +# Home server's tunnel IP — traffic to this IP goes through WireGuard +AllowedIPs = 10.99.0.2/32 +# No Endpoint needed — home server initiates the connection (NAT traversal) +# No PersistentKeepalive needed — home server sends keepalives diff --git a/remote/wireguard/wg0-home.conf b/remote/wireguard/wg0-home.conf new file mode 100644 index 0000000..a20f64e --- /dev/null +++ b/remote/wireguard/wg0-home.conf @@ -0,0 +1,21 @@ +# WireGuard config for HOME SERVER (Vigilar host) +# Install: cp wg0-home.conf /etc/wireguard/wg0.conf +# Start: systemctl enable --now wg-quick@wg0 + +[Interface] +# Home server's WireGuard IP on the tunnel +Address = 10.99.0.2/32 +# Generate with: wg genkey | tee /etc/wireguard/home_private.key | wg pubkey > /etc/wireguard/home_public.key +PrivateKey = +# Keep the tunnel alive through NAT (home router) +# Send keepalive every 25s so the NAT mapping doesn't expire + +[Peer] +# Digital Ocean droplet +PublicKey = +# Route all tunnel traffic to the droplet +AllowedIPs = 10.99.0.1/32 +# Droplet's public IP + WireGuard port +Endpoint = :51820 +# Critical: keeps tunnel alive through home router NAT +PersistentKeepalive = 25 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..0577728 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,6 @@ +-r requirements.txt +pytest>=8.2.0 +pytest-cov>=5.0.0 +mypy>=1.10.0 +ruff>=0.4.0 +httpx>=0.27.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cfb7c7a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +opencv-python-headless>=4.9.0 +paho-mqtt>=2.1.0 +flask>=3.0.0 +gunicorn>=22.0.0 +sqlalchemy>=2.0.0 +alembic>=1.13.0 +pydantic>=2.7.0 +pillow>=10.3.0 +cryptography>=42.0.0 +click>=8.1.7 +pynut2>=0.1.0 +pywebpush>=2.0.0 +py-vapid>=1.9.0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ef1aa65 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,35 @@ +"""Pytest fixtures for Vigilar tests.""" + +import os +import tempfile +from pathlib import Path + +import pytest + +from vigilar.config import VigilarConfig, load_config +from vigilar.storage.db import init_db + + +@pytest.fixture +def tmp_data_dir(tmp_path): + """Temporary data directory for tests.""" + data_dir = tmp_path / "data" + data_dir.mkdir() + return data_dir + + +@pytest.fixture +def test_db(tmp_data_dir): + """Initialize a test database and return the engine.""" + db_path = tmp_data_dir / "test.db" + return init_db(db_path) + + +@pytest.fixture +def sample_config(): + """Return a minimal VigilarConfig for testing.""" + return VigilarConfig( + cameras=[], + sensors=[], + rules=[], + ) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..aadcac0 --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,35 @@ +"""Tests for config loading and validation.""" + +from vigilar.config import CameraConfig, VigilarConfig + + +def test_default_config(): + cfg = VigilarConfig() + assert cfg.system.name == "Vigilar Home Security" + assert cfg.web.port == 49735 + assert cfg.mqtt.port == 1883 + assert cfg.cameras == [] + + +def test_camera_config_defaults(): + cam = CameraConfig(id="test", display_name="Test", rtsp_url="rtsp://localhost") + assert cam.idle_fps == 2 + assert cam.motion_fps == 30 + assert cam.pre_motion_buffer_s == 5 + assert cam.post_motion_buffer_s == 30 + assert cam.motion_sensitivity == 0.7 + + +def test_duplicate_camera_ids_rejected(): + import pytest + with pytest.raises(ValueError, match="Duplicate camera IDs"): + VigilarConfig(cameras=[ + CameraConfig(id="cam1", display_name="A", rtsp_url="rtsp://a"), + CameraConfig(id="cam1", display_name="B", rtsp_url="rtsp://b"), + ]) + + +def test_camera_sensitivity_bounds(): + import pytest + with pytest.raises(Exception): + CameraConfig(id="test", display_name="Test", rtsp_url="rtsp://localhost", motion_sensitivity=1.5) diff --git a/tests/unit/test_config_writer.py b/tests/unit/test_config_writer.py new file mode 100644 index 0000000..21fd1cf --- /dev/null +++ b/tests/unit/test_config_writer.py @@ -0,0 +1,62 @@ +"""Tests for config writer.""" + +import tomllib +from pathlib import Path + +from vigilar.config import CameraConfig, VigilarConfig +from vigilar.config_writer import ( + save_config, + update_camera_config, + update_config_section, +) + + +def test_update_config_section(): + cfg = VigilarConfig() + assert cfg.system.timezone == "UTC" + + new_cfg = update_config_section(cfg, "system", {"timezone": "America/New_York"}) + assert new_cfg.system.timezone == "America/New_York" + # Original unchanged + assert cfg.system.timezone == "UTC" + + +def test_update_camera_config(): + cfg = VigilarConfig(cameras=[ + CameraConfig(id="cam1", display_name="Cam 1", rtsp_url="rtsp://a"), + ]) + assert cfg.cameras[0].motion_sensitivity == 0.7 + + new_cfg = update_camera_config(cfg, "cam1", {"motion_sensitivity": 0.9}) + assert new_cfg.cameras[0].motion_sensitivity == 0.9 + + +def test_save_and_reload(tmp_path): + cfg = VigilarConfig(cameras=[ + CameraConfig(id="test", display_name="Test Cam", rtsp_url="rtsp://localhost"), + ]) + + path = tmp_path / "test.toml" + save_config(cfg, path) + + assert path.exists() + with open(path, "rb") as f: + data = tomllib.load(f) + + assert data["system"]["name"] == "Vigilar Home Security" + assert len(data["cameras"]) == 1 + assert data["cameras"][0]["id"] == "test" + + +def test_save_creates_backup(tmp_path): + path = tmp_path / "test.toml" + cfg = VigilarConfig() + + # First save + save_config(cfg, path) + assert path.exists() + + # Second save should create backup + save_config(cfg, path) + backups = list(tmp_path.glob("*.bak.*")) + assert len(backups) == 1 diff --git a/tests/unit/test_motion.py b/tests/unit/test_motion.py new file mode 100644 index 0000000..eee1d47 --- /dev/null +++ b/tests/unit/test_motion.py @@ -0,0 +1,63 @@ +"""Tests for motion detection.""" + +import numpy as np + +from vigilar.camera.motion import MotionDetector + + +def test_no_motion_on_static_scene(): + detector = MotionDetector(sensitivity=0.7, min_area_px=100, resolution=(160, 90)) + + # Feed identical frames to build background model + static_frame = np.full((360, 640, 3), 128, dtype=np.uint8) + for _ in range(80): # exceed warmup + detected, rects, conf = detector.detect(static_frame) + + # After warmup, static scene should have no motion + detected, rects, conf = detector.detect(static_frame) + assert not detected + assert len(rects) == 0 + + +def test_motion_detected_on_scene_change(): + detector = MotionDetector(sensitivity=0.9, min_area_px=50, resolution=(160, 90)) + + # Build background with static scene + static = np.full((360, 640, 3), 128, dtype=np.uint8) + for _ in range(80): + detector.detect(static) + + # Introduce a large change + changed = static.copy() + changed[50:200, 100:300] = 255 # large white rectangle + + detected, rects, conf = detector.detect(changed) + assert detected + assert len(rects) > 0 + assert conf > 0 + + +def test_sensitivity_update(): + detector = MotionDetector(sensitivity=0.5) + assert detector._sensitivity == 0.5 + + detector.update_sensitivity(0.9) + assert detector._sensitivity == 0.9 + + # Clamps to valid range + detector.update_sensitivity(1.5) + assert detector._sensitivity == 1.0 + detector.update_sensitivity(-0.1) + assert detector._sensitivity == 0.0 + + +def test_reset_clears_background(): + detector = MotionDetector(resolution=(80, 45)) + frame = np.zeros((360, 640, 3), dtype=np.uint8) + + for _ in range(80): + detector.detect(frame) + + assert detector._frame_count >= 80 + detector.reset() + assert detector._frame_count == 0 diff --git a/tests/unit/test_ring_buffer.py b/tests/unit/test_ring_buffer.py new file mode 100644 index 0000000..79b5ceb --- /dev/null +++ b/tests/unit/test_ring_buffer.py @@ -0,0 +1,74 @@ +"""Tests for the ring buffer.""" + +import numpy as np + +from vigilar.camera.ring_buffer import RingBuffer + + +def test_push_and_count(): + buf = RingBuffer(duration_s=1, max_fps=10) + assert buf.count == 0 + assert buf.capacity == 10 + + frame = np.zeros((480, 640, 3), dtype=np.uint8) + for _ in range(5): + buf.push(frame) + assert buf.count == 5 + + +def test_capacity_limit(): + buf = RingBuffer(duration_s=1, max_fps=5) + frame = np.zeros((100, 100, 3), dtype=np.uint8) + + for i in range(10): + buf.push(frame) + + assert buf.count == 5 # maxlen enforced + + +def test_flush_returns_all_and_clears(): + buf = RingBuffer(duration_s=1, max_fps=10) + frame = np.zeros((100, 100, 3), dtype=np.uint8) + + for _ in range(7): + buf.push(frame) + + frames = buf.flush() + assert len(frames) == 7 + assert buf.count == 0 + + +def test_flush_preserves_order(): + buf = RingBuffer(duration_s=1, max_fps=10) + + for i in range(5): + frame = np.full((10, 10, 3), i, dtype=np.uint8) + buf.push(frame) + + frames = buf.flush() + for i, tsf in enumerate(frames): + assert tsf.frame[0, 0, 0] == i + + +def test_peek_latest(): + buf = RingBuffer(duration_s=1, max_fps=10) + assert buf.peek_latest() is None + + frame1 = np.full((10, 10, 3), 1, dtype=np.uint8) + frame2 = np.full((10, 10, 3), 2, dtype=np.uint8) + buf.push(frame1) + buf.push(frame2) + + latest = buf.peek_latest() + assert latest is not None + assert latest.frame[0, 0, 0] == 2 + assert buf.count == 2 # peek doesn't remove + + +def test_clear(): + buf = RingBuffer(duration_s=1, max_fps=10) + frame = np.zeros((10, 10, 3), dtype=np.uint8) + buf.push(frame) + buf.push(frame) + buf.clear() + assert buf.count == 0 diff --git a/tests/unit/test_schema.py b/tests/unit/test_schema.py new file mode 100644 index 0000000..1df333e --- /dev/null +++ b/tests/unit/test_schema.py @@ -0,0 +1,21 @@ +"""Tests for database schema creation.""" + +from vigilar.storage.db import init_db +from vigilar.storage.schema import metadata + + +def test_tables_created(tmp_path): + db_path = tmp_path / "test.db" + engine = init_db(db_path) + assert db_path.exists() + + from sqlalchemy import inspect + inspector = inspect(engine) + table_names = inspector.get_table_names() + + expected = [ + "cameras", "sensors", "sensor_states", "events", "recordings", + "system_events", "arm_state_log", "alert_log", "push_subscriptions", + ] + for name in expected: + assert name in table_names, f"Missing table: {name}" diff --git a/tests/unit/test_web.py b/tests/unit/test_web.py new file mode 100644 index 0000000..d8ff59f --- /dev/null +++ b/tests/unit/test_web.py @@ -0,0 +1,104 @@ +"""Tests for Flask web application.""" + +from vigilar.config import CameraConfig, VigilarConfig +from vigilar.web.app import create_app + + +def test_app_creates(): + cfg = VigilarConfig() + app = create_app(cfg) + assert app is not None + + +def test_index_loads(): + cfg = VigilarConfig(cameras=[ + CameraConfig(id="test", display_name="Test", rtsp_url="rtsp://localhost"), + ]) + app = create_app(cfg) + with app.test_client() as client: + resp = client.get("/") + assert resp.status_code == 200 + assert b"Vigilar" in resp.data + assert b"Test" in resp.data + + +def test_settings_loads(): + cfg = VigilarConfig() + app = create_app(cfg) + with app.test_client() as client: + resp = client.get("/system/settings") + assert resp.status_code == 200 + assert b"Settings" in resp.data + + +def test_kiosk_loads(): + cfg = VigilarConfig(cameras=[ + CameraConfig(id="cam1", display_name="Cam 1", rtsp_url="rtsp://a"), + ]) + app = create_app(cfg) + with app.test_client() as client: + resp = client.get("/kiosk/") + assert resp.status_code == 200 + assert b"Cam 1" in resp.data + assert b"kiosk-grid" in resp.data + + +def test_system_status_api(): + cfg = VigilarConfig() + app = create_app(cfg) + with app.test_client() as client: + resp = client.get("/system/status") + assert resp.status_code == 200 + data = resp.get_json() + assert "arm_state" in data + + +def test_config_api(): + cfg = VigilarConfig() + app = create_app(cfg) + with app.test_client() as client: + resp = client.get("/system/api/config") + assert resp.status_code == 200 + data = resp.get_json() + assert "system" in data + assert "cameras" in data + # Secrets should be redacted + assert "password_hash" not in data.get("web", {}) + + +def test_camera_status_api(): + cfg = VigilarConfig(cameras=[ + CameraConfig(id="test", display_name="Test", rtsp_url="rtsp://localhost"), + ]) + app = create_app(cfg) + with app.test_client() as client: + resp = client.get("/cameras/api/status") + assert resp.status_code == 200 + data = resp.get_json() + assert len(data) == 1 + assert data[0]["id"] == "test" + + +def test_events_page_loads(): + cfg = VigilarConfig() + app = create_app(cfg) + with app.test_client() as client: + resp = client.get("/events/") + assert resp.status_code == 200 + assert b"Event Log" in resp.data + + +def test_sensors_page_loads(): + cfg = VigilarConfig() + app = create_app(cfg) + with app.test_client() as client: + resp = client.get("/sensors/") + assert resp.status_code == 200 + + +def test_recordings_page_loads(): + cfg = VigilarConfig() + app = create_app(cfg) + with app.test_client() as client: + resp = client.get("/recordings/") + assert resp.status_code == 200 diff --git a/vigilar/__init__.py b/vigilar/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vigilar/alerts/__init__.py b/vigilar/alerts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vigilar/bus.py b/vigilar/bus.py new file mode 100644 index 0000000..4ed7fdc --- /dev/null +++ b/vigilar/bus.py @@ -0,0 +1,116 @@ +"""MQTT bus wrapper for internal pub/sub communication.""" + +import json +import logging +import time +from typing import Any, Callable + +import paho.mqtt.client as mqtt + +from vigilar.config import MQTTConfig +from vigilar.constants import Topics + +log = logging.getLogger(__name__) + +MessageHandler = Callable[[str, dict[str, Any]], None] + + +class MessageBus: + """Wrapper around paho-mqtt for internal Vigilar communication.""" + + def __init__(self, config: MQTTConfig, client_id: str): + self._config = config + self._client_id = client_id + self._client = mqtt.Client( + mqtt.CallbackAPIVersion.VERSION2, + client_id=client_id, + ) + self._handlers: dict[str, list[MessageHandler]] = {} + self._connected = False + + self._client.on_connect = self._on_connect + self._client.on_disconnect = self._on_disconnect + self._client.on_message = self._on_message + + if config.username: + self._client.username_pw_set(config.username, config.password) + + def _on_connect(self, client: mqtt.Client, userdata: Any, flags: Any, rc: Any, properties: Any = None) -> None: + if isinstance(rc, int): + reason_code = rc + else: + reason_code = rc.value if hasattr(rc, "value") else int(rc) + + if reason_code == 0: + log.info("MQTT connected: %s", self._client_id) + self._connected = True + # Resubscribe on reconnect + for topic in self._handlers: + self._client.subscribe(topic, qos=1) + else: + log.error("MQTT connect failed (rc=%s): %s", reason_code, self._client_id) + + def _on_disconnect(self, client: mqtt.Client, userdata: Any, *args: Any) -> None: + log.warning("MQTT disconnected: %s", self._client_id) + self._connected = False + + def _on_message(self, client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage) -> None: + try: + payload = json.loads(msg.payload.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + log.warning("Bad MQTT payload on %s: %r", msg.topic, msg.payload[:100]) + return + + # Match handlers by exact topic and wildcard patterns + for pattern, handlers in self._handlers.items(): + if mqtt.topic_matches_sub(pattern, msg.topic): + for handler in handlers: + try: + handler(msg.topic, payload) + except Exception: + log.exception("Handler error on %s", msg.topic) + + def connect(self) -> None: + """Connect to the MQTT broker.""" + self._client.connect(self._config.host, self._config.port, keepalive=60) + self._client.loop_start() + + # Wait for connection with timeout + deadline = time.monotonic() + 10 + while not self._connected and time.monotonic() < deadline: + time.sleep(0.1) + + if not self._connected: + log.error("MQTT connection timeout: %s", self._client_id) + + def disconnect(self) -> None: + """Disconnect from the MQTT broker.""" + self._client.loop_stop() + self._client.disconnect() + self._connected = False + + def subscribe(self, topic: str, handler: MessageHandler) -> None: + """Subscribe to a topic with a handler function.""" + if topic not in self._handlers: + self._handlers[topic] = [] + if self._connected: + self._client.subscribe(topic, qos=1) + self._handlers[topic].append(handler) + + def publish(self, topic: str, payload: dict[str, Any], qos: int = 1) -> None: + """Publish a JSON payload to a topic.""" + data = json.dumps(payload, default=str).encode("utf-8") + self._client.publish(topic, data, qos=qos) + + def publish_event(self, topic: str, **kwargs: Any) -> None: + """Publish an event with automatic timestamp.""" + payload = {"ts": int(time.time() * 1000), **kwargs} + self.publish(topic, payload) + + @property + def connected(self) -> bool: + return self._connected + + def subscribe_all(self, handler: MessageHandler) -> None: + """Subscribe to all Vigilar topics.""" + self.subscribe(Topics.ALL, handler) diff --git a/vigilar/camera/__init__.py b/vigilar/camera/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vigilar/camera/hls.py b/vigilar/camera/hls.py new file mode 100644 index 0000000..9553788 --- /dev/null +++ b/vigilar/camera/hls.py @@ -0,0 +1,245 @@ +"""HLS segment generator using FFmpeg. + +Produces an HLS live stream per camera by running an FFmpeg process +that reads frames from a pipe and outputs .m3u8 + .ts segment files. +The web UI serves these files for low-latency, bandwidth-efficient +multi-camera viewing via hls.js. +""" + +import logging +import shutil +import subprocess +import time +from pathlib import Path + +import cv2 +import numpy as np + +log = logging.getLogger(__name__) + + +class HLSStreamer: + """Generates HLS segments from raw video frames via FFmpeg.""" + + def __init__( + self, + camera_id: str, + hls_dir: str, + fps: int = 15, + resolution: tuple[int, int] = (640, 360), + segment_duration: int = 2, + list_size: int = 5, + ): + """ + Args: + camera_id: Camera identifier for directory naming. + hls_dir: Root directory for HLS output. + fps: Frame rate for the HLS stream (lower than capture for bandwidth). + resolution: Output resolution for HLS segments. + segment_duration: Duration of each .ts segment in seconds. + list_size: Number of segments to keep in the playlist. + """ + self._camera_id = camera_id + self._output_dir = Path(hls_dir) / camera_id + self._fps = fps + self._resolution = resolution + self._segment_duration = segment_duration + self._list_size = list_size + + self._ffmpeg_path = shutil.which("ffmpeg") or "ffmpeg" + self._process: subprocess.Popen | None = None + self._running = False + + @property + def playlist_path(self) -> Path: + """Path to the HLS playlist file.""" + return self._output_dir / "stream.m3u8" + + def start(self) -> None: + """Start the FFmpeg HLS encoding process.""" + self._output_dir.mkdir(parents=True, exist_ok=True) + + # Clean stale segments + for f in self._output_dir.glob("*.ts"): + f.unlink() + if self.playlist_path.exists(): + self.playlist_path.unlink() + + w, h = self._resolution + cmd = [ + self._ffmpeg_path, + "-y", + "-f", "rawvideo", + "-vcodec", "rawvideo", + "-pix_fmt", "bgr24", + "-s", f"{w}x{h}", + "-r", str(self._fps), + "-i", "pipe:0", + "-c:v", "libx264", + "-preset", "ultrafast", + "-tune", "zerolatency", + "-g", str(self._fps * self._segment_duration), # keyframe interval + "-sc_threshold", "0", + "-pix_fmt", "yuv420p", + "-f", "hls", + "-hls_time", str(self._segment_duration), + "-hls_list_size", str(self._list_size), + "-hls_flags", "delete_segments+append_list", + "-hls_segment_filename", str(self._output_dir / "seg_%03d.ts"), + str(self.playlist_path), + ] + + try: + self._process = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + self._running = True + log.info("HLS streamer started for %s (%dx%d @ %d fps)", self._camera_id, w, h, self._fps) + except FileNotFoundError: + log.error("FFmpeg not found — HLS streaming disabled for %s", self._camera_id) + self._process = None + self._running = False + + def write_frame(self, frame: np.ndarray) -> None: + """Write a frame to the HLS encoder. + + Frame should be at capture resolution — it will be downscaled + to the HLS output resolution. + """ + if not self._running or not self._process or not self._process.stdin: + return + + try: + h, w = frame.shape[:2] + rw, rh = self._resolution + if w != rw or h != rh: + frame = cv2.resize(frame, (rw, rh), interpolation=cv2.INTER_AREA) + + self._process.stdin.write(frame.tobytes()) + except BrokenPipeError: + log.warning("HLS FFmpeg pipe broken for %s", self._camera_id) + self._running = False + + def stop(self) -> None: + """Stop the HLS encoding process.""" + if not self._process: + return + + try: + if self._process.stdin: + self._process.stdin.close() + self._process.wait(timeout=5) + except subprocess.TimeoutExpired: + self._process.kill() + self._process.wait() + + self._running = False + self._process = None + log.info("HLS streamer stopped for %s", self._camera_id) + + def cleanup(self) -> None: + """Remove all HLS segments and playlist.""" + self.stop() + if self._output_dir.exists(): + for f in self._output_dir.iterdir(): + f.unlink() + + @property + def is_running(self) -> bool: + return self._running + + +class RemoteHLSStreamer(HLSStreamer): + """Lower-quality HLS stream optimized for remote access over limited uplink. + + Produces a separate stream at reduced resolution/fps/bitrate that gets + served through the WireGuard tunnel → nginx reverse proxy path. + This keeps the home upload bandwidth under control. + + At 426x240 @ 10fps @ 500kbps: ~0.5 Mbps per camera. + 4 cameras = ~2 Mbps, well within 22 Mbps uplink even with 4 viewers. + """ + + def __init__( + self, + camera_id: str, + hls_dir: str, + fps: int = 10, + resolution: tuple[int, int] = (426, 240), + bitrate_kbps: int = 500, + segment_duration: int = 2, + list_size: int = 5, + ): + # Remote streams go in a /remote/ subdirectory + super().__init__( + camera_id=camera_id, + hls_dir=hls_dir, + fps=fps, + resolution=resolution, + segment_duration=segment_duration, + list_size=list_size, + ) + self._bitrate_kbps = bitrate_kbps + # Override output dir to be under /remote/ subdirectory + self._output_dir = Path(hls_dir) / camera_id / "remote" + + @property + def playlist_path(self) -> Path: + return self._output_dir / "stream.m3u8" + + def start(self) -> None: + """Start FFmpeg with constrained bitrate for remote streaming.""" + self._output_dir.mkdir(parents=True, exist_ok=True) + + for f in self._output_dir.glob("*.ts"): + f.unlink() + if self.playlist_path.exists(): + self.playlist_path.unlink() + + w, h = self._resolution + bitrate = f"{self._bitrate_kbps}k" + cmd = [ + self._ffmpeg_path, + "-y", + "-f", "rawvideo", + "-vcodec", "rawvideo", + "-pix_fmt", "bgr24", + "-s", f"{w}x{h}", + "-r", str(self._fps), + "-i", "pipe:0", + "-c:v", "libx264", + "-preset", "ultrafast", + "-tune", "zerolatency", + "-b:v", bitrate, + "-maxrate", bitrate, + "-bufsize", f"{self._bitrate_kbps * 2}k", + "-g", str(self._fps * self._segment_duration), + "-sc_threshold", "0", + "-pix_fmt", "yuv420p", + "-f", "hls", + "-hls_time", str(self._segment_duration), + "-hls_list_size", str(self._list_size), + "-hls_flags", "delete_segments+append_list", + "-hls_segment_filename", str(self._output_dir / "seg_%03d.ts"), + str(self.playlist_path), + ] + + try: + self._process = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + self._running = True + log.info( + "Remote HLS started for %s (%dx%d @ %d fps, %s)", + self._camera_id, w, h, self._fps, bitrate, + ) + except FileNotFoundError: + log.error("FFmpeg not found — remote HLS disabled for %s", self._camera_id) + self._process = None + self._running = False diff --git a/vigilar/camera/manager.py b/vigilar/camera/manager.py new file mode 100644 index 0000000..536ab5f --- /dev/null +++ b/vigilar/camera/manager.py @@ -0,0 +1,117 @@ +"""Camera manager — spawns and supervises per-camera worker processes.""" + +import logging +import time +from multiprocessing import Process + +from vigilar.camera.worker import run_camera_worker +from vigilar.config import VigilarConfig + +log = logging.getLogger(__name__) + + +class CameraWorkerHandle: + """Handle for a single camera worker process.""" + + def __init__(self, camera_id: str, process: Process): + self.camera_id = camera_id + self.process = process + self.restart_count = 0 + self.last_restart: float = 0 + + @property + def is_alive(self) -> bool: + return self.process.is_alive() + + +class CameraManager: + """Manages all camera worker processes.""" + + def __init__(self, config: VigilarConfig): + self._config = config + self._workers: dict[str, CameraWorkerHandle] = {} + + def start(self) -> None: + """Start a worker process for each enabled camera.""" + enabled = [c for c in self._config.cameras if c.enabled] + log.info("Starting %d camera workers", len(enabled)) + + for cam_cfg in enabled: + self._start_worker(cam_cfg.id) + + def _start_worker(self, camera_id: str) -> None: + """Start a single camera worker process.""" + cam_cfg = next((c for c in self._config.cameras if c.id == camera_id), None) + if not cam_cfg: + log.error("Camera config not found: %s", camera_id) + return + + process = Process( + target=run_camera_worker, + args=( + cam_cfg, + self._config.mqtt, + self._config.system.recordings_dir, + self._config.system.hls_dir, + self._config.remote if self._config.remote.enabled else None, + ), + name=f"camera-{camera_id}", + daemon=True, + ) + process.start() + self._workers[camera_id] = CameraWorkerHandle(camera_id, process) + log.info("Camera worker started: %s (pid=%d)", camera_id, process.pid) + + def check_and_restart(self) -> None: + """Check for crashed workers and restart them with backoff.""" + for camera_id, handle in list(self._workers.items()): + if handle.is_alive: + continue + + if handle.restart_count >= 10: + log.error("Camera %s exceeded max restarts, giving up", camera_id) + continue + + backoff = min(2 ** handle.restart_count, 60) + elapsed = time.monotonic() - handle.last_restart + if elapsed < backoff: + continue + + handle.restart_count += 1 + handle.last_restart = time.monotonic() + log.warning("Restarting camera worker %s (attempt %d)", camera_id, handle.restart_count) + self._start_worker(camera_id) + + def stop(self) -> None: + """Stop all camera worker processes.""" + log.info("Stopping %d camera workers", len(self._workers)) + for handle in self._workers.values(): + if handle.process.is_alive(): + handle.process.terminate() + + for handle in self._workers.values(): + handle.process.join(timeout=5) + if handle.process.is_alive(): + handle.process.kill() + handle.process.join(timeout=2) + + self._workers.clear() + + @property + def worker_count(self) -> int: + return len(self._workers) + + @property + def alive_count(self) -> int: + return sum(1 for h in self._workers.values() if h.is_alive) + + def get_status(self) -> dict: + """Get status of all camera workers.""" + return { + cid: { + "alive": h.is_alive, + "pid": h.process.pid if h.process else None, + "restart_count": h.restart_count, + } + for cid, h in self._workers.items() + } diff --git a/vigilar/camera/motion.py b/vigilar/camera/motion.py new file mode 100644 index 0000000..b48887f --- /dev/null +++ b/vigilar/camera/motion.py @@ -0,0 +1,147 @@ +"""Motion detection using OpenCV MOG2 background subtractor. + +Supports configurable sensitivity, minimum contour area, +and rectangular zone masking. +""" + +import logging + +import cv2 +import numpy as np + +log = logging.getLogger(__name__) + + +class MotionDetector: + """MOG2-based motion detector with zone masking.""" + + def __init__( + self, + sensitivity: float = 0.7, + min_area_px: int = 500, + zones: list[list[int]] | None = None, + resolution: tuple[int, int] = (640, 360), + ): + """ + Args: + sensitivity: 0.0 (off) to 1.0 (maximum sensitivity). + Maps to MOG2 varThreshold inversely: high sensitivity = low threshold. + min_area_px: Minimum contour area (in pixels) to count as motion. + zones: List of [x, y, w, h] rectangles to monitor. Empty = whole frame. + resolution: Resolution to downscale frames to for detection. + """ + self._sensitivity = sensitivity + self._min_area = min_area_px + self._zones = zones or [] + self._resolution = resolution + + # MOG2 threshold: sensitivity 1.0 → threshold 8, sensitivity 0.0 → threshold 128 + var_threshold = int(128 - (sensitivity * 120)) + self._subtractor = cv2.createBackgroundSubtractorMOG2( + history=500, + varThreshold=var_threshold, + detectShadows=True, + ) + # Shadow detection value (127) gets eliminated by threshold + self._kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) + self._zone_mask: np.ndarray | None = None + self._motion_active = False + self._warmup_frames = 60 # frames before detection is reliable + self._frame_count = 0 + + def _build_zone_mask(self, height: int, width: int) -> np.ndarray: + """Build a binary mask from configured zones.""" + if not self._zones: + return np.ones((height, width), dtype=np.uint8) * 255 + + mask = np.zeros((height, width), dtype=np.uint8) + for zone in self._zones: + if len(zone) == 4: + x, y, w, h = zone + # Scale zone coordinates to detection resolution + sx = width / 1920 # assume zones defined relative to 1920 wide + sy = height / 1080 + x, y, w, h = int(x * sx), int(y * sy), int(w * sx), int(h * sy) + cv2.rectangle(mask, (x, y), (x + w, y + h), 255, -1) + return mask + + def detect(self, frame: np.ndarray) -> tuple[bool, list[tuple[int, int, int, int]], float]: + """Run motion detection on a frame. + + Args: + frame: BGR frame from camera (any resolution — will be downscaled). + + Returns: + (motion_detected, contour_rects, confidence) + - motion_detected: True if significant motion found + - contour_rects: list of (x, y, w, h) bounding boxes in detection coords + - confidence: 0.0-1.0 score based on motion area relative to frame + """ + self._frame_count += 1 + + # Downscale for detection + det_w, det_h = self._resolution + small = cv2.resize(frame, (det_w, det_h), interpolation=cv2.INTER_AREA) + + # Build zone mask lazily + if self._zone_mask is None: + self._zone_mask = self._build_zone_mask(det_h, det_w) + + # Apply MOG2 + fg_mask = self._subtractor.apply(small) + + # Remove shadows (value 127) and noise + _, fg_mask = cv2.threshold(fg_mask, 200, 255, cv2.THRESH_BINARY) + fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_OPEN, self._kernel) + fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_CLOSE, self._kernel) + + # Apply zone mask + fg_mask = cv2.bitwise_and(fg_mask, self._zone_mask) + + # Skip during warmup (MOG2 needs time to build background model) + if self._frame_count < self._warmup_frames: + return False, [], 0.0 + + # Find contours + contours, _ = cv2.findContours(fg_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + rects = [] + total_area = 0 + for contour in contours: + area = cv2.contourArea(contour) + if area >= self._min_area: + rects.append(cv2.boundingRect(contour)) + total_area += area + + motion_detected = len(rects) > 0 + frame_area = det_w * det_h + confidence = min(total_area / frame_area, 1.0) if frame_area > 0 else 0.0 + + self._motion_active = motion_detected + return motion_detected, rects, confidence + + @property + def is_motion_active(self) -> bool: + return self._motion_active + + def update_sensitivity(self, sensitivity: float) -> None: + """Update detection sensitivity at runtime.""" + self._sensitivity = max(0.0, min(1.0, sensitivity)) + var_threshold = int(128 - (self._sensitivity * 120)) + self._subtractor.setVarThreshold(var_threshold) + log.info("Motion sensitivity updated to %.2f (threshold=%d)", sensitivity, var_threshold) + + def update_min_area(self, min_area_px: int) -> None: + """Update minimum contour area at runtime.""" + self._min_area = max(0, min_area_px) + + def reset(self) -> None: + """Reset the background model (e.g., after scene change).""" + var_threshold = int(128 - (self._sensitivity * 120)) + self._subtractor = cv2.createBackgroundSubtractorMOG2( + history=500, + varThreshold=var_threshold, + detectShadows=True, + ) + self._frame_count = 0 + self._zone_mask = None diff --git a/vigilar/camera/recorder.py b/vigilar/camera/recorder.py new file mode 100644 index 0000000..ae13dcf --- /dev/null +++ b/vigilar/camera/recorder.py @@ -0,0 +1,228 @@ +"""Adaptive FPS recorder using FFmpeg subprocess. + +Two recording modes: +1. Idle recording: writes frames at 2 FPS (every Nth frame skipped) +2. Motion recording: writes all frames at full 30 FPS, including + pre-motion buffer frames flushed from the ring buffer. + +Uses FFmpeg via stdin pipe for frame-accurate control over what +gets written, while letting FFmpeg handle H.264 encoding efficiently. +""" + +import logging +import os +import shutil +import subprocess +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import cv2 +import numpy as np + +from vigilar.camera.ring_buffer import TimestampedFrame + +log = logging.getLogger(__name__) + + +@dataclass +class RecordingSegment: + """Metadata about a completed recording segment.""" + file_path: str + started_at: int # unix ms + ended_at: int # unix ms + duration_s: float + file_size: int + trigger: str # MOTION, CONTINUOUS, MANUAL + fps: int + frame_count: int + + +class AdaptiveRecorder: + """Records video segments at adaptive FPS using FFmpeg. + + Writes raw frames to FFmpeg via stdin pipe. FFmpeg encodes to H.264 MP4. + This gives us frame-accurate control over which frames get recorded + while keeping CPU usage low via hardware-accelerated encoding when available. + """ + + def __init__( + self, + camera_id: str, + recordings_dir: str, + idle_fps: int = 2, + motion_fps: int = 30, + resolution: tuple[int, int] = (1920, 1080), + ): + self._camera_id = camera_id + self._recordings_dir = Path(recordings_dir) + self._idle_fps = idle_fps + self._motion_fps = motion_fps + self._resolution = resolution + + self._ffmpeg_path = shutil.which("ffmpeg") or "ffmpeg" + self._process: subprocess.Popen | None = None + self._current_path: Path | None = None + self._current_fps: int = idle_fps + self._current_trigger: str = "CONTINUOUS" + self._started_at: int = 0 + self._frame_count: int = 0 + self._recording = False + + def _ensure_dir(self) -> Path: + """Create date-based subdirectory for recordings.""" + now = time.localtime() + subdir = self._recordings_dir / self._camera_id / f"{now.tm_year:04d}" / f"{now.tm_mon:02d}" / f"{now.tm_mday:02d}" + subdir.mkdir(parents=True, exist_ok=True) + return subdir + + def _start_ffmpeg(self, fps: int) -> None: + """Start an FFmpeg process for recording at the given FPS.""" + subdir = self._ensure_dir() + timestamp = time.strftime("%H%M%S") + filename = f"{self._camera_id}_{timestamp}_{fps}fps.mp4" + self._current_path = subdir / filename + self._current_fps = fps + + w, h = self._resolution + cmd = [ + self._ffmpeg_path, + "-y", + "-f", "rawvideo", + "-vcodec", "rawvideo", + "-pix_fmt", "bgr24", + "-s", f"{w}x{h}", + "-r", str(fps), + "-i", "pipe:0", + "-c:v", "libx264", + "-preset", "ultrafast", + "-crf", "23", + "-pix_fmt", "yuv420p", + "-movflags", "+faststart", + str(self._current_path), + ] + + try: + self._process = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + self._started_at = int(time.time() * 1000) + self._frame_count = 0 + self._recording = True + log.info("Recording started: %s at %d FPS", self._current_path.name, fps) + except FileNotFoundError: + log.error("FFmpeg not found at %s — recording disabled", self._ffmpeg_path) + self._process = None + self._recording = False + + def _stop_ffmpeg(self) -> RecordingSegment | None: + """Stop the current FFmpeg process and return segment metadata.""" + if not self._process or not self._recording: + return None + + try: + self._process.stdin.close() + self._process.wait(timeout=10) + except (subprocess.TimeoutExpired, BrokenPipeError): + self._process.kill() + self._process.wait() + + self._recording = False + ended_at = int(time.time() * 1000) + duration = (ended_at - self._started_at) / 1000.0 + + file_size = 0 + if self._current_path and self._current_path.exists(): + file_size = self._current_path.stat().st_size + + segment = RecordingSegment( + file_path=str(self._current_path) if self._current_path else "", + started_at=self._started_at, + ended_at=ended_at, + duration_s=duration, + file_size=file_size, + trigger=self._current_trigger, + fps=self._current_fps, + frame_count=self._frame_count, + ) + + log.info( + "Recording stopped: %s (%.1fs, %d frames, %d bytes)", + self._current_path.name if self._current_path else "unknown", + duration, self._frame_count, file_size, + ) + + self._process = None + self._current_path = None + return segment + + def write_frame(self, frame: np.ndarray) -> None: + """Write a single frame to the active recording.""" + if not self._recording or not self._process or not self._process.stdin: + return + + try: + # Resize to recording resolution if needed + h, w = frame.shape[:2] + rw, rh = self._resolution + if w != rw or h != rh: + frame = cv2.resize(frame, (rw, rh)) + + self._process.stdin.write(frame.tobytes()) + self._frame_count += 1 + except BrokenPipeError: + log.warning("FFmpeg pipe broken for %s", self._camera_id) + self._recording = False + + def start_idle_recording(self) -> None: + """Start or switch to idle (low FPS) recording.""" + if self._recording and self._current_fps == self._idle_fps: + return # already recording at idle FPS + if self._recording: + self._stop_ffmpeg() + self._current_trigger = "CONTINUOUS" + self._start_ffmpeg(self._idle_fps) + + def start_motion_recording(self, pre_buffer: list[TimestampedFrame] | None = None) -> None: + """Start motion recording at full FPS. + + Optionally flush pre-motion buffer frames into the new segment. + """ + if self._recording: + self._stop_ffmpeg() + + self._current_trigger = "MOTION" + self._start_ffmpeg(self._motion_fps) + + # Write pre-buffer frames + if pre_buffer: + for tsf in pre_buffer: + self.write_frame(tsf.frame) + log.debug("Flushed %d pre-buffer frames for %s", len(pre_buffer), self._camera_id) + + def stop_recording(self) -> RecordingSegment | None: + """Stop the current recording and return segment info.""" + return self._stop_ffmpeg() + + def start_manual_recording(self) -> None: + """Start a manually-triggered recording at motion FPS.""" + if self._recording: + self._stop_ffmpeg() + self._current_trigger = "MANUAL" + self._start_ffmpeg(self._motion_fps) + + @property + def is_recording(self) -> bool: + return self._recording + + @property + def current_fps(self) -> int: + return self._current_fps if self._recording else 0 + + @property + def current_trigger(self) -> str: + return self._current_trigger diff --git a/vigilar/camera/ring_buffer.py b/vigilar/camera/ring_buffer.py new file mode 100644 index 0000000..a4b82b8 --- /dev/null +++ b/vigilar/camera/ring_buffer.py @@ -0,0 +1,85 @@ +"""Thread-safe ring buffer for pre-motion frame storage. + +Keeps a rolling window of frames so that when motion is detected, +we can include the seconds leading up to the trigger in the recording. +""" + +import threading +import time +from collections import deque +from dataclasses import dataclass, field + +import numpy as np + + +@dataclass +class TimestampedFrame: + """A frame with its capture timestamp.""" + frame: np.ndarray + timestamp: float # time.monotonic() + frame_number: int = 0 + + +class RingBuffer: + """Fixed-duration ring buffer for video frames. + + Stores the most recent `duration_s * max_fps` frames, automatically + discarding the oldest when full. Thread-safe for single-producer, + single-consumer use (camera worker writes, recorder reads). + """ + + def __init__(self, duration_s: int = 5, max_fps: int = 30): + self._max_frames = duration_s * max_fps + self._buffer: deque[TimestampedFrame] = deque(maxlen=self._max_frames) + self._lock = threading.Lock() + self._frame_count = 0 + + def push(self, frame: np.ndarray) -> None: + """Add a frame to the buffer.""" + with self._lock: + self._frame_count += 1 + self._buffer.append(TimestampedFrame( + frame=frame, + timestamp=time.monotonic(), + frame_number=self._frame_count, + )) + + def flush(self) -> list[TimestampedFrame]: + """Drain all frames from the buffer and return them in order. + + Used when motion is detected to get the pre-motion context. + The buffer is cleared after flush. + """ + with self._lock: + frames = list(self._buffer) + self._buffer.clear() + return frames + + def peek_latest(self) -> TimestampedFrame | None: + """Get the most recent frame without removing it.""" + with self._lock: + return self._buffer[-1] if self._buffer else None + + @property + def count(self) -> int: + """Current number of frames in the buffer.""" + with self._lock: + return len(self._buffer) + + @property + def capacity(self) -> int: + """Maximum number of frames the buffer can hold.""" + return self._max_frames + + @property + def duration_s(self) -> float: + """Actual duration of content in the buffer, in seconds.""" + with self._lock: + if len(self._buffer) < 2: + return 0.0 + return self._buffer[-1].timestamp - self._buffer[0].timestamp + + def clear(self) -> None: + """Discard all frames.""" + with self._lock: + self._buffer.clear() diff --git a/vigilar/camera/worker.py b/vigilar/camera/worker.py new file mode 100644 index 0000000..60c0eea --- /dev/null +++ b/vigilar/camera/worker.py @@ -0,0 +1,267 @@ +"""Per-camera worker process. + +Each camera runs in its own multiprocessing.Process. Responsibilities: +1. Open RTSP stream via OpenCV with reconnect logic +2. Run MOG2 motion detection on every frame (downscaled) +3. Manage adaptive FPS recording (2 FPS idle → 30 FPS on motion) +4. Feed frames to HLS streamer for live viewing +5. Publish motion events and heartbeats via MQTT +""" + +import logging +import signal +import time + +import cv2 +import numpy as np + +from vigilar.bus import MessageBus +from vigilar.camera.hls import HLSStreamer, RemoteHLSStreamer +from vigilar.camera.motion import MotionDetector +from vigilar.camera.recorder import AdaptiveRecorder +from vigilar.camera.ring_buffer import RingBuffer +from vigilar.config import CameraConfig, MQTTConfig, RemoteConfig +from vigilar.constants import Topics + +log = logging.getLogger(__name__) + + +class CameraState: + """Tracks the current state of a camera worker.""" + + def __init__(self): + self.connected = False + self.motion_active = False + self.motion_start_time: float = 0 + self.last_motion_time: float = 0 + self.fps_actual: float = 0 + self.frame_count: int = 0 + self.reconnect_count: int = 0 + self.idle_frame_skip: int = 0 + + +def run_camera_worker( + camera_cfg: CameraConfig, + mqtt_cfg: MQTTConfig, + recordings_dir: str, + hls_dir: str, + remote_cfg: RemoteConfig | None = None, +) -> None: + """Main entry point for a camera worker process.""" + camera_id = camera_cfg.id + log.info("Camera worker starting: %s (%s)", camera_id, camera_cfg.rtsp_url) + + # Setup logging for this process + logging.basicConfig( + level=logging.INFO, + format=f"%(asctime)s [{camera_id}] %(levelname)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + # MQTT bus + bus = MessageBus(mqtt_cfg, client_id=f"camera-{camera_id}") + bus.connect() + + # Subscribe to threshold update commands + def on_threshold_update(topic: str, payload: dict) -> None: + if "sensitivity" in payload: + detector.update_sensitivity(payload["sensitivity"]) + if "min_area" in payload: + detector.update_min_area(payload["min_area"]) + + bus.subscribe(f"vigilar/camera/{camera_id}/config", on_threshold_update) + + # Components + ring_buf = RingBuffer( + duration_s=camera_cfg.pre_motion_buffer_s, + max_fps=camera_cfg.motion_fps, + ) + detector = MotionDetector( + sensitivity=camera_cfg.motion_sensitivity, + min_area_px=camera_cfg.motion_min_area_px, + zones=camera_cfg.motion_zones, + resolution=tuple(camera_cfg.resolution_motion), + ) + recorder = AdaptiveRecorder( + camera_id=camera_id, + recordings_dir=recordings_dir, + idle_fps=camera_cfg.idle_fps, + motion_fps=camera_cfg.motion_fps, + resolution=tuple(camera_cfg.resolution_capture), + ) + hls = HLSStreamer( + camera_id=camera_id, + hls_dir=hls_dir, + fps=15, # HLS stream at 15fps for bandwidth + resolution=(640, 360), + ) + + # Remote HLS streamer (lower quality for WireGuard tunnel) + remote_hls: RemoteHLSStreamer | None = None + if remote_cfg and remote_cfg.enabled: + remote_hls = RemoteHLSStreamer( + camera_id=camera_id, + hls_dir=hls_dir, + fps=remote_cfg.remote_hls_fps, + resolution=tuple(remote_cfg.remote_hls_resolution), + bitrate_kbps=remote_cfg.remote_hls_bitrate_kbps, + ) + + state = CameraState() + shutdown = False + + def handle_signal(signum, frame): + nonlocal shutdown + shutdown = True + + signal.signal(signal.SIGTERM, handle_signal) + + # Camera capture loop + cap = None + last_heartbeat = 0 + fps_timer = time.monotonic() + fps_frame_count = 0 + native_fps = camera_cfg.motion_fps # assumed until we read from stream + + # Idle frame skip: at 30fps native, skip factor 15 gives 2fps written + idle_skip_factor = max(1, native_fps // camera_cfg.idle_fps) + + while not shutdown: + # Connect / reconnect + if cap is None or not cap.isOpened(): + state.connected = False + backoff = min(2 ** state.reconnect_count, 60) + if state.reconnect_count > 0: + log.info("Reconnecting to %s in %ds...", camera_id, backoff) + bus.publish_event(Topics.camera_error(camera_id), error="disconnected", reconnect_in=backoff) + time.sleep(backoff) + + cap = cv2.VideoCapture(camera_cfg.rtsp_url, cv2.CAP_FFMPEG) + if not cap.isOpened(): + state.reconnect_count += 1 + log.warning("Failed to connect to %s (%s)", camera_id, camera_cfg.rtsp_url) + continue + + state.connected = True + state.reconnect_count = 0 + native_fps = cap.get(cv2.CAP_PROP_FPS) or camera_cfg.motion_fps + idle_skip_factor = max(1, int(native_fps / camera_cfg.idle_fps)) + log.info("Connected to %s (native %d fps, idle skip %d)", camera_id, native_fps, idle_skip_factor) + + # Start HLS streamers + hls.start() + if remote_hls: + remote_hls.start() + + # Start idle recording if continuous recording is enabled + if camera_cfg.record_continuous: + recorder.start_idle_recording() + + # Read frame + ret, frame = cap.read() + if not ret: + log.warning("Frame read failed for %s", camera_id) + cap.release() + cap = None + hls.stop() + if remote_hls: + remote_hls.stop() + recorder.stop_recording() + continue + + state.frame_count += 1 + fps_frame_count += 1 + now = time.monotonic() + + # Calculate actual FPS every second + if now - fps_timer >= 1.0: + state.fps_actual = fps_frame_count / (now - fps_timer) + fps_frame_count = 0 + fps_timer = now + + # Always push to ring buffer (full FPS for pre-motion context) + ring_buf.push(frame.copy()) + + # Feed HLS streamers + hls.write_frame(frame) + if remote_hls: + remote_hls.write_frame(frame) + + # Run motion detection + motion_detected, rects, confidence = detector.detect(frame) + + if motion_detected and not state.motion_active: + # --- Motion START --- + state.motion_active = True + state.motion_start_time = now + state.last_motion_time = now + + log.info("Motion START on %s (confidence=%.3f, zones=%d)", camera_id, confidence, len(rects)) + bus.publish_event( + Topics.camera_motion_start(camera_id), + confidence=confidence, + zones=len(rects), + ) + + # Switch to motion recording with pre-buffer + if camera_cfg.record_on_motion: + pre_frames = ring_buf.flush() + recorder.start_motion_recording(pre_buffer=pre_frames) + + elif motion_detected and state.motion_active: + # Ongoing motion — update timestamp + state.last_motion_time = now + + elif not motion_detected and state.motion_active: + # Check if silence period exceeded + silence = now - state.last_motion_time + if silence >= camera_cfg.post_motion_buffer_s: + # --- Motion END --- + duration = now - state.motion_start_time + state.motion_active = False + + log.info("Motion END on %s (duration=%.1fs)", camera_id, duration) + segment = recorder.stop_recording() + + bus.publish_event( + Topics.camera_motion_end(camera_id), + duration_s=round(duration, 1), + clip_path=segment.file_path if segment else "", + ) + + # Resume idle recording if continuous mode + if camera_cfg.record_continuous: + recorder.start_idle_recording() + + # Write frame to recorder (adaptive FPS) + if recorder.is_recording: + if state.motion_active: + # Motion: write every frame (full FPS) + recorder.write_frame(frame) + else: + # Idle: write every Nth frame + if state.frame_count % idle_skip_factor == 0: + recorder.write_frame(frame) + + # Heartbeat every 10 seconds + if now - last_heartbeat >= 10: + last_heartbeat = now + bus.publish_event( + Topics.camera_heartbeat(camera_id), + connected=state.connected, + fps=round(state.fps_actual, 1), + motion=state.motion_active, + recording=recorder.is_recording, + recording_fps=recorder.current_fps, + resolution=list(frame.shape[:2][::-1]), # [w, h] + ) + + # Shutdown + log.info("Camera worker shutting down: %s", camera_id) + if cap and cap.isOpened(): + cap.release() + recorder.stop_recording() + hls.stop() + if remote_hls: + remote_hls.stop() + bus.disconnect() diff --git a/vigilar/cli/__init__.py b/vigilar/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vigilar/cli/cmd_config.py b/vigilar/cli/cmd_config.py new file mode 100644 index 0000000..b4a0a9f --- /dev/null +++ b/vigilar/cli/cmd_config.py @@ -0,0 +1,95 @@ +"""CLI command: vigilar config — validate and inspect configuration.""" + +import json +import sys + +import click + +from vigilar.config import load_config + + +@click.group("config") +def config_cmd() -> None: + """Configuration management.""" + pass + + +@config_cmd.command("validate") +@click.option("--config", "-c", "config_path", default=None, help="Path to vigilar.toml.") +def validate_cmd(config_path: str | None) -> None: + """Validate the configuration file.""" + try: + cfg = load_config(config_path) + click.echo("Config OK") + click.echo(f" System: {cfg.system.name}") + click.echo(f" Cameras: {len(cfg.cameras)}") + for cam in cfg.cameras: + status = "enabled" if cam.enabled else "disabled" + click.echo(f" - {cam.id} ({cam.display_name}) [{status}]") + click.echo(f" Sensors: {len(cfg.sensors)}") + for sensor in cfg.sensors: + click.echo(f" - {sensor.id} ({sensor.display_name}) [{sensor.protocol}]") + click.echo(f" Rules: {len(cfg.rules)}") + click.echo(f" UPS: {'enabled' if cfg.ups.enabled else 'disabled'}") + click.echo(f" Web: {cfg.web.host}:{cfg.web.port}") + except Exception as e: + click.echo(f"Config INVALID: {e}", err=True) + sys.exit(1) + + +@config_cmd.command("show") +@click.option("--config", "-c", "config_path", default=None, help="Path to vigilar.toml.") +def show_cmd(config_path: str | None) -> None: + """Show the parsed configuration as JSON (secrets redacted).""" + try: + cfg = load_config(config_path) + data = cfg.model_dump() + # Redact sensitive fields + if data.get("web", {}).get("password_hash"): + data["web"]["password_hash"] = "***" + if data.get("system", {}).get("arm_pin_hash"): + data["system"]["arm_pin_hash"] = "***" + if data.get("alerts", {}).get("webhook", {}).get("secret"): + data["alerts"]["webhook"]["secret"] = "***" + click.echo(json.dumps(data, indent=2)) + except Exception as e: + click.echo(f"Config error: {e}", err=True) + sys.exit(1) + + +@config_cmd.command("set-password") +@click.option("--config", "-c", "config_path", default=None, help="Path to vigilar.toml.") +def set_password_cmd(config_path: str | None) -> None: + """Generate a bcrypt hash for the web UI password.""" + try: + import hashlib + + password = click.prompt("Enter web UI password", hide_input=True, confirmation_prompt=True) + # Use SHA-256 hash (bcrypt requires external dep, but cryptography is available) + from cryptography.hazmat.primitives.kdf.scrypt import Scrypt + import os + + salt = os.urandom(16) + kdf = Scrypt(salt=salt, length=32, n=2**14, r=8, p=1) + key = kdf.derive(password.encode()) + hash_hex = salt.hex() + ":" + key.hex() + click.echo(f"\nAdd this to your vigilar.toml [web] section:") + click.echo(f'password_hash = "{hash_hex}"') + except Exception as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + + +@config_cmd.command("set-pin") +def set_pin_cmd() -> None: + """Generate an HMAC hash for the arm/disarm PIN.""" + import hashlib + import hmac + import os + + pin = click.prompt("Enter arm/disarm PIN", hide_input=True, confirmation_prompt=True) + secret = os.urandom(32) + mac = hmac.new(secret, pin.encode(), hashlib.sha256).hexdigest() + hash_str = secret.hex() + ":" + mac + click.echo(f"\nAdd this to your vigilar.toml [system] section:") + click.echo(f'arm_pin_hash = "{hash_str}"') diff --git a/vigilar/cli/cmd_start.py b/vigilar/cli/cmd_start.py new file mode 100644 index 0000000..fdd87e5 --- /dev/null +++ b/vigilar/cli/cmd_start.py @@ -0,0 +1,45 @@ +"""CLI command: vigilar start — launch all services.""" + +import logging +import sys + +import click + +from vigilar.config import load_config + + +@click.command("start") +@click.option( + "--config", "-c", + "config_path", + default=None, + help="Path to vigilar.toml config file.", +) +@click.option( + "--log-level", + default=None, + type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"], case_sensitive=False), + help="Override log level from config.", +) +def start_cmd(config_path: str | None, log_level: str | None) -> None: + """Start all Vigilar services.""" + try: + cfg = load_config(config_path) + except Exception as e: + click.echo(f"Config error: {e}", err=True) + sys.exit(1) + + level = log_level or cfg.system.log_level + logging.basicConfig( + level=getattr(logging, level.upper()), + format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + click.echo(f"Starting {cfg.system.name}...") + click.echo(f" Cameras: {len(cfg.cameras)} configured") + click.echo(f" Sensors: {len(cfg.sensors)} configured") + click.echo(f" UPS monitoring: {'enabled' if cfg.ups.enabled else 'disabled'}") + + from vigilar.main import run_supervisor + run_supervisor(cfg) diff --git a/vigilar/cli/main.py b/vigilar/cli/main.py new file mode 100644 index 0000000..ca49f2d --- /dev/null +++ b/vigilar/cli/main.py @@ -0,0 +1,21 @@ +"""Vigilar CLI entrypoint.""" + +import click + +from vigilar.cli.cmd_config import config_cmd +from vigilar.cli.cmd_start import start_cmd + + +@click.group() +@click.version_option(version="0.1.0", prog_name="vigilar") +def cli() -> None: + """Vigilar — DIY Home Security System.""" + pass + + +cli.add_command(start_cmd, "start") +cli.add_command(config_cmd, "config") + + +if __name__ == "__main__": + cli() diff --git a/vigilar/config.py b/vigilar/config.py new file mode 100644 index 0000000..5299014 --- /dev/null +++ b/vigilar/config.py @@ -0,0 +1,251 @@ +"""Configuration loading and validation via TOML + Pydantic.""" + +import sys +import tomllib +from pathlib import Path +from typing import Self + +from pydantic import BaseModel, Field, model_validator + +from vigilar.constants import ( + DEFAULT_CRITICAL_RUNTIME_THRESHOLD_S, + DEFAULT_IDLE_FPS, + DEFAULT_LOW_BATTERY_THRESHOLD_PCT, + DEFAULT_MOTION_FPS, + DEFAULT_MOTION_MIN_AREA_PX, + DEFAULT_MOTION_SENSITIVITY, + DEFAULT_MQTT_PORT, + DEFAULT_POST_MOTION_BUFFER_S, + DEFAULT_PRE_MOTION_BUFFER_S, + DEFAULT_RETENTION_DAYS, + DEFAULT_UPS_POLL_INTERVAL_S, + DEFAULT_WEB_PORT, +) + + +# --- Camera Config --- + +class CameraConfig(BaseModel): + id: str + display_name: str + rtsp_url: str + enabled: bool = True + record_continuous: bool = False + record_on_motion: bool = True + motion_sensitivity: float = Field(default=DEFAULT_MOTION_SENSITIVITY, ge=0.0, le=1.0) + motion_min_area_px: int = Field(default=DEFAULT_MOTION_MIN_AREA_PX, ge=0) + motion_zones: list[list[int]] = Field(default_factory=list) + pre_motion_buffer_s: int = Field(default=DEFAULT_PRE_MOTION_BUFFER_S, ge=0) + post_motion_buffer_s: int = Field(default=DEFAULT_POST_MOTION_BUFFER_S, ge=0) + idle_fps: int = Field(default=DEFAULT_IDLE_FPS, ge=1, le=30) + motion_fps: int = Field(default=DEFAULT_MOTION_FPS, ge=1, le=60) + retention_days: int = Field(default=DEFAULT_RETENTION_DAYS, ge=1) + resolution_capture: list[int] = Field(default_factory=lambda: [1920, 1080]) + resolution_motion: list[int] = Field(default_factory=lambda: [640, 360]) + + +# --- Sensor Config --- + +class SensorGPIOConfig(BaseModel): + bounce_time_ms: int = 50 + + +class SensorConfig(BaseModel): + id: str + display_name: str + type: str # CONTACT, MOTION, TEMPERATURE, etc. + protocol: str # ZIGBEE, ZWAVE, GPIO + device_address: str = "" + location: str = "" + enabled: bool = True + + +# --- MQTT Config --- + +class MQTTConfig(BaseModel): + host: str = "127.0.0.1" + port: int = DEFAULT_MQTT_PORT + username: str = "" + password: str = "" + + +# --- Web Config --- + +class WebConfig(BaseModel): + host: str = "0.0.0.0" + port: int = DEFAULT_WEB_PORT + tls_cert: str = "" + tls_key: str = "" + username: str = "admin" + password_hash: str = "" + session_timeout: int = 3600 + + +# --- Zigbee2MQTT Config --- + +class Zigbee2MQTTConfig(BaseModel): + mqtt_topic_prefix: str = "zigbee2mqtt" + + +# --- UPS Config --- + +class UPSConfig(BaseModel): + enabled: bool = True + nut_host: str = "127.0.0.1" + nut_port: int = 3493 + ups_name: str = "ups" + poll_interval_s: int = DEFAULT_UPS_POLL_INTERVAL_S + low_battery_threshold_pct: int = Field(default=DEFAULT_LOW_BATTERY_THRESHOLD_PCT, ge=5, le=95) + critical_runtime_threshold_s: int = DEFAULT_CRITICAL_RUNTIME_THRESHOLD_S + shutdown_delay_s: int = 60 + + +# --- Storage Config --- + +class StorageConfig(BaseModel): + encrypt_recordings: bool = True + key_file: str = "/etc/vigilar/secrets/storage.key" + max_disk_usage_gb: int = 200 + free_space_floor_gb: int = 10 + + +# --- Alert Configs --- + +class WebPushConfig(BaseModel): + enabled: bool = True + vapid_private_key_file: str = "/etc/vigilar/secrets/vapid_private.pem" + vapid_claim_email: str = "mailto:admin@vigilar.local" + + +class EmailAlertConfig(BaseModel): + enabled: bool = False + smtp_host: str = "" + smtp_port: int = 587 + from_addr: str = "" + to_addr: str = "" + use_tls: bool = True + + +class WebhookAlertConfig(BaseModel): + enabled: bool = False + url: str = "" + secret: str = "" + + +class LocalAlertConfig(BaseModel): + enabled: bool = True + syslog: bool = True + desktop_notify: bool = False + + +class AlertsConfig(BaseModel): + local: LocalAlertConfig = Field(default_factory=LocalAlertConfig) + web_push: WebPushConfig = Field(default_factory=WebPushConfig) + email: EmailAlertConfig = Field(default_factory=EmailAlertConfig) + webhook: WebhookAlertConfig = Field(default_factory=WebhookAlertConfig) + + +# --- Remote Access Config --- + +class RemoteConfig(BaseModel): + enabled: bool = False + upload_bandwidth_mbps: float = 22.0 + # Remote HLS: lower quality for bandwidth savings through the tunnel + remote_hls_resolution: list[int] = Field(default_factory=lambda: [426, 240]) + remote_hls_fps: int = 10 + remote_hls_bitrate_kbps: int = 500 + # Max simultaneous remote viewers (0 = unlimited) + max_remote_viewers: int = 4 + # WireGuard tunnel IP of the home server (for reference) + tunnel_ip: str = "10.99.0.2" + + +# --- Rule Config --- + +class RuleCondition(BaseModel): + type: str # arm_state, sensor_event, camera_motion, time_window + value: str = "" + sensor_id: str = "" + event: str = "" + + +class RuleConfig(BaseModel): + id: str + description: str = "" + conditions: list[RuleCondition] = Field(default_factory=list) + logic: str = "AND" # AND | OR + actions: list[str] = Field(default_factory=list) + cooldown_s: int = 60 + + +# --- System (top-level) Config --- + +class SystemConfig(BaseModel): + name: str = "Vigilar Home Security" + timezone: str = "UTC" + data_dir: str = "/var/vigilar/data" + recordings_dir: str = "/var/vigilar/recordings" + hls_dir: str = "/var/vigilar/hls" + log_level: str = "INFO" + arm_pin_hash: str = "" + + +# --- Root Config --- + +class VigilarConfig(BaseModel): + system: SystemConfig = Field(default_factory=SystemConfig) + mqtt: MQTTConfig = Field(default_factory=MQTTConfig) + web: WebConfig = Field(default_factory=WebConfig) + zigbee2mqtt: Zigbee2MQTTConfig = Field(default_factory=Zigbee2MQTTConfig) + ups: UPSConfig = Field(default_factory=UPSConfig) + storage: StorageConfig = Field(default_factory=StorageConfig) + alerts: AlertsConfig = Field(default_factory=AlertsConfig) + remote: RemoteConfig = Field(default_factory=RemoteConfig) + cameras: list[CameraConfig] = Field(default_factory=list) + sensors: list[SensorConfig] = Field(default_factory=list) + sensor_gpio: SensorGPIOConfig = Field(default_factory=SensorGPIOConfig, alias="sensors.gpio") + rules: list[RuleConfig] = Field(default_factory=list) + + model_config = {"populate_by_name": True} + + @model_validator(mode="after") + def check_camera_ids_unique(self) -> Self: + ids = [c.id for c in self.cameras] + dupes = [i for i in ids if ids.count(i) > 1] + if dupes: + raise ValueError(f"Duplicate camera IDs: {set(dupes)}") + return self + + @model_validator(mode="after") + def check_sensor_ids_unique(self) -> Self: + ids = [s.id for s in self.sensors] + dupes = [i for i in ids if ids.count(i) > 1] + if dupes: + raise ValueError(f"Duplicate sensor IDs: {set(dupes)}") + return self + + +def load_config(path: str | Path | None = None) -> VigilarConfig: + """Load and validate config from a TOML file.""" + import os + + if path is None: + path = os.environ.get("VIGILAR_CONFIG", "config/vigilar.toml") + + config_path = Path(path) + if not config_path.exists(): + print(f"Error: Config file not found: {config_path}", file=sys.stderr) + sys.exit(1) + + with open(config_path, "rb") as f: + raw = tomllib.load(f) + + # Flatten sensors.gpio to top-level key for Pydantic alias + if "sensors" in raw and isinstance(raw["sensors"], dict): + # sensors section could be a dict with 'gpio' sub-key alongside sensor list + gpio_config = raw["sensors"].pop("gpio", {}) + if gpio_config: + raw["sensors.gpio"] = gpio_config + # The [[sensors]] array items remain as 'sensors' key from TOML parsing + + return VigilarConfig(**raw) diff --git a/vigilar/config_writer.py b/vigilar/config_writer.py new file mode 100644 index 0000000..337402d --- /dev/null +++ b/vigilar/config_writer.py @@ -0,0 +1,226 @@ +"""Write configuration changes back to the TOML file. + +Since Python's tomllib is read-only, we use a simple approach: +serialize the Pydantic config to a dict and write it with tomli_w +(or a manual TOML serializer). This preserves all values but not +comments from the original file. +""" + +import copy +import logging +import os +import shutil +import time +from pathlib import Path +from typing import Any + +from vigilar.config import VigilarConfig + +log = logging.getLogger(__name__) + + +def _serialize_value(v: Any) -> Any: + """Convert Pydantic types to TOML-compatible types.""" + if isinstance(v, bool): + return v + if isinstance(v, (int, float, str)): + return v + if isinstance(v, list): + return [_serialize_value(i) for i in v] + if isinstance(v, dict): + return {k: _serialize_value(val) for k, val in v.items()} + return str(v) + + +def _to_toml_string(data: dict[str, Any], indent: int = 0) -> str: + """Convert a dict to TOML string format.""" + lines: list[str] = [] + + # Internal keys to skip in normal processing + skip_keys = {"_sensor_gpio"} + + # Separate simple key-value pairs from tables and arrays of tables + tables = {} + array_tables = {} + + for key, value in data.items(): + if key in skip_keys: + continue + if isinstance(value, dict): + tables[key] = value + elif isinstance(value, list) and value and isinstance(value[0], dict): + array_tables[key] = value + elif isinstance(value, list) and not value: + # Skip empty arrays — they conflict with array-of-tables notation + continue + else: + lines.append(f"{key} = {_format_toml_value(value)}") + + # Write tables + for key, value in tables.items(): + if lines: + lines.append("") + lines.append(f"[{key}]") + for k, v in value.items(): + if isinstance(v, dict): + lines.append("") + lines.append(f"[{key}.{k}]") + for kk, vv in v.items(): + lines.append(f"{kk} = {_format_toml_value(vv)}") + else: + lines.append(f"{k} = {_format_toml_value(v)}") + + # Write arrays of tables + for key, items in array_tables.items(): + for item in items: + lines.append("") + lines.append(f"[[{key}]]") + for k, v in item.items(): + if isinstance(v, list) and v and isinstance(v[0], dict): + # Inline array of dicts (e.g., rule conditions) + lines.append(f"{k} = [") + for d in v: + parts = ", ".join(f'{dk} = {_format_toml_value(dv)}' for dk, dv in d.items() if dv) + lines.append(f" {{{parts}}},") + lines.append("]") + else: + lines.append(f"{k} = {_format_toml_value(v)}") + + # Write sensor GPIO config as [sensors.gpio] after [[sensors]] entries + gpio_cfg = data.get("_sensor_gpio", {}) + if gpio_cfg: + lines.append("") + lines.append("[sensors.gpio]") + for k, v in gpio_cfg.items(): + lines.append(f"{k} = {_format_toml_value(v)}") + + return "\n".join(lines) + "\n" + + +def _format_toml_value(value: Any) -> str: + """Format a single value for TOML output.""" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int): + return str(value) + if isinstance(value, float): + return str(value) + if isinstance(value, str): + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + if isinstance(value, list): + if not value: + return "[]" + if all(isinstance(v, (int, float)) for v in value): + return f"[{', '.join(str(v) for v in value)}]" + if all(isinstance(v, str) for v in value): + return f"[{', '.join(_format_toml_value(v) for v in value)}]" + return f"[{', '.join(_format_toml_value(v) for v in value)}]" + return f'"{value}"' + + +def config_to_dict(cfg: VigilarConfig) -> dict[str, Any]: + """Convert a VigilarConfig to a TOML-ready dict structure.""" + data = cfg.model_dump(by_alias=False) + + # Restructure for TOML layout + result: dict[str, Any] = {} + + result["system"] = data.get("system", {}) + result["mqtt"] = data.get("mqtt", {}) + result["web"] = data.get("web", {}) + result["zigbee2mqtt"] = data.get("zigbee2mqtt", {}) + result["ups"] = data.get("ups", {}) + result["storage"] = data.get("storage", {}) + result["alerts"] = data.get("alerts", {}) + result["remote"] = data.get("remote", {}) + result["cameras"] = data.get("cameras", []) + + # Sensors as array-of-tables + gpio sub-table + sensors = data.get("sensors", []) + gpio_cfg = data.get("sensor_gpio", {}) + result["sensors"] = sensors + # gpio config stored separately — only written if sensors exist + result["_sensor_gpio"] = gpio_cfg + + result["rules"] = data.get("rules", []) + + return result + + +def save_config(cfg: VigilarConfig, path: str | Path) -> None: + """Save config to a TOML file, creating a backup of the original.""" + path = Path(path) + + # Backup existing config + if path.exists(): + backup = path.with_suffix(f".toml.bak.{int(time.time())}") + shutil.copy2(path, backup) + log.info("Config backup: %s", backup.name) + + data = config_to_dict(cfg) + toml_str = _to_toml_string(data) + + # Atomic write via temp file + tmp_path = path.with_suffix(".toml.tmp") + tmp_path.write_text(toml_str) + tmp_path.replace(path) + + log.info("Config saved: %s", path) + + +def update_config_section( + cfg: VigilarConfig, + section: str, + updates: dict[str, Any], +) -> VigilarConfig: + """Update a section of the config and return a new VigilarConfig. + + Args: + cfg: Current config. + section: Top-level section name (e.g., 'system', 'web', 'ups'). + updates: Dict of field names to new values. + + Returns: + New VigilarConfig with the updates applied. + """ + data = cfg.model_dump(by_alias=False) + + if section in data and isinstance(data[section], dict): + data[section].update(updates) + else: + data[section] = updates + + return VigilarConfig(**data) + + +def update_camera_config( + cfg: VigilarConfig, + camera_id: str, + updates: dict[str, Any], +) -> VigilarConfig: + """Update a specific camera's config.""" + data = cfg.model_dump(by_alias=False) + + for cam in data.get("cameras", []): + if cam["id"] == camera_id: + cam.update(updates) + break + + return VigilarConfig(**data) + + +def update_alert_config( + cfg: VigilarConfig, + channel: str, + updates: dict[str, Any], +) -> VigilarConfig: + """Update a specific alert channel config.""" + data = cfg.model_dump(by_alias=False) + + if "alerts" not in data: + data["alerts"] = {} + if channel in data["alerts"]: + data["alerts"][channel].update(updates) + + return VigilarConfig(**data) diff --git a/vigilar/constants.py b/vigilar/constants.py new file mode 100644 index 0000000..f24871d --- /dev/null +++ b/vigilar/constants.py @@ -0,0 +1,153 @@ +"""System-wide constants, enums, and MQTT topic definitions.""" + +from enum import StrEnum + + +# --- Arm States --- + +class ArmState(StrEnum): + DISARMED = "DISARMED" + ARMED_HOME = "ARMED_HOME" + ARMED_AWAY = "ARMED_AWAY" + + +# --- Event Severity --- + +class Severity(StrEnum): + INFO = "INFO" + WARNING = "WARNING" + ALERT = "ALERT" + CRITICAL = "CRITICAL" + + +# --- Event Types --- + +class EventType(StrEnum): + MOTION_START = "MOTION_START" + MOTION_END = "MOTION_END" + CONTACT_OPEN = "CONTACT_OPEN" + CONTACT_CLOSED = "CONTACT_CLOSED" + ALARM_TRIGGERED = "ALARM_TRIGGERED" + ARM_STATE_CHANGED = "ARM_STATE_CHANGED" + POWER_LOSS = "POWER_LOSS" + POWER_RESTORED = "POWER_RESTORED" + LOW_BATTERY = "LOW_BATTERY" + CAMERA_ERROR = "CAMERA_ERROR" + CAMERA_RECONNECTED = "CAMERA_RECONNECTED" + SYSTEM_STARTUP = "SYSTEM_STARTUP" + SYSTEM_SHUTDOWN = "SYSTEM_SHUTDOWN" + + +# --- Sensor Types --- + +class SensorType(StrEnum): + CONTACT = "CONTACT" + MOTION = "MOTION" + TEMPERATURE = "TEMPERATURE" + HUMIDITY = "HUMIDITY" + SMOKE = "SMOKE" + VIBRATION = "VIBRATION" + + +class SensorProtocol(StrEnum): + ZIGBEE = "ZIGBEE" + ZWAVE = "ZWAVE" + GPIO = "GPIO" + VIRTUAL = "VIRTUAL" + + +# --- Recording Triggers --- + +class RecordingTrigger(StrEnum): + MOTION = "MOTION" + CONTINUOUS = "CONTINUOUS" + MANUAL = "MANUAL" + + +# --- Alert Channels --- + +class AlertChannel(StrEnum): + WEB_PUSH = "WEB_PUSH" + EMAIL = "EMAIL" + WEBHOOK = "WEBHOOK" + LOCAL = "LOCAL" + + +class AlertStatus(StrEnum): + SENT = "SENT" + FAILED = "FAILED" + SUPPRESSED = "SUPPRESSED" + + +# --- UPS Status --- + +class UPSStatus(StrEnum): + ONLINE = "OL" + ON_BATTERY = "OB" + LOW_BATTERY = "LB" + UNKNOWN = "UNKNOWN" + + +# --- MQTT Topics --- + +class Topics: + PREFIX = "vigilar" + + # Camera + @staticmethod + def camera_motion_start(camera_id: str) -> str: + return f"vigilar/camera/{camera_id}/motion/start" + + @staticmethod + def camera_motion_end(camera_id: str) -> str: + return f"vigilar/camera/{camera_id}/motion/end" + + @staticmethod + def camera_heartbeat(camera_id: str) -> str: + return f"vigilar/camera/{camera_id}/heartbeat" + + @staticmethod + def camera_error(camera_id: str) -> str: + return f"vigilar/camera/{camera_id}/error" + + # Sensor + @staticmethod + def sensor_event(sensor_id: str, event_type: str) -> str: + return f"vigilar/sensor/{sensor_id}/{event_type}" + + @staticmethod + def sensor_heartbeat(sensor_id: str) -> str: + return f"vigilar/sensor/{sensor_id}/heartbeat" + + # UPS + UPS_STATUS = "vigilar/ups/status" + UPS_POWER_LOSS = "vigilar/ups/power_loss" + UPS_LOW_BATTERY = "vigilar/ups/low_battery" + UPS_CRITICAL = "vigilar/ups/critical" + UPS_RESTORED = "vigilar/ups/restored" + + # System + SYSTEM_ARM_STATE = "vigilar/system/arm_state" + SYSTEM_ALERT = "vigilar/system/alert" + SYSTEM_SHUTDOWN = "vigilar/system/shutdown" + + # Wildcard subscriptions + ALL = "vigilar/#" + ALL_CAMERAS = "vigilar/camera/#" + ALL_SENSORS = "vigilar/sensor/#" + + +# --- Defaults --- + +DEFAULT_IDLE_FPS = 2 +DEFAULT_MOTION_FPS = 30 +DEFAULT_PRE_MOTION_BUFFER_S = 5 +DEFAULT_POST_MOTION_BUFFER_S = 30 +DEFAULT_MOTION_SENSITIVITY = 0.7 +DEFAULT_MOTION_MIN_AREA_PX = 500 +DEFAULT_RETENTION_DAYS = 30 +DEFAULT_UPS_POLL_INTERVAL_S = 30 +DEFAULT_LOW_BATTERY_THRESHOLD_PCT = 20 +DEFAULT_CRITICAL_RUNTIME_THRESHOLD_S = 300 +DEFAULT_WEB_PORT = 49735 +DEFAULT_MQTT_PORT = 1883 diff --git a/vigilar/events/__init__.py b/vigilar/events/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vigilar/main.py b/vigilar/main.py new file mode 100644 index 0000000..740d324 --- /dev/null +++ b/vigilar/main.py @@ -0,0 +1,143 @@ +"""Process supervisor — starts and monitors all Vigilar subsystems.""" + +import logging +import signal +import sys +import time +from multiprocessing import Process +from pathlib import Path +from typing import Any + +from vigilar.config import VigilarConfig +from vigilar.storage.db import get_db_path, init_db + +log = logging.getLogger(__name__) + + +class SubsystemProcess: + """Wrapper for a subsystem running in a child process.""" + + def __init__(self, name: str, target: Any, args: tuple = ()): + self.name = name + self.target = target + self.args = args + self.process: Process | None = None + self.restart_count = 0 + self.max_restarts = 10 + self.last_restart: float = 0 + + def start(self) -> None: + self.process = Process(target=self.target, args=self.args, name=self.name, daemon=True) + self.process.start() + log.info("Started %s (pid=%s)", self.name, self.process.pid) + + def is_alive(self) -> bool: + return self.process is not None and self.process.is_alive() + + def stop(self, timeout: float = 5.0) -> None: + if self.process and self.process.is_alive(): + log.info("Stopping %s (pid=%s)", self.name, self.process.pid) + self.process.terminate() + self.process.join(timeout=timeout) + if self.process.is_alive(): + log.warning("Force-killing %s", self.name) + self.process.kill() + self.process.join(timeout=2) + + def maybe_restart(self) -> bool: + """Restart if crashed, with backoff. Returns True if restarted.""" + if self.is_alive(): + return False + if self.restart_count >= self.max_restarts: + log.error("%s exceeded max restarts (%d), giving up", self.name, self.max_restarts) + return False + + backoff = min(2 ** self.restart_count, 60) + elapsed = time.monotonic() - self.last_restart + if elapsed < backoff: + return False + + self.restart_count += 1 + self.last_restart = time.monotonic() + log.warning("Restarting %s (attempt %d)", self.name, self.restart_count) + self.start() + return True + + +def _run_web(cfg: VigilarConfig) -> None: + """Run the Flask web server in a subprocess.""" + from vigilar.web.app import create_app + + app = create_app(cfg) + app.run(host=cfg.web.host, port=cfg.web.port, debug=False, use_reloader=False) + + +def run_supervisor(cfg: VigilarConfig) -> None: + """Main supervisor loop — starts all subsystems, monitors, restarts on crash.""" + # Initialize database + db_path = get_db_path(cfg.system.data_dir) + init_db(db_path) + + # Ensure directories exist + Path(cfg.system.data_dir).mkdir(parents=True, exist_ok=True) + Path(cfg.system.recordings_dir).mkdir(parents=True, exist_ok=True) + Path(cfg.system.hls_dir).mkdir(parents=True, exist_ok=True) + + # Build subsystem list + subsystems: list[SubsystemProcess] = [] + + # Web server + subsystems.append(SubsystemProcess("web", _run_web, (cfg,))) + + # Camera manager (manages its own child processes internally) + from vigilar.camera.manager import CameraManager + camera_mgr = CameraManager(cfg) if cfg.cameras else None + + # TODO: Phase 6 — Event processor + # TODO: Phase 7 — Sensor bridge + # TODO: Phase 8 — UPS monitor + + # Handle signals for clean shutdown + shutdown_requested = False + + def handle_signal(signum: int, frame: Any) -> None: + nonlocal shutdown_requested + if shutdown_requested: + log.warning("Force shutdown") + sys.exit(1) + shutdown_requested = True + log.info("Shutdown requested (signal %d)", signum) + + signal.signal(signal.SIGTERM, handle_signal) + signal.signal(signal.SIGINT, handle_signal) + + # Start all subsystems + log.info("Starting %d subsystems", len(subsystems)) + for sub in subsystems: + sub.start() + + if camera_mgr: + camera_mgr.start() + + log.info("Vigilar is running") + + # Monitor loop + try: + while not shutdown_requested: + for sub in subsystems: + if not sub.is_alive(): + sub.maybe_restart() + if camera_mgr: + camera_mgr.check_and_restart() + time.sleep(2) + except KeyboardInterrupt: + pass + + # Shutdown + log.info("Shutting down subsystems...") + if camera_mgr: + camera_mgr.stop() + for sub in reversed(subsystems): + sub.stop() + + log.info("Vigilar stopped") diff --git a/vigilar/sensors/__init__.py b/vigilar/sensors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vigilar/storage/__init__.py b/vigilar/storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vigilar/storage/db.py b/vigilar/storage/db.py new file mode 100644 index 0000000..bafba15 --- /dev/null +++ b/vigilar/storage/db.py @@ -0,0 +1,53 @@ +"""Database engine setup and initialization.""" + +import logging +from pathlib import Path + +from sqlalchemy import create_engine, event, text +from sqlalchemy.engine import Engine + +from vigilar.storage.schema import metadata + +log = logging.getLogger(__name__) + +_engine: Engine | None = None + + +def _set_wal_mode(dbapi_conn: object, connection_record: object) -> None: + """Enable WAL mode for concurrent read/write support.""" + cursor = dbapi_conn.cursor() # type: ignore[union-attr] + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA synchronous=NORMAL") + cursor.execute("PRAGMA busy_timeout=5000") + cursor.close() + + +def get_engine(db_path: str | Path) -> Engine: + """Get or create the SQLAlchemy engine.""" + global _engine + if _engine is not None: + return _engine + + db_path = Path(db_path) + db_path.parent.mkdir(parents=True, exist_ok=True) + + url = f"sqlite:///{db_path}" + _engine = create_engine(url, echo=False, pool_pre_ping=True) + + event.listen(_engine, "connect", _set_wal_mode) + + log.info("Database engine created: %s", db_path) + return _engine + + +def init_db(db_path: str | Path) -> Engine: + """Initialize the database: create engine, create all tables.""" + engine = get_engine(db_path) + metadata.create_all(engine) + log.info("Database tables initialized") + return engine + + +def get_db_path(data_dir: str) -> Path: + """Return the standard database file path.""" + return Path(data_dir) / "vigilar.db" diff --git a/vigilar/storage/queries.py b/vigilar/storage/queries.py new file mode 100644 index 0000000..3288dda --- /dev/null +++ b/vigilar/storage/queries.py @@ -0,0 +1,248 @@ +"""Named query functions for Vigilar database operations.""" + +import json +import time +from typing import Any + +from sqlalchemy import desc, select +from sqlalchemy.engine import Engine + +from vigilar.storage.schema import ( + alert_log, + arm_state_log, + events, + push_subscriptions, + recordings, + sensor_states, + system_events, +) + + +# --- Events --- + +def insert_event( + engine: Engine, + event_type: str, + severity: str, + source_id: str | None = None, + payload: dict[str, Any] | None = None, +) -> int: + ts = int(time.time() * 1000) + with engine.begin() as conn: + result = conn.execute( + events.insert().values( + ts=ts, + type=event_type, + source_id=source_id, + severity=severity, + payload=json.dumps(payload) if payload else None, + ) + ) + return result.inserted_primary_key[0] + + +def get_events( + engine: Engine, + event_type: str | None = None, + severity: str | None = None, + source_id: str | None = None, + since_ts: int | None = None, + limit: int = 100, + offset: int = 0, +) -> list[dict[str, Any]]: + query = select(events).order_by(desc(events.c.ts)).limit(limit).offset(offset) + if event_type: + query = query.where(events.c.type == event_type) + if severity: + query = query.where(events.c.severity == severity) + if source_id: + query = query.where(events.c.source_id == source_id) + if since_ts: + query = query.where(events.c.ts >= since_ts) + + with engine.connect() as conn: + rows = conn.execute(query).mappings().all() + return [dict(r) for r in rows] + + +def acknowledge_event(engine: Engine, event_id: int) -> bool: + ts = int(time.time() * 1000) + with engine.begin() as conn: + result = conn.execute( + events.update() + .where(events.c.id == event_id) + .values(acknowledged=1, ack_ts=ts) + ) + return result.rowcount > 0 + + +# --- Recordings --- + +def insert_recording(engine: Engine, **kwargs: Any) -> int: + with engine.begin() as conn: + result = conn.execute(recordings.insert().values(**kwargs)) + return result.inserted_primary_key[0] + + +def get_recordings( + engine: Engine, + camera_id: str | None = None, + since_ts: int | None = None, + limit: int = 50, +) -> list[dict[str, Any]]: + query = select(recordings).order_by(desc(recordings.c.started_at)).limit(limit) + if camera_id: + query = query.where(recordings.c.camera_id == camera_id) + if since_ts: + query = query.where(recordings.c.started_at >= since_ts) + + with engine.connect() as conn: + return [dict(r) for r in conn.execute(query).mappings().all()] + + +def delete_recording(engine: Engine, recording_id: int) -> bool: + with engine.begin() as conn: + result = conn.execute( + recordings.delete().where(recordings.c.id == recording_id) + ) + return result.rowcount > 0 + + +# --- Sensor States --- + +def upsert_sensor_state( + engine: Engine, sensor_id: str, state_key: str, value: Any +) -> None: + ts = int(time.time() * 1000) + value_str = json.dumps(value) if not isinstance(value, str) else value + with engine.begin() as conn: + existing = conn.execute( + select(sensor_states).where( + sensor_states.c.sensor_id == sensor_id, + sensor_states.c.state_key == state_key, + ) + ).first() + if existing: + conn.execute( + sensor_states.update() + .where( + sensor_states.c.sensor_id == sensor_id, + sensor_states.c.state_key == state_key, + ) + .values(value=value_str, updated_at=ts) + ) + else: + conn.execute( + sensor_states.insert().values( + sensor_id=sensor_id, + state_key=state_key, + value=value_str, + updated_at=ts, + ) + ) + + +def get_sensor_state(engine: Engine, sensor_id: str) -> dict[str, Any]: + with engine.connect() as conn: + rows = conn.execute( + select(sensor_states).where(sensor_states.c.sensor_id == sensor_id) + ).mappings().all() + return {r["state_key"]: json.loads(r["value"]) for r in rows} + + +# --- Arm State --- + +def insert_arm_state( + engine: Engine, state: str, triggered_by: str, pin_hash: str | None = None +) -> None: + ts = int(time.time() * 1000) + with engine.begin() as conn: + conn.execute( + arm_state_log.insert().values( + ts=ts, state=state, triggered_by=triggered_by, pin_hash=pin_hash + ) + ) + + +def get_current_arm_state(engine: Engine) -> str | None: + with engine.connect() as conn: + row = conn.execute( + select(arm_state_log.c.state) + .order_by(desc(arm_state_log.c.ts)) + .limit(1) + ).first() + return row[0] if row else None + + +# --- Alert Log --- + +def insert_alert_log( + engine: Engine, + event_id: int | None, + channel: str, + status: str, + error: str | None = None, +) -> None: + ts = int(time.time() * 1000) + with engine.begin() as conn: + conn.execute( + alert_log.insert().values( + ts=ts, event_id=event_id, channel=channel, status=status, error=error + ) + ) + + +# --- System Events --- + +def insert_system_event( + engine: Engine, component: str, level: str, message: str +) -> None: + ts = int(time.time() * 1000) + with engine.begin() as conn: + conn.execute( + system_events.insert().values( + ts=ts, component=component, level=level, message=message + ) + ) + + +# --- Push Subscriptions --- + +def save_push_subscription( + engine: Engine, endpoint: str, p256dh_key: str, auth_key: str, user_agent: str = "" +) -> None: + ts = int(time.time() * 1000) + with engine.begin() as conn: + existing = conn.execute( + select(push_subscriptions).where(push_subscriptions.c.endpoint == endpoint) + ).first() + if existing: + conn.execute( + push_subscriptions.update() + .where(push_subscriptions.c.endpoint == endpoint) + .values(p256dh_key=p256dh_key, auth_key=auth_key, last_used_at=ts) + ) + else: + conn.execute( + push_subscriptions.insert().values( + endpoint=endpoint, + p256dh_key=p256dh_key, + auth_key=auth_key, + created_at=ts, + user_agent=user_agent, + ) + ) + + +def get_push_subscriptions(engine: Engine) -> list[dict[str, Any]]: + with engine.connect() as conn: + rows = conn.execute(select(push_subscriptions)).mappings().all() + return [dict(r) for r in rows] + + +def delete_push_subscription(engine: Engine, endpoint: str) -> bool: + with engine.begin() as conn: + result = conn.execute( + push_subscriptions.delete().where(push_subscriptions.c.endpoint == endpoint) + ) + return result.rowcount > 0 diff --git a/vigilar/storage/schema.py b/vigilar/storage/schema.py new file mode 100644 index 0000000..4bd789c --- /dev/null +++ b/vigilar/storage/schema.py @@ -0,0 +1,123 @@ +"""SQLAlchemy Core table definitions for Vigilar.""" + +from sqlalchemy import ( + Column, + Float, + Index, + Integer, + MetaData, + String, + Table, + Text, +) + +metadata = MetaData() + +cameras = Table( + "cameras", + metadata, + Column("id", String, primary_key=True), + Column("display_name", String, nullable=False), + Column("rtsp_url", String, nullable=False), + Column("enabled", Integer, nullable=False, default=1), + Column("created_at", Integer, nullable=False), +) + +sensors = Table( + "sensors", + metadata, + Column("id", String, primary_key=True), + Column("display_name", String, nullable=False), + Column("type", String, nullable=False), + Column("protocol", String, nullable=False), + Column("device_address", String), + Column("location", String), + Column("enabled", Integer, nullable=False, default=1), +) + +sensor_states = Table( + "sensor_states", + metadata, + Column("sensor_id", String, nullable=False), + Column("state_key", String, nullable=False), + Column("value", Text, nullable=False), + Column("updated_at", Integer, nullable=False), +) +Index("pk_sensor_states", sensor_states.c.sensor_id, sensor_states.c.state_key, unique=True) + +events = Table( + "events", + metadata, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("ts", Integer, nullable=False), + Column("type", String, nullable=False), + Column("source_id", String), + Column("severity", String, nullable=False), + Column("payload", Text), + Column("acknowledged", Integer, nullable=False, default=0), + Column("ack_ts", Integer), +) +Index("idx_events_ts", events.c.ts.desc()) +Index("idx_events_type", events.c.type) +Index("idx_events_source", events.c.source_id) + +recordings = Table( + "recordings", + metadata, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("camera_id", String, nullable=False), + Column("started_at", Integer, nullable=False), + Column("ended_at", Integer), + Column("duration_s", Float), + Column("file_path", String, nullable=False), + Column("file_size", Integer), + Column("trigger", String, nullable=False), + Column("event_id", Integer), + Column("encrypted", Integer, nullable=False, default=1), + Column("thumbnail_path", String), +) +Index("idx_recordings_camera_ts", recordings.c.camera_id, recordings.c.started_at.desc()) + +system_events = Table( + "system_events", + metadata, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("ts", Integer, nullable=False), + Column("component", String, nullable=False), + Column("level", String, nullable=False), + Column("message", Text, nullable=False), +) +Index("idx_sysevents_ts", system_events.c.ts.desc()) + +arm_state_log = Table( + "arm_state_log", + metadata, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("ts", Integer, nullable=False), + Column("state", String, nullable=False), + Column("triggered_by", String, nullable=False), + Column("pin_hash", String), +) + +alert_log = Table( + "alert_log", + metadata, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("ts", Integer, nullable=False), + Column("event_id", Integer), + Column("channel", String, nullable=False), + Column("status", String, nullable=False), + Column("error", Text), +) + +push_subscriptions = Table( + "push_subscriptions", + metadata, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("endpoint", String, nullable=False, unique=True), + Column("p256dh_key", String, nullable=False), + Column("auth_key", String, nullable=False), + Column("created_at", Integer, nullable=False), + Column("last_used_at", Integer), + Column("user_agent", String), +) diff --git a/vigilar/ups/__init__.py b/vigilar/ups/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vigilar/web/__init__.py b/vigilar/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vigilar/web/app.py b/vigilar/web/app.py new file mode 100644 index 0000000..c6194ab --- /dev/null +++ b/vigilar/web/app.py @@ -0,0 +1,47 @@ +"""Flask application factory.""" + +import logging +from pathlib import Path + +from flask import Flask + +from vigilar.config import VigilarConfig + +log = logging.getLogger(__name__) + + +def create_app(cfg: VigilarConfig | None = None) -> Flask: + """Create and configure the Flask application.""" + app = Flask( + __name__, + template_folder=str(Path(__file__).parent / "templates"), + static_folder=str(Path(__file__).parent / "static"), + ) + app.config["SECRET_KEY"] = "vigilar-dev-key-change-in-production" + + if cfg: + app.config["VIGILAR_CONFIG"] = cfg + + # Register blueprints + from vigilar.web.blueprints.cameras import cameras_bp + from vigilar.web.blueprints.events import events_bp + from vigilar.web.blueprints.kiosk import kiosk_bp + from vigilar.web.blueprints.recordings import recordings_bp + from vigilar.web.blueprints.sensors import sensors_bp + from vigilar.web.blueprints.system import system_bp + + app.register_blueprint(cameras_bp) + app.register_blueprint(events_bp) + app.register_blueprint(kiosk_bp) + app.register_blueprint(recordings_bp) + app.register_blueprint(sensors_bp) + app.register_blueprint(system_bp) + + # Root route → dashboard + @app.route("/") + def index(): + from flask import render_template + cameras = cfg.cameras if cfg else [] + return render_template("index.html", cameras=cameras) + + return app diff --git a/vigilar/web/blueprints/__init__.py b/vigilar/web/blueprints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vigilar/web/blueprints/cameras.py b/vigilar/web/blueprints/cameras.py new file mode 100644 index 0000000..c493631 --- /dev/null +++ b/vigilar/web/blueprints/cameras.py @@ -0,0 +1,87 @@ +"""Camera blueprint — HLS streams, snapshots, recordings.""" + +from pathlib import Path + +from flask import Blueprint, Response, abort, current_app, jsonify, render_template, send_from_directory + +cameras_bp = Blueprint("cameras", __name__, url_prefix="/cameras") + + +def _get_hls_dir() -> str: + cfg = current_app.config.get("VIGILAR_CONFIG") + return cfg.system.hls_dir if cfg else "/var/vigilar/hls" + + +@cameras_bp.route("/") +def camera_list(): + cfg = current_app.config.get("VIGILAR_CONFIG") + cameras = cfg.cameras if cfg else [] + return render_template("cameras.html", cameras=cameras) + + +@cameras_bp.route("//stream") +def camera_stream(camera_id: str): + """MJPEG stream for a single camera (low-latency fallback).""" + # TODO: connect to camera worker's frame queue for MJPEG + return Response("Stream not available — use HLS endpoint", status=503) + + +@cameras_bp.route("//snapshot") +def camera_snapshot(camera_id: str): + """Latest JPEG snapshot from a camera.""" + # TODO: grab latest frame from camera worker + return Response("Snapshot not available", status=503) + + +@cameras_bp.route("//hls/") +def camera_hls(camera_id: str, filename: str): + """Serve HLS playlist (.m3u8) and segment (.ts) files.""" + hls_dir = Path(_get_hls_dir()) / camera_id + if not hls_dir.is_dir(): + abort(404) + + file_path = hls_dir / filename + if not file_path.exists() or not file_path.is_relative_to(hls_dir): + abort(404) + + # Set correct MIME types and no-cache headers for live HLS + mimetype = "application/vnd.apple.mpegurl" if filename.endswith(".m3u8") else "video/mp2t" + response = send_from_directory(str(hls_dir), filename, mimetype=mimetype) + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + response.headers["Access-Control-Allow-Origin"] = "*" + return response + + +@cameras_bp.route("//hls/remote/") +def camera_hls_remote(camera_id: str, filename: str): + """Serve remote-quality HLS stream (lower bitrate for tunnel access).""" + hls_dir = Path(_get_hls_dir()) / camera_id / "remote" + if not hls_dir.is_dir(): + # Fall back to local quality if remote stream isn't available + return camera_hls(camera_id, filename) + + file_path = hls_dir / filename + if not file_path.exists() or not file_path.is_relative_to(hls_dir): + abort(404) + + mimetype = "application/vnd.apple.mpegurl" if filename.endswith(".m3u8") else "video/mp2t" + response = send_from_directory(str(hls_dir), filename, mimetype=mimetype) + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + response.headers["Access-Control-Allow-Origin"] = "*" + return response + + +@cameras_bp.route("/api/status") +def cameras_status_api(): + """JSON API: all camera statuses.""" + cfg = current_app.config.get("VIGILAR_CONFIG") + cameras = cfg.cameras if cfg else [] + return jsonify([ + { + "id": c.id, + "display_name": c.display_name, + "enabled": c.enabled, + "status": "offline", # TODO: check actual status + } + for c in cameras + ]) diff --git a/vigilar/web/blueprints/events.py b/vigilar/web/blueprints/events.py new file mode 100644 index 0000000..aae7d57 --- /dev/null +++ b/vigilar/web/blueprints/events.py @@ -0,0 +1,31 @@ +"""Events blueprint — event log, SSE stream.""" + +from flask import Blueprint, Response, jsonify, render_template, request + +events_bp = Blueprint("events", __name__, url_prefix="/events") + + +@events_bp.route("/") +def event_list(): + # TODO: Phase 6 — query events from DB + return render_template("events.html", events=[]) + + +@events_bp.route("/api/list") +def events_api(): + """JSON API: event list with filtering.""" + event_type = request.args.get("type") + severity = request.args.get("severity") + limit = request.args.get("limit", 100, type=int) + # TODO: Phase 6 — query from DB + return jsonify([]) + + +@events_bp.route("/stream") +def event_stream(): + """SSE endpoint for live events.""" + def generate(): + # TODO: Phase 6 — subscribe to MQTT events and yield SSE messages + yield "data: {\"type\": \"connected\"}\n\n" + + return Response(generate(), mimetype="text/event-stream") diff --git a/vigilar/web/blueprints/kiosk.py b/vigilar/web/blueprints/kiosk.py new file mode 100644 index 0000000..3eb7947 --- /dev/null +++ b/vigilar/web/blueprints/kiosk.py @@ -0,0 +1,12 @@ +"""Kiosk blueprint — fullscreen 2x2 grid for TV display.""" + +from flask import Blueprint, current_app, render_template + +kiosk_bp = Blueprint("kiosk", __name__, url_prefix="/kiosk") + + +@kiosk_bp.route("/") +def kiosk_view(): + cfg = current_app.config.get("VIGILAR_CONFIG") + cameras = cfg.cameras if cfg else [] + return render_template("kiosk.html", cameras=cameras) diff --git a/vigilar/web/blueprints/recordings.py b/vigilar/web/blueprints/recordings.py new file mode 100644 index 0000000..b40037d --- /dev/null +++ b/vigilar/web/blueprints/recordings.py @@ -0,0 +1,35 @@ +"""Recordings blueprint — browse, download, delete.""" + +from flask import Blueprint, jsonify, render_template, request + +recordings_bp = Blueprint("recordings", __name__, url_prefix="/recordings") + + +@recordings_bp.route("/") +def recordings_list(): + camera_id = request.args.get("camera") + # TODO: Phase 2 — query recordings from DB + return render_template("recordings.html", recordings=[], camera_id=camera_id) + + +@recordings_bp.route("/api/list") +def recordings_api(): + """JSON API: recording list.""" + camera_id = request.args.get("camera") + limit = request.args.get("limit", 50, type=int) + # TODO: query from DB + return jsonify([]) + + +@recordings_bp.route("//download") +def recording_download(recording_id: int): + """Stream decrypted recording for download/playback.""" + # TODO: Phase 6 — decrypt and stream + return "Recording not available", 503 + + +@recordings_bp.route("/", methods=["DELETE"]) +def recording_delete(recording_id: int): + """Delete a recording.""" + # TODO: delete from DB + filesystem + return jsonify({"ok": True}) diff --git a/vigilar/web/blueprints/sensors.py b/vigilar/web/blueprints/sensors.py new file mode 100644 index 0000000..c257b27 --- /dev/null +++ b/vigilar/web/blueprints/sensors.py @@ -0,0 +1,31 @@ +"""Sensors blueprint — sensor status and history.""" + +from flask import Blueprint, current_app, jsonify, render_template + +sensors_bp = Blueprint("sensors", __name__, url_prefix="/sensors") + + +@sensors_bp.route("/") +def sensor_list(): + cfg = current_app.config.get("VIGILAR_CONFIG") + sensors = cfg.sensors if cfg else [] + # TODO: Phase 7 — merge with live state from DB + return render_template("sensors.html", sensors=sensors) + + +@sensors_bp.route("/api/status") +def sensors_status_api(): + """JSON API: all sensor current states.""" + cfg = current_app.config.get("VIGILAR_CONFIG") + sensors = cfg.sensors if cfg else [] + return jsonify([ + { + "id": s.id, + "display_name": s.display_name, + "type": s.type, + "protocol": s.protocol, + "location": s.location, + "state": {}, # TODO: get from DB + } + for s in sensors + ]) diff --git a/vigilar/web/blueprints/system.py b/vigilar/web/blueprints/system.py new file mode 100644 index 0000000..296a0fa --- /dev/null +++ b/vigilar/web/blueprints/system.py @@ -0,0 +1,347 @@ +"""System blueprint — health, arm/disarm, UPS, admin settings.""" + +import os + +from flask import Blueprint, current_app, jsonify, render_template, request + +from vigilar.config import VigilarConfig +from vigilar.config_writer import ( + save_config, + update_alert_config, + update_camera_config, + update_config_section, +) + +system_bp = Blueprint("system", __name__, url_prefix="/system") + + +def _get_cfg() -> VigilarConfig: + return current_app.config.get("VIGILAR_CONFIG") or VigilarConfig() + + +def _get_config_path() -> str: + return os.environ.get("VIGILAR_CONFIG", "config/vigilar.toml") + + +def _save_and_reload(new_cfg: VigilarConfig) -> None: + """Save config to disk and update the app's live config.""" + save_config(new_cfg, _get_config_path()) + current_app.config["VIGILAR_CONFIG"] = new_cfg + + +# --- Pages --- + +@system_bp.route("/status") +def system_status(): + """JSON API: overall system health.""" + cfg = _get_cfg() + return jsonify({ + "arm_state": "DISARMED", + "ups": {"status": "UNKNOWN"}, + "cameras_online": 0, + "cameras_total": len(cfg.cameras), + "sensors_online": 0, + "sensors_total": len(cfg.sensors), + }) + + +@system_bp.route("/settings") +def settings_page(): + cfg = _get_cfg() + return render_template("settings.html", cameras=cfg.cameras, config=cfg) + + +# --- Arm/Disarm --- + +@system_bp.route("/api/arm", methods=["POST"]) +def arm_system(): + data = request.get_json() or {} + mode = data.get("mode", "ARMED_AWAY") + pin = data.get("pin", "") + # TODO: verify PIN against config hash + return jsonify({"ok": True, "state": mode}) + + +@system_bp.route("/api/disarm", methods=["POST"]) +def disarm_system(): + data = request.get_json() or {} + pin = data.get("pin", "") + # TODO: verify PIN + return jsonify({"ok": True, "state": "DISARMED"}) + + +# --- Config Read API --- + +@system_bp.route("/api/config") +def get_config_api(): + """Return full config (secrets redacted).""" + cfg = _get_cfg() + data = cfg.model_dump(by_alias=False) + # Redact secrets + data.get("web", {}).pop("password_hash", None) + data.get("system", {}).pop("arm_pin_hash", None) + data.get("alerts", {}).get("webhook", {}).pop("secret", None) + data.get("storage", {}).pop("key_file", None) + return jsonify(data) + + +@system_bp.route("/api/config/
") +def get_config_section_api(section: str): + """Return a specific config section.""" + cfg = _get_cfg() + data = cfg.model_dump(by_alias=False) + if section == "cameras": + return jsonify(data.get("cameras", [])) + if section == "sensors": + return jsonify(data.get("sensors", [])) + if section == "rules": + return jsonify(data.get("rules", [])) + if section in data: + return jsonify(data[section]) + return jsonify({"error": f"Unknown section: {section}"}), 404 + + +# --- System Settings --- + +@system_bp.route("/api/config/system", methods=["POST"]) +def update_system_config(): + """Update system settings (timezone, log level, dirs).""" + data = request.get_json() or {} + allowed = {"timezone", "log_level", "data_dir", "recordings_dir", "hls_dir", "name"} + updates = {k: v for k, v in data.items() if k in allowed} + if not updates: + return jsonify({"error": "No valid fields to update"}), 400 + + cfg = _get_cfg() + new_cfg = update_config_section(cfg, "system", updates) + _save_and_reload(new_cfg) + return jsonify({"ok": True, "updated": list(updates.keys())}) + + +# --- Web Settings --- + +@system_bp.route("/api/config/web", methods=["POST"]) +def update_web_config(): + data = request.get_json() or {} + allowed = {"host", "port", "username", "session_timeout"} + updates = {k: v for k, v in data.items() if k in allowed} + if not updates: + return jsonify({"error": "No valid fields"}), 400 + + cfg = _get_cfg() + new_cfg = update_config_section(cfg, "web", updates) + _save_and_reload(new_cfg) + return jsonify({"ok": True, "updated": list(updates.keys()), "note": "Restart required for port changes"}) + + +# --- Remote Access Settings --- + +@system_bp.route("/api/config/remote", methods=["POST"]) +def update_remote_config(): + data = request.get_json() or {} + allowed = { + "enabled", "upload_bandwidth_mbps", + "remote_hls_resolution", "remote_hls_fps", + "remote_hls_bitrate_kbps", "max_remote_viewers", "tunnel_ip", + } + updates = {k: v for k, v in data.items() if k in allowed} + if not updates: + return jsonify({"error": "No valid fields"}), 400 + + cfg = _get_cfg() + new_cfg = update_config_section(cfg, "remote", updates) + _save_and_reload(new_cfg) + return jsonify({"ok": True, "updated": list(updates.keys()), "note": "Restart required to apply stream changes"}) + + +# --- MQTT Settings --- + +@system_bp.route("/api/config/mqtt", methods=["POST"]) +def update_mqtt_config(): + data = request.get_json() or {} + allowed = {"host", "port", "username", "password"} + updates = {k: v for k, v in data.items() if k in allowed} + if not updates: + return jsonify({"error": "No valid fields"}), 400 + + cfg = _get_cfg() + new_cfg = update_config_section(cfg, "mqtt", updates) + _save_and_reload(new_cfg) + return jsonify({"ok": True, "updated": list(updates.keys()), "note": "Restart required"}) + + +# --- Camera Settings --- + +@system_bp.route("/api/config/camera/", methods=["POST"]) +def update_camera_config_api(camera_id: str): + """Update settings for a specific camera.""" + data = request.get_json() or {} + allowed = { + "display_name", "rtsp_url", "enabled", + "record_continuous", "record_on_motion", + "motion_sensitivity", "motion_min_area_px", "motion_zones", + "pre_motion_buffer_s", "post_motion_buffer_s", + "idle_fps", "motion_fps", "retention_days", + "resolution_capture", "resolution_motion", + } + updates = {k: v for k, v in data.items() if k in allowed} + if not updates: + return jsonify({"error": "No valid fields"}), 400 + + cfg = _get_cfg() + # Verify camera exists + if not any(c.id == camera_id for c in cfg.cameras): + return jsonify({"error": f"Camera not found: {camera_id}"}), 404 + + new_cfg = update_camera_config(cfg, camera_id, updates) + _save_and_reload(new_cfg) + + # Notify camera worker of runtime-changeable settings via MQTT + runtime_fields = {"motion_sensitivity", "motion_min_area_px"} + runtime_updates = {k: v for k, v in updates.items() if k in runtime_fields} + if runtime_updates: + try: + from vigilar.bus import MessageBus + bus = MessageBus(cfg.mqtt, client_id="web-config-push") + bus.connect() + bus.publish(f"vigilar/camera/{camera_id}/config", runtime_updates) + bus.disconnect() + except Exception: + pass # Non-critical — settings saved, worker picks up on restart + + return jsonify({"ok": True, "camera_id": camera_id, "updated": list(updates.keys())}) + + +# --- Camera threshold shortcut (used by settings sliders) --- + +@system_bp.route("/api/camera//threshold", methods=["POST"]) +def update_camera_threshold(camera_id: str): + """Quick endpoint for motion sensitivity slider.""" + data = request.get_json() or {} + sensitivity = data.get("sensitivity") + if sensitivity is None: + return jsonify({"error": "Missing sensitivity"}), 400 + + sensitivity = max(0.0, min(1.0, float(sensitivity))) + cfg = _get_cfg() + + if not any(c.id == camera_id for c in cfg.cameras): + return jsonify({"error": f"Camera not found: {camera_id}"}), 404 + + new_cfg = update_camera_config(cfg, camera_id, {"motion_sensitivity": sensitivity}) + _save_and_reload(new_cfg) + + # Push to live worker + try: + from vigilar.bus import MessageBus + bus = MessageBus(cfg.mqtt, client_id="web-threshold-push") + bus.connect() + bus.publish(f"vigilar/camera/{camera_id}/config", {"sensitivity": sensitivity}) + bus.disconnect() + except Exception: + pass + + return jsonify({"ok": True, "camera_id": camera_id, "sensitivity": sensitivity}) + + +# --- UPS Settings --- + +@system_bp.route("/api/config/ups", methods=["POST"]) +def update_ups_config(): + data = request.get_json() or {} + allowed = { + "enabled", "nut_host", "nut_port", "ups_name", + "poll_interval_s", "low_battery_threshold_pct", + "critical_runtime_threshold_s", "shutdown_delay_s", + } + updates = {k: v for k, v in data.items() if k in allowed} + if not updates: + return jsonify({"error": "No valid fields"}), 400 + + cfg = _get_cfg() + new_cfg = update_config_section(cfg, "ups", updates) + _save_and_reload(new_cfg) + return jsonify({"ok": True, "updated": list(updates.keys())}) + + +# --- Storage Settings --- + +@system_bp.route("/api/config/storage", methods=["POST"]) +def update_storage_config(): + data = request.get_json() or {} + allowed = {"encrypt_recordings", "max_disk_usage_gb", "free_space_floor_gb"} + updates = {k: v for k, v in data.items() if k in allowed} + if not updates: + return jsonify({"error": "No valid fields"}), 400 + + cfg = _get_cfg() + new_cfg = update_config_section(cfg, "storage", updates) + _save_and_reload(new_cfg) + return jsonify({"ok": True, "updated": list(updates.keys())}) + + +# --- Alert Settings --- + +@system_bp.route("/api/config/alerts/", methods=["POST"]) +def update_alert_config_api(channel: str): + """Update settings for a specific alert channel.""" + data = request.get_json() or {} + valid_channels = {"local", "web_push", "email", "webhook"} + if channel not in valid_channels: + return jsonify({"error": f"Invalid channel: {channel}"}), 404 + + cfg = _get_cfg() + new_cfg = update_alert_config(cfg, channel, data) + _save_and_reload(new_cfg) + return jsonify({"ok": True, "channel": channel, "updated": list(data.keys())}) + + +# --- VAPID Key --- + +@system_bp.route("/api/vapid-key") +def vapid_public_key(): + """Return the VAPID public key for push subscription.""" + cfg = _get_cfg() + key_file = cfg.alerts.web_push.vapid_private_key_file + if not os.path.exists(key_file): + return jsonify({"error": "VAPID keys not configured"}), 404 + + try: + from py_vapid import Vapid + vapid = Vapid.from_file(key_file) + public_key = vapid.public_key + # Encode as URL-safe base64 + import base64 + raw = public_key.public_bytes_raw() + b64 = base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + return jsonify({"publicKey": b64}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +# --- Push Subscription --- + +@system_bp.route("/api/push/subscribe", methods=["POST"]) +def push_subscribe(): + """Store a push notification subscription.""" + data = request.get_json() or {} + endpoint = data.get("endpoint") + keys = data.get("keys", {}) + p256dh = keys.get("p256dh", "") + auth = keys.get("auth", "") + + if not endpoint or not p256dh or not auth: + return jsonify({"error": "Missing subscription fields"}), 400 + + try: + from vigilar.storage.db import get_db_path, get_engine + from vigilar.storage.queries import save_push_subscription + cfg = _get_cfg() + engine = get_engine(get_db_path(cfg.system.data_dir)) + save_push_subscription( + engine, endpoint, p256dh, auth, + user_agent=request.headers.get("User-Agent", ""), + ) + return jsonify({"ok": True}) + except Exception as e: + return jsonify({"error": str(e)}), 500 diff --git a/vigilar/web/static/css/vigilar.css b/vigilar/web/static/css/vigilar.css new file mode 100644 index 0000000..ac02953 --- /dev/null +++ b/vigilar/web/static/css/vigilar.css @@ -0,0 +1,134 @@ +/* Vigilar — Bootstrap 5 Dark Theme Overrides */ + +:root { + --vigilar-bg: #0d1117; + --vigilar-surface: #161b22; + --vigilar-border: #30363d; + --vigilar-accent: #58a6ff; + --vigilar-danger: #dc3545; +} + +body { + background-color: var(--vigilar-bg); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; +} + +.navbar { + background-color: var(--vigilar-surface) !important; + border-color: var(--vigilar-border) !important; +} + +.card { + background-color: var(--vigilar-surface) !important; + border-color: var(--vigilar-border) !important; +} + +.card-header { + background-color: rgba(0, 0, 0, 0.2); + border-color: var(--vigilar-border); +} + +.table-dark { + --bs-table-bg: transparent; +} + +/* Camera Grid */ +.camera-card { + overflow: hidden; + border-radius: 8px; +} + +.camera-feed { + background: #000; + min-height: 200px; +} + +.camera-feed video { + object-fit: cover; +} + +.camera-offline-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: #111; +} + +.camera-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + padding: 8px 12px; + display: flex; + justify-content: space-between; + align-items: center; + background: linear-gradient(to bottom, rgba(0, 0, 0, 0.7), transparent); + pointer-events: none; + z-index: 2; +} + +.camera-name { + font-size: 0.85rem; + font-weight: 600; + color: #fff; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8); +} + +.camera-time { + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.8); + font-variant-numeric: tabular-nums; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8); +} + +.camera-motion-dot { + position: absolute; + bottom: 12px; + left: 12px; + z-index: 2; +} + +.motion-indicator { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 50%; + background-color: var(--vigilar-danger); + animation: motion-pulse 1s ease-in-out infinite; +} + +@keyframes motion-pulse { + 0%, 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.5; + transform: scale(1.4); + } +} + +/* Form range custom */ +.form-range::-webkit-slider-thumb { + background: var(--vigilar-accent); +} + +/* Responsive: single column on mobile */ +@media (max-width: 767.98px) { + .camera-feed { + min-height: 180px; + } +} + +/* Badge pulse for active alerts */ +.badge-pulse { + animation: badge-glow 2s ease-in-out infinite; +} + +@keyframes badge-glow { + 0%, 100% { box-shadow: 0 0 0 0 rgba(220, 53, 69, 0.4); } + 50% { box-shadow: 0 0 0 8px rgba(220, 53, 69, 0); } +} diff --git a/vigilar/web/static/js/app.js b/vigilar/web/static/js/app.js new file mode 100644 index 0000000..2c76b67 --- /dev/null +++ b/vigilar/web/static/js/app.js @@ -0,0 +1,112 @@ +/* Vigilar — Main application logic */ + +// Update camera timestamps +function updateTimestamps() { + const now = new Date().toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + document.querySelectorAll('[id^="time-"]').forEach(el => { + el.textContent = now; + }); +} +setInterval(updateTimestamps, 1000); +updateTimestamps(); + +// Poll system status +async function refreshSystemStatus() { + try { + const resp = await fetch('/system/status'); + if (!resp.ok) return; + const data = await resp.json(); + + // Update arm state badge + const badge = document.getElementById('arm-state-badge'); + if (badge) { + const states = { + 'DISARMED': { class: 'bg-success', icon: 'bi-unlock-fill', text: 'Disarmed' }, + 'ARMED_HOME': { class: 'bg-warning', icon: 'bi-house-lock-fill', text: 'Armed Home' }, + 'ARMED_AWAY': { class: 'bg-danger', icon: 'bi-shield-fill-exclamation', text: 'Armed Away' }, + }; + const s = states[data.arm_state] || states['DISARMED']; + badge.className = `badge ${s.class}`; + badge.innerHTML = `${s.text}`; + } + + // Update dashboard cards if present + const sysStatus = document.getElementById('system-status'); + if (sysStatus) sysStatus.textContent = data.arm_state || 'Unknown'; + + const camOnline = document.getElementById('cameras-online'); + if (camOnline && data.cameras_online !== undefined) { + camOnline.textContent = `${data.cameras_online} online`; + } + + const sensOnline = document.getElementById('sensors-online'); + if (sensOnline && data.sensors_online !== undefined) { + sensOnline.textContent = `${data.sensors_online} online`; + } + } catch (e) { + // Silently fail — offline mode + } +} +setInterval(refreshSystemStatus, 5000); +refreshSystemStatus(); + +// SSE event stream +function connectEventStream() { + const evtSource = new EventSource('/events/stream'); + + evtSource.onmessage = function(event) { + const data = JSON.parse(event.data); + handleLiveEvent(data); + }; + + evtSource.onerror = function() { + evtSource.close(); + // Reconnect after 5 seconds + setTimeout(connectEventStream, 5000); + }; +} + +function handleLiveEvent(data) { + // Show motion indicator on camera grid + if (data.type === 'MOTION_START' && data.source_id) { + const dot = document.getElementById(`motion-${data.source_id}`); + if (dot) dot.classList.remove('d-none'); + } + if (data.type === 'MOTION_END' && data.source_id) { + const dot = document.getElementById(`motion-${data.source_id}`); + if (dot) dot.classList.add('d-none'); + } + + // Add to recent events table + const tbody = document.getElementById('recent-events'); + if (tbody && data.type !== 'connected') { + const time = new Date(data.ts).toLocaleTimeString(); + const row = document.createElement('tr'); + row.innerHTML = ` + ${time} + ${data.type} + ${data.source_id || '—'} + ${data.severity || 'INFO'} + `; + // Remove "no events" placeholder + if (tbody.querySelector('td[colspan]')) { + tbody.innerHTML = ''; + } + tbody.insertBefore(row, tbody.firstChild); + // Keep max 20 rows + while (tbody.children.length > 20) { + tbody.removeChild(tbody.lastChild); + } + } +} + +connectEventStream(); + +// Register service worker for PWA +if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/static/sw.js').catch(() => {}); +} diff --git a/vigilar/web/static/js/grid.js b/vigilar/web/static/js/grid.js new file mode 100644 index 0000000..c2adefb --- /dev/null +++ b/vigilar/web/static/js/grid.js @@ -0,0 +1,70 @@ +/* Vigilar — Camera grid HLS playback + overlays */ + +(function() { + 'use strict'; + + // Initialize HLS streams for all camera feeds + function initCameraFeeds() { + const videos = document.querySelectorAll('video[id^="feed-"], video[id^="kiosk-feed-"]'); + + videos.forEach(video => { + const id = video.id.replace('feed-', '').replace('kiosk-feed-', ''); + const hlsUrl = `/cameras/${id}/hls/stream.m3u8`; + + if (typeof Hls !== 'undefined' && Hls.isSupported()) { + const hls = new Hls({ + liveSyncDurationCount: 3, + liveMaxLatencyDurationCount: 5, + enableWorker: true, + }); + hls.loadSource(hlsUrl); + hls.attachMedia(video); + hls.on(Hls.Events.MANIFEST_PARSED, () => { + video.play().catch(() => {}); + hideOfflineOverlay(id); + }); + hls.on(Hls.Events.ERROR, (event, data) => { + if (data.fatal) { + showOfflineOverlay(id); + // Retry after 5 seconds + setTimeout(() => { + hls.loadSource(hlsUrl); + }, 5000); + } + }); + } else if (video.canPlayType('application/vnd.apple.mpegurl')) { + // Safari native HLS + video.src = hlsUrl; + video.addEventListener('loadedmetadata', () => { + video.play().catch(() => {}); + hideOfflineOverlay(id); + }); + video.addEventListener('error', () => { + showOfflineOverlay(id); + }); + } + }); + } + + function hideOfflineOverlay(cameraId) { + const overlay = document.getElementById(`kiosk-offline-${cameraId}`); + if (overlay) overlay.style.display = 'none'; + const cardOverlay = document.querySelector(`#feed-${cameraId}`)?.closest('.camera-feed')?.querySelector('.camera-offline-overlay'); + if (cardOverlay) cardOverlay.style.display = 'none'; + } + + function showOfflineOverlay(cameraId) { + const overlay = document.getElementById(`kiosk-offline-${cameraId}`); + if (overlay) { + overlay.style.display = 'flex'; + overlay.textContent = overlay.textContent.replace('Connecting...', 'Reconnecting...'); + } + } + + // Wait for hls.js to load (or run immediately if not needed) + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initCameraFeeds); + } else { + initCameraFeeds(); + } +})(); diff --git a/vigilar/web/static/js/hls.min.js b/vigilar/web/static/js/hls.min.js new file mode 100644 index 0000000..c89ec78 --- /dev/null +++ b/vigilar/web/static/js/hls.min.js @@ -0,0 +1,4 @@ +/* hls.js placeholder — download from https://cdn.jsdelivr.net/npm/hls.js@latest/dist/hls.min.js */ +/* This file will be replaced with the actual hls.js library */ +console.log('hls.js placeholder loaded — replace with actual library for camera streaming'); +var Hls = undefined; diff --git a/vigilar/web/static/js/push.js b/vigilar/web/static/js/push.js new file mode 100644 index 0000000..299856e --- /dev/null +++ b/vigilar/web/static/js/push.js @@ -0,0 +1,24 @@ +/* Vigilar — Push notification registration */ +/* Push logic is in settings.js (enablePushNotifications) */ +/* This file handles auto-registration on PWA install */ + +(function() { + 'use strict'; + + // Check if push is already subscribed + async function checkPushSubscription() { + if (!('serviceWorker' in navigator) || !('PushManager' in window)) return; + + try { + const reg = await navigator.serviceWorker.ready; + const sub = await reg.pushManager.getSubscription(); + if (sub) { + console.log('Push notifications active'); + } + } catch (e) { + // Push not available + } + } + + checkPushSubscription(); +})(); diff --git a/vigilar/web/static/js/settings.js b/vigilar/web/static/js/settings.js new file mode 100644 index 0000000..5bf0005 --- /dev/null +++ b/vigilar/web/static/js/settings.js @@ -0,0 +1,274 @@ +/* Vigilar — Settings page logic */ + +// --- Section Navigation --- +document.querySelectorAll('.settings-nav .nav-link').forEach(link => { + link.addEventListener('click', (e) => { + e.preventDefault(); + const section = link.dataset.section; + + // Update nav active state + document.querySelectorAll('.settings-nav .nav-link').forEach(l => l.classList.remove('active')); + link.classList.add('active'); + + // Show correct section + document.querySelectorAll('.settings-section').forEach(s => s.classList.remove('active')); + document.getElementById(`section-${section}`).classList.add('active'); + + // Update URL hash + history.replaceState(null, '', `#${section}`); + }); +}); + +// Restore section from URL hash +if (location.hash) { + const hash = location.hash.slice(1); + const link = document.querySelector(`.settings-nav [data-section="${hash}"]`); + if (link) link.click(); +} + +// --- Save Indicator --- +function showSaved() { + const el = document.getElementById('save-indicator'); + el.classList.add('show'); + setTimeout(() => el.classList.remove('show'), 2000); +} + +function showError(msg) { + const el = document.getElementById('save-indicator'); + el.innerHTML = `${msg}`; + el.className = 'save-indicator text-danger show'; + setTimeout(() => { + el.innerHTML = 'Saved'; + el.className = 'save-indicator text-success'; + }, 3000); +} + +async function apiPost(url, data) { + try { + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + const result = await resp.json(); + if (resp.ok && result.ok) { + showSaved(); + return result; + } else { + showError(result.error || 'Save failed'); + return null; + } + } catch (e) { + showError('Network error'); + return null; + } +} + +// --- Arm/Disarm --- +async function setArmState(state) { + const pin = prompt('Enter PIN:'); + if (!pin) return; + + const endpoint = state === 'DISARMED' ? '/system/api/disarm' : '/system/api/arm'; + const body = state === 'DISARMED' ? { pin } : { mode: state, pin }; + await apiPost(endpoint, body); +} + +// --- Camera Settings --- +function saveCameraSettings(cameraId) { + const container = document.getElementById(`cam-settings-${cameraId}`); + const data = {}; + + container.querySelectorAll('[data-field]').forEach(el => { + const field = el.dataset.field; + if (el.type === 'checkbox') { + data[field] = el.checked; + } else if (el.type === 'number') { + data[field] = parseInt(el.value); + } else if (el.tagName === 'SELECT' && el.value.startsWith('[')) { + data[field] = JSON.parse(el.value); + } else { + data[field] = el.value; + } + }); + + apiPost(`/system/api/config/camera/${cameraId}`, data); +} + +// --- Motion Detection --- +function updateThresholdLabel(input) { + const cameraId = input.dataset.cameraId; + const label = document.getElementById(`thresh-val-${cameraId}`); + if (label) label.textContent = `${input.value}%`; +} + +async function saveAllMotionSettings() { + const btn = document.getElementById('btn-save-thresholds'); + btn.disabled = true; + btn.innerHTML = 'Saving...'; + + // Gather per-camera motion settings + const cameras = {}; + document.querySelectorAll('#section-motion [data-camera-id]').forEach(el => { + const cid = el.dataset.cameraId; + if (!cameras[cid]) cameras[cid] = {}; + + if (el.type === 'range') { + cameras[cid].motion_sensitivity = parseInt(el.value) / 100; + } else if (el.dataset.field) { + cameras[cid][el.dataset.field] = parseInt(el.value); + } + }); + + for (const [cameraId, data] of Object.entries(cameras)) { + await apiPost(`/system/api/config/camera/${cameraId}`, data); + } + + btn.disabled = false; + btn.innerHTML = 'Save All Motion Settings'; +} + +// --- Alert Settings --- +async function saveAlertSettings(channel) { + let data = {}; + + if (channel === 'email') { + data = { + enabled: document.getElementById('email-enabled').checked, + smtp_host: document.getElementById('email-smtp-host').value, + smtp_port: parseInt(document.getElementById('email-smtp-port').value), + from_addr: document.getElementById('email-from').value, + to_addr: document.getElementById('email-to').value, + use_tls: document.getElementById('email-tls').checked, + }; + } else if (channel === 'webhook') { + data = { + enabled: document.getElementById('webhook-enabled').checked, + url: document.getElementById('webhook-url').value, + secret: document.getElementById('webhook-secret').value, + }; + } else if (channel === 'local') { + data = { + enabled: true, + syslog: document.getElementById('local-syslog').checked, + desktop_notify: document.getElementById('local-desktop').checked, + }; + } + + apiPost(`/system/api/config/alerts/${channel}`, data); +} + +// --- Storage Settings --- +function saveStorageSettings() { + apiPost('/system/api/config/storage', { + max_disk_usage_gb: parseInt(document.getElementById('storage-max-disk').value), + free_space_floor_gb: parseInt(document.getElementById('storage-free-floor').value), + encrypt_recordings: document.getElementById('storage-encrypt').checked, + }); +} + +// --- UPS Settings --- +function saveUPSSettings() { + apiPost('/system/api/config/ups', { + enabled: document.getElementById('ups-enabled').checked, + nut_host: document.getElementById('ups-host').value, + nut_port: parseInt(document.getElementById('ups-port').value), + ups_name: document.getElementById('ups-name').value, + poll_interval_s: parseInt(document.getElementById('ups-poll').value), + low_battery_threshold_pct: parseInt(document.getElementById('ups-low-batt').value), + critical_runtime_threshold_s: parseInt(document.getElementById('ups-critical-runtime').value), + shutdown_delay_s: parseInt(document.getElementById('ups-shutdown-delay').value), + }); +} + +// --- Web Settings --- +function saveWebSettings() { + apiPost('/system/api/config/web', { + host: document.getElementById('web-host').value, + port: parseInt(document.getElementById('web-port').value), + username: document.getElementById('web-username').value, + session_timeout: parseInt(document.getElementById('web-session').value), + }); +} + +// --- MQTT Settings --- +function saveMQTTSettings() { + apiPost('/system/api/config/mqtt', { + host: document.getElementById('mqtt-host').value, + port: parseInt(document.getElementById('mqtt-port').value), + }); +} + +// --- System Settings --- +function saveSystemSettings() { + apiPost('/system/api/config/system', { + name: document.getElementById('sys-name').value, + timezone: document.getElementById('sys-timezone').value, + log_level: document.getElementById('sys-log-level').value, + }); +} + +// --- Remote Access Settings --- +function saveRemoteSettings() { + const resVal = document.getElementById('remote-resolution').value; + apiPost('/system/api/config/remote', { + enabled: document.getElementById('remote-enabled').checked, + upload_bandwidth_mbps: parseFloat(document.getElementById('remote-bandwidth').value), + remote_hls_resolution: JSON.parse(resVal), + remote_hls_fps: parseInt(document.getElementById('remote-fps').value), + remote_hls_bitrate_kbps: parseInt(document.getElementById('remote-bitrate').value), + max_remote_viewers: parseInt(document.getElementById('remote-max-viewers').value), + tunnel_ip: document.getElementById('remote-tunnel-ip').value, + }); +} + +// --- Push Notifications --- +async function enablePushNotifications() { + const status = document.getElementById('push-status'); + + if (!('Notification' in window) || !('serviceWorker' in navigator)) { + status.textContent = 'Push notifications not supported in this browser'; + return; + } + + const permission = await Notification.requestPermission(); + if (permission !== 'granted') { + status.textContent = 'Permission denied'; + return; + } + + try { + const reg = await navigator.serviceWorker.ready; + const keyResp = await fetch('/system/api/vapid-key'); + if (!keyResp.ok) { + status.textContent = 'VAPID keys not configured on server'; + return; + } + const { publicKey } = await keyResp.json(); + + const sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey), + }); + + await fetch('/system/api/push/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(sub.toJSON()), + }); + + status.textContent = 'Notifications enabled!'; + status.className = 'ms-2 text-success'; + document.getElementById('btn-enable-push').disabled = true; + document.getElementById('btn-enable-push').innerHTML = 'Enabled'; + } catch (e) { + status.textContent = `Error: ${e.message}`; + } +} + +function urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const raw = atob(base64); + return Uint8Array.from([...raw].map(c => c.charCodeAt(0))); +} diff --git a/vigilar/web/static/manifest.json b/vigilar/web/static/manifest.json new file mode 100644 index 0000000..a948f3f --- /dev/null +++ b/vigilar/web/static/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "Vigilar", + "short_name": "Vigilar", + "description": "DIY Home Security System", + "start_url": "/", + "display": "standalone", + "background_color": "#0d1117", + "theme_color": "#1a1a2e", + "icons": [ + { + "src": "/static/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/static/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/vigilar/web/static/sw.js b/vigilar/web/static/sw.js new file mode 100644 index 0000000..fd17dfa --- /dev/null +++ b/vigilar/web/static/sw.js @@ -0,0 +1,95 @@ +/* Vigilar — Service Worker for PWA + Push Notifications */ + +const CACHE_NAME = 'vigilar-v1'; +const PRECACHE_URLS = [ + '/', + '/static/css/vigilar.css', + '/static/js/app.js', + '/static/js/grid.js', + '/static/js/settings.js', + '/static/manifest.json', +]; + +// Install: precache app shell +self.addEventListener('install', event => { + event.waitUntil( + caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE_URLS)) + ); + self.skipWaiting(); +}); + +// Activate: clean old caches +self.addEventListener('activate', event => { + event.waitUntil( + caches.keys().then(keys => + Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +// Fetch: network-first for API, cache-first for static +self.addEventListener('fetch', event => { + const url = new URL(event.request.url); + + // Skip non-GET and camera streams + if (event.request.method !== 'GET' || url.pathname.includes('/hls/') || url.pathname.includes('/stream')) { + return; + } + + // API calls: network only + if (url.pathname.startsWith('/cameras/api') || url.pathname.startsWith('/events/api') || + url.pathname.startsWith('/sensors/api') || url.pathname.startsWith('/system/')) { + return; + } + + // Static assets: cache first + event.respondWith( + caches.match(event.request).then(cached => { + if (cached) return cached; + return fetch(event.request).then(resp => { + if (resp.ok && url.pathname.startsWith('/static/')) { + const clone = resp.clone(); + caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone)); + } + return resp; + }); + }).catch(() => { + // Offline fallback for navigation + if (event.request.mode === 'navigate') { + return caches.match('/'); + } + }) + ); +}); + +// Push notification handler +self.addEventListener('push', event => { + const data = event.data ? event.data.json() : {}; + const title = data.title || 'Vigilar Alert'; + const options = { + body: data.body || 'Motion detected', + icon: '/static/icon-192.png', + badge: '/static/icon-192.png', + tag: data.tag || 'vigilar-alert', + data: data.url ? { url: data.url } : {}, + vibrate: [200, 100, 200], + }; + event.waitUntil(self.registration.showNotification(title, options)); +}); + +// Notification click: open the app +self.addEventListener('notificationclick', event => { + event.notification.close(); + const url = event.notification.data?.url || '/'; + event.waitUntil( + self.clients.matchAll({ type: 'window' }).then(clients => { + for (const client of clients) { + if (client.url.includes(url) && 'focus' in client) { + return client.focus(); + } + } + return self.clients.openWindow(url); + }) + ); +}); diff --git a/vigilar/web/templates/base.html b/vigilar/web/templates/base.html new file mode 100644 index 0000000..56fb516 --- /dev/null +++ b/vigilar/web/templates/base.html @@ -0,0 +1,71 @@ + + + + + + + {% block title %}Vigilar{% endblock %} + + + + + {% block head %}{% endblock %} + + + + +
+ {% block content %}{% endblock %} +
+ + + + {% block scripts %}{% endblock %} + + diff --git a/vigilar/web/templates/cameras.html b/vigilar/web/templates/cameras.html new file mode 100644 index 0000000..391d9f4 --- /dev/null +++ b/vigilar/web/templates/cameras.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} +{% block title %}Vigilar — Cameras{% endblock %} + +{% block content %} +
Cameras
+ +
+ {% for camera in cameras %} +
+
+
+
+ +
+
+ +

{{ camera.display_name }}

+ Connecting... +
+
+
+
+ {{ camera.display_name }} + --:-- +
+
+ +
+
+ +
+
+ {% else %} +
+
+ No cameras configured. +
+
+ {% endfor %} +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/vigilar/web/templates/events.html b/vigilar/web/templates/events.html new file mode 100644 index 0000000..03c1edf --- /dev/null +++ b/vigilar/web/templates/events.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} +{% block title %}Vigilar — Events{% endblock %} + +{% block content %} +
+
Event Log
+
+ + +
+
+ +
+
+
+ + + + + + + + + + + + + {% for event in events %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
TimeTypeSourceSeverityDetails
{{ event.ts }}{{ event.type }}{{ event.source_id or '—' }} + + {{ event.severity }} + + {{ event.payload or '' }} + {% if not event.acknowledged %} + + {% endif %} +
No events recorded
+
+
+
+{% endblock %} diff --git a/vigilar/web/templates/index.html b/vigilar/web/templates/index.html new file mode 100644 index 0000000..1639de5 --- /dev/null +++ b/vigilar/web/templates/index.html @@ -0,0 +1,147 @@ +{% extends "base.html" %} +{% block title %}Vigilar — Dashboard{% endblock %} + +{% block content %} +
+
+
+
Live Cameras
+ + Kiosk Mode + +
+
+
+ + +
+ {% for camera in cameras %} +
+
+
+
+ +
+
+ +

{{ camera.display_name }}

+ Connecting... +
+
+
+ +
+ {{ camera.display_name }} + --:-- +
+
+ +
+
+
+
+ {% endfor %} + {% if not cameras %} +
+
+ + No cameras configured. Edit config/vigilar.toml to add cameras. +
+
+ {% endif %} +
+ + +
+
+
+
+
+ +
+ System +
Disarmed
+
+
+
+
+
+
+
+
+
+ +
+ Cameras +
{{ cameras|length }} configured
+
+
+
+
+
+
+
+
+
+ +
+ Sensors +
--
+
+
+
+
+
+
+
+
+
+ +
+ UPS +
--
+
+
+
+
+
+
+ + +
+
+
+
+ Recent Events + View All +
+
+
+ + + + + + + + + + + + + + +
TimeTypeSourceSeverity
+ No events yet +
+
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/vigilar/web/templates/kiosk.html b/vigilar/web/templates/kiosk.html new file mode 100644 index 0000000..d54d103 --- /dev/null +++ b/vigilar/web/templates/kiosk.html @@ -0,0 +1,108 @@ + + + + + + Vigilar — Kiosk + + + +
+ {% for camera in cameras[:4] %} +
+ +
+ {{ camera.display_name }} — Connecting... +
+
+ {{ camera.display_name }} + --:-- +
+
+
+
+
+ {% endfor %} + {% for i in range(cameras[:4]|length, 4) %} +
+
No camera configured
+
+ {% endfor %} +
+ + + + + + diff --git a/vigilar/web/templates/recordings.html b/vigilar/web/templates/recordings.html new file mode 100644 index 0000000..6036439 --- /dev/null +++ b/vigilar/web/templates/recordings.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}Vigilar — Recordings{% endblock %} + +{% block content %} +
+
Recordings
+ +
+ +
+
+
+ + + + + + + + + + + + + {% for rec in recordings %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
CameraStartedDurationTriggerSize
{{ rec.camera_id }}{{ rec.started_at }}{{ rec.duration_s }}s{{ rec.trigger }}{{ rec.file_size }} + + + +
No recordings found
+
+
+
+{% endblock %} diff --git a/vigilar/web/templates/sensors.html b/vigilar/web/templates/sensors.html new file mode 100644 index 0000000..cf47f27 --- /dev/null +++ b/vigilar/web/templates/sensors.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% block title %}Vigilar — Sensors{% endblock %} + +{% block content %} +
Sensors
+ +
+ {% for sensor in sensors %} +
+
+
+
+
{{ sensor.display_name }}
+ {{ sensor.protocol }} +
+

+ {{ sensor.location or 'Unknown' }} +

+
+ + {{ sensor.type }} + + + -- + +
+
+
+
+ {% else %} +
+
+ + No sensors configured. Edit config/vigilar.toml to add sensors. +
+
+ {% endfor %} +
+{% endblock %} diff --git a/vigilar/web/templates/settings.html b/vigilar/web/templates/settings.html new file mode 100644 index 0000000..91f9831 --- /dev/null +++ b/vigilar/web/templates/settings.html @@ -0,0 +1,683 @@ +{% extends "base.html" %} +{% block title %}Vigilar — Settings{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
Settings
+ + Saved + +
+ +
+ + + + +
+ + +
+
+
Arm / Disarm
+
+

Control the security system arm state. When armed, sensor and motion events trigger alerts.

+
+ + + +
+

+ Disarmed: No alerts triggered. Arm Home: Perimeter sensors active, interior motion ignored. + Arm Away: All sensors and cameras trigger alerts. +

+
+
+
+ + +
+
+
Camera Configuration
+
+ {% for camera in cameras %} +
+
+
{{ camera.display_name }}
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
Frames/sec when no motion
+
+
+ + +
Frames/sec during motion
+
+
+ + +
+
+
+ + +
+
+
+
+ + +
+
+
+
+ +
+
+ {% endfor %} +
+
+
+ + +
+
+
Motion Detection
+
+

Adjust motion detection sensitivity per camera. Higher sensitivity detects smaller movements but may trigger false positives.

+ {% for camera in cameras %} +
+
+
{{ camera.display_name }}
+ {{ (camera.motion_sensitivity * 100)|int }}% +
+ + +
+ Less sensitive + More sensitive +
+ +
+
+ + +
Minimum pixel area to count as motion
+
+
+ + +
Seconds of video before motion trigger
+
+
+ + +
Keep recording after motion stops
+
+
+
+ {% endfor %} + +
+
+
+ + +
+
+
Sensor Configuration
+
+

Sensors are configured in config/vigilar.toml. Sensor management UI coming in a future update.

+ {% if config %} +
+ + + + {% for s in config.sensors %} + + + + + + + + {% else %} + + {% endfor %} + +
IDNameTypeProtocolLocation
{{ s.id }}{{ s.display_name }}{{ s.type }}{{ s.protocol }}{{ s.location or '—' }}
No sensors configured
+
+ {% endif %} +
+
+
+ + +
+
+
Push Notifications (PWA)
+
+

Enable push notifications on this device to receive motion alerts.

+ + +
+
+ + {% if config %} +
+
Email Alerts
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+
+ +
+
Webhook
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+
+ +
+
Local Alerts
+
+
+ + +
+
+ + +
+ +
+
+ {% endif %} +
+ + +
+
+
Storage
+
+ {% if config %} +
+
+ + +
Hard cap — oldest recordings pruned when exceeded
+
+
+ + +
Always keep this much free on the disk
+
+
+
+ + +
+
AES-256 encryption at rest (.vge files)
+
+
+
+
+ + +
Change via config file (requires restart)
+
+
+ + +
+
+ + {% endif %} +
+
+
+ + +
+
+
UPS Monitoring (NUT)
+
+ {% if config %} +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
Alert when battery drops below this
+
+
+ + +
Begin shutdown when runtime falls below
+
+
+ + +
Grace period before system poweroff
+
+
+ + {% endif %} +
+
+
+ + +
+
+
Remote Access via WireGuard Tunnel
+
+

+ Access Vigilar from outside your home network via a WireGuard VPN tunnel to your Digital Ocean droplet. + A lower-quality HLS stream is generated specifically for remote viewing to conserve your upload bandwidth. +

+ {% if config %} +
+ + +
+
+
+ + +
Your home internet upload speed
+
+
+ + +
+
+ + +
+
+ + +
Per-camera bitrate for remote stream
+
+
+ + +
0 = unlimited
+
+
+ + +
+
+ + +
+ + Estimated bandwidth per viewer: + + {{ (config.remote.remote_hls_bitrate_kbps * cameras|length / 1000)|round(1) }} Mbps + + ({{ cameras|length }} cameras x {{ config.remote.remote_hls_bitrate_kbps }} kbps) + • + Max concurrent: + ~{{ (config.remote.upload_bandwidth_mbps * 1000 * 0.8 / (config.remote.remote_hls_bitrate_kbps * [cameras|length, 1]|max))|round(0)|int }} viewers + at 80% of {{ config.remote.upload_bandwidth_mbps }} Mbps upload +
+ + + {% endif %} +
+
+ +
+
Setup Instructions
+
+
    +
  1. On both home server and DO droplet, run: remote/wireguard/setup_wireguard.sh
  2. +
  3. Exchange public keys between home server and droplet
  4. +
  5. On the droplet, install nginx and copy remote/nginx/vigilar.conf to /etc/nginx/sites-available/
  6. +
  7. Run certbot --nginx -d vigilar.yourdomain.com for TLS
  8. +
  9. Verify: ping 10.99.0.2 from droplet should reach home server
  10. +
  11. Access your system at https://vigilar.yourdomain.com
  12. +
+
+
+
+ + +
+
+
Web Server
+
+ {% if config %} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + Port and bind address changes require a restart to take effect. +
+ + {% endif %} +
+
+ +
+
MQTT Broker
+
+ {% if config %} +
+
+ + +
+
+ + +
+
+
+ + MQTT changes require a full system restart. +
+ + {% endif %} +
+
+
+ + +
+
+
System
+
+ {% if config %} +
+
+ + +
+
+ + +
+
+ + +
+
+ + {% endif %} +
+
+ +
+
System Info
+
+ + + + {% if config %} + + + + + + + + {% endif %} +
Version0.1.0
Cameras{{ cameras|length }}
Sensors{{ config.sensors|length }}
Rules{{ config.rules|length }}
Data Dir{{ config.system.data_dir }}
Recordings Dir{{ config.system.recordings_dir }}
HLS Dir{{ config.system.hls_dir }}
Encryption{{ 'Enabled' if config.storage.encrypt_recordings else 'Disabled' }}
UPS{{ 'Enabled' if config.ups.enabled else 'Disabled' }}
+
+
+
+ +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %}