homesec 1.2.2__py3-none-any.whl → 1.2.3__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/app.py +5 -14
- homesec/cli.py +5 -4
- homesec/config/__init__.py +8 -1
- homesec/config/loader.py +17 -2
- homesec/config/validation.py +99 -6
- homesec/interfaces.py +2 -2
- homesec/maintenance/cleanup_clips.py +17 -4
- homesec/models/__init__.py +3 -25
- homesec/models/clip.py +1 -1
- homesec/models/config.py +10 -261
- homesec/models/enums.py +8 -0
- homesec/models/events.py +1 -1
- homesec/models/filter.py +3 -21
- homesec/models/vlm.py +11 -20
- homesec/pipeline/__init__.py +1 -2
- homesec/pipeline/core.py +9 -10
- homesec/plugins/alert_policies/__init__.py +5 -5
- homesec/plugins/alert_policies/default.py +21 -2
- homesec/plugins/analyzers/__init__.py +1 -3
- homesec/plugins/analyzers/openai.py +20 -13
- homesec/plugins/filters/__init__.py +1 -2
- homesec/plugins/filters/yolo.py +25 -5
- homesec/plugins/notifiers/__init__.py +1 -6
- homesec/plugins/notifiers/mqtt.py +21 -1
- homesec/plugins/notifiers/sendgrid_email.py +52 -1
- homesec/plugins/registry.py +27 -0
- homesec/plugins/sources/__init__.py +4 -4
- homesec/plugins/sources/ftp.py +1 -1
- homesec/plugins/sources/local_folder.py +1 -1
- homesec/plugins/sources/rtsp.py +1 -1
- homesec/plugins/storage/__init__.py +1 -9
- homesec/plugins/storage/dropbox.py +13 -1
- homesec/plugins/storage/local.py +8 -1
- homesec/repository/clip_repository.py +1 -1
- homesec/sources/__init__.py +3 -6
- homesec/sources/ftp.py +95 -2
- homesec/sources/local_folder.py +27 -2
- homesec/sources/rtsp/core.py +162 -2
- {homesec-1.2.2.dist-info → homesec-1.2.3.dist-info}/METADATA +7 -12
- homesec-1.2.3.dist-info/RECORD +73 -0
- homesec/models/source/__init__.py +0 -3
- homesec/models/source/ftp.py +0 -97
- homesec/models/source/local_folder.py +0 -30
- homesec/models/source/rtsp.py +0 -165
- homesec/pipeline/alert_policy.py +0 -5
- homesec-1.2.2.dist-info/RECORD +0 -78
- /homesec/{plugins/notifiers → notifiers}/multiplex.py +0 -0
- {homesec-1.2.2.dist-info → homesec-1.2.3.dist-info}/WHEEL +0 -0
- {homesec-1.2.2.dist-info → homesec-1.2.3.dist-info}/entry_points.txt +0 -0
- {homesec-1.2.2.dist-info → homesec-1.2.3.dist-info}/licenses/LICENSE +0 -0
homesec/models/config.py
CHANGED
|
@@ -2,36 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from typing import Any
|
|
5
|
+
from typing import Any
|
|
6
6
|
|
|
7
7
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
8
8
|
|
|
9
|
-
from homesec.models.enums import RiskLevel, RiskLevelField
|
|
10
9
|
from homesec.models.filter import FilterConfig
|
|
11
|
-
from homesec.models.source.ftp import FtpSourceConfig
|
|
12
|
-
from homesec.models.source.local_folder import LocalFolderSourceConfig
|
|
13
|
-
from homesec.models.source.rtsp import RTSPSourceConfig
|
|
14
10
|
from homesec.models.vlm import VLMConfig
|
|
15
11
|
|
|
16
12
|
|
|
17
|
-
class AlertPolicyOverrides(BaseModel):
|
|
18
|
-
"""Per-camera alert policy overrides (only non-None fields override base)."""
|
|
19
|
-
|
|
20
|
-
min_risk_level: RiskLevelField | None = None
|
|
21
|
-
notify_on_activity_types: list[str] | None = None
|
|
22
|
-
notify_on_motion: bool | None = None
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
class DefaultAlertPolicySettings(BaseModel):
|
|
26
|
-
"""Default alert policy settings."""
|
|
27
|
-
|
|
28
|
-
min_risk_level: RiskLevelField = RiskLevel.MEDIUM
|
|
29
|
-
notify_on_activity_types: list[str] = Field(default_factory=list)
|
|
30
|
-
notify_on_motion: bool = False
|
|
31
|
-
overrides: dict[str, AlertPolicyOverrides] = Field(default_factory=dict, exclude=True)
|
|
32
|
-
trigger_classes: list[str] = Field(default_factory=list, exclude=True)
|
|
33
|
-
|
|
34
|
-
|
|
35
13
|
class AlertPolicyConfig(BaseModel):
|
|
36
14
|
"""Alert policy plugin configuration."""
|
|
37
15
|
|
|
@@ -46,31 +24,6 @@ class AlertPolicyConfig(BaseModel):
|
|
|
46
24
|
return value.lower()
|
|
47
25
|
return value
|
|
48
26
|
|
|
49
|
-
@model_validator(mode="after")
|
|
50
|
-
def _validate_alert_policy(self) -> AlertPolicyConfig:
|
|
51
|
-
# Validate and reassign config for built-in backends
|
|
52
|
-
if self.backend == "default" and isinstance(self.config, dict):
|
|
53
|
-
validated = DefaultAlertPolicySettings.model_validate(self.config)
|
|
54
|
-
object.__setattr__(self, "config", validated)
|
|
55
|
-
return self
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
class DropboxStorageConfig(BaseModel):
|
|
59
|
-
"""Dropbox storage configuration."""
|
|
60
|
-
|
|
61
|
-
root: str
|
|
62
|
-
token_env: str = "DROPBOX_TOKEN"
|
|
63
|
-
app_key_env: str = "DROPBOX_APP_KEY"
|
|
64
|
-
app_secret_env: str = "DROPBOX_APP_SECRET"
|
|
65
|
-
refresh_token_env: str = "DROPBOX_REFRESH_TOKEN"
|
|
66
|
-
web_url_prefix: str = "https://www.dropbox.com/home"
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
class LocalStorageConfig(BaseModel):
|
|
70
|
-
"""Local storage configuration."""
|
|
71
|
-
|
|
72
|
-
root: str = "./storage"
|
|
73
|
-
|
|
74
27
|
|
|
75
28
|
class StoragePathsConfig(BaseModel):
|
|
76
29
|
"""Logical storage paths for different artifact types."""
|
|
@@ -81,17 +34,12 @@ class StoragePathsConfig(BaseModel):
|
|
|
81
34
|
|
|
82
35
|
|
|
83
36
|
class StorageConfig(BaseModel):
|
|
84
|
-
"""Storage backend configuration.
|
|
37
|
+
"""Storage backend configuration."""
|
|
85
38
|
|
|
86
|
-
|
|
87
|
-
validate_plugin_names(). This allows third-party storage plugins via entry points.
|
|
88
|
-
"""
|
|
89
|
-
|
|
90
|
-
model_config = {"extra": "allow"} # Allow third-party backend configs
|
|
39
|
+
model_config = {"extra": "forbid"}
|
|
91
40
|
|
|
92
41
|
backend: str = "dropbox"
|
|
93
|
-
|
|
94
|
-
local: LocalStorageConfig | None = None
|
|
42
|
+
config: dict[str, Any] | BaseModel = Field(default_factory=dict)
|
|
95
43
|
paths: StoragePathsConfig = Field(default_factory=StoragePathsConfig)
|
|
96
44
|
|
|
97
45
|
@field_validator("backend", mode="before")
|
|
@@ -101,30 +49,6 @@ class StorageConfig(BaseModel):
|
|
|
101
49
|
return value.lower()
|
|
102
50
|
return value
|
|
103
51
|
|
|
104
|
-
@model_validator(mode="after")
|
|
105
|
-
def _validate_builtin_backends(self) -> StorageConfig:
|
|
106
|
-
"""Validate that built-in backends have their required config.
|
|
107
|
-
|
|
108
|
-
Third-party backends are validated later in load_storage_plugin().
|
|
109
|
-
"""
|
|
110
|
-
match self.backend:
|
|
111
|
-
case "dropbox":
|
|
112
|
-
if self.dropbox is None:
|
|
113
|
-
raise ValueError(
|
|
114
|
-
"storage.dropbox is required when backend=dropbox. "
|
|
115
|
-
"Add 'storage.dropbox' section to your config."
|
|
116
|
-
)
|
|
117
|
-
case "local":
|
|
118
|
-
if self.local is None:
|
|
119
|
-
raise ValueError(
|
|
120
|
-
"storage.local is required when backend=local. "
|
|
121
|
-
"Add 'storage.local' section to your config."
|
|
122
|
-
)
|
|
123
|
-
case _:
|
|
124
|
-
# Third-party backend - validation happens in load_storage_plugin()
|
|
125
|
-
pass
|
|
126
|
-
return self
|
|
127
|
-
|
|
128
52
|
|
|
129
53
|
class StateStoreConfig(BaseModel):
|
|
130
54
|
"""State store configuration."""
|
|
@@ -139,81 +63,12 @@ class StateStoreConfig(BaseModel):
|
|
|
139
63
|
return self
|
|
140
64
|
|
|
141
65
|
|
|
142
|
-
class MQTTAuthConfig(BaseModel):
|
|
143
|
-
"""MQTT auth configuration using env var names."""
|
|
144
|
-
|
|
145
|
-
username_env: str | None = None
|
|
146
|
-
password_env: str | None = None
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
class MQTTConfig(BaseModel):
|
|
150
|
-
"""MQTT notifier configuration."""
|
|
151
|
-
|
|
152
|
-
host: str
|
|
153
|
-
port: int = 1883
|
|
154
|
-
auth: MQTTAuthConfig | None = None
|
|
155
|
-
topic_template: str = "homecam/alerts/{camera_name}"
|
|
156
|
-
qos: int = 1
|
|
157
|
-
retain: bool = False
|
|
158
|
-
connection_timeout: float = 10.0
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
class SendGridEmailConfig(BaseModel):
|
|
162
|
-
"""SendGrid email notifier configuration."""
|
|
163
|
-
|
|
164
|
-
api_key_env: str = "SENDGRID_API_KEY"
|
|
165
|
-
from_email: str
|
|
166
|
-
from_name: str | None = None
|
|
167
|
-
to_emails: list[str] = Field(min_length=1)
|
|
168
|
-
cc_emails: list[str] = Field(default_factory=list)
|
|
169
|
-
bcc_emails: list[str] = Field(default_factory=list)
|
|
170
|
-
subject_template: str = "[HomeSec] {camera_name}: {activity_type} ({risk_level})"
|
|
171
|
-
text_template: str = (
|
|
172
|
-
"HomeSec alert\n"
|
|
173
|
-
"Camera: {camera_name}\n"
|
|
174
|
-
"Clip: {clip_id}\n"
|
|
175
|
-
"Risk: {risk_level}\n"
|
|
176
|
-
"Activity: {activity_type}\n"
|
|
177
|
-
"Reason: {notify_reason}\n"
|
|
178
|
-
"Summary: {summary}\n"
|
|
179
|
-
"View: {view_url}\n"
|
|
180
|
-
"Storage: {storage_uri}\n"
|
|
181
|
-
"Time: {ts}\n"
|
|
182
|
-
"Upload failed: {upload_failed}\n"
|
|
183
|
-
)
|
|
184
|
-
html_template: str = (
|
|
185
|
-
"<html><body>"
|
|
186
|
-
"<h2>HomeSec alert</h2>"
|
|
187
|
-
"<p><strong>Camera:</strong> {camera_name}</p>"
|
|
188
|
-
"<p><strong>Clip:</strong> {clip_id}</p>"
|
|
189
|
-
"<p><strong>Risk:</strong> {risk_level}</p>"
|
|
190
|
-
"<p><strong>Activity:</strong> {activity_type}</p>"
|
|
191
|
-
"<p><strong>Reason:</strong> {notify_reason}</p>"
|
|
192
|
-
"<p><strong>Summary:</strong> {summary}</p>"
|
|
193
|
-
'<p><strong>View:</strong> <a href="{view_url}">{view_url}</a></p>'
|
|
194
|
-
"<p><strong>Storage:</strong> {storage_uri}</p>"
|
|
195
|
-
"<p><strong>Time:</strong> {ts}</p>"
|
|
196
|
-
"<p><strong>Upload failed:</strong> {upload_failed}</p>"
|
|
197
|
-
"<h3>Structured analysis</h3>"
|
|
198
|
-
"{analysis_html}"
|
|
199
|
-
"</body></html>"
|
|
200
|
-
)
|
|
201
|
-
request_timeout_s: float = 10.0
|
|
202
|
-
api_base: str = "https://api.sendgrid.com/v3"
|
|
203
|
-
|
|
204
|
-
@model_validator(mode="after")
|
|
205
|
-
def _validate_templates(self) -> SendGridEmailConfig:
|
|
206
|
-
if not self.text_template and not self.html_template:
|
|
207
|
-
raise ValueError("sendgrid_email requires at least one of text_template/html_template")
|
|
208
|
-
return self
|
|
209
|
-
|
|
210
|
-
|
|
211
66
|
class NotifierConfig(BaseModel):
|
|
212
67
|
"""Notifier configuration entry."""
|
|
213
68
|
|
|
214
69
|
backend: str
|
|
215
70
|
enabled: bool = True
|
|
216
|
-
config: dict[str, Any] = Field(default_factory=dict)
|
|
71
|
+
config: dict[str, Any] | BaseModel = Field(default_factory=dict)
|
|
217
72
|
|
|
218
73
|
@field_validator("backend", mode="before")
|
|
219
74
|
@classmethod
|
|
@@ -254,72 +109,20 @@ class HealthConfig(BaseModel):
|
|
|
254
109
|
|
|
255
110
|
|
|
256
111
|
class CameraSourceConfig(BaseModel):
|
|
257
|
-
"""Camera source configuration wrapper.
|
|
258
|
-
|
|
259
|
-
The `type` field is a string to allow external plugins to register new source types.
|
|
260
|
-
Built-in types ("rtsp", "local_folder", "ftp") have their configs validated here.
|
|
261
|
-
External plugin configs are validated by the plugin at load time.
|
|
262
|
-
"""
|
|
112
|
+
"""Camera source configuration wrapper."""
|
|
263
113
|
|
|
264
114
|
model_config = {"extra": "forbid"}
|
|
265
115
|
|
|
266
|
-
|
|
267
|
-
config:
|
|
116
|
+
backend: str
|
|
117
|
+
config: dict[str, Any] | BaseModel = Field(default_factory=dict)
|
|
268
118
|
|
|
269
|
-
@
|
|
270
|
-
@classmethod
|
|
271
|
-
def _parse_source_config(cls, data: object) -> object:
|
|
272
|
-
if not isinstance(data, dict):
|
|
273
|
-
return data
|
|
274
|
-
source_type = data.get("type")
|
|
275
|
-
raw_config = data.get("config")
|
|
276
|
-
if isinstance(raw_config, (RTSPSourceConfig, LocalFolderSourceConfig, FtpSourceConfig)):
|
|
277
|
-
return data
|
|
278
|
-
|
|
279
|
-
config_data = raw_config or {}
|
|
280
|
-
updated = dict(data)
|
|
281
|
-
if isinstance(source_type, str):
|
|
282
|
-
source_type = source_type.lower()
|
|
283
|
-
updated["type"] = source_type
|
|
284
|
-
match source_type:
|
|
285
|
-
case "rtsp":
|
|
286
|
-
updated["config"] = RTSPSourceConfig.model_validate(config_data)
|
|
287
|
-
case "local_folder":
|
|
288
|
-
updated["config"] = LocalFolderSourceConfig.model_validate(config_data)
|
|
289
|
-
case "ftp":
|
|
290
|
-
updated["config"] = FtpSourceConfig.model_validate(config_data)
|
|
291
|
-
case _:
|
|
292
|
-
# External plugin source type - keep config as dict
|
|
293
|
-
# Plugin will validate at load time
|
|
294
|
-
updated["config"] = config_data if isinstance(config_data, dict) else {}
|
|
295
|
-
return updated
|
|
296
|
-
|
|
297
|
-
@field_validator("type", mode="before")
|
|
119
|
+
@field_validator("backend", mode="before")
|
|
298
120
|
@classmethod
|
|
299
|
-
def
|
|
121
|
+
def _normalize_backend(cls, value: Any) -> Any:
|
|
300
122
|
if isinstance(value, str):
|
|
301
123
|
return value.lower()
|
|
302
124
|
return value
|
|
303
125
|
|
|
304
|
-
@model_validator(mode="after")
|
|
305
|
-
def _validate_source_config(self) -> CameraSourceConfig:
|
|
306
|
-
match self.type:
|
|
307
|
-
case "rtsp":
|
|
308
|
-
if not isinstance(self.config, RTSPSourceConfig):
|
|
309
|
-
raise ValueError("camera.source.config must be RTSPSourceConfig for type=rtsp")
|
|
310
|
-
case "local_folder":
|
|
311
|
-
if not isinstance(self.config, LocalFolderSourceConfig):
|
|
312
|
-
raise ValueError(
|
|
313
|
-
"camera.source.config must be LocalFolderSourceConfig for type=local_folder"
|
|
314
|
-
)
|
|
315
|
-
case "ftp":
|
|
316
|
-
if not isinstance(self.config, FtpSourceConfig):
|
|
317
|
-
raise ValueError("camera.source.config must be FtpSourceConfig for type=ftp")
|
|
318
|
-
case _:
|
|
319
|
-
# External plugin source type - validation happens at plugin load time
|
|
320
|
-
pass
|
|
321
|
-
return self
|
|
322
|
-
|
|
323
126
|
|
|
324
127
|
class CameraConfig(BaseModel):
|
|
325
128
|
"""Camera configuration and clip source selection."""
|
|
@@ -328,9 +131,6 @@ class CameraConfig(BaseModel):
|
|
|
328
131
|
source: CameraSourceConfig
|
|
329
132
|
|
|
330
133
|
|
|
331
|
-
TConfig = TypeVar("TConfig", bound=BaseModel)
|
|
332
|
-
|
|
333
|
-
|
|
334
134
|
class Config(BaseModel):
|
|
335
135
|
"""Main configuration with per-camera override support."""
|
|
336
136
|
|
|
@@ -347,59 +147,8 @@ class Config(BaseModel):
|
|
|
347
147
|
vlm: VLMConfig
|
|
348
148
|
alert_policy: AlertPolicyConfig
|
|
349
149
|
|
|
350
|
-
# Per-camera overrides (alert policy only)
|
|
351
|
-
per_camera_alert: dict[str, AlertPolicyOverrides] = Field(default_factory=dict)
|
|
352
|
-
|
|
353
150
|
@model_validator(mode="after")
|
|
354
151
|
def _validate_notifiers(self) -> Config:
|
|
355
152
|
if not self.notifiers:
|
|
356
153
|
raise ValueError("notifiers must include at least one notifier")
|
|
357
154
|
return self
|
|
358
|
-
|
|
359
|
-
@model_validator(mode="after")
|
|
360
|
-
def _validate_builtin_plugin_configs(self) -> Config:
|
|
361
|
-
"""Validate built-in plugin configs for early error detection.
|
|
362
|
-
|
|
363
|
-
Third-party plugin configs are validated later during plugin loading.
|
|
364
|
-
"""
|
|
365
|
-
# Validate built-in filter configs
|
|
366
|
-
if self.filter.plugin == "yolo":
|
|
367
|
-
from homesec.models.filter import YoloFilterSettings
|
|
368
|
-
|
|
369
|
-
if isinstance(self.filter.config, dict):
|
|
370
|
-
validated_filter_config = YoloFilterSettings.model_validate(self.filter.config)
|
|
371
|
-
# Replace dict with validated object for built-in plugins
|
|
372
|
-
object.__setattr__(self.filter, "config", validated_filter_config)
|
|
373
|
-
|
|
374
|
-
# Validate built-in VLM configs
|
|
375
|
-
if self.vlm.backend == "openai":
|
|
376
|
-
from homesec.models.vlm import OpenAILLMConfig
|
|
377
|
-
|
|
378
|
-
if isinstance(self.vlm.llm, dict):
|
|
379
|
-
validated_llm_config = OpenAILLMConfig.model_validate(self.vlm.llm)
|
|
380
|
-
# Replace dict with validated object for built-in plugins
|
|
381
|
-
object.__setattr__(self.vlm, "llm", validated_llm_config)
|
|
382
|
-
|
|
383
|
-
return self
|
|
384
|
-
|
|
385
|
-
def _merge_overrides(
|
|
386
|
-
self, base: TConfig, overrides: BaseModel, model_type: type[TConfig]
|
|
387
|
-
) -> TConfig:
|
|
388
|
-
merged = {
|
|
389
|
-
**base.model_dump(),
|
|
390
|
-
**overrides.model_dump(exclude_none=True),
|
|
391
|
-
}
|
|
392
|
-
return model_type.model_validate(merged)
|
|
393
|
-
|
|
394
|
-
def get_default_alert_policy(self, camera_name: str) -> DefaultAlertPolicySettings:
|
|
395
|
-
"""Get merged default alert policy settings for a specific camera."""
|
|
396
|
-
if self.alert_policy.backend != "default":
|
|
397
|
-
raise ValueError(
|
|
398
|
-
f"default alert policy requested but backend is {self.alert_policy.backend}"
|
|
399
|
-
)
|
|
400
|
-
base = DefaultAlertPolicySettings.model_validate(self.alert_policy.config)
|
|
401
|
-
if camera_name not in self.per_camera_alert:
|
|
402
|
-
return base
|
|
403
|
-
|
|
404
|
-
overrides = self.per_camera_alert[camera_name]
|
|
405
|
-
return self._merge_overrides(base, overrides, DefaultAlertPolicySettings)
|
homesec/models/enums.py
CHANGED
|
@@ -66,6 +66,14 @@ class ClipStatus(StrEnum):
|
|
|
66
66
|
DELETED = "deleted"
|
|
67
67
|
|
|
68
68
|
|
|
69
|
+
class VLMRunMode(StrEnum):
|
|
70
|
+
"""Policy for when to run VLM analysis."""
|
|
71
|
+
|
|
72
|
+
TRIGGER_ONLY = "trigger_only"
|
|
73
|
+
ALWAYS = "always"
|
|
74
|
+
NEVER = "never"
|
|
75
|
+
|
|
76
|
+
|
|
69
77
|
class RiskLevel(IntEnum):
|
|
70
78
|
"""VLM risk assessment levels.
|
|
71
79
|
|
homesec/models/events.py
CHANGED
homesec/models/filter.py
CHANGED
|
@@ -16,23 +16,6 @@ class FilterResult(BaseModel):
|
|
|
16
16
|
sampled_frames: int
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
class YoloFilterSettings(BaseModel):
|
|
20
|
-
"""YOLO filter settings.
|
|
21
|
-
|
|
22
|
-
model_path accepts a filename; bare names resolve under ./yolo_cache.
|
|
23
|
-
"""
|
|
24
|
-
|
|
25
|
-
model_config = {"extra": "forbid"}
|
|
26
|
-
|
|
27
|
-
model_path: str = "yolo11n.pt"
|
|
28
|
-
classes: list[str] = Field(default_factory=lambda: ["person"], min_length=1)
|
|
29
|
-
min_confidence: float = Field(default=0.5, ge=0.0, le=1.0)
|
|
30
|
-
sample_fps: int = Field(default=2, ge=1)
|
|
31
|
-
min_box_h_ratio: float = Field(default=0.1, ge=0.0, le=1.0)
|
|
32
|
-
min_hits: int = Field(default=1, ge=1)
|
|
33
|
-
max_workers: int = Field(default=4, ge=1)
|
|
34
|
-
|
|
35
|
-
|
|
36
19
|
class FilterOverrides(BaseModel):
|
|
37
20
|
"""Runtime overrides for filter settings (model path not allowed)."""
|
|
38
21
|
|
|
@@ -58,13 +41,12 @@ class FilterConfig(BaseModel):
|
|
|
58
41
|
|
|
59
42
|
model_config = {"extra": "forbid"}
|
|
60
43
|
|
|
61
|
-
|
|
62
|
-
max_workers: int = Field(default=4, ge=1)
|
|
44
|
+
backend: str
|
|
63
45
|
config: dict[str, Any] | BaseModel # Dict before validation, BaseModel after
|
|
64
46
|
|
|
65
|
-
@field_validator("
|
|
47
|
+
@field_validator("backend", mode="before")
|
|
66
48
|
@classmethod
|
|
67
|
-
def
|
|
49
|
+
def _normalize_backend(cls, value: Any) -> Any:
|
|
68
50
|
if isinstance(value, str):
|
|
69
51
|
return value.lower()
|
|
70
52
|
return value
|
homesec/models/vlm.py
CHANGED
|
@@ -6,7 +6,7 @@ from typing import Any, Literal
|
|
|
6
6
|
|
|
7
7
|
from pydantic import BaseModel, Field, field_validator
|
|
8
8
|
|
|
9
|
-
from homesec.models.enums import RiskLevelField
|
|
9
|
+
from homesec.models.enums import RiskLevelField, VLMRunMode
|
|
10
10
|
|
|
11
11
|
__all__ = ["AnalysisResult", "VLMConfig", "VLMPreprocessConfig"]
|
|
12
12
|
|
|
@@ -67,26 +67,10 @@ class VLMPreprocessConfig(BaseModel):
|
|
|
67
67
|
quality: int = 85
|
|
68
68
|
|
|
69
69
|
|
|
70
|
-
class OpenAILLMConfig(BaseModel):
|
|
71
|
-
"""OpenAI-compatible LLM configuration."""
|
|
72
|
-
|
|
73
|
-
model_config = {"extra": "forbid"}
|
|
74
|
-
api_key_env: str
|
|
75
|
-
model: str
|
|
76
|
-
base_url: str = "https://api.openai.com/v1"
|
|
77
|
-
token_param: Literal["max_tokens", "max_completion_tokens"] = "max_completion_tokens"
|
|
78
|
-
max_completion_tokens: int = 10_000
|
|
79
|
-
max_tokens: int | None = None
|
|
80
|
-
temperature: float | None = 0.0
|
|
81
|
-
request_timeout: float = 60.0
|
|
82
|
-
trigger_classes: list[str] = Field(default_factory=lambda: ["person"])
|
|
83
|
-
max_workers: int = Field(default=2, ge=1)
|
|
84
|
-
|
|
85
|
-
|
|
86
70
|
class VLMConfig(BaseModel):
|
|
87
71
|
"""Base VLM configuration.
|
|
88
72
|
|
|
89
|
-
|
|
73
|
+
Backend-specific config is stored in the 'config' field.
|
|
90
74
|
- During YAML parsing: dict[str, Any] (preserves all third-party fields)
|
|
91
75
|
- After plugin discovery: BaseModel subclass (validated against plugin.config_model)
|
|
92
76
|
|
|
@@ -97,8 +81,8 @@ class VLMConfig(BaseModel):
|
|
|
97
81
|
model_config = {"extra": "forbid"}
|
|
98
82
|
backend: str
|
|
99
83
|
trigger_classes: list[str] = Field(default_factory=lambda: ["person"])
|
|
100
|
-
|
|
101
|
-
|
|
84
|
+
run_mode: VLMRunMode = VLMRunMode.TRIGGER_ONLY
|
|
85
|
+
config: dict[str, Any] | BaseModel # Dict before validation, BaseModel after
|
|
102
86
|
preprocessing: VLMPreprocessConfig = Field(default_factory=VLMPreprocessConfig)
|
|
103
87
|
|
|
104
88
|
@field_validator("backend", mode="before")
|
|
@@ -107,3 +91,10 @@ class VLMConfig(BaseModel):
|
|
|
107
91
|
if isinstance(value, str):
|
|
108
92
|
return value.lower()
|
|
109
93
|
return value
|
|
94
|
+
|
|
95
|
+
@field_validator("run_mode", mode="before")
|
|
96
|
+
@classmethod
|
|
97
|
+
def _normalize_run_mode(cls, value: Any) -> Any:
|
|
98
|
+
if isinstance(value, str):
|
|
99
|
+
return value.lower()
|
|
100
|
+
return value
|
homesec/pipeline/__init__.py
CHANGED
homesec/pipeline/core.py
CHANGED
|
@@ -16,7 +16,7 @@ from homesec.models.clip import Clip
|
|
|
16
16
|
from homesec.models.config import Config
|
|
17
17
|
from homesec.models.filter import FilterResult
|
|
18
18
|
from homesec.models.vlm import AnalysisResult
|
|
19
|
-
from homesec.
|
|
19
|
+
from homesec.notifiers.multiplex import NotifierEntry
|
|
20
20
|
from homesec.repository import ClipRepository
|
|
21
21
|
from homesec.storage_paths import build_clip_path
|
|
22
22
|
|
|
@@ -194,7 +194,7 @@ class ClipPipeline:
|
|
|
194
194
|
# Stage 3: VLM (conditional)
|
|
195
195
|
analysis_result: AnalysisResult | None = None
|
|
196
196
|
vlm_failed = False
|
|
197
|
-
if self._should_run_vlm(
|
|
197
|
+
if self._should_run_vlm(filter_res):
|
|
198
198
|
vlm_result = await self._vlm_stage(clip, filter_res)
|
|
199
199
|
match vlm_result:
|
|
200
200
|
case VLMError() as vlm_err:
|
|
@@ -418,7 +418,7 @@ class ClipPipeline:
|
|
|
418
418
|
on_attempt_failure=on_attempt_failure,
|
|
419
419
|
)
|
|
420
420
|
except Exception as e:
|
|
421
|
-
return FilterError(clip.clip_id, plugin_name=self._config.filter.
|
|
421
|
+
return FilterError(clip.clip_id, plugin_name=self._config.filter.backend, cause=e)
|
|
422
422
|
|
|
423
423
|
async def _vlm_stage(
|
|
424
424
|
self, clip: Clip, filter_result: FilterResult
|
|
@@ -564,15 +564,14 @@ class ClipPipeline:
|
|
|
564
564
|
return type(exc.cause).__name__
|
|
565
565
|
return type(exc).__name__
|
|
566
566
|
|
|
567
|
-
def _should_run_vlm(self,
|
|
567
|
+
def _should_run_vlm(self, filter_result: FilterResult) -> bool:
|
|
568
568
|
"""Check if VLM should run based on detected classes and config."""
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
569
|
+
run_mode = self._config.vlm.run_mode
|
|
570
|
+
if run_mode == "never":
|
|
571
|
+
return False
|
|
572
|
+
if run_mode == "always":
|
|
573
|
+
return True
|
|
574
574
|
|
|
575
|
-
# Otherwise check if detected classes intersect trigger classes
|
|
576
575
|
detected = set(filter_result.detected_classes)
|
|
577
576
|
trigger = set(self._config.vlm.trigger_classes)
|
|
578
577
|
return bool(detected & trigger)
|
|
@@ -6,7 +6,6 @@ import logging
|
|
|
6
6
|
from typing import Any, cast
|
|
7
7
|
|
|
8
8
|
from homesec.interfaces import AlertPolicy
|
|
9
|
-
from homesec.models.config import AlertPolicyOverrides
|
|
10
9
|
from homesec.plugins.alert_policies.noop import NoopAlertPolicySettings
|
|
11
10
|
from homesec.plugins.registry import PluginType, load_plugin
|
|
12
11
|
|
|
@@ -15,14 +14,12 @@ logger = logging.getLogger(__name__)
|
|
|
15
14
|
|
|
16
15
|
def load_alert_policy(
|
|
17
16
|
config: Any, # AlertPolicyConfig but trying to avoid circular import if possible
|
|
18
|
-
per_camera_overrides: dict[str, AlertPolicyOverrides],
|
|
19
17
|
trigger_classes: list[str],
|
|
20
18
|
) -> AlertPolicy:
|
|
21
19
|
"""Load and instantiate an alert policy plugin.
|
|
22
20
|
|
|
23
21
|
Args:
|
|
24
22
|
config: Alert policy configuration (AlertPolicyConfig)
|
|
25
|
-
per_camera_overrides: Map of camera name to override settings
|
|
26
23
|
trigger_classes: List of object classes that trigger analysis
|
|
27
24
|
|
|
28
25
|
Returns:
|
|
@@ -39,14 +36,17 @@ def load_alert_policy(
|
|
|
39
36
|
),
|
|
40
37
|
)
|
|
41
38
|
|
|
39
|
+
runtime_context: dict[str, Any] = {}
|
|
40
|
+
if config.backend == "default":
|
|
41
|
+
runtime_context["trigger_classes"] = list(trigger_classes)
|
|
42
|
+
|
|
42
43
|
return cast(
|
|
43
44
|
AlertPolicy,
|
|
44
45
|
load_plugin(
|
|
45
46
|
PluginType.ALERT_POLICY,
|
|
46
47
|
config.backend,
|
|
47
48
|
config.config,
|
|
48
|
-
|
|
49
|
-
trigger_classes=trigger_classes,
|
|
49
|
+
**runtime_context,
|
|
50
50
|
),
|
|
51
51
|
)
|
|
52
52
|
|
|
@@ -2,15 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
5
7
|
from homesec.interfaces import AlertPolicy
|
|
6
8
|
from homesec.models.alert import AlertDecision
|
|
7
|
-
from homesec.models.
|
|
8
|
-
from homesec.models.enums import RiskLevel
|
|
9
|
+
from homesec.models.enums import RiskLevel, RiskLevelField
|
|
9
10
|
from homesec.models.filter import FilterResult
|
|
10
11
|
from homesec.models.vlm import AnalysisResult
|
|
11
12
|
from homesec.plugins.registry import PluginType, plugin
|
|
12
13
|
|
|
13
14
|
|
|
15
|
+
class AlertPolicyOverrides(BaseModel):
|
|
16
|
+
"""Per-camera alert policy overrides (only non-None fields override base)."""
|
|
17
|
+
|
|
18
|
+
min_risk_level: RiskLevelField | None = None
|
|
19
|
+
notify_on_activity_types: list[str] | None = None
|
|
20
|
+
notify_on_motion: bool | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DefaultAlertPolicySettings(BaseModel):
|
|
24
|
+
"""Default alert policy settings."""
|
|
25
|
+
|
|
26
|
+
min_risk_level: RiskLevelField = RiskLevel.MEDIUM
|
|
27
|
+
notify_on_activity_types: list[str] = Field(default_factory=list)
|
|
28
|
+
notify_on_motion: bool = False
|
|
29
|
+
overrides: dict[str, AlertPolicyOverrides] = Field(default_factory=dict)
|
|
30
|
+
trigger_classes: list[str] = Field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
|
|
14
33
|
@plugin(plugin_type=PluginType.ALERT_POLICY, name="default")
|
|
15
34
|
class DefaultAlertPolicy(AlertPolicy):
|
|
16
35
|
"""Default alert policy implementation."""
|