homesec 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- homesec/__init__.py +20 -0
- homesec/app.py +393 -0
- homesec/cli.py +159 -0
- homesec/config/__init__.py +18 -0
- homesec/config/loader.py +109 -0
- homesec/config/validation.py +82 -0
- homesec/errors.py +71 -0
- homesec/health/__init__.py +5 -0
- homesec/health/server.py +226 -0
- homesec/interfaces.py +249 -0
- homesec/logging_setup.py +176 -0
- homesec/maintenance/__init__.py +1 -0
- homesec/maintenance/cleanup_clips.py +632 -0
- homesec/models/__init__.py +79 -0
- homesec/models/alert.py +32 -0
- homesec/models/clip.py +71 -0
- homesec/models/config.py +362 -0
- homesec/models/events.py +184 -0
- homesec/models/filter.py +62 -0
- homesec/models/source.py +77 -0
- homesec/models/storage.py +12 -0
- homesec/models/vlm.py +99 -0
- homesec/pipeline/__init__.py +6 -0
- homesec/pipeline/alert_policy.py +5 -0
- homesec/pipeline/core.py +639 -0
- homesec/plugins/__init__.py +62 -0
- homesec/plugins/alert_policies/__init__.py +80 -0
- homesec/plugins/alert_policies/default.py +111 -0
- homesec/plugins/alert_policies/noop.py +60 -0
- homesec/plugins/analyzers/__init__.py +126 -0
- homesec/plugins/analyzers/openai.py +446 -0
- homesec/plugins/filters/__init__.py +124 -0
- homesec/plugins/filters/yolo.py +317 -0
- homesec/plugins/notifiers/__init__.py +80 -0
- homesec/plugins/notifiers/mqtt.py +189 -0
- homesec/plugins/notifiers/multiplex.py +106 -0
- homesec/plugins/notifiers/sendgrid_email.py +228 -0
- homesec/plugins/storage/__init__.py +116 -0
- homesec/plugins/storage/dropbox.py +272 -0
- homesec/plugins/storage/local.py +108 -0
- homesec/plugins/utils.py +63 -0
- homesec/py.typed +0 -0
- homesec/repository/__init__.py +5 -0
- homesec/repository/clip_repository.py +552 -0
- homesec/sources/__init__.py +17 -0
- homesec/sources/base.py +224 -0
- homesec/sources/ftp.py +209 -0
- homesec/sources/local_folder.py +238 -0
- homesec/sources/rtsp.py +1251 -0
- homesec/state/__init__.py +10 -0
- homesec/state/postgres.py +501 -0
- homesec/storage_paths.py +46 -0
- homesec/telemetry/__init__.py +0 -0
- homesec/telemetry/db/__init__.py +1 -0
- homesec/telemetry/db/log_table.py +16 -0
- homesec/telemetry/db_log_handler.py +246 -0
- homesec/telemetry/postgres_settings.py +42 -0
- homesec-0.1.0.dist-info/METADATA +446 -0
- homesec-0.1.0.dist-info/RECORD +62 -0
- homesec-0.1.0.dist-info/WHEEL +4 -0
- homesec-0.1.0.dist-info/entry_points.txt +2 -0
- homesec-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""HomeSec data models."""
|
|
2
|
+
|
|
3
|
+
from homesec.models.alert import Alert, AlertDecision
|
|
4
|
+
from homesec.models.clip import Clip, ClipStateData, _resolve_forward_refs
|
|
5
|
+
from homesec.models.config import (
|
|
6
|
+
AlertPolicyConfig,
|
|
7
|
+
AlertPolicyOverrides,
|
|
8
|
+
DefaultAlertPolicySettings,
|
|
9
|
+
CameraConfig,
|
|
10
|
+
CameraSourceConfig,
|
|
11
|
+
ConcurrencyConfig,
|
|
12
|
+
Config,
|
|
13
|
+
DropboxStorageConfig,
|
|
14
|
+
HealthConfig,
|
|
15
|
+
LocalStorageConfig,
|
|
16
|
+
MQTTAuthConfig,
|
|
17
|
+
MQTTConfig,
|
|
18
|
+
NotifierConfig,
|
|
19
|
+
RetentionConfig,
|
|
20
|
+
RetryConfig,
|
|
21
|
+
SendGridEmailConfig,
|
|
22
|
+
StateStoreConfig,
|
|
23
|
+
StorageConfig,
|
|
24
|
+
StoragePathsConfig,
|
|
25
|
+
)
|
|
26
|
+
from homesec.models.filter import FilterConfig, FilterOverrides, FilterResult, YoloFilterSettings
|
|
27
|
+
from homesec.models.source import FtpSourceConfig, LocalFolderSourceConfig, RTSPSourceConfig
|
|
28
|
+
from homesec.models.vlm import (
|
|
29
|
+
AnalysisResult,
|
|
30
|
+
EntityTimeline,
|
|
31
|
+
OpenAILLMConfig,
|
|
32
|
+
RiskLevel,
|
|
33
|
+
SequenceAnalysis,
|
|
34
|
+
VLMConfig,
|
|
35
|
+
VLMPreprocessConfig,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Resolve forward references in ClipStateData
|
|
39
|
+
_resolve_forward_refs()
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"Alert",
|
|
43
|
+
"AlertDecision",
|
|
44
|
+
"AlertPolicyConfig",
|
|
45
|
+
"AlertPolicyOverrides",
|
|
46
|
+
"DefaultAlertPolicySettings",
|
|
47
|
+
"AnalysisResult",
|
|
48
|
+
"CameraConfig",
|
|
49
|
+
"CameraSourceConfig",
|
|
50
|
+
"Clip",
|
|
51
|
+
"ClipStateData",
|
|
52
|
+
"ConcurrencyConfig",
|
|
53
|
+
"Config",
|
|
54
|
+
"DropboxStorageConfig",
|
|
55
|
+
"EntityTimeline",
|
|
56
|
+
"FilterConfig",
|
|
57
|
+
"FilterOverrides",
|
|
58
|
+
"FtpSourceConfig",
|
|
59
|
+
"FilterResult",
|
|
60
|
+
"HealthConfig",
|
|
61
|
+
"LocalStorageConfig",
|
|
62
|
+
"LocalFolderSourceConfig",
|
|
63
|
+
"MQTTAuthConfig",
|
|
64
|
+
"MQTTConfig",
|
|
65
|
+
"NotifierConfig",
|
|
66
|
+
"OpenAILLMConfig",
|
|
67
|
+
"RTSPSourceConfig",
|
|
68
|
+
"SendGridEmailConfig",
|
|
69
|
+
"RetentionConfig",
|
|
70
|
+
"RetryConfig",
|
|
71
|
+
"RiskLevel",
|
|
72
|
+
"SequenceAnalysis",
|
|
73
|
+
"StateStoreConfig",
|
|
74
|
+
"StorageConfig",
|
|
75
|
+
"StoragePathsConfig",
|
|
76
|
+
"VLMConfig",
|
|
77
|
+
"VLMPreprocessConfig",
|
|
78
|
+
"YoloFilterSettings",
|
|
79
|
+
]
|
homesec/models/alert.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Alert decision and notification payload models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from homesec.models.vlm import RiskLevel, SequenceAnalysis
|
|
9
|
+
|
|
10
|
+
class AlertDecision(BaseModel):
|
|
11
|
+
"""Decision whether to send an alert for a clip."""
|
|
12
|
+
|
|
13
|
+
notify: bool
|
|
14
|
+
notify_reason: str # e.g., "risk_level=high" or "activity_type=delivery (per-camera)"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Alert(BaseModel):
|
|
18
|
+
"""MQTT notification payload."""
|
|
19
|
+
|
|
20
|
+
clip_id: str
|
|
21
|
+
camera_name: str
|
|
22
|
+
storage_uri: str | None
|
|
23
|
+
view_url: str | None
|
|
24
|
+
risk_level: RiskLevel | None # None if VLM skipped
|
|
25
|
+
activity_type: str | None
|
|
26
|
+
notify_reason: str
|
|
27
|
+
summary: str | None
|
|
28
|
+
analysis: SequenceAnalysis | None = None
|
|
29
|
+
ts: datetime
|
|
30
|
+
dedupe_key: str # Same as clip_id for MVP
|
|
31
|
+
upload_failed: bool # True if storage_uri is None due to upload failure
|
|
32
|
+
vlm_failed: bool = False # True if VLM analysis failed but alert sent anyway
|
homesec/models/clip.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Core clip-centric data models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from homesec.models.alert import AlertDecision
|
|
13
|
+
from homesec.models.filter import FilterResult
|
|
14
|
+
from homesec.models.vlm import AnalysisResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Clip(BaseModel):
|
|
18
|
+
"""Represents a finalized video clip ready for processing."""
|
|
19
|
+
|
|
20
|
+
clip_id: str
|
|
21
|
+
camera_name: str
|
|
22
|
+
local_path: Path
|
|
23
|
+
start_ts: datetime
|
|
24
|
+
end_ts: datetime
|
|
25
|
+
duration_s: float
|
|
26
|
+
source_type: str # "rtsp", "ftp", etc.
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ClipStateData(BaseModel):
|
|
30
|
+
"""Lightweight snapshot of current clip state (stored in clip_states.data JSONB)."""
|
|
31
|
+
|
|
32
|
+
schema_version: int = 1
|
|
33
|
+
camera_name: str
|
|
34
|
+
|
|
35
|
+
# High-level status for queries
|
|
36
|
+
status: Literal["queued_local", "uploaded", "analyzed", "done", "error", "deleted"]
|
|
37
|
+
|
|
38
|
+
# Pointers
|
|
39
|
+
local_path: str
|
|
40
|
+
storage_uri: str | None = None
|
|
41
|
+
view_url: str | None = None
|
|
42
|
+
|
|
43
|
+
# Latest results (denormalized for fast access)
|
|
44
|
+
filter_result: FilterResult | None = None
|
|
45
|
+
analysis_result: AnalysisResult | None = None
|
|
46
|
+
alert_decision: AlertDecision | None = None
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def upload_completed(self) -> bool:
|
|
50
|
+
"""Check if upload stage completed."""
|
|
51
|
+
return self.storage_uri is not None
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def filter_completed(self) -> bool:
|
|
55
|
+
"""Check if filter stage completed."""
|
|
56
|
+
return self.filter_result is not None
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def vlm_completed(self) -> bool:
|
|
60
|
+
"""Check if VLM stage completed."""
|
|
61
|
+
return self.analysis_result is not None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Resolve forward references after imports are available
|
|
65
|
+
def _resolve_forward_refs() -> None:
|
|
66
|
+
"""Resolve forward references in ClipStateData."""
|
|
67
|
+
from homesec.models.alert import AlertDecision
|
|
68
|
+
from homesec.models.filter import FilterResult
|
|
69
|
+
from homesec.models.vlm import AnalysisResult
|
|
70
|
+
|
|
71
|
+
ClipStateData.model_rebuild()
|
homesec/models/config.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Configuration models with per-camera override support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal, TypeVar
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field, model_validator
|
|
8
|
+
|
|
9
|
+
from homesec.models.filter import FilterConfig
|
|
10
|
+
from homesec.models.source import FtpSourceConfig, LocalFolderSourceConfig, RTSPSourceConfig
|
|
11
|
+
from homesec.models.vlm import (
|
|
12
|
+
OpenAILLMConfig,
|
|
13
|
+
RiskLevel,
|
|
14
|
+
VLMConfig,
|
|
15
|
+
VLMPreprocessConfig,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AlertPolicyOverrides(BaseModel):
|
|
20
|
+
"""Per-camera alert policy overrides (only non-None fields override base)."""
|
|
21
|
+
|
|
22
|
+
min_risk_level: RiskLevel | None = None
|
|
23
|
+
notify_on_activity_types: list[str] | None = None
|
|
24
|
+
notify_on_motion: bool | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DefaultAlertPolicySettings(BaseModel):
|
|
28
|
+
"""Default alert policy settings."""
|
|
29
|
+
|
|
30
|
+
min_risk_level: RiskLevel = "medium"
|
|
31
|
+
notify_on_activity_types: list[str] = Field(default_factory=list)
|
|
32
|
+
notify_on_motion: bool = False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AlertPolicyConfig(BaseModel):
|
|
36
|
+
"""Alert policy plugin configuration."""
|
|
37
|
+
|
|
38
|
+
backend: str = "default"
|
|
39
|
+
enabled: bool = True
|
|
40
|
+
config: dict[str, object] = Field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
@model_validator(mode="after")
|
|
43
|
+
def _validate_alert_policy(self) -> "AlertPolicyConfig":
|
|
44
|
+
if self.backend == "default":
|
|
45
|
+
DefaultAlertPolicySettings.model_validate(self.config)
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DropboxStorageConfig(BaseModel):
|
|
50
|
+
"""Dropbox storage configuration."""
|
|
51
|
+
|
|
52
|
+
root: str
|
|
53
|
+
token_env: str = "DROPBOX_TOKEN"
|
|
54
|
+
app_key_env: str = "DROPBOX_APP_KEY"
|
|
55
|
+
app_secret_env: str = "DROPBOX_APP_SECRET"
|
|
56
|
+
refresh_token_env: str = "DROPBOX_REFRESH_TOKEN"
|
|
57
|
+
web_url_prefix: str = "https://www.dropbox.com/home"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class LocalStorageConfig(BaseModel):
|
|
61
|
+
"""Local storage configuration."""
|
|
62
|
+
|
|
63
|
+
root: str = "./storage"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class StoragePathsConfig(BaseModel):
|
|
67
|
+
"""Logical storage paths for different artifact types."""
|
|
68
|
+
|
|
69
|
+
clips_dir: str = "clips"
|
|
70
|
+
backups_dir: str = "backups"
|
|
71
|
+
artifacts_dir: str = "artifacts"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class StorageConfig(BaseModel):
|
|
75
|
+
"""Storage backend configuration.
|
|
76
|
+
|
|
77
|
+
Note: Backend names are validated against the registry at runtime via
|
|
78
|
+
validate_plugin_names(). This allows third-party storage plugins via entry points.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
model_config = {"extra": "allow"} # Allow third-party backend configs
|
|
82
|
+
|
|
83
|
+
backend: str = "dropbox"
|
|
84
|
+
dropbox: DropboxStorageConfig | None = None
|
|
85
|
+
local: LocalStorageConfig | None = None
|
|
86
|
+
paths: StoragePathsConfig = Field(default_factory=StoragePathsConfig)
|
|
87
|
+
|
|
88
|
+
@model_validator(mode="after")
|
|
89
|
+
def _validate_builtin_backends(self) -> "StorageConfig":
|
|
90
|
+
"""Validate that built-in backends have their required config.
|
|
91
|
+
|
|
92
|
+
Third-party backends are validated later in create_storage().
|
|
93
|
+
"""
|
|
94
|
+
match self.backend:
|
|
95
|
+
case "dropbox":
|
|
96
|
+
if self.dropbox is None:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
"storage.dropbox is required when backend=dropbox. "
|
|
99
|
+
"Add 'storage.dropbox' section to your config."
|
|
100
|
+
)
|
|
101
|
+
case "local":
|
|
102
|
+
if self.local is None:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
"storage.local is required when backend=local. "
|
|
105
|
+
"Add 'storage.local' section to your config."
|
|
106
|
+
)
|
|
107
|
+
case _:
|
|
108
|
+
# Third-party backend - validation happens in create_storage()
|
|
109
|
+
pass
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class StateStoreConfig(BaseModel):
|
|
114
|
+
"""State store configuration."""
|
|
115
|
+
|
|
116
|
+
dsn_env: str | None = None
|
|
117
|
+
dsn: str | None = None
|
|
118
|
+
|
|
119
|
+
@model_validator(mode="after")
|
|
120
|
+
def _validate_backend(self) -> "StateStoreConfig":
|
|
121
|
+
if not (self.dsn_env or self.dsn):
|
|
122
|
+
raise ValueError("state_store.dsn_env or state_store.dsn required for postgres")
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class MQTTAuthConfig(BaseModel):
|
|
127
|
+
"""MQTT auth configuration using env var names."""
|
|
128
|
+
|
|
129
|
+
username_env: str | None = None
|
|
130
|
+
password_env: str | None = None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class MQTTConfig(BaseModel):
|
|
134
|
+
"""MQTT notifier configuration."""
|
|
135
|
+
|
|
136
|
+
host: str
|
|
137
|
+
port: int = 1883
|
|
138
|
+
auth: MQTTAuthConfig | None = None
|
|
139
|
+
topic_template: str = "homecam/alerts/{camera_name}"
|
|
140
|
+
qos: int = 1
|
|
141
|
+
retain: bool = False
|
|
142
|
+
connection_timeout: float = 10.0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class SendGridEmailConfig(BaseModel):
|
|
146
|
+
"""SendGrid email notifier configuration."""
|
|
147
|
+
|
|
148
|
+
api_key_env: str = "SENDGRID_API_KEY"
|
|
149
|
+
from_email: str
|
|
150
|
+
from_name: str | None = None
|
|
151
|
+
to_emails: list[str] = Field(min_length=1)
|
|
152
|
+
cc_emails: list[str] = Field(default_factory=list)
|
|
153
|
+
bcc_emails: list[str] = Field(default_factory=list)
|
|
154
|
+
subject_template: str = "[HomeSec] {camera_name}: {activity_type} ({risk_level})"
|
|
155
|
+
text_template: str = (
|
|
156
|
+
"HomeSec alert\n"
|
|
157
|
+
"Camera: {camera_name}\n"
|
|
158
|
+
"Clip: {clip_id}\n"
|
|
159
|
+
"Risk: {risk_level}\n"
|
|
160
|
+
"Activity: {activity_type}\n"
|
|
161
|
+
"Reason: {notify_reason}\n"
|
|
162
|
+
"Summary: {summary}\n"
|
|
163
|
+
"View: {view_url}\n"
|
|
164
|
+
"Storage: {storage_uri}\n"
|
|
165
|
+
"Time: {ts}\n"
|
|
166
|
+
"Upload failed: {upload_failed}\n"
|
|
167
|
+
)
|
|
168
|
+
html_template: str = (
|
|
169
|
+
"<html><body>"
|
|
170
|
+
"<h2>HomeSec alert</h2>"
|
|
171
|
+
"<p><strong>Camera:</strong> {camera_name}</p>"
|
|
172
|
+
"<p><strong>Clip:</strong> {clip_id}</p>"
|
|
173
|
+
"<p><strong>Risk:</strong> {risk_level}</p>"
|
|
174
|
+
"<p><strong>Activity:</strong> {activity_type}</p>"
|
|
175
|
+
"<p><strong>Reason:</strong> {notify_reason}</p>"
|
|
176
|
+
"<p><strong>Summary:</strong> {summary}</p>"
|
|
177
|
+
"<p><strong>View:</strong> <a href=\"{view_url}\">{view_url}</a></p>"
|
|
178
|
+
"<p><strong>Storage:</strong> {storage_uri}</p>"
|
|
179
|
+
"<p><strong>Time:</strong> {ts}</p>"
|
|
180
|
+
"<p><strong>Upload failed:</strong> {upload_failed}</p>"
|
|
181
|
+
"<h3>Structured analysis</h3>"
|
|
182
|
+
"{analysis_html}"
|
|
183
|
+
"</body></html>"
|
|
184
|
+
)
|
|
185
|
+
request_timeout_s: float = 10.0
|
|
186
|
+
api_base: str = "https://api.sendgrid.com/v3"
|
|
187
|
+
|
|
188
|
+
@model_validator(mode="after")
|
|
189
|
+
def _validate_templates(self) -> "SendGridEmailConfig":
|
|
190
|
+
if not self.text_template and not self.html_template:
|
|
191
|
+
raise ValueError("sendgrid_email requires at least one of text_template/html_template")
|
|
192
|
+
return self
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class NotifierConfig(BaseModel):
|
|
196
|
+
"""Notifier configuration entry."""
|
|
197
|
+
|
|
198
|
+
backend: str
|
|
199
|
+
enabled: bool = True
|
|
200
|
+
config: dict[str, object] = Field(default_factory=dict)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class RetentionConfig(BaseModel):
|
|
204
|
+
"""Retention configuration for local storage."""
|
|
205
|
+
|
|
206
|
+
max_local_size: str | None = None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class ConcurrencyConfig(BaseModel):
|
|
210
|
+
"""Concurrency limits for pipeline stages."""
|
|
211
|
+
|
|
212
|
+
max_clips_in_flight: int = Field(default=4, ge=1)
|
|
213
|
+
upload_workers: int = Field(default=4, ge=1)
|
|
214
|
+
filter_workers: int = Field(default=4, ge=1)
|
|
215
|
+
vlm_workers: int = Field(default=2, ge=1)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class RetryConfig(BaseModel):
|
|
219
|
+
"""Retry configuration for transient failures."""
|
|
220
|
+
|
|
221
|
+
max_attempts: int = Field(default=3, ge=1)
|
|
222
|
+
backoff_s: float = Field(default=1.0, ge=0.0)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class HealthConfig(BaseModel):
|
|
226
|
+
"""Health endpoint configuration."""
|
|
227
|
+
|
|
228
|
+
host: str = "0.0.0.0"
|
|
229
|
+
port: int = 8080
|
|
230
|
+
mqtt_is_critical: bool = False
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class CameraSourceConfig(BaseModel):
|
|
234
|
+
"""Camera source configuration wrapper."""
|
|
235
|
+
|
|
236
|
+
model_config = {"extra": "forbid"}
|
|
237
|
+
|
|
238
|
+
type: Literal["rtsp", "local_folder", "ftp"]
|
|
239
|
+
config: RTSPSourceConfig | LocalFolderSourceConfig | FtpSourceConfig
|
|
240
|
+
|
|
241
|
+
@model_validator(mode="before")
|
|
242
|
+
@classmethod
|
|
243
|
+
def _parse_source_config(cls, data: object) -> object:
|
|
244
|
+
if not isinstance(data, dict):
|
|
245
|
+
return data
|
|
246
|
+
source_type = data.get("type")
|
|
247
|
+
raw_config = data.get("config")
|
|
248
|
+
if isinstance(
|
|
249
|
+
raw_config, (RTSPSourceConfig, LocalFolderSourceConfig, FtpSourceConfig)
|
|
250
|
+
):
|
|
251
|
+
return data
|
|
252
|
+
|
|
253
|
+
config_data = raw_config or {}
|
|
254
|
+
updated = dict(data)
|
|
255
|
+
match source_type:
|
|
256
|
+
case "rtsp":
|
|
257
|
+
updated["config"] = RTSPSourceConfig.model_validate(config_data)
|
|
258
|
+
case "local_folder":
|
|
259
|
+
updated["config"] = LocalFolderSourceConfig.model_validate(config_data)
|
|
260
|
+
case "ftp":
|
|
261
|
+
updated["config"] = FtpSourceConfig.model_validate(config_data)
|
|
262
|
+
return updated
|
|
263
|
+
|
|
264
|
+
@model_validator(mode="after")
|
|
265
|
+
def _validate_source_config(self) -> "CameraSourceConfig":
|
|
266
|
+
match self.type:
|
|
267
|
+
case "rtsp":
|
|
268
|
+
if not isinstance(self.config, RTSPSourceConfig):
|
|
269
|
+
raise ValueError("camera.source.config must be RTSPSourceConfig for type=rtsp")
|
|
270
|
+
case "local_folder":
|
|
271
|
+
if not isinstance(self.config, LocalFolderSourceConfig):
|
|
272
|
+
raise ValueError(
|
|
273
|
+
"camera.source.config must be LocalFolderSourceConfig for type=local_folder"
|
|
274
|
+
)
|
|
275
|
+
case "ftp":
|
|
276
|
+
if not isinstance(self.config, FtpSourceConfig):
|
|
277
|
+
raise ValueError("camera.source.config must be FtpSourceConfig for type=ftp")
|
|
278
|
+
return self
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class CameraConfig(BaseModel):
|
|
282
|
+
"""Camera configuration and clip source selection."""
|
|
283
|
+
|
|
284
|
+
name: str
|
|
285
|
+
source: CameraSourceConfig
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
TConfig = TypeVar("TConfig", bound=BaseModel)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class Config(BaseModel):
|
|
292
|
+
"""Main configuration with per-camera override support."""
|
|
293
|
+
|
|
294
|
+
version: int = 1
|
|
295
|
+
cameras: list[CameraConfig]
|
|
296
|
+
storage: StorageConfig
|
|
297
|
+
state_store: StateStoreConfig = Field(default_factory=StateStoreConfig)
|
|
298
|
+
notifiers: list[NotifierConfig]
|
|
299
|
+
retention: RetentionConfig = Field(default_factory=RetentionConfig)
|
|
300
|
+
concurrency: ConcurrencyConfig = Field(default_factory=ConcurrencyConfig)
|
|
301
|
+
retry: RetryConfig = Field(default_factory=RetryConfig)
|
|
302
|
+
health: HealthConfig = Field(default_factory=HealthConfig)
|
|
303
|
+
filter: FilterConfig
|
|
304
|
+
vlm: VLMConfig
|
|
305
|
+
alert_policy: AlertPolicyConfig
|
|
306
|
+
|
|
307
|
+
# Per-camera overrides (alert policy only)
|
|
308
|
+
per_camera_alert: dict[str, AlertPolicyOverrides] = Field(default_factory=dict)
|
|
309
|
+
|
|
310
|
+
@model_validator(mode="after")
|
|
311
|
+
def _validate_notifiers(self) -> "Config":
|
|
312
|
+
if not self.notifiers:
|
|
313
|
+
raise ValueError("notifiers must include at least one notifier")
|
|
314
|
+
return self
|
|
315
|
+
|
|
316
|
+
@model_validator(mode="after")
|
|
317
|
+
def _validate_builtin_plugin_configs(self) -> "Config":
|
|
318
|
+
"""Validate built-in plugin configs for early error detection.
|
|
319
|
+
|
|
320
|
+
Third-party plugin configs are validated later during plugin loading.
|
|
321
|
+
"""
|
|
322
|
+
# Validate built-in filter configs
|
|
323
|
+
if self.filter.plugin == "yolo":
|
|
324
|
+
from homesec.models.filter import YoloFilterSettings
|
|
325
|
+
|
|
326
|
+
if isinstance(self.filter.config, dict):
|
|
327
|
+
validated_filter_config = YoloFilterSettings.model_validate(self.filter.config)
|
|
328
|
+
# Replace dict with validated object for built-in plugins
|
|
329
|
+
object.__setattr__(self.filter, "config", validated_filter_config)
|
|
330
|
+
|
|
331
|
+
# Validate built-in VLM configs
|
|
332
|
+
if self.vlm.backend == "openai":
|
|
333
|
+
from homesec.models.vlm import OpenAILLMConfig
|
|
334
|
+
|
|
335
|
+
if isinstance(self.vlm.llm, dict):
|
|
336
|
+
validated_llm_config = OpenAILLMConfig.model_validate(self.vlm.llm)
|
|
337
|
+
# Replace dict with validated object for built-in plugins
|
|
338
|
+
object.__setattr__(self.vlm, "llm", validated_llm_config)
|
|
339
|
+
|
|
340
|
+
return self
|
|
341
|
+
|
|
342
|
+
def _merge_overrides(
|
|
343
|
+
self, base: TConfig, overrides: BaseModel, model_type: type[TConfig]
|
|
344
|
+
) -> TConfig:
|
|
345
|
+
merged = {
|
|
346
|
+
**base.model_dump(),
|
|
347
|
+
**overrides.model_dump(exclude_none=True),
|
|
348
|
+
}
|
|
349
|
+
return model_type.model_validate(merged)
|
|
350
|
+
|
|
351
|
+
def get_default_alert_policy(self, camera_name: str) -> DefaultAlertPolicySettings:
|
|
352
|
+
"""Get merged default alert policy settings for a specific camera."""
|
|
353
|
+
if self.alert_policy.backend != "default":
|
|
354
|
+
raise ValueError(
|
|
355
|
+
f"default alert policy requested but backend is {self.alert_policy.backend}"
|
|
356
|
+
)
|
|
357
|
+
base = DefaultAlertPolicySettings.model_validate(self.alert_policy.config)
|
|
358
|
+
if camera_name not in self.per_camera_alert:
|
|
359
|
+
return base
|
|
360
|
+
|
|
361
|
+
overrides = self.per_camera_alert[camera_name]
|
|
362
|
+
return self._merge_overrides(base, overrides, DefaultAlertPolicySettings)
|