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.
- homesec/__init__.py +1 -1
- homesec/app.py +38 -84
- homesec/cli.py +9 -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 +3 -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/state/postgres.py +46 -17
- {homesec-1.1.0.dist-info → homesec-1.1.2.dist-info}/METADATA +1 -1
- homesec-1.1.2.dist-info/RECORD +68 -0
- homesec-1.1.0.dist-info/RECORD +0 -62
- {homesec-1.1.0.dist-info → homesec-1.1.2.dist-info}/WHEEL +0 -0
- {homesec-1.1.0.dist-info → homesec-1.1.2.dist-info}/entry_points.txt +0 -0
- {homesec-1.1.0.dist-info → homesec-1.1.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -3,121 +3,37 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
-
from
|
|
7
|
-
from dataclasses import dataclass
|
|
8
|
-
from typing import TYPE_CHECKING, TypeVar
|
|
9
|
-
|
|
10
|
-
from pydantic import BaseModel
|
|
6
|
+
from typing import cast
|
|
11
7
|
|
|
12
8
|
from homesec.interfaces import ObjectFilter
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
from homesec.models.filter import FilterConfig
|
|
9
|
+
from homesec.models.filter import FilterConfig
|
|
10
|
+
from homesec.plugins.registry import PluginType, load_plugin
|
|
16
11
|
|
|
17
12
|
logger = logging.getLogger(__name__)
|
|
18
13
|
|
|
19
|
-
# Type alias for clarity
|
|
20
|
-
FilterFactory = Callable[["FilterConfig"], ObjectFilter]
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
@dataclass(frozen=True)
|
|
24
|
-
class FilterPlugin:
|
|
25
|
-
"""Metadata for a filter plugin."""
|
|
26
|
-
|
|
27
|
-
name: str
|
|
28
|
-
config_model: type[BaseModel]
|
|
29
|
-
factory: FilterFactory
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
FILTER_REGISTRY: dict[str, FilterPlugin] = {}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def register_filter(plugin: FilterPlugin) -> None:
|
|
36
|
-
"""Register a filter plugin with collision detection.
|
|
37
|
-
|
|
38
|
-
Args:
|
|
39
|
-
plugin: Filter plugin to register
|
|
40
|
-
|
|
41
|
-
Raises:
|
|
42
|
-
ValueError: If a plugin with the same name is already registered
|
|
43
|
-
"""
|
|
44
|
-
if plugin.name in FILTER_REGISTRY:
|
|
45
|
-
raise ValueError(
|
|
46
|
-
f"Filter plugin '{plugin.name}' is already registered. "
|
|
47
|
-
f"Plugin names must be unique across all filter plugins."
|
|
48
|
-
)
|
|
49
|
-
FILTER_REGISTRY[plugin.name] = plugin
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
T = TypeVar("T", bound=Callable[[], FilterPlugin])
|
|
53
|
-
|
|
54
14
|
|
|
55
|
-
def
|
|
56
|
-
"""
|
|
57
|
-
|
|
58
|
-
Usage:
|
|
59
|
-
@filter_plugin(name="my_filter")
|
|
60
|
-
def my_filter_plugin() -> FilterPlugin:
|
|
61
|
-
return FilterPlugin(...)
|
|
15
|
+
def load_filter(config: FilterConfig) -> ObjectFilter:
|
|
16
|
+
"""Load and instantiate a filter plugin.
|
|
62
17
|
|
|
63
18
|
Args:
|
|
64
|
-
|
|
19
|
+
config: Filter configuration
|
|
65
20
|
|
|
66
21
|
Returns:
|
|
67
|
-
|
|
68
|
-
"""
|
|
69
|
-
|
|
70
|
-
def decorator(factory_fn: T) -> T:
|
|
71
|
-
plugin = factory_fn()
|
|
72
|
-
register_filter(plugin)
|
|
73
|
-
return factory_fn
|
|
74
|
-
|
|
75
|
-
return decorator
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def load_filter_plugin(config: FilterConfig) -> ObjectFilter:
|
|
79
|
-
"""Load filter plugin by name from config.
|
|
80
|
-
|
|
81
|
-
Validates the config dict against the plugin's config_model and creates
|
|
82
|
-
a FilterConfig with the validated settings object.
|
|
83
|
-
|
|
84
|
-
Args:
|
|
85
|
-
config: Filter configuration with plugin name and raw config dict
|
|
86
|
-
|
|
87
|
-
Returns:
|
|
88
|
-
Instantiated filter plugin
|
|
22
|
+
Configured ObjectFilter instance
|
|
89
23
|
|
|
90
24
|
Raises:
|
|
91
|
-
ValueError: If plugin
|
|
25
|
+
ValueError: If plugin not found in registry
|
|
26
|
+
ValidationError: If config validation fails
|
|
92
27
|
"""
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
# Validate config.config dict against plugin's config_model
|
|
102
|
-
validated_settings = plugin.config_model.model_validate(config.config)
|
|
103
|
-
|
|
104
|
-
# Create new FilterConfig with validated settings object
|
|
105
|
-
from homesec.models.filter import FilterConfig as FilterConfigModel
|
|
106
|
-
|
|
107
|
-
validated_config = FilterConfigModel(
|
|
108
|
-
plugin=config.plugin,
|
|
109
|
-
max_workers=config.max_workers,
|
|
110
|
-
config=validated_settings,
|
|
28
|
+
return cast(
|
|
29
|
+
ObjectFilter,
|
|
30
|
+
load_plugin(
|
|
31
|
+
PluginType.FILTER,
|
|
32
|
+
config.plugin,
|
|
33
|
+
config.config,
|
|
34
|
+
max_workers=config.max_workers,
|
|
35
|
+
),
|
|
111
36
|
)
|
|
112
37
|
|
|
113
|
-
return plugin.factory(validated_config)
|
|
114
|
-
|
|
115
38
|
|
|
116
|
-
__all__ = [
|
|
117
|
-
"FilterPlugin",
|
|
118
|
-
"FilterFactory",
|
|
119
|
-
"FILTER_REGISTRY",
|
|
120
|
-
"register_filter",
|
|
121
|
-
"filter_plugin",
|
|
122
|
-
"load_filter_plugin",
|
|
123
|
-
]
|
|
39
|
+
__all__ = ["load_filter"]
|
homesec/plugins/filters/yolo.py
CHANGED
|
@@ -5,17 +5,31 @@ from __future__ import annotations
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import logging
|
|
7
7
|
import shutil
|
|
8
|
-
|
|
8
|
+
import threading
|
|
9
9
|
from concurrent.futures import ProcessPoolExecutor
|
|
10
10
|
from pathlib import Path
|
|
11
|
-
from typing import
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
cv2: Any
|
|
14
|
+
torch: Any
|
|
15
|
+
YOLO_CLASS: Any
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import cv2 as _cv2
|
|
19
|
+
import torch as _torch
|
|
20
|
+
from ultralytics import YOLO as _YOLO # type: ignore[attr-defined]
|
|
21
|
+
except Exception:
|
|
22
|
+
cv2 = None
|
|
23
|
+
torch = None
|
|
24
|
+
YOLO_CLASS = None
|
|
25
|
+
else:
|
|
26
|
+
cv2 = _cv2
|
|
27
|
+
torch = _torch
|
|
28
|
+
YOLO_CLASS = _YOLO
|
|
16
29
|
|
|
17
30
|
from homesec.interfaces import ObjectFilter
|
|
18
|
-
from homesec.models.filter import
|
|
31
|
+
from homesec.models.filter import FilterOverrides, FilterResult, YoloFilterSettings
|
|
32
|
+
from homesec.plugins.registry import PluginType, plugin
|
|
19
33
|
|
|
20
34
|
logger = logging.getLogger(__name__)
|
|
21
35
|
|
|
@@ -34,10 +48,20 @@ HUMAN_ANIMAL_CLASSES = {
|
|
|
34
48
|
23: "giraffe",
|
|
35
49
|
}
|
|
36
50
|
|
|
37
|
-
_MODEL_CACHE: dict[tuple[str, str],
|
|
51
|
+
_MODEL_CACHE: dict[tuple[str, str], Any] = {}
|
|
52
|
+
_MODEL_CACHE_LOCK = threading.Lock()
|
|
38
53
|
_YOLO_CACHE_DIR = Path.cwd() / "yolo_cache"
|
|
39
54
|
|
|
40
55
|
|
|
56
|
+
def _ensure_yolo_dependencies() -> None:
|
|
57
|
+
"""Fail fast with a clear error if YOLO dependencies are missing."""
|
|
58
|
+
if cv2 is None or torch is None or YOLO_CLASS is None:
|
|
59
|
+
raise RuntimeError(
|
|
60
|
+
"Missing dependency for YOLO filter. "
|
|
61
|
+
"Install with: uv pip install ultralytics opencv-python"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
41
65
|
def _resolve_requested_path(model_path: str) -> Path:
|
|
42
66
|
requested = Path(model_path)
|
|
43
67
|
if not requested.is_absolute() and requested.parent == Path("."):
|
|
@@ -45,6 +69,23 @@ def _resolve_requested_path(model_path: str) -> Path:
|
|
|
45
69
|
return requested
|
|
46
70
|
|
|
47
71
|
|
|
72
|
+
def _check_file_safe(key: str) -> str | None:
|
|
73
|
+
"""Call ultralytics check_file with type-safe result handling.
|
|
74
|
+
|
|
75
|
+
Returns the resolved path string if found, None otherwise.
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
from ultralytics.utils.checks import check_file
|
|
79
|
+
|
|
80
|
+
result = check_file(key) # type: ignore[no-untyped-call]
|
|
81
|
+
# Validate result is string (ultralytics may return various types)
|
|
82
|
+
if isinstance(result, str):
|
|
83
|
+
return result
|
|
84
|
+
return None
|
|
85
|
+
except Exception:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
48
89
|
def _resolve_weights_path(model_path: str) -> Path:
|
|
49
90
|
requested_path = _resolve_requested_path(model_path)
|
|
50
91
|
if requested_path.exists():
|
|
@@ -52,23 +93,18 @@ def _resolve_weights_path(model_path: str) -> Path:
|
|
|
52
93
|
|
|
53
94
|
resolved: Path | None = None
|
|
54
95
|
try:
|
|
55
|
-
from ultralytics.utils.checks import check_file
|
|
56
|
-
|
|
57
|
-
check_file_fn = cast(Callable[[str], str | None], check_file)
|
|
58
|
-
|
|
59
96
|
for key in (str(requested_path), requested_path.name):
|
|
60
|
-
|
|
61
|
-
candidate = check_file_fn(key)
|
|
62
|
-
except Exception:
|
|
63
|
-
continue
|
|
97
|
+
candidate = _check_file_safe(key)
|
|
64
98
|
if candidate and Path(candidate).exists():
|
|
65
99
|
resolved = Path(candidate)
|
|
66
100
|
break
|
|
67
101
|
|
|
68
102
|
if resolved is None and requested_path.suffix.lower() == ".pt":
|
|
69
103
|
try:
|
|
70
|
-
|
|
71
|
-
|
|
104
|
+
if YOLO_CLASS is None:
|
|
105
|
+
raise RuntimeError("YOLO dependencies are not available")
|
|
106
|
+
_ = YOLO_CLASS(requested_path.name)
|
|
107
|
+
candidate = _check_file_safe(requested_path.name)
|
|
72
108
|
if candidate and Path(candidate).exists():
|
|
73
109
|
resolved = Path(candidate)
|
|
74
110
|
except Exception:
|
|
@@ -90,15 +126,31 @@ def _resolve_weights_path(model_path: str) -> Path:
|
|
|
90
126
|
return resolved
|
|
91
127
|
|
|
92
128
|
|
|
93
|
-
def _get_model(model_path: str, device: str) ->
|
|
129
|
+
def _get_model(model_path: str, device: str) -> Any:
|
|
130
|
+
"""Get or create a cached YOLO model instance.
|
|
131
|
+
|
|
132
|
+
Thread-safe: uses a lock to prevent duplicate model loading when
|
|
133
|
+
multiple threads access the cache simultaneously.
|
|
134
|
+
"""
|
|
94
135
|
key = (model_path, device)
|
|
136
|
+
# Fast path: check without lock (safe for reads)
|
|
95
137
|
model = _MODEL_CACHE.get(key)
|
|
96
|
-
if model is None:
|
|
97
|
-
model
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
138
|
+
if model is not None:
|
|
139
|
+
return model
|
|
140
|
+
|
|
141
|
+
# Slow path: acquire lock for potential write
|
|
142
|
+
with _MODEL_CACHE_LOCK:
|
|
143
|
+
# Double-check after acquiring lock
|
|
144
|
+
model = _MODEL_CACHE.get(key)
|
|
145
|
+
if model is None:
|
|
146
|
+
if YOLO_CLASS is None:
|
|
147
|
+
raise RuntimeError("YOLO dependencies are not available")
|
|
148
|
+
model = YOLO_CLASS(model_path).to(device)
|
|
149
|
+
_MODEL_CACHE[key] = model
|
|
150
|
+
return model
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@plugin(plugin_type=PluginType.FILTER, name="yolo")
|
|
102
154
|
class YOLOv8Filter(ObjectFilter):
|
|
103
155
|
"""YOLO-based object detection filter.
|
|
104
156
|
|
|
@@ -107,34 +159,29 @@ class YOLOv8Filter(ObjectFilter):
|
|
|
107
159
|
Bare model filenames resolve under ./yolo_cache and auto-download if missing.
|
|
108
160
|
"""
|
|
109
161
|
|
|
110
|
-
|
|
111
|
-
"""Initialize YOLO filter with config validation.
|
|
162
|
+
config_cls = YoloFilterSettings
|
|
112
163
|
|
|
113
|
-
|
|
114
|
-
|
|
164
|
+
@classmethod
|
|
165
|
+
def create(cls, config: YoloFilterSettings) -> ObjectFilter:
|
|
166
|
+
return cls(config)
|
|
115
167
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
168
|
+
def __init__(self, settings: YoloFilterSettings) -> None:
|
|
169
|
+
"""Initialize YOLO filter with validated settings.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
settings: YOLO-specific configuration (model_path, classes, thresholds)
|
|
173
|
+
Also assumes settings.max_workers is populated.
|
|
122
174
|
"""
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
case _:
|
|
127
|
-
raise ValueError("YOLOv8Filter requires YoloFilterSettings config")
|
|
128
|
-
|
|
129
|
-
self._settings = cfg
|
|
130
|
-
self.model_path = _resolve_weights_path(str(cfg.model_path))
|
|
175
|
+
_ensure_yolo_dependencies()
|
|
176
|
+
self._settings = settings
|
|
177
|
+
self.model_path = _resolve_weights_path(str(settings.model_path))
|
|
131
178
|
if not self.model_path.exists():
|
|
132
179
|
raise FileNotFoundError(f"Model not found: {self.model_path}")
|
|
133
180
|
|
|
134
181
|
self._class_id_cache: dict[tuple[str, ...], list[int]] = {}
|
|
135
182
|
|
|
136
183
|
# Initialize executor
|
|
137
|
-
self._executor = ProcessPoolExecutor(max_workers=
|
|
184
|
+
self._executor = ProcessPoolExecutor(max_workers=settings.max_workers)
|
|
138
185
|
self._shutdown_called = False
|
|
139
186
|
|
|
140
187
|
logger.info(
|
|
@@ -187,6 +234,15 @@ class YOLOv8Filter(ObjectFilter):
|
|
|
187
234
|
self._executor.shutdown(wait=True, cancel_futures=False)
|
|
188
235
|
logger.info("YOLOv8Filter shutdown complete")
|
|
189
236
|
|
|
237
|
+
async def ping(self) -> bool:
|
|
238
|
+
"""Health check - verify executor is alive and model path exists."""
|
|
239
|
+
if self._shutdown_called:
|
|
240
|
+
return False
|
|
241
|
+
if not self.model_path.exists():
|
|
242
|
+
return False
|
|
243
|
+
# Executor is considered healthy if not shut down
|
|
244
|
+
return True
|
|
245
|
+
|
|
190
246
|
def _apply_overrides(self, overrides: FilterOverrides | None) -> YoloFilterSettings:
|
|
191
247
|
if overrides is None:
|
|
192
248
|
return self._settings
|
|
@@ -218,6 +274,9 @@ def _detect_worker(
|
|
|
218
274
|
|
|
219
275
|
This runs in a separate process, so it needs to load the model fresh.
|
|
220
276
|
"""
|
|
277
|
+
if cv2 is None or torch is None:
|
|
278
|
+
raise RuntimeError("YOLO dependencies are not available")
|
|
279
|
+
|
|
221
280
|
# Determine device
|
|
222
281
|
device = (
|
|
223
282
|
"mps"
|
|
@@ -291,25 +350,3 @@ def _detect_worker(
|
|
|
291
350
|
model=Path(model_path).name,
|
|
292
351
|
sampled_frames=sampled_frames,
|
|
293
352
|
)
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
# Plugin registration
|
|
297
|
-
from homesec.plugins.filters import FilterPlugin, filter_plugin
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
@filter_plugin(name="yolo")
|
|
301
|
-
def yolo_filter_plugin() -> FilterPlugin:
|
|
302
|
-
"""YOLO filter plugin factory.
|
|
303
|
-
|
|
304
|
-
Returns:
|
|
305
|
-
FilterPlugin for YOLOv8 object detection
|
|
306
|
-
"""
|
|
307
|
-
|
|
308
|
-
def factory(cfg: FilterConfig) -> ObjectFilter:
|
|
309
|
-
return YOLOv8Filter(cfg)
|
|
310
|
-
|
|
311
|
-
return FilterPlugin(
|
|
312
|
-
name="yolo",
|
|
313
|
-
config_model=YoloFilterSettings,
|
|
314
|
-
factory=factory,
|
|
315
|
-
)
|
|
@@ -3,79 +3,43 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
-
from
|
|
7
|
-
from dataclasses import dataclass
|
|
8
|
-
from typing import TypeVar
|
|
6
|
+
from typing import cast
|
|
9
7
|
|
|
10
8
|
from pydantic import BaseModel
|
|
11
9
|
|
|
12
10
|
from homesec.interfaces import Notifier
|
|
13
11
|
from homesec.plugins.notifiers.multiplex import MultiplexNotifier, NotifierEntry
|
|
12
|
+
from homesec.plugins.registry import PluginType, load_plugin
|
|
14
13
|
|
|
15
14
|
logger = logging.getLogger(__name__)
|
|
16
15
|
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@dataclass(frozen=True)
|
|
22
|
-
class NotifierPlugin:
|
|
23
|
-
name: str
|
|
24
|
-
config_model: type[BaseModel]
|
|
25
|
-
factory: NotifierFactory
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
NOTIFIER_REGISTRY: dict[str, NotifierPlugin] = {}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def register_notifier(plugin: NotifierPlugin) -> None:
|
|
32
|
-
"""Register a notifier plugin with collision detection.
|
|
33
|
-
|
|
34
|
-
Args:
|
|
35
|
-
plugin: Notifier plugin to register
|
|
36
|
-
|
|
37
|
-
Raises:
|
|
38
|
-
ValueError: If a plugin with the same name is already registered
|
|
39
|
-
"""
|
|
40
|
-
if plugin.name in NOTIFIER_REGISTRY:
|
|
41
|
-
raise ValueError(
|
|
42
|
-
f"Notifier plugin '{plugin.name}' is already registered. "
|
|
43
|
-
f"Plugin names must be unique across all notifier plugins."
|
|
44
|
-
)
|
|
45
|
-
NOTIFIER_REGISTRY[plugin.name] = plugin
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
T = TypeVar("T", bound=Callable[[], NotifierPlugin])
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def notifier_plugin(name: str) -> Callable[[T], T]:
|
|
52
|
-
"""Decorator to register a notifier plugin.
|
|
53
|
-
|
|
54
|
-
Usage:
|
|
55
|
-
@notifier_plugin(name="my_notifier")
|
|
56
|
-
def my_notifier_plugin() -> NotifierPlugin:
|
|
57
|
-
return NotifierPlugin(...)
|
|
17
|
+
def load_notifier_plugin(backend: str, config: dict[str, object] | BaseModel) -> Notifier:
|
|
18
|
+
"""Load and instantiate a notifier plugin.
|
|
58
19
|
|
|
59
20
|
Args:
|
|
60
|
-
|
|
21
|
+
backend: Notifier backend name (e.g., "mqtt", "sendgrid_email")
|
|
22
|
+
config: Raw config dict or already-validated BaseModel
|
|
61
23
|
|
|
62
24
|
Returns:
|
|
63
|
-
|
|
64
|
-
"""
|
|
65
|
-
|
|
66
|
-
def decorator(factory_fn: T) -> T:
|
|
67
|
-
plugin = factory_fn()
|
|
68
|
-
register_notifier(plugin)
|
|
69
|
-
return factory_fn
|
|
25
|
+
Configured Notifier instance
|
|
70
26
|
|
|
71
|
-
|
|
27
|
+
Raises:
|
|
28
|
+
ValueError: If backend not found in registry
|
|
29
|
+
ValidationError: If config validation fails
|
|
30
|
+
"""
|
|
31
|
+
return cast(
|
|
32
|
+
Notifier,
|
|
33
|
+
load_plugin(
|
|
34
|
+
PluginType.NOTIFIER,
|
|
35
|
+
backend,
|
|
36
|
+
config,
|
|
37
|
+
),
|
|
38
|
+
)
|
|
72
39
|
|
|
73
40
|
|
|
74
41
|
__all__ = [
|
|
75
42
|
"MultiplexNotifier",
|
|
76
43
|
"NotifierEntry",
|
|
77
|
-
"
|
|
78
|
-
"NOTIFIER_REGISTRY",
|
|
79
|
-
"register_notifier",
|
|
80
|
-
"notifier_plugin",
|
|
44
|
+
"load_notifier_plugin",
|
|
81
45
|
]
|
|
@@ -6,28 +6,49 @@ import asyncio
|
|
|
6
6
|
import logging
|
|
7
7
|
import os
|
|
8
8
|
import threading
|
|
9
|
+
from typing import Any
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
mqtt: Any
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import paho.mqtt.client as _mqtt
|
|
15
|
+
except Exception:
|
|
16
|
+
mqtt = None
|
|
17
|
+
else:
|
|
18
|
+
mqtt = _mqtt
|
|
11
19
|
|
|
12
20
|
from homesec.interfaces import Notifier
|
|
13
21
|
from homesec.models.alert import Alert
|
|
14
22
|
from homesec.models.config import MQTTConfig
|
|
23
|
+
from homesec.plugins.registry import PluginType, plugin
|
|
15
24
|
|
|
16
25
|
logger = logging.getLogger(__name__)
|
|
17
26
|
|
|
18
27
|
|
|
28
|
+
@plugin(plugin_type=PluginType.NOTIFIER, name="mqtt")
|
|
19
29
|
class MQTTNotifier(Notifier):
|
|
20
30
|
"""MQTT notifier for Home Assistant alerts.
|
|
21
31
|
|
|
22
32
|
Publishes alert messages to configured topics with QoS settings.
|
|
23
33
|
"""
|
|
24
34
|
|
|
35
|
+
config_cls = MQTTConfig
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def create(cls, config: MQTTConfig) -> Notifier:
|
|
39
|
+
return cls(config)
|
|
40
|
+
|
|
25
41
|
def __init__(self, config: MQTTConfig) -> None:
|
|
26
42
|
"""Initialize MQTT notifier with config validation.
|
|
27
43
|
|
|
28
44
|
Args:
|
|
29
45
|
config: MQTTConfig instance
|
|
30
46
|
"""
|
|
47
|
+
if mqtt is None:
|
|
48
|
+
raise RuntimeError(
|
|
49
|
+
"Missing dependency: paho-mqtt. Install with: uv pip install paho-mqtt"
|
|
50
|
+
)
|
|
51
|
+
|
|
31
52
|
self.host = config.host
|
|
32
53
|
self.port = int(config.port)
|
|
33
54
|
self.topic_template = config.topic_template
|
|
@@ -157,32 +178,3 @@ class MQTTNotifier(Notifier):
|
|
|
157
178
|
raise RuntimeError(
|
|
158
179
|
f"MQTT broker not connected after {self.connection_timeout}s timeout"
|
|
159
180
|
)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
# Plugin registration
|
|
163
|
-
from typing import cast
|
|
164
|
-
|
|
165
|
-
from pydantic import BaseModel
|
|
166
|
-
|
|
167
|
-
from homesec.interfaces import Notifier
|
|
168
|
-
from homesec.plugins.notifiers import NotifierPlugin, notifier_plugin
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
@notifier_plugin(name="mqtt")
|
|
172
|
-
def mqtt_plugin() -> NotifierPlugin:
|
|
173
|
-
"""MQTT notifier plugin factory.
|
|
174
|
-
|
|
175
|
-
Returns:
|
|
176
|
-
NotifierPlugin for MQTT notifications
|
|
177
|
-
"""
|
|
178
|
-
from homesec.models.config import MQTTConfig
|
|
179
|
-
|
|
180
|
-
def factory(cfg: BaseModel) -> Notifier:
|
|
181
|
-
# Config is already validated by app.py, just cast
|
|
182
|
-
return MQTTNotifier(cast(MQTTConfig, cfg))
|
|
183
|
-
|
|
184
|
-
return NotifierPlugin(
|
|
185
|
-
name="mqtt",
|
|
186
|
-
config_model=MQTTConfig,
|
|
187
|
-
factory=factory,
|
|
188
|
-
)
|
|
@@ -7,21 +7,47 @@ import html
|
|
|
7
7
|
import logging
|
|
8
8
|
import os
|
|
9
9
|
from collections import defaultdict
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
aiohttp: Any
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import aiohttp as _aiohttp
|
|
16
|
+
except Exception:
|
|
17
|
+
aiohttp = None
|
|
18
|
+
else:
|
|
19
|
+
aiohttp = _aiohttp
|
|
10
20
|
|
|
11
|
-
import aiohttp
|
|
12
21
|
|
|
13
22
|
from homesec.interfaces import Notifier
|
|
14
23
|
from homesec.models.alert import Alert
|
|
15
24
|
from homesec.models.config import SendGridEmailConfig
|
|
16
25
|
from homesec.models.vlm import SequenceAnalysis
|
|
26
|
+
from homesec.plugins.registry import PluginType, plugin
|
|
17
27
|
|
|
18
28
|
logger = logging.getLogger(__name__)
|
|
19
29
|
|
|
20
30
|
|
|
31
|
+
def _ensure_sendgrid_dependencies() -> None:
|
|
32
|
+
"""Fail fast with a clear error if SendGrid dependencies are missing."""
|
|
33
|
+
if aiohttp is None:
|
|
34
|
+
raise RuntimeError(
|
|
35
|
+
"Missing dependency for SendGrid notifier. Install with: uv pip install aiohttp"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@plugin(plugin_type=PluginType.NOTIFIER, name="sendgrid_email")
|
|
21
40
|
class SendGridEmailNotifier(Notifier):
|
|
22
41
|
"""SendGrid email notifier for HomeSec alerts."""
|
|
23
42
|
|
|
43
|
+
config_cls = SendGridEmailConfig
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def create(cls, config: SendGridEmailConfig) -> Notifier:
|
|
47
|
+
return cls(config)
|
|
48
|
+
|
|
24
49
|
def __init__(self, config: SendGridEmailConfig) -> None:
|
|
50
|
+
_ensure_sendgrid_dependencies()
|
|
25
51
|
self._api_key_env = config.api_key_env
|
|
26
52
|
self._from_email = config.from_email
|
|
27
53
|
self._from_name = config.from_name
|
|
@@ -62,7 +88,8 @@ class SendGridEmailNotifier(Notifier):
|
|
|
62
88
|
async with session.post(url, json=payload, headers=headers) as response:
|
|
63
89
|
if response.status >= 400:
|
|
64
90
|
details = await response.text()
|
|
65
|
-
|
|
91
|
+
logger.debug("SendGrid API error details: %s", details)
|
|
92
|
+
raise RuntimeError(f"SendGrid email send failed: HTTP {response.status}")
|
|
66
93
|
|
|
67
94
|
logger.info(
|
|
68
95
|
"Sent SendGrid email alert: to=%s clip_id=%s",
|
|
@@ -74,6 +101,8 @@ class SendGridEmailNotifier(Notifier):
|
|
|
74
101
|
"""Health check - verify SendGrid credentials and connectivity."""
|
|
75
102
|
if self._shutdown_called or not self._api_key:
|
|
76
103
|
return False
|
|
104
|
+
if aiohttp is None:
|
|
105
|
+
return False
|
|
77
106
|
|
|
78
107
|
url = f"{self._api_base}/user/profile"
|
|
79
108
|
headers = {"Authorization": f"Bearer {self._api_key}"}
|
|
@@ -100,6 +129,8 @@ class SendGridEmailNotifier(Notifier):
|
|
|
100
129
|
|
|
101
130
|
async def _get_session(self) -> aiohttp.ClientSession:
|
|
102
131
|
if self._session is None or self._session.closed:
|
|
132
|
+
if aiohttp is None:
|
|
133
|
+
raise RuntimeError("aiohttp dependency is required for SendGrid notifier")
|
|
103
134
|
timeout = aiohttp.ClientTimeout(total=self._timeout_s)
|
|
104
135
|
self._session = aiohttp.ClientSession(timeout=timeout)
|
|
105
136
|
return self._session
|
|
@@ -138,7 +169,7 @@ class SendGridEmailNotifier(Notifier):
|
|
|
138
169
|
{
|
|
139
170
|
"camera_name": alert.camera_name,
|
|
140
171
|
"clip_id": alert.clip_id,
|
|
141
|
-
"risk_level": alert.risk_level
|
|
172
|
+
"risk_level": str(alert.risk_level) if alert.risk_level is not None else "unknown",
|
|
142
173
|
"activity_type": alert.activity_type or "unknown",
|
|
143
174
|
"notify_reason": alert.notify_reason,
|
|
144
175
|
"summary": alert.summary or "",
|
|
@@ -195,32 +226,3 @@ class SendGridEmailNotifier(Notifier):
|
|
|
195
226
|
else:
|
|
196
227
|
rendered_items.append(f"<li>{rendered_value}</li>")
|
|
197
228
|
return "<ul>" + "".join(rendered_items) + "</ul>"
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
# Plugin registration
|
|
201
|
-
from typing import cast
|
|
202
|
-
|
|
203
|
-
from pydantic import BaseModel
|
|
204
|
-
|
|
205
|
-
from homesec.interfaces import Notifier
|
|
206
|
-
from homesec.plugins.notifiers import NotifierPlugin, notifier_plugin
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
@notifier_plugin(name="sendgrid_email")
|
|
210
|
-
def sendgrid_email_plugin() -> NotifierPlugin:
|
|
211
|
-
"""SendGrid email notifier plugin factory.
|
|
212
|
-
|
|
213
|
-
Returns:
|
|
214
|
-
NotifierPlugin for SendGrid email notifications
|
|
215
|
-
"""
|
|
216
|
-
from homesec.models.config import SendGridEmailConfig
|
|
217
|
-
|
|
218
|
-
def factory(cfg: BaseModel) -> Notifier:
|
|
219
|
-
# Config is already validated by app.py, just cast
|
|
220
|
-
return SendGridEmailNotifier(cast(SendGridEmailConfig, cfg))
|
|
221
|
-
|
|
222
|
-
return NotifierPlugin(
|
|
223
|
-
name="sendgrid_email",
|
|
224
|
-
config_model=SendGridEmailConfig,
|
|
225
|
-
factory=factory,
|
|
226
|
-
)
|