- CameraConfig.location now uses CameraLocation enum (Pydantic v2 coerces TOML strings) - Wildlife classifier returns ThreatLevel enum values with correct return type annotation - Model backup path fixed: pet_id_backup.pt instead of pet_id.backup.pt - Dashboard submitLabel JS now posts to /pets/<sighting_id>/label matching Flask route - Pet status API computes status field (safe/unknown) based on last-seen recency - digest.py comment explains timestamp unit difference between tables Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
384 lines
12 KiB
Python
384 lines
12 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)
|
|
|
|
|
|
# --- 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)
|
|
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)
|