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:
commit
845a85d618
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal 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
39
CLAUDE.md
Normal 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
165
config/vigilar.toml
Normal 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
|
||||
711
docs/camera-hardware-guide.md
Normal file
711
docs/camera-hardware-guide.md
Normal 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
61
pyproject.toml
Normal 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
165
remote/nginx/vigilar.conf
Normal 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
187
remote/setup_droplet.sh
Executable 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 "============================================"
|
||||
121
remote/wireguard/setup_wireguard.sh
Executable file
121
remote/wireguard/setup_wireguard.sh
Executable 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)"
|
||||
18
remote/wireguard/wg0-droplet.conf
Normal file
18
remote/wireguard/wg0-droplet.conf
Normal 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
|
||||
21
remote/wireguard/wg0-home.conf
Normal file
21
remote/wireguard/wg0-home.conf
Normal 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
6
requirements-dev.txt
Normal 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
13
requirements.txt
Normal 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
35
tests/conftest.py
Normal 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
35
tests/unit/test_config.py
Normal 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)
|
||||
62
tests/unit/test_config_writer.py
Normal file
62
tests/unit/test_config_writer.py
Normal 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
63
tests/unit/test_motion.py
Normal 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
|
||||
74
tests/unit/test_ring_buffer.py
Normal file
74
tests/unit/test_ring_buffer.py
Normal 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
21
tests/unit/test_schema.py
Normal 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
104
tests/unit/test_web.py
Normal 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
0
vigilar/__init__.py
Normal file
0
vigilar/alerts/__init__.py
Normal file
0
vigilar/alerts/__init__.py
Normal file
116
vigilar/bus.py
Normal file
116
vigilar/bus.py
Normal 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)
|
||||
0
vigilar/camera/__init__.py
Normal file
0
vigilar/camera/__init__.py
Normal file
245
vigilar/camera/hls.py
Normal file
245
vigilar/camera/hls.py
Normal 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
117
vigilar/camera/manager.py
Normal 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
147
vigilar/camera/motion.py
Normal 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
228
vigilar/camera/recorder.py
Normal 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
|
||||
85
vigilar/camera/ring_buffer.py
Normal file
85
vigilar/camera/ring_buffer.py
Normal 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
267
vigilar/camera/worker.py
Normal 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
0
vigilar/cli/__init__.py
Normal file
95
vigilar/cli/cmd_config.py
Normal file
95
vigilar/cli/cmd_config.py
Normal 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
45
vigilar/cli/cmd_start.py
Normal 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
21
vigilar/cli/main.py
Normal 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
251
vigilar/config.py
Normal 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
226
vigilar/config_writer.py
Normal 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
153
vigilar/constants.py
Normal 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
|
||||
0
vigilar/events/__init__.py
Normal file
0
vigilar/events/__init__.py
Normal file
143
vigilar/main.py
Normal file
143
vigilar/main.py
Normal 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")
|
||||
0
vigilar/sensors/__init__.py
Normal file
0
vigilar/sensors/__init__.py
Normal file
0
vigilar/storage/__init__.py
Normal file
0
vigilar/storage/__init__.py
Normal file
53
vigilar/storage/db.py
Normal file
53
vigilar/storage/db.py
Normal 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
248
vigilar/storage/queries.py
Normal 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
123
vigilar/storage/schema.py
Normal 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
0
vigilar/ups/__init__.py
Normal file
0
vigilar/web/__init__.py
Normal file
0
vigilar/web/__init__.py
Normal file
47
vigilar/web/app.py
Normal file
47
vigilar/web/app.py
Normal 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
|
||||
0
vigilar/web/blueprints/__init__.py
Normal file
0
vigilar/web/blueprints/__init__.py
Normal file
87
vigilar/web/blueprints/cameras.py
Normal file
87
vigilar/web/blueprints/cameras.py
Normal 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
|
||||
])
|
||||
31
vigilar/web/blueprints/events.py
Normal file
31
vigilar/web/blueprints/events.py
Normal 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")
|
||||
12
vigilar/web/blueprints/kiosk.py
Normal file
12
vigilar/web/blueprints/kiosk.py
Normal 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)
|
||||
35
vigilar/web/blueprints/recordings.py
Normal file
35
vigilar/web/blueprints/recordings.py
Normal 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})
|
||||
31
vigilar/web/blueprints/sensors.py
Normal file
31
vigilar/web/blueprints/sensors.py
Normal 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
|
||||
])
|
||||
347
vigilar/web/blueprints/system.py
Normal file
347
vigilar/web/blueprints/system.py
Normal 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
|
||||
134
vigilar/web/static/css/vigilar.css
Normal file
134
vigilar/web/static/css/vigilar.css
Normal 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); }
|
||||
}
|
||||
112
vigilar/web/static/js/app.js
Normal file
112
vigilar/web/static/js/app.js
Normal 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(() => {});
|
||||
}
|
||||
70
vigilar/web/static/js/grid.js
Normal file
70
vigilar/web/static/js/grid.js
Normal 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
4
vigilar/web/static/js/hls.min.js
vendored
Normal 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;
|
||||
24
vigilar/web/static/js/push.js
Normal file
24
vigilar/web/static/js/push.js
Normal 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();
|
||||
})();
|
||||
274
vigilar/web/static/js/settings.js
Normal file
274
vigilar/web/static/js/settings.js
Normal 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)));
|
||||
}
|
||||
21
vigilar/web/static/manifest.json
Normal file
21
vigilar/web/static/manifest.json
Normal 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
95
vigilar/web/static/sw.js
Normal 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);
|
||||
})
|
||||
);
|
||||
});
|
||||
71
vigilar/web/templates/base.html
Normal file
71
vigilar/web/templates/base.html
Normal 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>
|
||||
56
vigilar/web/templates/cameras.html
Normal file
56
vigilar/web/templates/cameras.html
Normal 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 %}
|
||||
69
vigilar/web/templates/events.html
Normal file
69
vigilar/web/templates/events.html
Normal 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 %}
|
||||
147
vigilar/web/templates/index.html
Normal file
147
vigilar/web/templates/index.html
Normal 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 %}
|
||||
108
vigilar/web/templates/kiosk.html
Normal file
108
vigilar/web/templates/kiosk.html
Normal 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>
|
||||
50
vigilar/web/templates/recordings.html
Normal file
50
vigilar/web/templates/recordings.html
Normal 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 %}
|
||||
39
vigilar/web/templates/sensors.html
Normal file
39
vigilar/web/templates/sensors.html
Normal 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 %}
|
||||
683
vigilar/web/templates/settings.html
Normal file
683
vigilar/web/templates/settings.html
Normal 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)
|
||||
•
|
||||
<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 %}
|
||||
Loading…
Reference in New Issue
Block a user