homesec 1.1.0__py3-none-any.whl → 1.1.2__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 (45) hide show
  1. homesec/__init__.py +1 -1
  2. homesec/app.py +38 -84
  3. homesec/cli.py +9 -10
  4. homesec/config/validation.py +38 -12
  5. homesec/interfaces.py +50 -2
  6. homesec/maintenance/cleanup_clips.py +4 -4
  7. homesec/models/__init__.py +6 -5
  8. homesec/models/alert.py +3 -2
  9. homesec/models/clip.py +4 -2
  10. homesec/models/config.py +62 -17
  11. homesec/models/enums.py +114 -0
  12. homesec/models/events.py +19 -18
  13. homesec/models/filter.py +13 -3
  14. homesec/models/source.py +3 -0
  15. homesec/models/vlm.py +18 -7
  16. homesec/plugins/__init__.py +7 -33
  17. homesec/plugins/alert_policies/__init__.py +34 -59
  18. homesec/plugins/alert_policies/default.py +20 -45
  19. homesec/plugins/alert_policies/noop.py +14 -29
  20. homesec/plugins/analyzers/__init__.py +20 -105
  21. homesec/plugins/analyzers/openai.py +70 -53
  22. homesec/plugins/filters/__init__.py +18 -102
  23. homesec/plugins/filters/yolo.py +103 -66
  24. homesec/plugins/notifiers/__init__.py +20 -56
  25. homesec/plugins/notifiers/mqtt.py +22 -30
  26. homesec/plugins/notifiers/sendgrid_email.py +34 -32
  27. homesec/plugins/registry.py +160 -0
  28. homesec/plugins/sources/__init__.py +45 -0
  29. homesec/plugins/sources/ftp.py +25 -0
  30. homesec/plugins/sources/local_folder.py +30 -0
  31. homesec/plugins/sources/rtsp.py +27 -0
  32. homesec/plugins/storage/__init__.py +18 -88
  33. homesec/plugins/storage/dropbox.py +36 -37
  34. homesec/plugins/storage/local.py +8 -29
  35. homesec/plugins/utils.py +8 -4
  36. homesec/repository/clip_repository.py +20 -14
  37. homesec/sources/base.py +24 -2
  38. homesec/sources/local_folder.py +57 -78
  39. homesec/state/postgres.py +46 -17
  40. {homesec-1.1.0.dist-info → homesec-1.1.2.dist-info}/METADATA +1 -1
  41. homesec-1.1.2.dist-info/RECORD +68 -0
  42. homesec-1.1.0.dist-info/RECORD +0 -62
  43. {homesec-1.1.0.dist-info → homesec-1.1.2.dist-info}/WHEEL +0 -0
  44. {homesec-1.1.0.dist-info → homesec-1.1.2.dist-info}/entry_points.txt +0 -0
  45. {homesec-1.1.0.dist-info → homesec-1.1.2.dist-info}/licenses/LICENSE +0 -0
homesec/models/config.py CHANGED
@@ -2,22 +2,20 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Literal, TypeVar
5
+ from typing import Any, TypeVar
6
6
 
7
- from pydantic import BaseModel, Field, model_validator
7
+ from pydantic import BaseModel, Field, field_validator, model_validator
8
8
 
9
+ from homesec.models.enums import RiskLevel, RiskLevelField
9
10
  from homesec.models.filter import FilterConfig
10
11
  from homesec.models.source import FtpSourceConfig, LocalFolderSourceConfig, RTSPSourceConfig
11
- from homesec.models.vlm import (
12
- RiskLevel,
13
- VLMConfig,
14
- )
12
+ from homesec.models.vlm import VLMConfig
15
13
 
16
14
 
17
15
  class AlertPolicyOverrides(BaseModel):
18
16
  """Per-camera alert policy overrides (only non-None fields override base)."""
19
17
 
20
- min_risk_level: RiskLevel | None = None
18
+ min_risk_level: RiskLevelField | None = None
21
19
  notify_on_activity_types: list[str] | None = None
22
20
  notify_on_motion: bool | None = None
23
21
 
@@ -25,9 +23,11 @@ class AlertPolicyOverrides(BaseModel):
25
23
  class DefaultAlertPolicySettings(BaseModel):
26
24
  """Default alert policy settings."""
27
25
 
28
- min_risk_level: RiskLevel = "medium"
26
+ min_risk_level: RiskLevelField = RiskLevel.MEDIUM
29
27
  notify_on_activity_types: list[str] = Field(default_factory=list)
30
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
31
 
32
32
 
33
33
  class AlertPolicyConfig(BaseModel):
@@ -35,12 +35,21 @@ class AlertPolicyConfig(BaseModel):
35
35
 
36
36
  backend: str = "default"
37
37
  enabled: bool = True
38
- config: dict[str, object] = Field(default_factory=dict)
38
+ config: dict[str, Any] | BaseModel = Field(default_factory=dict)
39
+
40
+ @field_validator("backend", mode="before")
41
+ @classmethod
42
+ def _normalize_backend(cls, value: Any) -> Any:
43
+ if isinstance(value, str):
44
+ return value.lower()
45
+ return value
39
46
 
40
47
  @model_validator(mode="after")
41
48
  def _validate_alert_policy(self) -> AlertPolicyConfig:
42
- if self.backend == "default":
43
- DefaultAlertPolicySettings.model_validate(self.config)
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)
44
53
  return self
45
54
 
46
55
 
@@ -83,11 +92,18 @@ class StorageConfig(BaseModel):
83
92
  local: LocalStorageConfig | None = None
84
93
  paths: StoragePathsConfig = Field(default_factory=StoragePathsConfig)
85
94
 
95
+ @field_validator("backend", mode="before")
96
+ @classmethod
97
+ def _normalize_backend(cls, value: Any) -> Any:
98
+ if isinstance(value, str):
99
+ return value.lower()
100
+ return value
101
+
86
102
  @model_validator(mode="after")
87
103
  def _validate_builtin_backends(self) -> StorageConfig:
88
104
  """Validate that built-in backends have their required config.
89
105
 
90
- Third-party backends are validated later in create_storage().
106
+ Third-party backends are validated later in load_storage_plugin().
91
107
  """
92
108
  match self.backend:
93
109
  case "dropbox":
@@ -103,7 +119,7 @@ class StorageConfig(BaseModel):
103
119
  "Add 'storage.local' section to your config."
104
120
  )
105
121
  case _:
106
- # Third-party backend - validation happens in create_storage()
122
+ # Third-party backend - validation happens in load_storage_plugin()
107
123
  pass
108
124
  return self
109
125
 
@@ -195,7 +211,14 @@ class NotifierConfig(BaseModel):
195
211
 
196
212
  backend: str
197
213
  enabled: bool = True
198
- config: dict[str, object] = Field(default_factory=dict)
214
+ config: dict[str, Any] = Field(default_factory=dict)
215
+
216
+ @field_validator("backend", mode="before")
217
+ @classmethod
218
+ def _normalize_backend(cls, value: Any) -> Any:
219
+ if isinstance(value, str):
220
+ return value.lower()
221
+ return value
199
222
 
200
223
 
201
224
  class RetentionConfig(BaseModel):
@@ -229,12 +252,17 @@ class HealthConfig(BaseModel):
229
252
 
230
253
 
231
254
  class CameraSourceConfig(BaseModel):
232
- """Camera source configuration wrapper."""
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
+ """
233
261
 
234
262
  model_config = {"extra": "forbid"}
235
263
 
236
- type: Literal["rtsp", "local_folder", "ftp"]
237
- config: RTSPSourceConfig | LocalFolderSourceConfig | FtpSourceConfig
264
+ type: str
265
+ config: RTSPSourceConfig | LocalFolderSourceConfig | FtpSourceConfig | dict[str, Any]
238
266
 
239
267
  @model_validator(mode="before")
240
268
  @classmethod
@@ -248,6 +276,9 @@ class CameraSourceConfig(BaseModel):
248
276
 
249
277
  config_data = raw_config or {}
250
278
  updated = dict(data)
279
+ if isinstance(source_type, str):
280
+ source_type = source_type.lower()
281
+ updated["type"] = source_type
251
282
  match source_type:
252
283
  case "rtsp":
253
284
  updated["config"] = RTSPSourceConfig.model_validate(config_data)
@@ -255,8 +286,19 @@ class CameraSourceConfig(BaseModel):
255
286
  updated["config"] = LocalFolderSourceConfig.model_validate(config_data)
256
287
  case "ftp":
257
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 {}
258
293
  return updated
259
294
 
295
+ @field_validator("type", mode="before")
296
+ @classmethod
297
+ def _normalize_type(cls, value: Any) -> Any:
298
+ if isinstance(value, str):
299
+ return value.lower()
300
+ return value
301
+
260
302
  @model_validator(mode="after")
261
303
  def _validate_source_config(self) -> CameraSourceConfig:
262
304
  match self.type:
@@ -271,6 +313,9 @@ class CameraSourceConfig(BaseModel):
271
313
  case "ftp":
272
314
  if not isinstance(self.config, FtpSourceConfig):
273
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
274
319
  return self
275
320
 
276
321
 
@@ -0,0 +1,114 @@
1
+ """Centralized enums for type safety and IDE support."""
2
+
3
+ from enum import IntEnum, StrEnum
4
+ from typing import Annotated, Any
5
+
6
+ from pydantic import BeforeValidator, PlainSerializer
7
+
8
+
9
+ def _validate_risk_level(value: Any) -> "RiskLevel":
10
+ """Validate and convert input to RiskLevel.
11
+
12
+ Accepts:
13
+ - RiskLevel enum member
14
+ - Integer (0-3)
15
+ - String ("low", "medium", "high", "critical", case-insensitive)
16
+ """
17
+ if isinstance(value, RiskLevel):
18
+ return value
19
+ if isinstance(value, int):
20
+ return RiskLevel(value)
21
+ if isinstance(value, str):
22
+ return RiskLevel.from_string(value)
23
+ raise ValueError(f"Cannot convert {type(value).__name__} to RiskLevel")
24
+
25
+
26
+ def _serialize_risk_level(value: "RiskLevel") -> str:
27
+ """Serialize RiskLevel to lowercase string for config compatibility."""
28
+ return str(value)
29
+
30
+
31
+ class EventType(StrEnum):
32
+ """All clip lifecycle event types.
33
+
34
+ Used in event models and the event store for type-safe event handling.
35
+ """
36
+
37
+ CLIP_RECORDED = "clip_recorded"
38
+ CLIP_DELETED = "clip_deleted"
39
+ CLIP_RECHECKED = "clip_rechecked"
40
+ UPLOAD_STARTED = "upload_started"
41
+ UPLOAD_COMPLETED = "upload_completed"
42
+ UPLOAD_FAILED = "upload_failed"
43
+ FILTER_STARTED = "filter_started"
44
+ FILTER_COMPLETED = "filter_completed"
45
+ FILTER_FAILED = "filter_failed"
46
+ VLM_STARTED = "vlm_started"
47
+ VLM_COMPLETED = "vlm_completed"
48
+ VLM_FAILED = "vlm_failed"
49
+ VLM_SKIPPED = "vlm_skipped"
50
+ ALERT_DECISION_MADE = "alert_decision_made"
51
+ NOTIFICATION_SENT = "notification_sent"
52
+ NOTIFICATION_FAILED = "notification_failed"
53
+
54
+
55
+ class ClipStatus(StrEnum):
56
+ """Clip processing status values.
57
+
58
+ Represents the high-level status of a clip in the processing pipeline.
59
+ """
60
+
61
+ QUEUED_LOCAL = "queued_local"
62
+ UPLOADED = "uploaded"
63
+ ANALYZED = "analyzed"
64
+ DONE = "done"
65
+ ERROR = "error"
66
+ DELETED = "deleted"
67
+
68
+
69
+ class RiskLevel(IntEnum):
70
+ """VLM risk assessment levels.
71
+
72
+ Uses IntEnum for natural ordering and comparison:
73
+ RiskLevel.HIGH > RiskLevel.LOW # True
74
+ RiskLevel.MEDIUM >= RiskLevel.MEDIUM # True
75
+
76
+ String serialization is handled by Pydantic for config compatibility.
77
+ """
78
+
79
+ LOW = 0
80
+ MEDIUM = 1
81
+ HIGH = 2
82
+ CRITICAL = 3
83
+
84
+ def __str__(self) -> str:
85
+ """Return lowercase name for human-readable output."""
86
+ return self.name.lower()
87
+
88
+ @classmethod
89
+ def from_string(cls, value: str) -> "RiskLevel":
90
+ """Parse risk level from string (case-insensitive).
91
+
92
+ Args:
93
+ value: Risk level string (e.g., "low", "MEDIUM", "High")
94
+
95
+ Returns:
96
+ Corresponding RiskLevel enum member
97
+
98
+ Raises:
99
+ ValueError: If value is not a valid risk level
100
+ """
101
+ try:
102
+ return cls[value.upper()]
103
+ except KeyError:
104
+ valid = ", ".join(member.name.lower() for member in cls)
105
+ raise ValueError(f"Invalid risk level '{value}'. Valid values: {valid}") from None
106
+
107
+
108
+ # Pydantic-compatible type that accepts strings, ints, and RiskLevel
109
+ # Use this in Pydantic models instead of raw RiskLevel
110
+ RiskLevelField = Annotated[
111
+ RiskLevel,
112
+ BeforeValidator(_validate_risk_level),
113
+ PlainSerializer(_serialize_risk_level, return_type=str),
114
+ ]
homesec/models/events.py CHANGED
@@ -7,6 +7,7 @@ from typing import Annotated, Any, Literal
7
7
 
8
8
  from pydantic import BaseModel, Field
9
9
 
10
+ from homesec.models.enums import EventType, RiskLevelField
10
11
  from homesec.models.filter import FilterResult
11
12
 
12
13
 
@@ -22,7 +23,7 @@ class ClipEvent(BaseModel):
22
23
  class ClipRecordedEvent(ClipEvent):
23
24
  """Clip was recorded and queued for processing."""
24
25
 
25
- event_type: Literal["clip_recorded"] = "clip_recorded"
26
+ event_type: Literal[EventType.CLIP_RECORDED] = EventType.CLIP_RECORDED
26
27
  camera_name: str
27
28
  duration_s: float
28
29
  source_type: str
@@ -31,7 +32,7 @@ class ClipRecordedEvent(ClipEvent):
31
32
  class ClipDeletedEvent(ClipEvent):
32
33
  """Clip was deleted by a maintenance workflow (e.g., cleanup CLI)."""
33
34
 
34
- event_type: Literal["clip_deleted"] = "clip_deleted"
35
+ event_type: Literal[EventType.CLIP_DELETED] = EventType.CLIP_DELETED
35
36
  camera_name: str
36
37
  reason: str
37
38
  run_id: str
@@ -44,7 +45,7 @@ class ClipDeletedEvent(ClipEvent):
44
45
  class ClipRecheckedEvent(ClipEvent):
45
46
  """Clip was re-analyzed by a maintenance workflow."""
46
47
 
47
- event_type: Literal["clip_rechecked"] = "clip_rechecked"
48
+ event_type: Literal[EventType.CLIP_RECHECKED] = EventType.CLIP_RECHECKED
48
49
  camera_name: str
49
50
  reason: str
50
51
  run_id: str
@@ -55,7 +56,7 @@ class ClipRecheckedEvent(ClipEvent):
55
56
  class UploadStartedEvent(ClipEvent):
56
57
  """Upload to storage backend started."""
57
58
 
58
- event_type: Literal["upload_started"] = "upload_started"
59
+ event_type: Literal[EventType.UPLOAD_STARTED] = EventType.UPLOAD_STARTED
59
60
  dest_key: str
60
61
  attempt: int
61
62
 
@@ -63,7 +64,7 @@ class UploadStartedEvent(ClipEvent):
63
64
  class UploadCompletedEvent(ClipEvent):
64
65
  """Upload to storage backend completed successfully."""
65
66
 
66
- event_type: Literal["upload_completed"] = "upload_completed"
67
+ event_type: Literal[EventType.UPLOAD_COMPLETED] = EventType.UPLOAD_COMPLETED
67
68
  storage_uri: str
68
69
  view_url: str | None
69
70
  attempt: int
@@ -73,7 +74,7 @@ class UploadCompletedEvent(ClipEvent):
73
74
  class UploadFailedEvent(ClipEvent):
74
75
  """Upload to storage backend failed."""
75
76
 
76
- event_type: Literal["upload_failed"] = "upload_failed"
77
+ event_type: Literal[EventType.UPLOAD_FAILED] = EventType.UPLOAD_FAILED
77
78
  attempt: int
78
79
  error_message: str
79
80
  error_type: str
@@ -83,14 +84,14 @@ class UploadFailedEvent(ClipEvent):
83
84
  class FilterStartedEvent(ClipEvent):
84
85
  """Object detection filter started."""
85
86
 
86
- event_type: Literal["filter_started"] = "filter_started"
87
+ event_type: Literal[EventType.FILTER_STARTED] = EventType.FILTER_STARTED
87
88
  attempt: int
88
89
 
89
90
 
90
91
  class FilterCompletedEvent(ClipEvent):
91
92
  """Object detection filter completed."""
92
93
 
93
- event_type: Literal["filter_completed"] = "filter_completed"
94
+ event_type: Literal[EventType.FILTER_COMPLETED] = EventType.FILTER_COMPLETED
94
95
  detected_classes: list[str]
95
96
  confidence: float
96
97
  model: str
@@ -102,7 +103,7 @@ class FilterCompletedEvent(ClipEvent):
102
103
  class FilterFailedEvent(ClipEvent):
103
104
  """Object detection filter failed."""
104
105
 
105
- event_type: Literal["filter_failed"] = "filter_failed"
106
+ event_type: Literal[EventType.FILTER_FAILED] = EventType.FILTER_FAILED
106
107
  attempt: int
107
108
  error_message: str
108
109
  error_type: str
@@ -112,15 +113,15 @@ class FilterFailedEvent(ClipEvent):
112
113
  class VLMStartedEvent(ClipEvent):
113
114
  """VLM analysis started."""
114
115
 
115
- event_type: Literal["vlm_started"] = "vlm_started"
116
+ event_type: Literal[EventType.VLM_STARTED] = EventType.VLM_STARTED
116
117
  attempt: int
117
118
 
118
119
 
119
120
  class VLMCompletedEvent(ClipEvent):
120
121
  """VLM analysis completed."""
121
122
 
122
- event_type: Literal["vlm_completed"] = "vlm_completed"
123
- risk_level: str
123
+ event_type: Literal[EventType.VLM_COMPLETED] = EventType.VLM_COMPLETED
124
+ risk_level: RiskLevelField
124
125
  activity_type: str
125
126
  summary: str
126
127
  analysis: dict[str, Any]
@@ -133,7 +134,7 @@ class VLMCompletedEvent(ClipEvent):
133
134
  class VLMFailedEvent(ClipEvent):
134
135
  """VLM analysis failed."""
135
136
 
136
- event_type: Literal["vlm_failed"] = "vlm_failed"
137
+ event_type: Literal[EventType.VLM_FAILED] = EventType.VLM_FAILED
137
138
  attempt: int
138
139
  error_message: str
139
140
  error_type: str
@@ -143,24 +144,24 @@ class VLMFailedEvent(ClipEvent):
143
144
  class VLMSkippedEvent(ClipEvent):
144
145
  """VLM analysis skipped (no trigger classes detected)."""
145
146
 
146
- event_type: Literal["vlm_skipped"] = "vlm_skipped"
147
+ event_type: Literal[EventType.VLM_SKIPPED] = EventType.VLM_SKIPPED
147
148
  reason: str
148
149
 
149
150
 
150
151
  class AlertDecisionMadeEvent(ClipEvent):
151
152
  """Alert policy decision made."""
152
153
 
153
- event_type: Literal["alert_decision_made"] = "alert_decision_made"
154
+ event_type: Literal[EventType.ALERT_DECISION_MADE] = EventType.ALERT_DECISION_MADE
154
155
  should_notify: bool
155
156
  reason: str
156
157
  detected_classes: list[str] | None
157
- vlm_risk: str | None
158
+ vlm_risk: RiskLevelField | None
158
159
 
159
160
 
160
161
  class NotificationSentEvent(ClipEvent):
161
162
  """Notification sent successfully."""
162
163
 
163
- event_type: Literal["notification_sent"] = "notification_sent"
164
+ event_type: Literal[EventType.NOTIFICATION_SENT] = EventType.NOTIFICATION_SENT
164
165
  notifier_name: str
165
166
  dedupe_key: str
166
167
  attempt: int = 1
@@ -169,7 +170,7 @@ class NotificationSentEvent(ClipEvent):
169
170
  class NotificationFailedEvent(ClipEvent):
170
171
  """Notification send failed."""
171
172
 
172
- event_type: Literal["notification_failed"] = "notification_failed"
173
+ event_type: Literal[EventType.NOTIFICATION_FAILED] = EventType.NOTIFICATION_FAILED
173
174
  notifier_name: str
174
175
  error_message: str
175
176
  error_type: str
homesec/models/filter.py CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from pydantic import BaseModel, Field
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, Field, field_validator
6
8
 
7
9
 
8
10
  class FilterResult(BaseModel):
@@ -28,6 +30,7 @@ class YoloFilterSettings(BaseModel):
28
30
  sample_fps: int = Field(default=2, ge=1)
29
31
  min_box_h_ratio: float = Field(default=0.1, ge=0.0, le=1.0)
30
32
  min_hits: int = Field(default=1, ge=1)
33
+ max_workers: int = Field(default=4, ge=1)
31
34
 
32
35
 
33
36
  class FilterOverrides(BaseModel):
@@ -46,7 +49,7 @@ class FilterConfig(BaseModel):
46
49
  """Base filter configuration (plugin-agnostic).
47
50
 
48
51
  Plugin-specific config is stored in the 'config' field.
49
- - During YAML parsing: dict[str, object] (preserves all third-party fields)
52
+ - During YAML parsing: dict[str, Any] (preserves all third-party fields)
50
53
  - After plugin discovery: BaseModel subclass (validated against plugin.config_model)
51
54
 
52
55
  Note: Plugin names are validated against the registry at runtime via
@@ -57,4 +60,11 @@ class FilterConfig(BaseModel):
57
60
 
58
61
  plugin: str
59
62
  max_workers: int = Field(default=4, ge=1)
60
- config: dict[str, object] | BaseModel # Dict before validation, BaseModel after
63
+ config: dict[str, Any] | BaseModel # Dict before validation, BaseModel after
64
+
65
+ @field_validator("plugin", mode="before")
66
+ @classmethod
67
+ def _normalize_plugin(cls, value: Any) -> Any:
68
+ if isinstance(value, str):
69
+ return value.lower()
70
+ return value
homesec/models/source.py CHANGED
@@ -10,6 +10,7 @@ class RTSPSourceConfig(BaseModel):
10
10
 
11
11
  model_config = {"extra": "forbid"}
12
12
 
13
+ camera_name: str | None = None
13
14
  rtsp_url_env: str | None = None
14
15
  rtsp_url: str | None = None
15
16
  detect_rtsp_url_env: str | None = None
@@ -36,6 +37,7 @@ class LocalFolderSourceConfig(BaseModel):
36
37
 
37
38
  model_config = {"extra": "forbid"}
38
39
 
40
+ camera_name: str | None = None
39
41
  watch_dir: str = "recordings"
40
42
  poll_interval: float = 1.0
41
43
  stability_threshold_s: float = 3.0
@@ -46,6 +48,7 @@ class FtpSourceConfig(BaseModel):
46
48
 
47
49
  model_config = {"extra": "forbid"}
48
50
 
51
+ camera_name: str | None = None
49
52
  host: str = "0.0.0.0"
50
53
  port: int = 2121
51
54
  root_dir: str = "./ftp_incoming"
homesec/models/vlm.py CHANGED
@@ -2,17 +2,19 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Literal
5
+ from typing import Any, Literal
6
6
 
7
- from pydantic import BaseModel, Field
7
+ from pydantic import BaseModel, Field, field_validator
8
8
 
9
- RiskLevel = Literal["low", "medium", "high", "critical"]
9
+ from homesec.models.enums import RiskLevelField
10
+
11
+ __all__ = ["AnalysisResult", "VLMConfig", "VLMPreprocessConfig"]
10
12
 
11
13
 
12
14
  class AnalysisResult(BaseModel):
13
15
  """Structured result from VLM analysis of a video clip."""
14
16
 
15
- risk_level: RiskLevel
17
+ risk_level: RiskLevelField
16
18
  activity_type: str
17
19
  summary: str
18
20
  analysis: SequenceAnalysis | None = None
@@ -38,7 +40,7 @@ class SequenceAnalysis(BaseModel):
38
40
 
39
41
  model_config = {"extra": "forbid"}
40
42
  sequence_description: str
41
- max_risk_level: RiskLevel
43
+ max_risk_level: RiskLevelField
42
44
  primary_activity: Literal[
43
45
  "normal_delivery",
44
46
  "normal_visitor",
@@ -77,13 +79,15 @@ class OpenAILLMConfig(BaseModel):
77
79
  max_tokens: int | None = None
78
80
  temperature: float | None = 0.0
79
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)
80
84
 
81
85
 
82
86
  class VLMConfig(BaseModel):
83
87
  """Base VLM configuration.
84
88
 
85
89
  LLM-specific config is stored in the 'llm' field.
86
- - During YAML parsing: dict[str, object] (preserves all third-party fields)
90
+ - During YAML parsing: dict[str, Any] (preserves all third-party fields)
87
91
  - After plugin discovery: BaseModel subclass (validated against plugin.config_model)
88
92
 
89
93
  Note: Backend names are validated against the registry at runtime via
@@ -94,5 +98,12 @@ class VLMConfig(BaseModel):
94
98
  backend: str
95
99
  trigger_classes: list[str] = Field(default_factory=lambda: ["person"])
96
100
  max_workers: int = Field(default=2, ge=1)
97
- llm: dict[str, object] | BaseModel # Dict before validation, BaseModel after
101
+ llm: dict[str, Any] | BaseModel # Dict before validation, BaseModel after
98
102
  preprocessing: VLMPreprocessConfig = Field(default_factory=VLMPreprocessConfig)
103
+
104
+ @field_validator("backend", mode="before")
105
+ @classmethod
106
+ def _normalize_backend(cls, value: Any) -> Any:
107
+ if isinstance(value, str):
108
+ return value.lower()
109
+ return value
@@ -19,44 +19,18 @@ def discover_all_plugins() -> None:
19
19
  triggers registration automatically.
20
20
  """
21
21
  # 1. Discover built-in plugins by importing all modules
22
- plugin_types = ["filters", "analyzers", "storage", "notifiers", "alert_policies"]
22
+ plugin_types = ["filters", "analyzers", "storage", "notifiers", "alert_policies", "sources"]
23
23
 
24
24
  for plugin_type in plugin_types:
25
- try:
26
- package = importlib.import_module(f"homesec.plugins.{plugin_type}")
27
- for _, module_name, _ in pkgutil.iter_modules(package.__path__):
28
- if module_name.startswith("_"):
29
- continue # Skip private modules
30
- try:
31
- importlib.import_module(f"homesec.plugins.{plugin_type}.{module_name}")
32
- except Exception as exc:
33
- logger.error(
34
- "Failed to import built-in plugin module %s.%s: %s",
35
- plugin_type,
36
- module_name,
37
- exc,
38
- exc_info=True,
39
- )
40
- except Exception as exc:
41
- logger.error(
42
- "Failed to discover built-in plugins for %s: %s",
43
- plugin_type,
44
- exc,
45
- exc_info=True,
46
- )
25
+ package = importlib.import_module(f"homesec.plugins.{plugin_type}")
26
+ for _, module_name, _ in pkgutil.iter_modules(package.__path__):
27
+ if module_name.startswith("_"):
28
+ continue # Skip private modules
29
+ importlib.import_module(f"homesec.plugins.{plugin_type}.{module_name}")
47
30
 
48
31
  # 2. Discover external plugins via entry points
49
32
  for point in iter_entry_points("homesec.plugins"):
50
- try:
51
- importlib.import_module(point.module)
52
- except Exception as exc:
53
- logger.error(
54
- "Failed to load external plugin %s from %s: %s",
55
- point.name,
56
- point.module,
57
- exc,
58
- exc_info=True,
59
- )
33
+ importlib.import_module(point.module)
60
34
 
61
35
 
62
36
  __all__ = ["discover_all_plugins"]