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.
Files changed (50) 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 -25
  9. homesec/models/clip.py +1 -1
  10. homesec/models/config.py +10 -261
  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 +1 -1
  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 -6
  36. homesec/sources/ftp.py +95 -2
  37. homesec/sources/local_folder.py +27 -2
  38. homesec/sources/rtsp/core.py +162 -2
  39. {homesec-1.2.2.dist-info → homesec-1.2.3.dist-info}/METADATA +7 -12
  40. homesec-1.2.3.dist-info/RECORD +73 -0
  41. homesec/models/source/__init__.py +0 -3
  42. homesec/models/source/ftp.py +0 -97
  43. homesec/models/source/local_folder.py +0 -30
  44. homesec/models/source/rtsp.py +0 -165
  45. homesec/pipeline/alert_policy.py +0 -5
  46. homesec-1.2.2.dist-info/RECORD +0 -78
  47. /homesec/{plugins/notifiers → notifiers}/multiplex.py +0 -0
  48. {homesec-1.2.2.dist-info → homesec-1.2.3.dist-info}/WHEEL +0 -0
  49. {homesec-1.2.2.dist-info → homesec-1.2.3.dist-info}/entry_points.txt +0 -0
  50. {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, 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.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
- Note: Backend names are validated against the registry at runtime via
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
- dropbox: DropboxStorageConfig | None = None
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
- type: str
267
- config: RTSPSourceConfig | LocalFolderSourceConfig | FtpSourceConfig | dict[str, Any]
116
+ backend: str
117
+ config: dict[str, Any] | BaseModel = Field(default_factory=dict)
268
118
 
269
- @model_validator(mode="before")
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 _normalize_type(cls, value: Any) -> Any:
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
@@ -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