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) <noreply@anthropic.com>
This commit is contained in:
Aaron D. Lee 2026-04-02 23:11:27 -04:00
commit 845a85d618
69 changed files with 7061 additions and 0 deletions

50
.gitignore vendored Normal file
View File

@ -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

39
CLAUDE.md Normal file
View File

@ -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

165
config/vigilar.toml Normal file
View File

@ -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

View File

@ -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:<password>@<ip>/Preview_01_main
Sub stream (D1): rtsp://admin:<password>@<ip>/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:<password>@<ip>/Preview_01_main
Sub stream: rtsp://admin:<password>@<ip>/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:<password>@<ip>:554/cam/realmonitor?channel=1&subtype=0
Sub stream: rtsp://admin:<password>@<ip>:554/cam/realmonitor?channel=1&subtype=1
```
Alternative simplified URL (also works):
```
rtsp://admin:<password>@<ip>: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:<password>@<ip>:554/Preview_01_main
# Sub stream (low resolution, for motion detection)
rtsp://admin:<password>@<ip>: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://<ip>` (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:<password>@<ip>:554/cam/realmonitor?channel=1&subtype=0
# Sub stream (low resolution, for motion detection)
rtsp://admin:<password>@<ip>: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://<ip>` (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://<ip>` 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)

61
pyproject.toml Normal file
View File

@ -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

165
remote/nginx/vigilar.conf Normal file
View File

@ -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;
}

187
remote/setup_droplet.sh Executable file
View File

@ -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 <<EOF
[Interface]
Address = 10.99.0.1/32
ListenPort = 51820
PrivateKey = $PRIV_KEY
[Peer]
PublicKey = $HOME_PUB_KEY
AllowedIPs = 10.99.0.2/32
EOF
chmod 600 /etc/wireguard/wg0.conf
systemctl enable --now wg-quick@wg0
echo " WireGuard started. Tunnel IP: 10.99.0.1"
fi
# --- Step 3: Firewall ---
echo ""
echo "[3/6] Configuring firewall..."
ufw --force reset
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp # SSH
ufw allow 80/tcp # HTTP (certbot + redirect)
ufw allow 443/tcp # HTTPS
ufw allow 51820/udp # WireGuard
ufw --force enable
echo " Firewall configured."
# --- Step 4: Nginx ---
echo ""
echo "[4/6] Configuring nginx..."
read -p " Enter your domain (e.g., vigilar.yourdomain.com): " DOMAIN
# Create cache directory
mkdir -p /var/cache/nginx/vigilar_hls
# Deploy config
NGINX_CONF="/etc/nginx/sites-available/vigilar"
if [[ -f "$NGINX_CONF" ]]; then
cp "$NGINX_CONF" "${NGINX_CONF}.bak.$(date +%s)"
fi
# Copy template and replace domain
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
if [[ -f "$SCRIPT_DIR/nginx/vigilar.conf" ]]; then
sed "s/vigilar.yourdomain.com/$DOMAIN/g" "$SCRIPT_DIR/nginx/vigilar.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 <<EOF
server {
listen 80;
server_name $DOMAIN;
location / {
proxy_pass http://10.99.0.2:49735;
}
}
EOF
ln -sf /etc/nginx/sites-available/vigilar-temp /etc/nginx/sites-enabled/vigilar-temp
rm -f /etc/nginx/sites-enabled/vigilar
nginx -t && systemctl reload nginx
# Run certbot
certbot --nginx -d "$DOMAIN" --non-interactive --agree-tos --register-unsafely-without-email || {
echo ""
echo " Certbot failed. Make sure DNS for $DOMAIN points to this server."
echo " You can retry manually: certbot --nginx -d $DOMAIN"
}
# Remove temp config, restore full config
rm -f /etc/nginx/sites-enabled/vigilar-temp /etc/nginx/sites-available/vigilar-temp
ln -sf "$NGINX_CONF" /etc/nginx/sites-enabled/vigilar
# Update the config with certbot's cert paths
sed -i "s|ssl_certificate .*|ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem;|" "$NGINX_CONF"
sed -i "s|ssl_certificate_key .*|ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;|" "$NGINX_CONF"
nginx -t && systemctl reload nginx
# --- Step 6: Verify ---
echo ""
echo "[6/6] Verifying setup..."
echo ""
# Check WireGuard
if wg show wg0 &>/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 "============================================"

View File

@ -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 <<EOF
[Interface]
Address = 10.99.0.1/32
ListenPort = 51820
PrivateKey = $PRIV_KEY
[Peer]
PublicKey = $HOME_PUB_KEY
AllowedIPs = 10.99.0.2/32
EOF
sudo chmod 600 /etc/wireguard/wg0.conf
# Open firewall
if command -v ufw &>/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 <<EOF
[Interface]
Address = 10.99.0.2/32
PrivateKey = $PRIV_KEY
[Peer]
PublicKey = $DROPLET_PUB_KEY
AllowedIPs = 10.99.0.1/32
Endpoint = ${DROPLET_IP}:51820
PersistentKeepalive = 25
EOF
sudo chmod 600 /etc/wireguard/wg0.conf
# Enable and start
sudo systemctl enable --now wg-quick@wg0
echo ""
echo "WireGuard started on home server."
echo "Home tunnel IP: 10.99.0.2"
echo ""
echo "Share this public key with your droplet: $PUB_KEY"
fi
echo ""
echo "Test connectivity with: ping 10.99.0.1 (from home) or ping 10.99.0.2 (from droplet)"

View File

@ -0,0 +1,18 @@
# WireGuard config for DIGITAL OCEAN DROPLET (reverse proxy)
# Install: cp wg0-droplet.conf /etc/wireguard/wg0.conf
# Start: systemctl enable --now wg-quick@wg0
[Interface]
# Droplet's WireGuard IP on the tunnel
Address = 10.99.0.1/32
ListenPort = 51820
# Generate with: wg genkey | tee /etc/wireguard/droplet_private.key | wg pubkey > /etc/wireguard/droplet_public.key
PrivateKey = <DROPLET_PRIVATE_KEY>
[Peer]
# Home server
PublicKey = <HOME_PUBLIC_KEY>
# 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

View File

@ -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 = <HOME_PRIVATE_KEY>
# Keep the tunnel alive through NAT (home router)
# Send keepalive every 25s so the NAT mapping doesn't expire
[Peer]
# Digital Ocean droplet
PublicKey = <DROPLET_PUBLIC_KEY>
# Route all tunnel traffic to the droplet
AllowedIPs = 10.99.0.1/32
# Droplet's public IP + WireGuard port
Endpoint = <DROPLET_PUBLIC_IP>:51820
# Critical: keeps tunnel alive through home router NAT
PersistentKeepalive = 25

6
requirements-dev.txt Normal file
View File

@ -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

13
requirements.txt Normal file
View File

@ -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

35
tests/conftest.py Normal file
View File

@ -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=[],
)

35
tests/unit/test_config.py Normal file
View File

@ -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)

View File

@ -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

63
tests/unit/test_motion.py Normal file
View File

@ -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

View File

@ -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

21
tests/unit/test_schema.py Normal file
View File

@ -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}"

104
tests/unit/test_web.py Normal file
View File

@ -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

0
vigilar/__init__.py Normal file
View File

View File

116
vigilar/bus.py Normal file
View File

@ -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)

View File

245
vigilar/camera/hls.py Normal file
View File

@ -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

117
vigilar/camera/manager.py Normal file
View File

@ -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()
}

147
vigilar/camera/motion.py Normal file
View File

@ -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

228
vigilar/camera/recorder.py Normal file
View File

@ -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

View File

@ -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()

267
vigilar/camera/worker.py Normal file
View File

@ -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()

0
vigilar/cli/__init__.py Normal file
View File

95
vigilar/cli/cmd_config.py Normal file
View File

@ -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}"')

45
vigilar/cli/cmd_start.py Normal file
View File

@ -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)

21
vigilar/cli/main.py Normal file
View File

@ -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()

251
vigilar/config.py Normal file
View File

@ -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)

226
vigilar/config_writer.py Normal file
View File

@ -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)

153
vigilar/constants.py Normal file
View File

@ -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

View File

143
vigilar/main.py Normal file
View File

@ -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")

View File

View File

53
vigilar/storage/db.py Normal file
View File

@ -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"

248
vigilar/storage/queries.py Normal file
View File

@ -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

123
vigilar/storage/schema.py Normal file
View File

@ -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),
)

0
vigilar/ups/__init__.py Normal file
View File

0
vigilar/web/__init__.py Normal file
View File

47
vigilar/web/app.py Normal file
View File

@ -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

View File

View File

@ -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("/<camera_id>/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("/<camera_id>/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("/<camera_id>/hls/<path:filename>")
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("/<camera_id>/hls/remote/<path:filename>")
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
])

View File

@ -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")

View File

@ -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)

View File

@ -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("/<int:recording_id>/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("/<int:recording_id>", methods=["DELETE"])
def recording_delete(recording_id: int):
"""Delete a recording."""
# TODO: delete from DB + filesystem
return jsonify({"ok": True})

View File

@ -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
])

View File

@ -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/<section>")
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/<camera_id>", 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/<camera_id>/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/<channel>", 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

View File

@ -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); }
}

View File

@ -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 = `<i class="bi ${s.icon} me-1"></i>${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 = `
<td>${time}</td>
<td><span class="badge bg-secondary">${data.type}</span></td>
<td>${data.source_id || '—'}</td>
<td><span class="badge bg-info">${data.severity || 'INFO'}</span></td>
`;
// 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(() => {});
}

View File

@ -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();
}
})();

4
vigilar/web/static/js/hls.min.js vendored Normal file
View File

@ -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;

View File

@ -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();
})();

View File

@ -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 = `<i class="bi bi-x-circle me-1"></i>${msg}`;
el.className = 'save-indicator text-danger show';
setTimeout(() => {
el.innerHTML = '<i class="bi bi-check-circle me-1"></i>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 = '<i class="bi bi-hourglass me-1"></i>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 = '<i class="bi bi-check-lg me-1"></i>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 = '<i class="bi bi-check-circle me-1"></i>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)));
}

View File

@ -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"
}
]
}

95
vigilar/web/static/sw.js Normal file
View File

@ -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);
})
);
});

View File

@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#1a1a2e">
<title>{% block title %}Vigilar{% endblock %}</title>
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<link href="{{ url_for('static', filename='css/vigilar.css') }}" rel="stylesheet">
{% block head %}{% endblock %}
</head>
<body>
<nav class="navbar navbar-expand-lg border-bottom">
<div class="container-fluid">
<a class="navbar-brand fw-bold" href="/">
<i class="bi bi-shield-lock-fill me-2"></i>Vigilar
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link {% if request.path == '/' %}active{% endif %}" href="/">
<i class="bi bi-grid-fill me-1"></i>Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if '/events' in request.path %}active{% endif %}" href="/events/">
<i class="bi bi-list-ul me-1"></i>Events
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if '/sensors' in request.path %}active{% endif %}" href="/sensors/">
<i class="bi bi-broadcast me-1"></i>Sensors
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if '/recordings' in request.path %}active{% endif %}" href="/recordings/">
<i class="bi bi-camera-video me-1"></i>Recordings
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if '/system/settings' in request.path %}active{% endif %}" href="/system/settings">
<i class="bi bi-gear me-1"></i>Settings
</a>
</li>
</ul>
<div class="d-flex align-items-center gap-3">
<span id="arm-state-badge" class="badge bg-success">
<i class="bi bi-unlock-fill me-1"></i>Disarmed
</span>
<span id="ups-badge" class="badge bg-secondary">
<i class="bi bi-battery-full me-1"></i>UPS
</span>
</div>
</div>
</div>
</nav>
<main class="container-fluid py-3">
{% block content %}{% endblock %}
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,56 @@
{% extends "base.html" %}
{% block title %}Vigilar — Cameras{% endblock %}
{% block content %}
<h5 class="mb-3"><i class="bi bi-camera-video me-2"></i>Cameras</h5>
<div class="row g-3">
{% for camera in cameras %}
<div class="col-md-6">
<div class="card bg-dark border-secondary camera-card">
<div class="card-body p-0 position-relative">
<div class="camera-feed ratio ratio-16x9">
<video id="feed-{{ camera.id }}" autoplay muted playsinline></video>
<div class="camera-offline-overlay d-flex align-items-center justify-content-center">
<div class="text-center text-muted">
<i class="bi bi-camera-video-off fs-1"></i>
<p class="mt-2 mb-0">{{ camera.display_name }}</p>
<small>Connecting...</small>
</div>
</div>
</div>
<div class="camera-overlay">
<span class="camera-name">{{ camera.display_name }}</span>
<span class="camera-time" id="time-{{ camera.id }}">--:--</span>
</div>
<div class="camera-motion-dot d-none" id="motion-{{ camera.id }}">
<span class="motion-indicator"></span>
</div>
</div>
<div class="card-footer d-flex justify-content-between align-items-center">
<small class="text-muted">
{{ camera.resolution_capture[0] }}x{{ camera.resolution_capture[1] }} |
{{ camera.idle_fps }}/{{ camera.motion_fps }} FPS |
{{ camera.retention_days }}d retention
</small>
<div>
<a href="/recordings/?camera={{ camera.id }}" class="btn btn-sm btn-outline-light">
<i class="bi bi-film"></i>
</a>
</div>
</div>
</div>
</div>
{% else %}
<div class="col-12">
<div class="alert alert-secondary text-center">
No cameras configured.
</div>
</div>
{% endfor %}
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/grid.js') }}"></script>
{% endblock %}

View File

@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block title %}Vigilar — Events{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0"><i class="bi bi-list-ul me-2"></i>Event Log</h5>
<div class="d-flex gap-2">
<select class="form-select form-select-sm" id="filter-severity" style="width: auto;">
<option value="">All Severities</option>
<option value="INFO">Info</option>
<option value="WARNING">Warning</option>
<option value="ALERT">Alert</option>
<option value="CRITICAL">Critical</option>
</select>
<select class="form-select form-select-sm" id="filter-type" style="width: auto;">
<option value="">All Types</option>
<option value="MOTION_START">Motion</option>
<option value="CONTACT_OPEN">Contact</option>
<option value="POWER_LOSS">Power</option>
<option value="ARM_STATE_CHANGED">Arm State</option>
</select>
</div>
</div>
<div class="card bg-dark border-secondary">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead>
<tr>
<th>Time</th>
<th>Type</th>
<th>Source</th>
<th>Severity</th>
<th>Details</th>
<th></th>
</tr>
</thead>
<tbody id="event-list">
{% for event in events %}
<tr>
<td>{{ event.ts }}</td>
<td><span class="badge bg-secondary">{{ event.type }}</span></td>
<td>{{ event.source_id or '—' }}</td>
<td>
<span class="badge bg-{{ 'danger' if event.severity == 'CRITICAL' else 'warning' if event.severity == 'ALERT' else 'info' if event.severity == 'WARNING' else 'secondary' }}">
{{ event.severity }}
</span>
</td>
<td>{{ event.payload or '' }}</td>
<td>
{% if not event.acknowledged %}
<button class="btn btn-sm btn-outline-success" onclick="ackEvent({{ event.id }})">
<i class="bi bi-check"></i>
</button>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center text-muted py-3">No events recorded</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,147 @@
{% extends "base.html" %}
{% block title %}Vigilar — Dashboard{% endblock %}
{% block content %}
<div class="row g-3 mb-3">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-grid-fill me-2"></i>Live Cameras</h5>
<a href="/kiosk/" class="btn btn-sm btn-outline-light" target="_blank">
<i class="bi bi-fullscreen me-1"></i>Kiosk Mode
</a>
</div>
</div>
</div>
<!-- 2x2 Camera Grid -->
<div class="row g-2" id="camera-grid">
{% for camera in cameras %}
<div class="col-md-6">
<div class="card bg-dark border-secondary camera-card">
<div class="card-body p-0 position-relative">
<div class="camera-feed ratio ratio-16x9">
<video id="feed-{{ camera.id }}" class="w-100" autoplay muted playsinline>
</video>
<div class="camera-offline-overlay d-flex align-items-center justify-content-center">
<div class="text-center text-muted">
<i class="bi bi-camera-video-off fs-1"></i>
<p class="mt-2 mb-0">{{ camera.display_name }}</p>
<small>Connecting...</small>
</div>
</div>
</div>
<!-- Overlay -->
<div class="camera-overlay">
<span class="camera-name">{{ camera.display_name }}</span>
<span class="camera-time" id="time-{{ camera.id }}">--:--</span>
</div>
<div class="camera-motion-dot d-none" id="motion-{{ camera.id }}">
<span class="motion-indicator"></span>
</div>
</div>
</div>
</div>
{% endfor %}
{% if not cameras %}
<div class="col-12">
<div class="alert alert-secondary text-center">
<i class="bi bi-camera-video-off me-2"></i>
No cameras configured. Edit <code>config/vigilar.toml</code> to add cameras.
</div>
</div>
{% endif %}
</div>
<!-- Quick Status Cards -->
<div class="row g-2 mt-3">
<div class="col-md-3">
<div class="card bg-dark border-secondary">
<div class="card-body py-2">
<div class="d-flex align-items-center">
<i class="bi bi-shield-check fs-4 me-3 text-success"></i>
<div>
<small class="text-muted">System</small>
<div id="system-status" class="fw-bold">Disarmed</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-dark border-secondary">
<div class="card-body py-2">
<div class="d-flex align-items-center">
<i class="bi bi-camera-video fs-4 me-3 text-info"></i>
<div>
<small class="text-muted">Cameras</small>
<div id="cameras-online" class="fw-bold">{{ cameras|length }} configured</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-dark border-secondary">
<div class="card-body py-2">
<div class="d-flex align-items-center">
<i class="bi bi-broadcast fs-4 me-3 text-warning"></i>
<div>
<small class="text-muted">Sensors</small>
<div id="sensors-online" class="fw-bold">--</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-dark border-secondary">
<div class="card-body py-2">
<div class="d-flex align-items-center">
<i class="bi bi-battery-full fs-4 me-3 text-success"></i>
<div>
<small class="text-muted">UPS</small>
<div id="ups-status" class="fw-bold">--</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Recent Events -->
<div class="row mt-3">
<div class="col-12">
<div class="card bg-dark border-secondary">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-list-ul me-2"></i>Recent Events</span>
<a href="/events/" class="btn btn-sm btn-outline-light">View All</a>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead>
<tr>
<th>Time</th>
<th>Type</th>
<th>Source</th>
<th>Severity</th>
</tr>
</thead>
<tbody id="recent-events">
<tr>
<td colspan="4" class="text-center text-muted py-3">
No events yet
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/grid.js') }}"></script>
{% endblock %}

View File

@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vigilar — Kiosk</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; }
.kiosk-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
width: 100vw;
height: 100vh;
gap: 2px;
background: #000;
}
.kiosk-cell {
position: relative;
overflow: hidden;
background: #111;
}
.kiosk-cell video {
width: 100%;
height: 100%;
object-fit: cover;
}
.kiosk-offline {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #555;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 1.2rem;
}
.kiosk-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
padding: 8px 12px;
display: flex;
justify-content: space-between;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 0.85rem;
color: #fff;
text-shadow: 0 1px 3px rgba(0,0,0,0.8);
background: linear-gradient(to bottom, rgba(0,0,0,0.6), transparent);
pointer-events: none;
}
.kiosk-motion {
position: absolute;
bottom: 12px;
left: 12px;
display: none;
}
.kiosk-motion.active { display: block; }
.kiosk-motion-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #dc3545;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.3); }
}
</style>
</head>
<body>
<div class="kiosk-grid">
{% for camera in cameras[:4] %}
<div class="kiosk-cell" data-camera-id="{{ camera.id }}">
<video id="kiosk-feed-{{ camera.id }}" autoplay muted playsinline></video>
<div class="kiosk-offline" id="kiosk-offline-{{ camera.id }}">
{{ camera.display_name }} — Connecting...
</div>
<div class="kiosk-overlay">
<span>{{ camera.display_name }}</span>
<span class="kiosk-time" id="kiosk-time-{{ camera.id }}">--:--</span>
</div>
<div class="kiosk-motion" id="kiosk-motion-{{ camera.id }}">
<div class="kiosk-motion-dot"></div>
</div>
</div>
{% endfor %}
{% for i in range(cameras[:4]|length, 4) %}
<div class="kiosk-cell">
<div class="kiosk-offline">No camera configured</div>
</div>
{% endfor %}
</div>
<script src="{{ url_for('static', filename='js/hls.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/grid.js') }}"></script>
<script>
// Update timestamps every second
setInterval(() => {
const now = new Date().toLocaleTimeString([], {hour: '2-digit', minute: '2-digit', second: '2-digit'});
document.querySelectorAll('.kiosk-time').forEach(el => el.textContent = now);
}, 1000);
</script>
</body>
</html>

View File

@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}Vigilar — Recordings{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0"><i class="bi bi-camera-video me-2"></i>Recordings</h5>
<select class="form-select form-select-sm" id="filter-camera" style="width: auto;">
<option value="">All Cameras</option>
</select>
</div>
<div class="card bg-dark border-secondary">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead>
<tr>
<th>Camera</th>
<th>Started</th>
<th>Duration</th>
<th>Trigger</th>
<th>Size</th>
<th></th>
</tr>
</thead>
<tbody id="recordings-list">
{% for rec in recordings %}
<tr>
<td>{{ rec.camera_id }}</td>
<td>{{ rec.started_at }}</td>
<td>{{ rec.duration_s }}s</td>
<td><span class="badge bg-secondary">{{ rec.trigger }}</span></td>
<td>{{ rec.file_size }}</td>
<td>
<a href="/recordings/{{ rec.id }}/download" class="btn btn-sm btn-outline-light">
<i class="bi bi-download"></i>
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center text-muted py-3">No recordings found</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}Vigilar — Sensors{% endblock %}
{% block content %}
<h5 class="mb-3"><i class="bi bi-broadcast me-2"></i>Sensors</h5>
<div class="row g-3">
{% for sensor in sensors %}
<div class="col-md-4 col-lg-3">
<div class="card bg-dark border-secondary">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<h6 class="card-title mb-0">{{ sensor.display_name }}</h6>
<span class="badge bg-secondary">{{ sensor.protocol }}</span>
</div>
<p class="text-muted small mb-2">
<i class="bi bi-geo-alt me-1"></i>{{ sensor.location or 'Unknown' }}
</p>
<div class="d-flex justify-content-between align-items-center">
<span class="badge bg-{{ 'success' if sensor.type == 'CONTACT' else 'info' }}">
{{ sensor.type }}
</span>
<span class="text-muted small" id="sensor-state-{{ sensor.id }}">
--
</span>
</div>
</div>
</div>
</div>
{% else %}
<div class="col-12">
<div class="alert alert-secondary text-center">
<i class="bi bi-broadcast me-2"></i>
No sensors configured. Edit <code>config/vigilar.toml</code> to add sensors.
</div>
</div>
{% endfor %}
</div>
{% endblock %}

View File

@ -0,0 +1,683 @@
{% extends "base.html" %}
{% block title %}Vigilar — Settings{% endblock %}
{% block head %}
<style>
.settings-nav .nav-link { color: #8b949e; border: none; padding: 0.5rem 1rem; }
.settings-nav .nav-link.active { color: #fff; background-color: rgba(255,255,255,0.05); border-left: 3px solid #58a6ff; }
.settings-nav .nav-link:hover { color: #c9d1d9; }
.settings-section { display: none; }
.settings-section.active { display: block; }
.save-indicator { opacity: 0; transition: opacity 0.3s; }
.save-indicator.show { opacity: 1; }
.form-label { font-weight: 500; }
.setting-desc { font-size: 0.8rem; color: #8b949e; margin-top: 0.15rem; }
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0"><i class="bi bi-gear me-2"></i>Settings</h5>
<span class="save-indicator text-success" id="save-indicator">
<i class="bi bi-check-circle me-1"></i>Saved
</span>
</div>
<div class="row">
<!-- Settings Navigation -->
<div class="col-md-3 col-lg-2 mb-3">
<nav class="nav flex-column settings-nav">
<a class="nav-link active" href="#" data-section="arm">
<i class="bi bi-shield-lock me-2"></i>Arm / Disarm
</a>
<a class="nav-link" href="#" data-section="cameras">
<i class="bi bi-camera-video me-2"></i>Cameras
</a>
<a class="nav-link" href="#" data-section="motion">
<i class="bi bi-activity me-2"></i>Motion Detection
</a>
<a class="nav-link" href="#" data-section="sensors">
<i class="bi bi-broadcast me-2"></i>Sensors
</a>
<a class="nav-link" href="#" data-section="alerts">
<i class="bi bi-bell me-2"></i>Notifications
</a>
<a class="nav-link" href="#" data-section="storage">
<i class="bi bi-hdd me-2"></i>Storage
</a>
<a class="nav-link" href="#" data-section="ups">
<i class="bi bi-battery-charging me-2"></i>UPS
</a>
<a class="nav-link" href="#" data-section="remote">
<i class="bi bi-globe me-2"></i>Remote Access
</a>
<a class="nav-link" href="#" data-section="network">
<i class="bi bi-wifi me-2"></i>Network
</a>
<a class="nav-link" href="#" data-section="system">
<i class="bi bi-cpu me-2"></i>System
</a>
</nav>
</div>
<!-- Settings Content -->
<div class="col-md-9 col-lg-10">
<!-- ARM / DISARM -->
<div class="settings-section active" id="section-arm">
<div class="card bg-dark border-secondary">
<div class="card-header"><i class="bi bi-shield-lock me-2"></i>Arm / Disarm</div>
<div class="card-body">
<p class="text-muted mb-3">Control the security system arm state. When armed, sensor and motion events trigger alerts.</p>
<div class="d-flex gap-2 mb-3">
<button class="btn btn-success btn-lg" onclick="setArmState('DISARMED')">
<i class="bi bi-unlock-fill me-1"></i>Disarm
</button>
<button class="btn btn-warning btn-lg" onclick="setArmState('ARMED_HOME')">
<i class="bi bi-house-lock-fill me-1"></i>Arm Home
</button>
<button class="btn btn-danger btn-lg" onclick="setArmState('ARMED_AWAY')">
<i class="bi bi-shield-fill-exclamation me-1"></i>Arm Away
</button>
</div>
<p class="setting-desc">
<strong>Disarmed:</strong> No alerts triggered. <strong>Arm Home:</strong> Perimeter sensors active, interior motion ignored.
<strong>Arm Away:</strong> All sensors and cameras trigger alerts.
</p>
</div>
</div>
</div>
<!-- CAMERAS -->
<div class="settings-section" id="section-cameras">
<div class="card bg-dark border-secondary">
<div class="card-header"><i class="bi bi-camera-video me-2"></i>Camera Configuration</div>
<div class="card-body">
{% for camera in cameras %}
<div class="border border-secondary rounded p-3 mb-3" id="cam-settings-{{ camera.id }}">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0">{{ camera.display_name }}</h6>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="cam-enabled-{{ camera.id }}"
{{ 'checked' if camera.enabled }} data-camera-id="{{ camera.id }}" data-field="enabled">
<label class="form-check-label" for="cam-enabled-{{ camera.id }}">Enabled</label>
</div>
</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Display Name</label>
<input type="text" class="form-control form-control-sm" value="{{ camera.display_name }}"
data-camera-id="{{ camera.id }}" data-field="display_name">
</div>
<div class="col-md-6">
<label class="form-label">RTSP URL</label>
<input type="text" class="form-control form-control-sm" value="{{ camera.rtsp_url }}"
data-camera-id="{{ camera.id }}" data-field="rtsp_url">
</div>
<div class="col-md-3">
<label class="form-label">Capture Resolution</label>
<select class="form-select form-select-sm" data-camera-id="{{ camera.id }}" data-field="resolution_capture">
<option value="[3840,2160]" {{ 'selected' if camera.resolution_capture == [3840,2160] }}>4K (3840x2160)</option>
<option value="[2560,1440]" {{ 'selected' if camera.resolution_capture == [2560,1440] }}>1440p (2560x1440)</option>
<option value="[1920,1080]" {{ 'selected' if camera.resolution_capture == [1920,1080] }}>1080p (1920x1080)</option>
<option value="[1280,720]" {{ 'selected' if camera.resolution_capture == [1280,720] }}>720p (1280x720)</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Idle FPS</label>
<input type="number" class="form-control form-control-sm" value="{{ camera.idle_fps }}"
min="1" max="15" data-camera-id="{{ camera.id }}" data-field="idle_fps">
<div class="setting-desc">Frames/sec when no motion</div>
</div>
<div class="col-md-3">
<label class="form-label">Motion FPS</label>
<input type="number" class="form-control form-control-sm" value="{{ camera.motion_fps }}"
min="5" max="60" data-camera-id="{{ camera.id }}" data-field="motion_fps">
<div class="setting-desc">Frames/sec during motion</div>
</div>
<div class="col-md-3">
<label class="form-label">Retention (days)</label>
<input type="number" class="form-control form-control-sm" value="{{ camera.retention_days }}"
min="1" max="365" data-camera-id="{{ camera.id }}" data-field="retention_days">
</div>
<div class="col-md-6">
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" id="cam-motion-{{ camera.id }}"
{{ 'checked' if camera.record_on_motion }}
data-camera-id="{{ camera.id }}" data-field="record_on_motion">
<label class="form-check-label" for="cam-motion-{{ camera.id }}">Record on Motion</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" id="cam-continuous-{{ camera.id }}"
{{ 'checked' if camera.record_continuous }}
data-camera-id="{{ camera.id }}" data-field="record_continuous">
<label class="form-check-label" for="cam-continuous-{{ camera.id }}">Continuous Recording</label>
</div>
</div>
</div>
<div class="mt-3">
<button class="btn btn-sm btn-primary" onclick="saveCameraSettings('{{ camera.id }}')">
<i class="bi bi-check-lg me-1"></i>Save Camera
</button>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- MOTION DETECTION -->
<div class="settings-section" id="section-motion">
<div class="card bg-dark border-secondary">
<div class="card-header"><i class="bi bi-activity me-2"></i>Motion Detection</div>
<div class="card-body">
<p class="text-muted mb-3">Adjust motion detection sensitivity per camera. Higher sensitivity detects smaller movements but may trigger false positives.</p>
{% for camera in cameras %}
<div class="mb-4 pb-3 {{ 'border-bottom border-secondary' if not loop.last }}">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">{{ camera.display_name }}</h6>
<span class="badge bg-secondary" id="thresh-val-{{ camera.id }}">{{ (camera.motion_sensitivity * 100)|int }}%</span>
</div>
<label class="form-label small text-muted">Sensitivity</label>
<input type="range" class="form-range" min="0" max="100" step="5"
value="{{ (camera.motion_sensitivity * 100)|int }}"
id="thresh-{{ camera.id }}" data-camera-id="{{ camera.id }}"
oninput="updateThresholdLabel(this)">
<div class="d-flex justify-content-between">
<small class="text-muted">Less sensitive</small>
<small class="text-muted">More sensitive</small>
</div>
<div class="row g-3 mt-1">
<div class="col-md-4">
<label class="form-label small text-muted">Min Contour Area (px)</label>
<input type="number" class="form-control form-control-sm"
value="{{ camera.motion_min_area_px }}" min="0" max="10000" step="50"
data-camera-id="{{ camera.id }}" data-field="motion_min_area_px">
<div class="setting-desc">Minimum pixel area to count as motion</div>
</div>
<div class="col-md-4">
<label class="form-label small text-muted">Pre-Motion Buffer (sec)</label>
<input type="number" class="form-control form-control-sm"
value="{{ camera.pre_motion_buffer_s }}" min="0" max="30"
data-camera-id="{{ camera.id }}" data-field="pre_motion_buffer_s">
<div class="setting-desc">Seconds of video before motion trigger</div>
</div>
<div class="col-md-4">
<label class="form-label small text-muted">Post-Motion Buffer (sec)</label>
<input type="number" class="form-control form-control-sm"
value="{{ camera.post_motion_buffer_s }}" min="5" max="120"
data-camera-id="{{ camera.id }}" data-field="post_motion_buffer_s">
<div class="setting-desc">Keep recording after motion stops</div>
</div>
</div>
</div>
{% endfor %}
<button class="btn btn-primary" id="btn-save-thresholds" onclick="saveAllMotionSettings()">
<i class="bi bi-check-lg me-1"></i>Save All Motion Settings
</button>
</div>
</div>
</div>
<!-- SENSORS -->
<div class="settings-section" id="section-sensors">
<div class="card bg-dark border-secondary">
<div class="card-header"><i class="bi bi-broadcast me-2"></i>Sensor Configuration</div>
<div class="card-body">
<p class="text-muted mb-3">Sensors are configured in <code>config/vigilar.toml</code>. Sensor management UI coming in a future update.</p>
{% if config %}
<div class="table-responsive">
<table class="table table-dark table-sm">
<thead><tr><th>ID</th><th>Name</th><th>Type</th><th>Protocol</th><th>Location</th></tr></thead>
<tbody>
{% for s in config.sensors %}
<tr>
<td><code>{{ s.id }}</code></td>
<td>{{ s.display_name }}</td>
<td><span class="badge bg-secondary">{{ s.type }}</span></td>
<td><span class="badge bg-info">{{ s.protocol }}</span></td>
<td>{{ s.location or '—' }}</td>
</tr>
{% else %}
<tr><td colspan="5" class="text-center text-muted">No sensors configured</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
</div>
<!-- NOTIFICATIONS -->
<div class="settings-section" id="section-alerts">
<div class="card bg-dark border-secondary mb-3">
<div class="card-header"><i class="bi bi-bell me-2"></i>Push Notifications (PWA)</div>
<div class="card-body">
<p class="text-muted mb-2">Enable push notifications on this device to receive motion alerts.</p>
<button class="btn btn-outline-light" id="btn-enable-push" onclick="enablePushNotifications()">
<i class="bi bi-bell-fill me-1"></i>Enable Notifications
</button>
<span id="push-status" class="ms-2 text-muted"></span>
</div>
</div>
{% if config %}
<div class="card bg-dark border-secondary mb-3">
<div class="card-header"><i class="bi bi-envelope me-2"></i>Email Alerts</div>
<div class="card-body">
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="email-enabled"
{{ 'checked' if config.alerts.email.enabled }}>
<label class="form-check-label" for="email-enabled">Enable Email Alerts</label>
</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">SMTP Host</label>
<input type="text" class="form-control form-control-sm" id="email-smtp-host"
value="{{ config.alerts.email.smtp_host }}">
</div>
<div class="col-md-2">
<label class="form-label">SMTP Port</label>
<input type="number" class="form-control form-control-sm" id="email-smtp-port"
value="{{ config.alerts.email.smtp_port }}">
</div>
<div class="col-md-4">
<div class="form-check form-switch mt-4">
<input class="form-check-input" type="checkbox" id="email-tls"
{{ 'checked' if config.alerts.email.use_tls }}>
<label class="form-check-label" for="email-tls">Use TLS</label>
</div>
</div>
<div class="col-md-6">
<label class="form-label">From Address</label>
<input type="email" class="form-control form-control-sm" id="email-from"
value="{{ config.alerts.email.from_addr }}">
</div>
<div class="col-md-6">
<label class="form-label">To Address</label>
<input type="email" class="form-control form-control-sm" id="email-to"
value="{{ config.alerts.email.to_addr }}">
</div>
</div>
<button class="btn btn-sm btn-primary mt-3" onclick="saveAlertSettings('email')">
<i class="bi bi-check-lg me-1"></i>Save Email Settings
</button>
</div>
</div>
<div class="card bg-dark border-secondary mb-3">
<div class="card-header"><i class="bi bi-link-45deg me-2"></i>Webhook</div>
<div class="card-body">
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="webhook-enabled"
{{ 'checked' if config.alerts.webhook.enabled }}>
<label class="form-check-label" for="webhook-enabled">Enable Webhook</label>
</div>
<div class="row g-3">
<div class="col-md-8">
<label class="form-label">Webhook URL</label>
<input type="url" class="form-control form-control-sm" id="webhook-url"
value="{{ config.alerts.webhook.url }}" placeholder="https://...">
</div>
<div class="col-md-4">
<label class="form-label">HMAC Secret</label>
<input type="password" class="form-control form-control-sm" id="webhook-secret"
value="{{ config.alerts.webhook.secret }}" placeholder="Optional">
</div>
</div>
<button class="btn btn-sm btn-primary mt-3" onclick="saveAlertSettings('webhook')">
<i class="bi bi-check-lg me-1"></i>Save Webhook Settings
</button>
</div>
</div>
<div class="card bg-dark border-secondary">
<div class="card-header"><i class="bi bi-terminal me-2"></i>Local Alerts</div>
<div class="card-body">
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="local-syslog"
{{ 'checked' if config.alerts.local.syslog }}>
<label class="form-check-label" for="local-syslog">Log to syslog</label>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="local-desktop"
{{ 'checked' if config.alerts.local.desktop_notify }}>
<label class="form-check-label" for="local-desktop">Desktop notifications (libnotify)</label>
</div>
<button class="btn btn-sm btn-primary" onclick="saveAlertSettings('local')">
<i class="bi bi-check-lg me-1"></i>Save Local Settings
</button>
</div>
</div>
{% endif %}
</div>
<!-- STORAGE -->
<div class="settings-section" id="section-storage">
<div class="card bg-dark border-secondary">
<div class="card-header"><i class="bi bi-hdd me-2"></i>Storage</div>
<div class="card-body">
{% if config %}
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Max Disk Usage (GB)</label>
<input type="number" class="form-control form-control-sm" id="storage-max-disk"
value="{{ config.storage.max_disk_usage_gb }}" min="10">
<div class="setting-desc">Hard cap — oldest recordings pruned when exceeded</div>
</div>
<div class="col-md-4">
<label class="form-label">Free Space Floor (GB)</label>
<input type="number" class="form-control form-control-sm" id="storage-free-floor"
value="{{ config.storage.free_space_floor_gb }}" min="1">
<div class="setting-desc">Always keep this much free on the disk</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch mt-4">
<input class="form-check-input" type="checkbox" id="storage-encrypt"
{{ 'checked' if config.storage.encrypt_recordings }}>
<label class="form-check-label" for="storage-encrypt">Encrypt Recordings</label>
</div>
<div class="setting-desc">AES-256 encryption at rest (.vge files)</div>
</div>
</div>
<div class="row g-3 mt-2">
<div class="col-md-6">
<label class="form-label">Recordings Directory</label>
<input type="text" class="form-control form-control-sm" id="sys-recordings-dir"
value="{{ config.system.recordings_dir }}" readonly>
<div class="setting-desc">Change via config file (requires restart)</div>
</div>
<div class="col-md-6">
<label class="form-label">Data Directory</label>
<input type="text" class="form-control form-control-sm" id="sys-data-dir"
value="{{ config.system.data_dir }}" readonly>
</div>
</div>
<button class="btn btn-primary mt-3" onclick="saveStorageSettings()">
<i class="bi bi-check-lg me-1"></i>Save Storage Settings
</button>
{% endif %}
</div>
</div>
</div>
<!-- UPS -->
<div class="settings-section" id="section-ups">
<div class="card bg-dark border-secondary">
<div class="card-header"><i class="bi bi-battery-charging me-2"></i>UPS Monitoring (NUT)</div>
<div class="card-body">
{% if config %}
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="ups-enabled"
{{ 'checked' if config.ups.enabled }}>
<label class="form-check-label" for="ups-enabled">Enable UPS Monitoring</label>
</div>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">NUT Host</label>
<input type="text" class="form-control form-control-sm" id="ups-host"
value="{{ config.ups.nut_host }}">
</div>
<div class="col-md-2">
<label class="form-label">NUT Port</label>
<input type="number" class="form-control form-control-sm" id="ups-port"
value="{{ config.ups.nut_port }}">
</div>
<div class="col-md-3">
<label class="form-label">UPS Name</label>
<input type="text" class="form-control form-control-sm" id="ups-name"
value="{{ config.ups.ups_name }}">
</div>
<div class="col-md-3">
<label class="form-label">Poll Interval (sec)</label>
<input type="number" class="form-control form-control-sm" id="ups-poll"
value="{{ config.ups.poll_interval_s }}" min="5" max="300">
</div>
<div class="col-md-4">
<label class="form-label">Low Battery Threshold (%)</label>
<input type="number" class="form-control form-control-sm" id="ups-low-batt"
value="{{ config.ups.low_battery_threshold_pct }}" min="5" max="95">
<div class="setting-desc">Alert when battery drops below this</div>
</div>
<div class="col-md-4">
<label class="form-label">Critical Runtime (sec)</label>
<input type="number" class="form-control form-control-sm" id="ups-critical-runtime"
value="{{ config.ups.critical_runtime_threshold_s }}" min="60">
<div class="setting-desc">Begin shutdown when runtime falls below</div>
</div>
<div class="col-md-4">
<label class="form-label">Shutdown Delay (sec)</label>
<input type="number" class="form-control form-control-sm" id="ups-shutdown-delay"
value="{{ config.ups.shutdown_delay_s }}" min="10">
<div class="setting-desc">Grace period before system poweroff</div>
</div>
</div>
<button class="btn btn-primary mt-3" onclick="saveUPSSettings()">
<i class="bi bi-check-lg me-1"></i>Save UPS Settings
</button>
{% endif %}
</div>
</div>
</div>
<!-- REMOTE ACCESS -->
<div class="settings-section" id="section-remote">
<div class="card bg-dark border-secondary mb-3">
<div class="card-header"><i class="bi bi-globe me-2"></i>Remote Access via WireGuard Tunnel</div>
<div class="card-body">
<p class="text-muted mb-3">
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.
</p>
{% if config %}
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="remote-enabled"
{{ 'checked' if config.remote.enabled }}>
<label class="form-check-label" for="remote-enabled">Enable Remote Access Streams</label>
</div>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Upload Bandwidth (Mbps)</label>
<input type="number" class="form-control form-control-sm" id="remote-bandwidth"
value="{{ config.remote.upload_bandwidth_mbps }}" min="1" max="1000" step="0.5">
<div class="setting-desc">Your home internet upload speed</div>
</div>
<div class="col-md-4">
<label class="form-label">Remote Stream Resolution</label>
<select class="form-select form-select-sm" id="remote-resolution">
<option value="[640,360]" {{ 'selected' if config.remote.remote_hls_resolution == [640,360] }}>640x360 (better quality)</option>
<option value="[426,240]" {{ 'selected' if config.remote.remote_hls_resolution == [426,240] }}>426x240 (recommended)</option>
<option value="[320,180]" {{ 'selected' if config.remote.remote_hls_resolution == [320,180] }}>320x180 (low bandwidth)</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Remote Stream FPS</label>
<input type="number" class="form-control form-control-sm" id="remote-fps"
value="{{ config.remote.remote_hls_fps }}" min="1" max="30">
</div>
<div class="col-md-4">
<label class="form-label">Bitrate (kbps)</label>
<input type="number" class="form-control form-control-sm" id="remote-bitrate"
value="{{ config.remote.remote_hls_bitrate_kbps }}" min="100" max="5000" step="50">
<div class="setting-desc">Per-camera bitrate for remote stream</div>
</div>
<div class="col-md-4">
<label class="form-label">Max Remote Viewers</label>
<input type="number" class="form-control form-control-sm" id="remote-max-viewers"
value="{{ config.remote.max_remote_viewers }}" min="0" max="20">
<div class="setting-desc">0 = unlimited</div>
</div>
<div class="col-md-4">
<label class="form-label">WireGuard Tunnel IP</label>
<input type="text" class="form-control form-control-sm" id="remote-tunnel-ip"
value="{{ config.remote.tunnel_ip }}">
</div>
</div>
<!-- Bandwidth estimate -->
<div class="alert alert-info mt-3 py-2 small" id="bandwidth-estimate">
<i class="bi bi-speedometer me-1"></i>
<strong>Estimated bandwidth per viewer:</strong>
<span id="bw-per-viewer">
{{ (config.remote.remote_hls_bitrate_kbps * cameras|length / 1000)|round(1) }} Mbps
</span>
({{ cameras|length }} cameras x {{ config.remote.remote_hls_bitrate_kbps }} kbps)
&bull;
<strong>Max concurrent:</strong>
~{{ (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
</div>
<button class="btn btn-primary mt-2" onclick="saveRemoteSettings()">
<i class="bi bi-check-lg me-1"></i>Save Remote Settings
</button>
{% endif %}
</div>
</div>
<div class="card bg-dark border-secondary">
<div class="card-header"><i class="bi bi-tools me-2"></i>Setup Instructions</div>
<div class="card-body">
<ol class="mb-0">
<li class="mb-2">On both home server and DO droplet, run: <code>remote/wireguard/setup_wireguard.sh</code></li>
<li class="mb-2">Exchange public keys between home server and droplet</li>
<li class="mb-2">On the droplet, install nginx and copy <code>remote/nginx/vigilar.conf</code> to <code>/etc/nginx/sites-available/</code></li>
<li class="mb-2">Run <code>certbot --nginx -d vigilar.yourdomain.com</code> for TLS</li>
<li class="mb-2">Verify: <code>ping 10.99.0.2</code> from droplet should reach home server</li>
<li>Access your system at <code>https://vigilar.yourdomain.com</code></li>
</ol>
</div>
</div>
</div>
<!-- NETWORK -->
<div class="settings-section" id="section-network">
<div class="card bg-dark border-secondary mb-3">
<div class="card-header"><i class="bi bi-wifi me-2"></i>Web Server</div>
<div class="card-body">
{% if config %}
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Bind Address</label>
<input type="text" class="form-control form-control-sm" id="web-host"
value="{{ config.web.host }}">
</div>
<div class="col-md-3">
<label class="form-label">Port</label>
<input type="number" class="form-control form-control-sm" id="web-port"
value="{{ config.web.port }}">
</div>
<div class="col-md-3">
<label class="form-label">Session Timeout (sec)</label>
<input type="number" class="form-control form-control-sm" id="web-session"
value="{{ config.web.session_timeout }}" min="300">
</div>
<div class="col-md-2">
<label class="form-label">Username</label>
<input type="text" class="form-control form-control-sm" id="web-username"
value="{{ config.web.username }}">
</div>
</div>
<div class="alert alert-warning mt-3 py-2 small">
<i class="bi bi-exclamation-triangle me-1"></i>
Port and bind address changes require a restart to take effect.
</div>
<button class="btn btn-primary" onclick="saveWebSettings()">
<i class="bi bi-check-lg me-1"></i>Save Web Settings
</button>
{% endif %}
</div>
</div>
<div class="card bg-dark border-secondary">
<div class="card-header"><i class="bi bi-diagram-3 me-2"></i>MQTT Broker</div>
<div class="card-body">
{% if config %}
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">MQTT Host</label>
<input type="text" class="form-control form-control-sm" id="mqtt-host"
value="{{ config.mqtt.host }}">
</div>
<div class="col-md-3">
<label class="form-label">MQTT Port</label>
<input type="number" class="form-control form-control-sm" id="mqtt-port"
value="{{ config.mqtt.port }}">
</div>
</div>
<div class="alert alert-warning mt-3 py-2 small">
<i class="bi bi-exclamation-triangle me-1"></i>
MQTT changes require a full system restart.
</div>
<button class="btn btn-primary mt-1" onclick="saveMQTTSettings()">
<i class="bi bi-check-lg me-1"></i>Save MQTT Settings
</button>
{% endif %}
</div>
</div>
</div>
<!-- SYSTEM -->
<div class="settings-section" id="section-system">
<div class="card bg-dark border-secondary mb-3">
<div class="card-header"><i class="bi bi-cpu me-2"></i>System</div>
<div class="card-body">
{% if config %}
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">System Name</label>
<input type="text" class="form-control form-control-sm" id="sys-name"
value="{{ config.system.name }}">
</div>
<div class="col-md-3">
<label class="form-label">Timezone</label>
<input type="text" class="form-control form-control-sm" id="sys-timezone"
value="{{ config.system.timezone }}">
</div>
<div class="col-md-3">
<label class="form-label">Log Level</label>
<select class="form-select form-select-sm" id="sys-log-level">
{% for level in ['DEBUG', 'INFO', 'WARNING', 'ERROR'] %}
<option value="{{ level }}" {{ 'selected' if config.system.log_level == level }}>{{ level }}</option>
{% endfor %}
</select>
</div>
</div>
<button class="btn btn-primary mt-3" onclick="saveSystemSettings()">
<i class="bi bi-check-lg me-1"></i>Save System Settings
</button>
{% endif %}
</div>
</div>
<div class="card bg-dark border-secondary">
<div class="card-header"><i class="bi bi-info-circle me-2"></i>System Info</div>
<div class="card-body">
<table class="table table-dark table-sm mb-0">
<tr><td class="text-muted" style="width:200px">Version</td><td>0.1.0</td></tr>
<tr><td class="text-muted">Cameras</td><td>{{ cameras|length }}</td></tr>
{% if config %}
<tr><td class="text-muted">Sensors</td><td>{{ config.sensors|length }}</td></tr>
<tr><td class="text-muted">Rules</td><td>{{ config.rules|length }}</td></tr>
<tr><td class="text-muted">Data Dir</td><td><code>{{ config.system.data_dir }}</code></td></tr>
<tr><td class="text-muted">Recordings Dir</td><td><code>{{ config.system.recordings_dir }}</code></td></tr>
<tr><td class="text-muted">HLS Dir</td><td><code>{{ config.system.hls_dir }}</code></td></tr>
<tr><td class="text-muted">Encryption</td><td>{{ 'Enabled' if config.storage.encrypt_recordings else 'Disabled' }}</td></tr>
<tr><td class="text-muted">UPS</td><td>{{ 'Enabled' if config.ups.enabled else 'Disabled' }}</td></tr>
{% endif %}
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/settings.js') }}"></script>
{% endblock %}