homesec 0.1.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 +20 -0
- homesec/app.py +393 -0
- homesec/cli.py +159 -0
- homesec/config/__init__.py +18 -0
- homesec/config/loader.py +109 -0
- homesec/config/validation.py +82 -0
- homesec/errors.py +71 -0
- homesec/health/__init__.py +5 -0
- homesec/health/server.py +226 -0
- homesec/interfaces.py +249 -0
- homesec/logging_setup.py +176 -0
- homesec/maintenance/__init__.py +1 -0
- homesec/maintenance/cleanup_clips.py +632 -0
- homesec/models/__init__.py +79 -0
- homesec/models/alert.py +32 -0
- homesec/models/clip.py +71 -0
- homesec/models/config.py +362 -0
- homesec/models/events.py +184 -0
- homesec/models/filter.py +62 -0
- homesec/models/source.py +77 -0
- homesec/models/storage.py +12 -0
- homesec/models/vlm.py +99 -0
- homesec/pipeline/__init__.py +6 -0
- homesec/pipeline/alert_policy.py +5 -0
- homesec/pipeline/core.py +639 -0
- homesec/plugins/__init__.py +62 -0
- homesec/plugins/alert_policies/__init__.py +80 -0
- homesec/plugins/alert_policies/default.py +111 -0
- homesec/plugins/alert_policies/noop.py +60 -0
- homesec/plugins/analyzers/__init__.py +126 -0
- homesec/plugins/analyzers/openai.py +446 -0
- homesec/plugins/filters/__init__.py +124 -0
- homesec/plugins/filters/yolo.py +317 -0
- homesec/plugins/notifiers/__init__.py +80 -0
- homesec/plugins/notifiers/mqtt.py +189 -0
- homesec/plugins/notifiers/multiplex.py +106 -0
- homesec/plugins/notifiers/sendgrid_email.py +228 -0
- homesec/plugins/storage/__init__.py +116 -0
- homesec/plugins/storage/dropbox.py +272 -0
- homesec/plugins/storage/local.py +108 -0
- homesec/plugins/utils.py +63 -0
- homesec/py.typed +0 -0
- homesec/repository/__init__.py +5 -0
- homesec/repository/clip_repository.py +552 -0
- homesec/sources/__init__.py +17 -0
- homesec/sources/base.py +224 -0
- homesec/sources/ftp.py +209 -0
- homesec/sources/local_folder.py +238 -0
- homesec/sources/rtsp.py +1251 -0
- homesec/state/__init__.py +10 -0
- homesec/state/postgres.py +501 -0
- homesec/storage_paths.py +46 -0
- homesec/telemetry/__init__.py +0 -0
- homesec/telemetry/db/__init__.py +1 -0
- homesec/telemetry/db/log_table.py +16 -0
- homesec/telemetry/db_log_handler.py +246 -0
- homesec/telemetry/postgres_settings.py +42 -0
- homesec-0.1.0.dist-info/METADATA +446 -0
- homesec-0.1.0.dist-info/RECORD +62 -0
- homesec-0.1.0.dist-info/WHEEL +4 -0
- homesec-0.1.0.dist-info/entry_points.txt +2 -0
- homesec-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""YOLOv8 object detection filter plugin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import shutil
|
|
8
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Callable, cast
|
|
11
|
+
|
|
12
|
+
import cv2
|
|
13
|
+
import torch
|
|
14
|
+
from ultralytics import YOLO # type: ignore[attr-defined]
|
|
15
|
+
|
|
16
|
+
from homesec.models.filter import FilterConfig, FilterOverrides, FilterResult, YoloFilterSettings
|
|
17
|
+
from homesec.interfaces import ObjectFilter
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# COCO classes for humans and animals
|
|
22
|
+
HUMAN_ANIMAL_CLASSES = {
|
|
23
|
+
0: "person",
|
|
24
|
+
14: "bird",
|
|
25
|
+
15: "cat",
|
|
26
|
+
16: "dog",
|
|
27
|
+
17: "horse",
|
|
28
|
+
18: "sheep",
|
|
29
|
+
19: "cow",
|
|
30
|
+
20: "elephant",
|
|
31
|
+
21: "bear",
|
|
32
|
+
22: "zebra",
|
|
33
|
+
23: "giraffe",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
_MODEL_CACHE: dict[tuple[str, str], YOLO] = {}
|
|
37
|
+
_YOLO_CACHE_DIR = Path.cwd() / "yolo_cache"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _resolve_requested_path(model_path: str) -> Path:
|
|
41
|
+
requested = Path(model_path)
|
|
42
|
+
if not requested.is_absolute() and requested.parent == Path("."):
|
|
43
|
+
return _YOLO_CACHE_DIR / requested.name
|
|
44
|
+
return requested
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _resolve_weights_path(model_path: str) -> Path:
|
|
48
|
+
requested_path = _resolve_requested_path(model_path)
|
|
49
|
+
if requested_path.exists():
|
|
50
|
+
return requested_path
|
|
51
|
+
|
|
52
|
+
resolved: Path | None = None
|
|
53
|
+
try:
|
|
54
|
+
from ultralytics.utils.checks import check_file
|
|
55
|
+
|
|
56
|
+
check_file_fn = cast(Callable[[str], str | None], check_file)
|
|
57
|
+
|
|
58
|
+
for key in (str(requested_path), requested_path.name):
|
|
59
|
+
try:
|
|
60
|
+
candidate = check_file_fn(key)
|
|
61
|
+
except Exception:
|
|
62
|
+
continue
|
|
63
|
+
if candidate and Path(candidate).exists():
|
|
64
|
+
resolved = Path(candidate)
|
|
65
|
+
break
|
|
66
|
+
|
|
67
|
+
if resolved is None and requested_path.suffix.lower() == ".pt":
|
|
68
|
+
try:
|
|
69
|
+
_ = YOLO(requested_path.name)
|
|
70
|
+
candidate = check_file_fn(requested_path.name)
|
|
71
|
+
if candidate and Path(candidate).exists():
|
|
72
|
+
resolved = Path(candidate)
|
|
73
|
+
except Exception:
|
|
74
|
+
resolved = None
|
|
75
|
+
except Exception:
|
|
76
|
+
resolved = None
|
|
77
|
+
|
|
78
|
+
if resolved is None:
|
|
79
|
+
return requested_path
|
|
80
|
+
|
|
81
|
+
if requested_path.suffix.lower() == ".pt" and resolved != requested_path:
|
|
82
|
+
try:
|
|
83
|
+
requested_path.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
shutil.copy2(resolved, requested_path)
|
|
85
|
+
return requested_path
|
|
86
|
+
except Exception:
|
|
87
|
+
return resolved
|
|
88
|
+
|
|
89
|
+
return resolved
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _get_model(model_path: str, device: str) -> YOLO:
|
|
93
|
+
key = (model_path, device)
|
|
94
|
+
model = _MODEL_CACHE.get(key)
|
|
95
|
+
if model is None:
|
|
96
|
+
model = YOLO(model_path).to(device)
|
|
97
|
+
_MODEL_CACHE[key] = model
|
|
98
|
+
return model
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class YOLOv8Filter(ObjectFilter):
|
|
102
|
+
"""YOLO-based object detection filter.
|
|
103
|
+
|
|
104
|
+
Uses ProcessPoolExecutor internally for CPU/GPU-bound inference.
|
|
105
|
+
Supports frame sampling and early exit on detection.
|
|
106
|
+
Bare model filenames resolve under ./yolo_cache and auto-download if missing.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(self, config: FilterConfig) -> None:
|
|
110
|
+
"""Initialize YOLO filter with config validation.
|
|
111
|
+
|
|
112
|
+
Required config:
|
|
113
|
+
model_path: Path to .pt model file
|
|
114
|
+
|
|
115
|
+
Optional config:
|
|
116
|
+
classes: List of class names to detect (default: person)
|
|
117
|
+
min_confidence: Minimum confidence threshold (default: 0.5)
|
|
118
|
+
sample_fps: Frame sampling rate (default: 2)
|
|
119
|
+
min_box_h_ratio: Minimum box height ratio (default: 0.1)
|
|
120
|
+
min_hits: Minimum detections to confirm (default: 1)
|
|
121
|
+
"""
|
|
122
|
+
match config.config:
|
|
123
|
+
case YoloFilterSettings() as settings:
|
|
124
|
+
cfg = settings
|
|
125
|
+
case _:
|
|
126
|
+
raise ValueError("YOLOv8Filter requires YoloFilterSettings config")
|
|
127
|
+
|
|
128
|
+
self._settings = cfg
|
|
129
|
+
self.model_path = _resolve_weights_path(str(cfg.model_path))
|
|
130
|
+
if not self.model_path.exists():
|
|
131
|
+
raise FileNotFoundError(f"Model not found: {self.model_path}")
|
|
132
|
+
|
|
133
|
+
self._class_id_cache: dict[tuple[str, ...], list[int]] = {}
|
|
134
|
+
|
|
135
|
+
# Initialize executor
|
|
136
|
+
self._executor = ProcessPoolExecutor(max_workers=config.max_workers)
|
|
137
|
+
self._shutdown_called = False
|
|
138
|
+
|
|
139
|
+
logger.info(
|
|
140
|
+
"YOLOv8Filter initialized: model=%s, classes=%s, confidence=%.2f",
|
|
141
|
+
self.model_path,
|
|
142
|
+
self._settings.classes,
|
|
143
|
+
self._settings.min_confidence,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
async def detect(
|
|
147
|
+
self,
|
|
148
|
+
video_path: Path,
|
|
149
|
+
overrides: FilterOverrides | None = None,
|
|
150
|
+
) -> FilterResult:
|
|
151
|
+
"""Detect objects in video clip.
|
|
152
|
+
|
|
153
|
+
Runs inference in ProcessPoolExecutor to avoid blocking event loop.
|
|
154
|
+
Samples frames at configured rate and exits early on first detection.
|
|
155
|
+
"""
|
|
156
|
+
if self._shutdown_called:
|
|
157
|
+
raise RuntimeError("Filter has been shut down")
|
|
158
|
+
|
|
159
|
+
# Run blocking work in executor
|
|
160
|
+
loop = asyncio.get_running_loop()
|
|
161
|
+
effective = self._apply_overrides(overrides)
|
|
162
|
+
target_class_ids = self._class_ids_for(effective.classes)
|
|
163
|
+
|
|
164
|
+
result = await loop.run_in_executor(
|
|
165
|
+
self._executor,
|
|
166
|
+
_detect_worker,
|
|
167
|
+
str(video_path),
|
|
168
|
+
str(self.model_path),
|
|
169
|
+
target_class_ids,
|
|
170
|
+
float(effective.min_confidence),
|
|
171
|
+
int(effective.sample_fps),
|
|
172
|
+
float(effective.min_box_h_ratio),
|
|
173
|
+
int(effective.min_hits),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
return result
|
|
177
|
+
|
|
178
|
+
async def shutdown(self, timeout: float | None = None) -> None:
|
|
179
|
+
"""Cleanup resources - shutdown executor."""
|
|
180
|
+
_ = timeout
|
|
181
|
+
if self._shutdown_called:
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
self._shutdown_called = True
|
|
185
|
+
logger.info("Shutting down YOLOv8Filter...")
|
|
186
|
+
self._executor.shutdown(wait=True, cancel_futures=False)
|
|
187
|
+
logger.info("YOLOv8Filter shutdown complete")
|
|
188
|
+
|
|
189
|
+
def _apply_overrides(self, overrides: FilterOverrides | None) -> YoloFilterSettings:
|
|
190
|
+
if overrides is None:
|
|
191
|
+
return self._settings
|
|
192
|
+
update = overrides.model_dump(exclude_none=True)
|
|
193
|
+
return self._settings.model_copy(update=update)
|
|
194
|
+
|
|
195
|
+
def _class_ids_for(self, classes: list[str]) -> list[int]:
|
|
196
|
+
key = tuple(classes)
|
|
197
|
+
cached = self._class_id_cache.get(key)
|
|
198
|
+
if cached is not None:
|
|
199
|
+
return cached
|
|
200
|
+
target_class_ids = [
|
|
201
|
+
cid for cid, name in HUMAN_ANIMAL_CLASSES.items()
|
|
202
|
+
if name in classes
|
|
203
|
+
]
|
|
204
|
+
if not target_class_ids:
|
|
205
|
+
raise ValueError(f"No valid classes found in config: {classes}")
|
|
206
|
+
self._class_id_cache[key] = target_class_ids
|
|
207
|
+
return target_class_ids
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _detect_worker(
|
|
211
|
+
video_path: str,
|
|
212
|
+
model_path: str,
|
|
213
|
+
target_class_ids: list[int],
|
|
214
|
+
min_confidence: float,
|
|
215
|
+
sample_fps: int,
|
|
216
|
+
min_box_h_ratio: float,
|
|
217
|
+
min_hits: int,
|
|
218
|
+
) -> FilterResult:
|
|
219
|
+
"""Worker function for video analysis (must be at module level for pickling).
|
|
220
|
+
|
|
221
|
+
This runs in a separate process, so it needs to load the model fresh.
|
|
222
|
+
"""
|
|
223
|
+
# Determine device
|
|
224
|
+
device = (
|
|
225
|
+
"mps"
|
|
226
|
+
if torch.backends.mps.is_available()
|
|
227
|
+
else "cuda"
|
|
228
|
+
if torch.cuda.is_available()
|
|
229
|
+
else "cpu"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Load model (cached per process)
|
|
233
|
+
model = _get_model(model_path, device)
|
|
234
|
+
|
|
235
|
+
# Open video
|
|
236
|
+
cap = cv2.VideoCapture(video_path)
|
|
237
|
+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
238
|
+
frame_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
239
|
+
min_box_h = frame_h * min_box_h_ratio
|
|
240
|
+
|
|
241
|
+
detected_classes: list[str] = []
|
|
242
|
+
max_confidence = 0.0
|
|
243
|
+
sampled_frames = 0
|
|
244
|
+
|
|
245
|
+
frame_idx = 0
|
|
246
|
+
while frame_idx < total_frames:
|
|
247
|
+
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
|
|
248
|
+
ret, frame = cap.read()
|
|
249
|
+
if not ret:
|
|
250
|
+
break
|
|
251
|
+
|
|
252
|
+
sampled_frames += 1
|
|
253
|
+
|
|
254
|
+
# Run inference
|
|
255
|
+
results = model(frame, verbose=False, conf=min_confidence, classes=target_class_ids)
|
|
256
|
+
|
|
257
|
+
for result in results:
|
|
258
|
+
for box in result.boxes:
|
|
259
|
+
cls = int(box.cls[0])
|
|
260
|
+
class_name = HUMAN_ANIMAL_CLASSES.get(cls)
|
|
261
|
+
if not class_name:
|
|
262
|
+
continue
|
|
263
|
+
|
|
264
|
+
# Check box height
|
|
265
|
+
xyxy = box.xyxy[0].tolist()
|
|
266
|
+
box_h = xyxy[3] - xyxy[1]
|
|
267
|
+
if box_h < min_box_h:
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
# Track detection
|
|
271
|
+
confidence = float(box.conf[0])
|
|
272
|
+
if class_name not in detected_classes:
|
|
273
|
+
detected_classes.append(class_name)
|
|
274
|
+
max_confidence = max(max_confidence, confidence)
|
|
275
|
+
|
|
276
|
+
# Early exit if we have enough hits
|
|
277
|
+
if len(detected_classes) >= min_hits:
|
|
278
|
+
cap.release()
|
|
279
|
+
return FilterResult(
|
|
280
|
+
detected_classes=detected_classes,
|
|
281
|
+
confidence=max_confidence,
|
|
282
|
+
model=Path(model_path).name,
|
|
283
|
+
sampled_frames=sampled_frames,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
frame_idx += sample_fps
|
|
287
|
+
|
|
288
|
+
cap.release()
|
|
289
|
+
|
|
290
|
+
return FilterResult(
|
|
291
|
+
detected_classes=detected_classes,
|
|
292
|
+
confidence=max_confidence if detected_classes else 0.0,
|
|
293
|
+
model=Path(model_path).name,
|
|
294
|
+
sampled_frames=sampled_frames,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# Plugin registration
|
|
299
|
+
from homesec.plugins.filters import FilterPlugin, filter_plugin
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@filter_plugin(name="yolo")
|
|
303
|
+
def yolo_filter_plugin() -> FilterPlugin:
|
|
304
|
+
"""YOLO filter plugin factory.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
FilterPlugin for YOLOv8 object detection
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
def factory(cfg: FilterConfig) -> ObjectFilter:
|
|
311
|
+
return YOLOv8Filter(cfg)
|
|
312
|
+
|
|
313
|
+
return FilterPlugin(
|
|
314
|
+
name="yolo",
|
|
315
|
+
config_model=YoloFilterSettings,
|
|
316
|
+
factory=factory,
|
|
317
|
+
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Notifier plugins and registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Callable, TypeVar
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from homesec.interfaces import Notifier
|
|
12
|
+
from homesec.plugins.notifiers.multiplex import MultiplexNotifier, NotifierEntry
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
NotifierFactory = Callable[[BaseModel], Notifier]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class NotifierPlugin:
|
|
22
|
+
name: str
|
|
23
|
+
config_model: type[BaseModel]
|
|
24
|
+
factory: NotifierFactory
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
NOTIFIER_REGISTRY: dict[str, NotifierPlugin] = {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def register_notifier(plugin: NotifierPlugin) -> None:
|
|
31
|
+
"""Register a notifier plugin with collision detection.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
plugin: Notifier plugin to register
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
ValueError: If a plugin with the same name is already registered
|
|
38
|
+
"""
|
|
39
|
+
if plugin.name in NOTIFIER_REGISTRY:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"Notifier plugin '{plugin.name}' is already registered. "
|
|
42
|
+
f"Plugin names must be unique across all notifier plugins."
|
|
43
|
+
)
|
|
44
|
+
NOTIFIER_REGISTRY[plugin.name] = plugin
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
T = TypeVar("T", bound=Callable[[], NotifierPlugin])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def notifier_plugin(name: str) -> Callable[[T], T]:
|
|
51
|
+
"""Decorator to register a notifier plugin.
|
|
52
|
+
|
|
53
|
+
Usage:
|
|
54
|
+
@notifier_plugin(name="my_notifier")
|
|
55
|
+
def my_notifier_plugin() -> NotifierPlugin:
|
|
56
|
+
return NotifierPlugin(...)
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
name: Plugin name (for validation only - must match plugin.name)
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Decorator function that registers the plugin
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def decorator(factory_fn: T) -> T:
|
|
66
|
+
plugin = factory_fn()
|
|
67
|
+
register_notifier(plugin)
|
|
68
|
+
return factory_fn
|
|
69
|
+
|
|
70
|
+
return decorator
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
__all__ = [
|
|
74
|
+
"MultiplexNotifier",
|
|
75
|
+
"NotifierEntry",
|
|
76
|
+
"NotifierPlugin",
|
|
77
|
+
"NOTIFIER_REGISTRY",
|
|
78
|
+
"register_notifier",
|
|
79
|
+
"notifier_plugin",
|
|
80
|
+
]
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""MQTT notifier plugin for Home Assistant integration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import threading
|
|
10
|
+
|
|
11
|
+
import paho.mqtt.client as mqtt
|
|
12
|
+
|
|
13
|
+
from homesec.models.alert import Alert
|
|
14
|
+
from homesec.models.config import MQTTConfig
|
|
15
|
+
from homesec.interfaces import Notifier
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MQTTNotifier(Notifier):
|
|
21
|
+
"""MQTT notifier for Home Assistant alerts.
|
|
22
|
+
|
|
23
|
+
Publishes alert messages to configured topics with QoS settings.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, config: MQTTConfig) -> None:
|
|
27
|
+
"""Initialize MQTT notifier with config validation.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
config: MQTTConfig instance
|
|
31
|
+
"""
|
|
32
|
+
self.host = config.host
|
|
33
|
+
self.port = int(config.port)
|
|
34
|
+
self.topic_template = config.topic_template
|
|
35
|
+
self.qos = int(config.qos)
|
|
36
|
+
self.retain = bool(config.retain)
|
|
37
|
+
self.connection_timeout = float(config.connection_timeout)
|
|
38
|
+
|
|
39
|
+
# Get credentials from env if provided
|
|
40
|
+
self.username: str | None = None
|
|
41
|
+
self.password: str | None = None
|
|
42
|
+
|
|
43
|
+
if config.auth and config.auth.username_env:
|
|
44
|
+
username_var = config.auth.username_env
|
|
45
|
+
self.username = os.getenv(username_var)
|
|
46
|
+
if not self.username:
|
|
47
|
+
logger.warning("MQTT username not found in env: %s", username_var)
|
|
48
|
+
|
|
49
|
+
if config.auth and config.auth.password_env:
|
|
50
|
+
password_var = config.auth.password_env
|
|
51
|
+
self.password = os.getenv(password_var)
|
|
52
|
+
if not self.password:
|
|
53
|
+
logger.warning("MQTT password not found in env: %s", password_var)
|
|
54
|
+
|
|
55
|
+
# Initialize MQTT client
|
|
56
|
+
self.client = mqtt.Client()
|
|
57
|
+
|
|
58
|
+
if self.username and self.password:
|
|
59
|
+
self.client.username_pw_set(self.username, self.password)
|
|
60
|
+
|
|
61
|
+
# Connection state
|
|
62
|
+
self._connected = False
|
|
63
|
+
self._connected_event = threading.Event()
|
|
64
|
+
self._shutdown_called = False
|
|
65
|
+
self._loop_started = False
|
|
66
|
+
|
|
67
|
+
def _on_connect(
|
|
68
|
+
client: mqtt.Client, userdata: object, flags: dict[str, object], rc: int
|
|
69
|
+
) -> None:
|
|
70
|
+
if rc == 0:
|
|
71
|
+
self._connected = True
|
|
72
|
+
self._connected_event.set()
|
|
73
|
+
logger.info("MQTTNotifier connected: %s:%d", self.host, self.port)
|
|
74
|
+
return
|
|
75
|
+
self._connected = False
|
|
76
|
+
logger.warning("MQTTNotifier connection failed: rc=%s", rc)
|
|
77
|
+
|
|
78
|
+
def _on_disconnect(client: mqtt.Client, userdata: object, rc: int) -> None:
|
|
79
|
+
self._connected = False
|
|
80
|
+
if rc != 0:
|
|
81
|
+
logger.warning("MQTTNotifier disconnected unexpectedly: rc=%s", rc)
|
|
82
|
+
|
|
83
|
+
self.client.on_connect = _on_connect
|
|
84
|
+
self.client.on_disconnect = _on_disconnect
|
|
85
|
+
|
|
86
|
+
# Connect to broker
|
|
87
|
+
try:
|
|
88
|
+
self.client.connect(self.host, self.port, keepalive=60)
|
|
89
|
+
self.client.loop_start()
|
|
90
|
+
self._loop_started = True
|
|
91
|
+
except Exception as e:
|
|
92
|
+
logger.error("Failed to connect to MQTT broker: %s", e, exc_info=True)
|
|
93
|
+
self._connected = False
|
|
94
|
+
|
|
95
|
+
async def send(self, alert: Alert) -> None:
|
|
96
|
+
"""Send alert notification to MQTT topic."""
|
|
97
|
+
await self._ensure_connected()
|
|
98
|
+
|
|
99
|
+
# Format topic
|
|
100
|
+
topic = self.topic_template.format(camera_name=alert.camera_name)
|
|
101
|
+
|
|
102
|
+
# Serialize alert to JSON
|
|
103
|
+
payload = alert.model_dump_json()
|
|
104
|
+
|
|
105
|
+
# Publish message
|
|
106
|
+
await asyncio.to_thread(
|
|
107
|
+
self._publish,
|
|
108
|
+
topic,
|
|
109
|
+
payload,
|
|
110
|
+
self.qos,
|
|
111
|
+
self.retain,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
logger.info(
|
|
115
|
+
"Published alert to MQTT: topic=%s, clip_id=%s",
|
|
116
|
+
topic,
|
|
117
|
+
alert.clip_id,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def _publish(self, topic: str, payload: str, qos: int, retain: bool) -> None:
|
|
121
|
+
"""Publish message (blocking operation)."""
|
|
122
|
+
result = self.client.publish(topic, payload, qos=qos, retain=retain)
|
|
123
|
+
result.wait_for_publish()
|
|
124
|
+
|
|
125
|
+
async def ping(self) -> bool:
|
|
126
|
+
"""Health check - verify MQTT connection."""
|
|
127
|
+
if self._shutdown_called:
|
|
128
|
+
return False
|
|
129
|
+
if self._connected and self.client.is_connected():
|
|
130
|
+
return True
|
|
131
|
+
await asyncio.to_thread(self._connected_event.wait, 2.0)
|
|
132
|
+
return self._connected and self.client.is_connected()
|
|
133
|
+
|
|
134
|
+
async def shutdown(self, timeout: float | None = None) -> None:
|
|
135
|
+
"""Cleanup resources - disconnect from broker."""
|
|
136
|
+
_ = timeout
|
|
137
|
+
if self._shutdown_called:
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
self._shutdown_called = True
|
|
141
|
+
logger.info("Shutting down MQTTNotifier...")
|
|
142
|
+
|
|
143
|
+
# Stop loop if it was started (prevents thread leak even if never connected)
|
|
144
|
+
if self._loop_started:
|
|
145
|
+
await asyncio.to_thread(self.client.loop_stop)
|
|
146
|
+
await asyncio.to_thread(self.client.disconnect)
|
|
147
|
+
|
|
148
|
+
logger.info("MQTTNotifier shutdown complete")
|
|
149
|
+
|
|
150
|
+
async def _ensure_connected(self) -> None:
|
|
151
|
+
if self._shutdown_called:
|
|
152
|
+
raise RuntimeError("Notifier has been shut down")
|
|
153
|
+
if self._connected:
|
|
154
|
+
return
|
|
155
|
+
# Wait for connection with timeout
|
|
156
|
+
connected = await asyncio.to_thread(
|
|
157
|
+
self._connected_event.wait, self.connection_timeout
|
|
158
|
+
)
|
|
159
|
+
if not connected or not self._connected:
|
|
160
|
+
raise RuntimeError(
|
|
161
|
+
f"MQTT broker not connected after {self.connection_timeout}s timeout"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# Plugin registration
|
|
166
|
+
from typing import cast
|
|
167
|
+
from pydantic import BaseModel
|
|
168
|
+
from homesec.plugins.notifiers import NotifierPlugin, notifier_plugin
|
|
169
|
+
from homesec.interfaces import Notifier
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@notifier_plugin(name="mqtt")
|
|
173
|
+
def mqtt_plugin() -> NotifierPlugin:
|
|
174
|
+
"""MQTT notifier plugin factory.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
NotifierPlugin for MQTT notifications
|
|
178
|
+
"""
|
|
179
|
+
from homesec.models.config import MQTTConfig
|
|
180
|
+
|
|
181
|
+
def factory(cfg: BaseModel) -> Notifier:
|
|
182
|
+
# Config is already validated by app.py, just cast
|
|
183
|
+
return MQTTNotifier(cast(MQTTConfig, cfg))
|
|
184
|
+
|
|
185
|
+
return NotifierPlugin(
|
|
186
|
+
name="mqtt",
|
|
187
|
+
config_model=MQTTConfig,
|
|
188
|
+
factory=factory,
|
|
189
|
+
)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Fan-out notifier that sends alerts to multiple notifiers in parallel."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Callable, Coroutine
|
|
9
|
+
|
|
10
|
+
from homesec.models.alert import Alert
|
|
11
|
+
from homesec.interfaces import Notifier
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class NotifierEntry:
|
|
18
|
+
"""Notifier entry with a name for logging."""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
notifier: Notifier
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MultiplexNotifier(Notifier):
|
|
25
|
+
"""Send notifications to multiple notifiers in parallel."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, entries: list[NotifierEntry]) -> None:
|
|
28
|
+
if not entries:
|
|
29
|
+
raise ValueError("MultiplexNotifier requires at least one notifier")
|
|
30
|
+
self._entries = list(entries)
|
|
31
|
+
self._shutdown_called = False
|
|
32
|
+
|
|
33
|
+
async def send(self, alert: Alert) -> None:
|
|
34
|
+
"""Send alert notification via all notifiers in parallel."""
|
|
35
|
+
if self._shutdown_called:
|
|
36
|
+
raise RuntimeError("Notifier has been shut down")
|
|
37
|
+
|
|
38
|
+
results = await self._call_all(lambda notifier: notifier.send(alert))
|
|
39
|
+
|
|
40
|
+
failures: list[tuple[str, BaseException]] = []
|
|
41
|
+
for entry, result in results:
|
|
42
|
+
match result:
|
|
43
|
+
case BaseException() as err:
|
|
44
|
+
failures.append((entry.name, err))
|
|
45
|
+
logger.error(
|
|
46
|
+
"Notifier send failed: notifier=%s error=%s",
|
|
47
|
+
entry.name,
|
|
48
|
+
err,
|
|
49
|
+
exc_info=err,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if failures:
|
|
53
|
+
detail = "; ".join(
|
|
54
|
+
f"{name}={type(error).__name__}: {error}" for name, error in failures
|
|
55
|
+
)
|
|
56
|
+
raise RuntimeError(f"Notifier failures: {detail}") from failures[0][1]
|
|
57
|
+
|
|
58
|
+
async def ping(self) -> bool:
|
|
59
|
+
"""Health check - verify all notifiers are reachable."""
|
|
60
|
+
if self._shutdown_called:
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
results = await self._call_all(lambda notifier: notifier.ping())
|
|
64
|
+
|
|
65
|
+
healthy = True
|
|
66
|
+
for entry, result in results:
|
|
67
|
+
match result:
|
|
68
|
+
case bool() as ok:
|
|
69
|
+
if not ok:
|
|
70
|
+
healthy = False
|
|
71
|
+
logger.warning("Notifier ping failed: notifier=%s", entry.name)
|
|
72
|
+
case BaseException() as err:
|
|
73
|
+
healthy = False
|
|
74
|
+
logger.warning(
|
|
75
|
+
"Notifier ping failed: notifier=%s error=%s",
|
|
76
|
+
entry.name,
|
|
77
|
+
err,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return healthy
|
|
81
|
+
|
|
82
|
+
async def shutdown(self, timeout: float | None = None) -> None:
|
|
83
|
+
"""Cleanup resources for all notifiers."""
|
|
84
|
+
_ = timeout
|
|
85
|
+
if self._shutdown_called:
|
|
86
|
+
return
|
|
87
|
+
self._shutdown_called = True
|
|
88
|
+
|
|
89
|
+
results = await self._call_all(lambda notifier: notifier.shutdown())
|
|
90
|
+
for entry, result in results:
|
|
91
|
+
match result:
|
|
92
|
+
case BaseException() as err:
|
|
93
|
+
logger.warning(
|
|
94
|
+
"Notifier close failed: notifier=%s error=%s",
|
|
95
|
+
entry.name,
|
|
96
|
+
err,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
async def _call_all(
|
|
100
|
+
self, func: Callable[[Notifier], Coroutine[Any, Any, object]]
|
|
101
|
+
) -> list[tuple[NotifierEntry, object | BaseException]]:
|
|
102
|
+
tasks: list[asyncio.Task[object]] = [
|
|
103
|
+
asyncio.create_task(func(entry.notifier)) for entry in self._entries
|
|
104
|
+
]
|
|
105
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
106
|
+
return list(zip(self._entries, results, strict=True))
|