homesec 1.1.1__py3-none-any.whl → 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- homesec/__init__.py +1 -1
- homesec/app.py +38 -84
- homesec/cli.py +6 -10
- homesec/config/validation.py +38 -12
- homesec/interfaces.py +50 -2
- homesec/maintenance/cleanup_clips.py +4 -4
- homesec/models/__init__.py +6 -5
- homesec/models/alert.py +3 -2
- homesec/models/clip.py +4 -2
- homesec/models/config.py +62 -17
- homesec/models/enums.py +114 -0
- homesec/models/events.py +19 -18
- homesec/models/filter.py +13 -3
- homesec/models/source.py +4 -0
- homesec/models/vlm.py +18 -7
- homesec/plugins/__init__.py +7 -33
- homesec/plugins/alert_policies/__init__.py +34 -59
- homesec/plugins/alert_policies/default.py +20 -45
- homesec/plugins/alert_policies/noop.py +14 -29
- homesec/plugins/analyzers/__init__.py +20 -105
- homesec/plugins/analyzers/openai.py +70 -53
- homesec/plugins/filters/__init__.py +18 -102
- homesec/plugins/filters/yolo.py +103 -66
- homesec/plugins/notifiers/__init__.py +20 -56
- homesec/plugins/notifiers/mqtt.py +22 -30
- homesec/plugins/notifiers/sendgrid_email.py +34 -32
- homesec/plugins/registry.py +160 -0
- homesec/plugins/sources/__init__.py +45 -0
- homesec/plugins/sources/ftp.py +25 -0
- homesec/plugins/sources/local_folder.py +30 -0
- homesec/plugins/sources/rtsp.py +27 -0
- homesec/plugins/storage/__init__.py +18 -88
- homesec/plugins/storage/dropbox.py +36 -37
- homesec/plugins/storage/local.py +8 -29
- homesec/plugins/utils.py +8 -4
- homesec/repository/clip_repository.py +20 -14
- homesec/sources/base.py +24 -2
- homesec/sources/local_folder.py +57 -78
- homesec/sources/rtsp.py +45 -4
- homesec/state/postgres.py +46 -17
- {homesec-1.1.1.dist-info → homesec-1.2.0.dist-info}/METADATA +1 -1
- homesec-1.2.0.dist-info/RECORD +68 -0
- homesec-1.1.1.dist-info/RECORD +0 -62
- {homesec-1.1.1.dist-info → homesec-1.2.0.dist-info}/WHEEL +0 -0
- {homesec-1.1.1.dist-info → homesec-1.2.0.dist-info}/entry_points.txt +0 -0
- {homesec-1.1.1.dist-info → homesec-1.2.0.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
|
|
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:
|
|
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:
|
|
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,
|
|
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
|
-
|
|
43
|
-
|
|
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
|
|
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
|
|
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,
|
|
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:
|
|
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
|
|
homesec/models/enums.py
ADDED
|
@@ -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[
|
|
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[
|
|
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[
|
|
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[
|
|
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[
|
|
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[
|
|
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[
|
|
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[
|
|
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[
|
|
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[
|
|
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[
|
|
123
|
-
risk_level:
|
|
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[
|
|
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[
|
|
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[
|
|
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:
|
|
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[
|
|
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[
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
@@ -29,6 +30,7 @@ class RTSPSourceConfig(BaseModel):
|
|
|
29
30
|
heartbeat_s: float = 30.0
|
|
30
31
|
rtsp_connect_timeout_s: float = 2.0
|
|
31
32
|
rtsp_io_timeout_s: float = 2.0
|
|
33
|
+
ffmpeg_flags: list[str] = Field(default_factory=list)
|
|
32
34
|
|
|
33
35
|
|
|
34
36
|
class LocalFolderSourceConfig(BaseModel):
|
|
@@ -36,6 +38,7 @@ class LocalFolderSourceConfig(BaseModel):
|
|
|
36
38
|
|
|
37
39
|
model_config = {"extra": "forbid"}
|
|
38
40
|
|
|
41
|
+
camera_name: str | None = None
|
|
39
42
|
watch_dir: str = "recordings"
|
|
40
43
|
poll_interval: float = 1.0
|
|
41
44
|
stability_threshold_s: float = 3.0
|
|
@@ -46,6 +49,7 @@ class FtpSourceConfig(BaseModel):
|
|
|
46
49
|
|
|
47
50
|
model_config = {"extra": "forbid"}
|
|
48
51
|
|
|
52
|
+
camera_name: str | None = None
|
|
49
53
|
host: str = "0.0.0.0"
|
|
50
54
|
port: int = 2121
|
|
51
55
|
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
|
-
|
|
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:
|
|
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:
|
|
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,
|
|
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,
|
|
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
|
homesec/plugins/__init__.py
CHANGED
|
@@ -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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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"]
|