vigilar/vigilar/config.py
Aaron D. Lee d69bf6d6af feat(Q1,Q4): add HighlightsConfig, KioskConfig, HIGHLIGHT/TIMELAPSE triggers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 19:06:08 -04:00

439 lines
13 KiB
Python

"""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,
CameraLocation,
)
# --- 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])
zones: list["CameraZone"] = Field(default_factory=list)
location: CameraLocation = CameraLocation.INTERIOR
# --- 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)
sleep_start: str = "23:00"
sleep_end: str = "06:00"
profiles: list["AlertProfileConfig"] = Field(default_factory=list)
# --- 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"
# --- Detection Config ---
class CameraZone(BaseModel):
name: str
region: list[int] = Field(default_factory=list) # [x, y, w, h]
watch_for: list[str] = Field(default_factory=lambda: ["person", "vehicle"])
alert_unknown_vehicles: bool = False
class DetectionConfig(BaseModel):
person_detection: bool = False
model_path: str = "/var/vigilar/models/mobilenet_ssd_v2.pb"
model_config_path: str = "/var/vigilar/models/mobilenet_ssd_v2.pbtxt"
confidence_threshold: float = 0.5
cameras: list[str] = Field(default_factory=list) # empty = all
class KnownVehicle(BaseModel):
name: str
color_profile: str = "" # white, black, silver, red, blue, etc.
size_class: str = "" # compact, midsize, large
calibration_file: str = ""
class VehicleConfig(BaseModel):
known: list[KnownVehicle] = Field(default_factory=list)
# --- Presence Config ---
class PresenceMember(BaseModel):
name: str
ip: str
role: str = "adult" # adult | child
class PresenceConfig(BaseModel):
enabled: bool = False
ping_interval_s: int = 30
departure_delay_m: int = 10
method: str = "icmp" # icmp | arping
members: list[PresenceMember] = Field(default_factory=list)
actions: dict[str, str] = Field(default_factory=lambda: {
"EMPTY": "ARMED_AWAY",
"ADULTS_HOME": "DISARMED",
"KIDS_HOME": "ARMED_HOME",
"ALL_HOME": "DISARMED",
})
# --- Alert Profile Config ---
class AlertProfileRule(BaseModel):
detection_type: str # person, unknown_vehicle, known_vehicle, motion
camera_location: str = "any" # any | exterior | common_area | specific camera id
action: str = "record_only" # push_and_record, push_adults, record_only, quiet_log
recipients: str = "all" # all, adults, none
class AlertProfileConfig(BaseModel):
name: str
enabled: bool = True
presence_states: list[str] = Field(default_factory=list)
time_window: str = "" # "" = all day, "23:00-06:00" = sleep hours
rules: list[AlertProfileRule] = Field(default_factory=list)
# --- Health Config ---
class HealthConfig(BaseModel):
enabled: bool = True
disk_warn_pct: int = 85
disk_critical_pct: int = 95
auto_prune: bool = True
auto_prune_target_pct: int = 80
daily_digest: bool = True
daily_digest_time: str = "08:00"
# --- Pet Detection Config ---
class WildlifeThreatMap(BaseModel):
predator: list[str] = Field(default_factory=lambda: ["bear"])
nuisance: list[str] = Field(default_factory=list)
passive: list[str] = Field(default_factory=lambda: ["bird", "horse", "cow", "sheep"])
class WildlifeSizeHeuristics(BaseModel):
small: float = 0.02 # < 2% of frame → nuisance
medium: float = 0.08 # 2-8% → predator
large: float = 0.15 # > 8% → passive (deer-sized)
class WildlifeConfig(BaseModel):
threat_map: WildlifeThreatMap = Field(default_factory=WildlifeThreatMap)
size_heuristics: WildlifeSizeHeuristics = Field(default_factory=WildlifeSizeHeuristics)
class PetActivityConfig(BaseModel):
daily_digest: bool = True
highlight_clips: bool = True
zoomie_threshold: float = 0.8
class PetsConfig(BaseModel):
enabled: bool = False
model: str = "yolov8s"
model_path: str = "/var/vigilar/models/yolov8s.pt"
confidence_threshold: float = 0.5
pet_id_enabled: bool = True
pet_id_model_path: str = "/var/vigilar/models/pet_id.pt"
pet_id_threshold: float = 0.7
pet_id_low_confidence: float = 0.5
training_dir: str = "/var/vigilar/pets/training"
crop_staging_dir: str = "/var/vigilar/pets/staging"
crop_retention_days: int = 7
min_training_images: int = 20
wildlife: WildlifeConfig = Field(default_factory=WildlifeConfig)
activity: PetActivityConfig = Field(default_factory=PetActivityConfig)
max_pets: int = 8
max_rules_per_pet: int = 32
rule_eval_interval_s: int = 60
# --- Visitors Config ---
class VisitorsConfig(BaseModel):
enabled: bool = False
match_threshold: float = 0.6
cameras: list[str] = Field(default_factory=list)
unknown_alert_threshold: int = 3
departure_timeout_s: int = 300
max_embeddings_per_profile: int = 10
face_crop_dir: str = "/var/vigilar/faces"
# --- Highlights Config ---
class HighlightsConfig(BaseModel):
enabled: bool = True
generate_time: str = "06:00"
max_clips: int = 10
clip_duration_s: int = 5
cameras: list[str] = Field(default_factory=list)
event_types: list[str] = Field(default_factory=list)
class KioskConfig(BaseModel):
ambient_enabled: bool = True
camera_rotation_s: int = 10
alert_timeout_s: int = 30
predator_alert_timeout_s: int = 60
dim_start: str = "23:00"
dim_end: str = "06:00"
highlight_play_time: str = "07:00"
# --- Location Config ---
class LocationConfig(BaseModel):
latitude: float = 0.0
longitude: float = 0.0
# --- Security Config ---
class SecurityConfig(BaseModel):
pin_hash: str = ""
recovery_passphrase_hash: str = ""
# --- 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)
presence: PresenceConfig = Field(default_factory=PresenceConfig)
detection: DetectionConfig = Field(default_factory=DetectionConfig)
vehicles: VehicleConfig = Field(default_factory=VehicleConfig)
health: HealthConfig = Field(default_factory=HealthConfig)
pets: PetsConfig = Field(default_factory=PetsConfig)
visitors: VisitorsConfig = Field(default_factory=VisitorsConfig)
highlights: HighlightsConfig = Field(default_factory=HighlightsConfig)
kiosk: KioskConfig = Field(default_factory=KioskConfig)
security: SecurityConfig = Field(default_factory=SecurityConfig)
location: LocationConfig = Field(default_factory=LocationConfig)
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
# Resolve forward references
CameraConfig.model_rebuild()
AlertsConfig.model_rebuild()
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)