homesec 1.2.1__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.
Files changed (55) hide show
  1. homesec/app.py +5 -14
  2. homesec/cli.py +5 -4
  3. homesec/config/__init__.py +8 -1
  4. homesec/config/loader.py +17 -2
  5. homesec/config/validation.py +99 -6
  6. homesec/interfaces.py +2 -2
  7. homesec/maintenance/cleanup_clips.py +17 -4
  8. homesec/models/__init__.py +3 -23
  9. homesec/models/clip.py +1 -1
  10. homesec/models/config.py +10 -259
  11. homesec/models/enums.py +8 -0
  12. homesec/models/events.py +1 -1
  13. homesec/models/filter.py +3 -21
  14. homesec/models/vlm.py +11 -20
  15. homesec/pipeline/__init__.py +1 -2
  16. homesec/pipeline/core.py +9 -10
  17. homesec/plugins/alert_policies/__init__.py +5 -5
  18. homesec/plugins/alert_policies/default.py +21 -2
  19. homesec/plugins/analyzers/__init__.py +1 -3
  20. homesec/plugins/analyzers/openai.py +20 -13
  21. homesec/plugins/filters/__init__.py +1 -2
  22. homesec/plugins/filters/yolo.py +25 -5
  23. homesec/plugins/notifiers/__init__.py +1 -6
  24. homesec/plugins/notifiers/mqtt.py +21 -1
  25. homesec/plugins/notifiers/sendgrid_email.py +52 -1
  26. homesec/plugins/registry.py +27 -0
  27. homesec/plugins/sources/__init__.py +4 -4
  28. homesec/plugins/sources/ftp.py +1 -1
  29. homesec/plugins/sources/local_folder.py +1 -1
  30. homesec/plugins/sources/rtsp.py +2 -2
  31. homesec/plugins/storage/__init__.py +1 -9
  32. homesec/plugins/storage/dropbox.py +13 -1
  33. homesec/plugins/storage/local.py +8 -1
  34. homesec/repository/clip_repository.py +1 -1
  35. homesec/sources/__init__.py +3 -4
  36. homesec/sources/ftp.py +95 -2
  37. homesec/sources/local_folder.py +27 -2
  38. homesec/sources/rtsp/__init__.py +5 -0
  39. homesec/sources/rtsp/clock.py +18 -0
  40. homesec/sources/rtsp/core.py +1424 -0
  41. homesec/sources/rtsp/frame_pipeline.py +325 -0
  42. homesec/sources/rtsp/hardware.py +143 -0
  43. homesec/sources/rtsp/motion.py +94 -0
  44. homesec/sources/rtsp/recorder.py +180 -0
  45. homesec/sources/rtsp/utils.py +35 -0
  46. {homesec-1.2.1.dist-info → homesec-1.2.3.dist-info}/METADATA +13 -16
  47. homesec-1.2.3.dist-info/RECORD +73 -0
  48. homesec/models/source.py +0 -81
  49. homesec/pipeline/alert_policy.py +0 -5
  50. homesec/sources/rtsp.py +0 -1304
  51. homesec-1.2.1.dist-info/RECORD +0 -68
  52. /homesec/{plugins/notifiers → notifiers}/multiplex.py +0 -0
  53. {homesec-1.2.1.dist-info → homesec-1.2.3.dist-info}/WHEEL +0 -0
  54. {homesec-1.2.1.dist-info → homesec-1.2.3.dist-info}/entry_points.txt +0 -0
  55. {homesec-1.2.1.dist-info → homesec-1.2.3.dist-info}/licenses/LICENSE +0 -0
homesec/models/config.py CHANGED
@@ -2,34 +2,14 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Any, TypeVar
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 import FtpSourceConfig, LocalFolderSourceConfig, RTSPSourceConfig
12
10
  from homesec.models.vlm import VLMConfig
13
11
 
14
12
 
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, exclude=True)
30
- trigger_classes: list[str] = Field(default_factory=list, exclude=True)
31
-
32
-
33
13
  class AlertPolicyConfig(BaseModel):
34
14
  """Alert policy plugin configuration."""
35
15
 
@@ -44,31 +24,6 @@ class AlertPolicyConfig(BaseModel):
44
24
  return value.lower()
45
25
  return value
46
26
 
47
- @model_validator(mode="after")
48
- def _validate_alert_policy(self) -> AlertPolicyConfig:
49
- # Validate and reassign config for built-in backends
50
- if self.backend == "default" and isinstance(self.config, dict):
51
- validated = DefaultAlertPolicySettings.model_validate(self.config)
52
- object.__setattr__(self, "config", validated)
53
- return self
54
-
55
-
56
- class DropboxStorageConfig(BaseModel):
57
- """Dropbox storage configuration."""
58
-
59
- root: str
60
- token_env: str = "DROPBOX_TOKEN"
61
- app_key_env: str = "DROPBOX_APP_KEY"
62
- app_secret_env: str = "DROPBOX_APP_SECRET"
63
- refresh_token_env: str = "DROPBOX_REFRESH_TOKEN"
64
- web_url_prefix: str = "https://www.dropbox.com/home"
65
-
66
-
67
- class LocalStorageConfig(BaseModel):
68
- """Local storage configuration."""
69
-
70
- root: str = "./storage"
71
-
72
27
 
73
28
  class StoragePathsConfig(BaseModel):
74
29
  """Logical storage paths for different artifact types."""
@@ -79,17 +34,12 @@ class StoragePathsConfig(BaseModel):
79
34
 
80
35
 
81
36
  class StorageConfig(BaseModel):
82
- """Storage backend configuration.
37
+ """Storage backend configuration."""
83
38
 
84
- Note: Backend names are validated against the registry at runtime via
85
- validate_plugin_names(). This allows third-party storage plugins via entry points.
86
- """
87
-
88
- model_config = {"extra": "allow"} # Allow third-party backend configs
39
+ model_config = {"extra": "forbid"}
89
40
 
90
41
  backend: str = "dropbox"
91
- dropbox: DropboxStorageConfig | None = None
92
- local: LocalStorageConfig | None = None
42
+ config: dict[str, Any] | BaseModel = Field(default_factory=dict)
93
43
  paths: StoragePathsConfig = Field(default_factory=StoragePathsConfig)
94
44
 
95
45
  @field_validator("backend", mode="before")
@@ -99,30 +49,6 @@ class StorageConfig(BaseModel):
99
49
  return value.lower()
100
50
  return value
101
51
 
102
- @model_validator(mode="after")
103
- def _validate_builtin_backends(self) -> StorageConfig:
104
- """Validate that built-in backends have their required config.
105
-
106
- Third-party backends are validated later in load_storage_plugin().
107
- """
108
- match self.backend:
109
- case "dropbox":
110
- if self.dropbox is None:
111
- raise ValueError(
112
- "storage.dropbox is required when backend=dropbox. "
113
- "Add 'storage.dropbox' section to your config."
114
- )
115
- case "local":
116
- if self.local is None:
117
- raise ValueError(
118
- "storage.local is required when backend=local. "
119
- "Add 'storage.local' section to your config."
120
- )
121
- case _:
122
- # Third-party backend - validation happens in load_storage_plugin()
123
- pass
124
- return self
125
-
126
52
 
127
53
  class StateStoreConfig(BaseModel):
128
54
  """State store configuration."""
@@ -137,81 +63,12 @@ class StateStoreConfig(BaseModel):
137
63
  return self
138
64
 
139
65
 
140
- class MQTTAuthConfig(BaseModel):
141
- """MQTT auth configuration using env var names."""
142
-
143
- username_env: str | None = None
144
- password_env: str | None = None
145
-
146
-
147
- class MQTTConfig(BaseModel):
148
- """MQTT notifier configuration."""
149
-
150
- host: str
151
- port: int = 1883
152
- auth: MQTTAuthConfig | None = None
153
- topic_template: str = "homecam/alerts/{camera_name}"
154
- qos: int = 1
155
- retain: bool = False
156
- connection_timeout: float = 10.0
157
-
158
-
159
- class SendGridEmailConfig(BaseModel):
160
- """SendGrid email notifier configuration."""
161
-
162
- api_key_env: str = "SENDGRID_API_KEY"
163
- from_email: str
164
- from_name: str | None = None
165
- to_emails: list[str] = Field(min_length=1)
166
- cc_emails: list[str] = Field(default_factory=list)
167
- bcc_emails: list[str] = Field(default_factory=list)
168
- subject_template: str = "[HomeSec] {camera_name}: {activity_type} ({risk_level})"
169
- text_template: str = (
170
- "HomeSec alert\n"
171
- "Camera: {camera_name}\n"
172
- "Clip: {clip_id}\n"
173
- "Risk: {risk_level}\n"
174
- "Activity: {activity_type}\n"
175
- "Reason: {notify_reason}\n"
176
- "Summary: {summary}\n"
177
- "View: {view_url}\n"
178
- "Storage: {storage_uri}\n"
179
- "Time: {ts}\n"
180
- "Upload failed: {upload_failed}\n"
181
- )
182
- html_template: str = (
183
- "<html><body>"
184
- "<h2>HomeSec alert</h2>"
185
- "<p><strong>Camera:</strong> {camera_name}</p>"
186
- "<p><strong>Clip:</strong> {clip_id}</p>"
187
- "<p><strong>Risk:</strong> {risk_level}</p>"
188
- "<p><strong>Activity:</strong> {activity_type}</p>"
189
- "<p><strong>Reason:</strong> {notify_reason}</p>"
190
- "<p><strong>Summary:</strong> {summary}</p>"
191
- '<p><strong>View:</strong> <a href="{view_url}">{view_url}</a></p>'
192
- "<p><strong>Storage:</strong> {storage_uri}</p>"
193
- "<p><strong>Time:</strong> {ts}</p>"
194
- "<p><strong>Upload failed:</strong> {upload_failed}</p>"
195
- "<h3>Structured analysis</h3>"
196
- "{analysis_html}"
197
- "</body></html>"
198
- )
199
- request_timeout_s: float = 10.0
200
- api_base: str = "https://api.sendgrid.com/v3"
201
-
202
- @model_validator(mode="after")
203
- def _validate_templates(self) -> SendGridEmailConfig:
204
- if not self.text_template and not self.html_template:
205
- raise ValueError("sendgrid_email requires at least one of text_template/html_template")
206
- return self
207
-
208
-
209
66
  class NotifierConfig(BaseModel):
210
67
  """Notifier configuration entry."""
211
68
 
212
69
  backend: str
213
70
  enabled: bool = True
214
- config: dict[str, Any] = Field(default_factory=dict)
71
+ config: dict[str, Any] | BaseModel = Field(default_factory=dict)
215
72
 
216
73
  @field_validator("backend", mode="before")
217
74
  @classmethod
@@ -252,72 +109,20 @@ class HealthConfig(BaseModel):
252
109
 
253
110
 
254
111
  class CameraSourceConfig(BaseModel):
255
- """Camera source configuration wrapper.
256
-
257
- The `type` field is a string to allow external plugins to register new source types.
258
- Built-in types ("rtsp", "local_folder", "ftp") have their configs validated here.
259
- External plugin configs are validated by the plugin at load time.
260
- """
112
+ """Camera source configuration wrapper."""
261
113
 
262
114
  model_config = {"extra": "forbid"}
263
115
 
264
- type: str
265
- config: RTSPSourceConfig | LocalFolderSourceConfig | FtpSourceConfig | dict[str, Any]
116
+ backend: str
117
+ config: dict[str, Any] | BaseModel = Field(default_factory=dict)
266
118
 
267
- @model_validator(mode="before")
268
- @classmethod
269
- def _parse_source_config(cls, data: object) -> object:
270
- if not isinstance(data, dict):
271
- return data
272
- source_type = data.get("type")
273
- raw_config = data.get("config")
274
- if isinstance(raw_config, (RTSPSourceConfig, LocalFolderSourceConfig, FtpSourceConfig)):
275
- return data
276
-
277
- config_data = raw_config or {}
278
- updated = dict(data)
279
- if isinstance(source_type, str):
280
- source_type = source_type.lower()
281
- updated["type"] = source_type
282
- match source_type:
283
- case "rtsp":
284
- updated["config"] = RTSPSourceConfig.model_validate(config_data)
285
- case "local_folder":
286
- updated["config"] = LocalFolderSourceConfig.model_validate(config_data)
287
- case "ftp":
288
- updated["config"] = FtpSourceConfig.model_validate(config_data)
289
- case _:
290
- # External plugin source type - keep config as dict
291
- # Plugin will validate at load time
292
- updated["config"] = config_data if isinstance(config_data, dict) else {}
293
- return updated
294
-
295
- @field_validator("type", mode="before")
119
+ @field_validator("backend", mode="before")
296
120
  @classmethod
297
- def _normalize_type(cls, value: Any) -> Any:
121
+ def _normalize_backend(cls, value: Any) -> Any:
298
122
  if isinstance(value, str):
299
123
  return value.lower()
300
124
  return value
301
125
 
302
- @model_validator(mode="after")
303
- def _validate_source_config(self) -> CameraSourceConfig:
304
- match self.type:
305
- case "rtsp":
306
- if not isinstance(self.config, RTSPSourceConfig):
307
- raise ValueError("camera.source.config must be RTSPSourceConfig for type=rtsp")
308
- case "local_folder":
309
- if not isinstance(self.config, LocalFolderSourceConfig):
310
- raise ValueError(
311
- "camera.source.config must be LocalFolderSourceConfig for type=local_folder"
312
- )
313
- case "ftp":
314
- if not isinstance(self.config, FtpSourceConfig):
315
- raise ValueError("camera.source.config must be FtpSourceConfig for type=ftp")
316
- case _:
317
- # External plugin source type - validation happens at plugin load time
318
- pass
319
- return self
320
-
321
126
 
322
127
  class CameraConfig(BaseModel):
323
128
  """Camera configuration and clip source selection."""
@@ -326,9 +131,6 @@ class CameraConfig(BaseModel):
326
131
  source: CameraSourceConfig
327
132
 
328
133
 
329
- TConfig = TypeVar("TConfig", bound=BaseModel)
330
-
331
-
332
134
  class Config(BaseModel):
333
135
  """Main configuration with per-camera override support."""
334
136
 
@@ -345,59 +147,8 @@ class Config(BaseModel):
345
147
  vlm: VLMConfig
346
148
  alert_policy: AlertPolicyConfig
347
149
 
348
- # Per-camera overrides (alert policy only)
349
- per_camera_alert: dict[str, AlertPolicyOverrides] = Field(default_factory=dict)
350
-
351
150
  @model_validator(mode="after")
352
151
  def _validate_notifiers(self) -> Config:
353
152
  if not self.notifiers:
354
153
  raise ValueError("notifiers must include at least one notifier")
355
154
  return self
356
-
357
- @model_validator(mode="after")
358
- def _validate_builtin_plugin_configs(self) -> Config:
359
- """Validate built-in plugin configs for early error detection.
360
-
361
- Third-party plugin configs are validated later during plugin loading.
362
- """
363
- # Validate built-in filter configs
364
- if self.filter.plugin == "yolo":
365
- from homesec.models.filter import YoloFilterSettings
366
-
367
- if isinstance(self.filter.config, dict):
368
- validated_filter_config = YoloFilterSettings.model_validate(self.filter.config)
369
- # Replace dict with validated object for built-in plugins
370
- object.__setattr__(self.filter, "config", validated_filter_config)
371
-
372
- # Validate built-in VLM configs
373
- if self.vlm.backend == "openai":
374
- from homesec.models.vlm import OpenAILLMConfig
375
-
376
- if isinstance(self.vlm.llm, dict):
377
- validated_llm_config = OpenAILLMConfig.model_validate(self.vlm.llm)
378
- # Replace dict with validated object for built-in plugins
379
- object.__setattr__(self.vlm, "llm", validated_llm_config)
380
-
381
- return self
382
-
383
- def _merge_overrides(
384
- self, base: TConfig, overrides: BaseModel, model_type: type[TConfig]
385
- ) -> TConfig:
386
- merged = {
387
- **base.model_dump(),
388
- **overrides.model_dump(exclude_none=True),
389
- }
390
- return model_type.model_validate(merged)
391
-
392
- def get_default_alert_policy(self, camera_name: str) -> DefaultAlertPolicySettings:
393
- """Get merged default alert policy settings for a specific camera."""
394
- if self.alert_policy.backend != "default":
395
- raise ValueError(
396
- f"default alert policy requested but backend is {self.alert_policy.backend}"
397
- )
398
- base = DefaultAlertPolicySettings.model_validate(self.alert_policy.config)
399
- if camera_name not in self.per_camera_alert:
400
- return base
401
-
402
- overrides = self.per_camera_alert[camera_name]
403
- 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
@@ -26,7 +26,7 @@ class ClipRecordedEvent(ClipEvent):
26
26
  event_type: Literal[EventType.CLIP_RECORDED] = EventType.CLIP_RECORDED
27
27
  camera_name: str
28
28
  duration_s: float
29
- source_type: str
29
+ source_backend: str
30
30
 
31
31
 
32
32
  class ClipDeletedEvent(ClipEvent):
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
- plugin: str
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("plugin", mode="before")
47
+ @field_validator("backend", mode="before")
66
48
  @classmethod
67
- def _normalize_plugin(cls, value: Any) -> Any:
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
- LLM-specific config is stored in the 'llm' field.
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
- max_workers: int = Field(default=2, ge=1)
101
- llm: dict[str, Any] | BaseModel # Dict before validation, BaseModel after
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
@@ -1,6 +1,5 @@
1
1
  """Pipeline module - clip processing orchestration."""
2
2
 
3
- from homesec.pipeline.alert_policy import DefaultAlertPolicy
4
3
  from homesec.pipeline.core import ClipPipeline
5
4
 
6
- __all__ = ["ClipPipeline", "DefaultAlertPolicy"]
5
+ __all__ = ["ClipPipeline"]
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.plugins.notifiers.multiplex import NotifierEntry
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(clip.camera_name, filter_res):
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.plugin, cause=e)
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, camera_name: str, filter_result: FilterResult) -> bool:
567
+ def _should_run_vlm(self, filter_result: FilterResult) -> bool:
568
568
  """Check if VLM should run based on detected classes and config."""
569
- if self._config.alert_policy.backend == "default":
570
- alert_config = self._config.get_default_alert_policy(camera_name)
571
- # If notify_on_motion enabled, always run VLM for richer context
572
- if alert_config.notify_on_motion:
573
- return True
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
- overrides=per_camera_overrides,
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.config import DefaultAlertPolicySettings
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."""
@@ -30,9 +30,7 @@ def load_analyzer(config: VLMConfig) -> VLMAnalyzer:
30
30
  load_plugin(
31
31
  PluginType.ANALYZER,
32
32
  config.backend,
33
- config.llm,
34
- trigger_classes=config.trigger_classes,
35
- max_workers=config.max_workers,
33
+ config.config,
36
34
  ),
37
35
  )
38
36