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.
Files changed (62) hide show
  1. homesec/__init__.py +20 -0
  2. homesec/app.py +393 -0
  3. homesec/cli.py +159 -0
  4. homesec/config/__init__.py +18 -0
  5. homesec/config/loader.py +109 -0
  6. homesec/config/validation.py +82 -0
  7. homesec/errors.py +71 -0
  8. homesec/health/__init__.py +5 -0
  9. homesec/health/server.py +226 -0
  10. homesec/interfaces.py +249 -0
  11. homesec/logging_setup.py +176 -0
  12. homesec/maintenance/__init__.py +1 -0
  13. homesec/maintenance/cleanup_clips.py +632 -0
  14. homesec/models/__init__.py +79 -0
  15. homesec/models/alert.py +32 -0
  16. homesec/models/clip.py +71 -0
  17. homesec/models/config.py +362 -0
  18. homesec/models/events.py +184 -0
  19. homesec/models/filter.py +62 -0
  20. homesec/models/source.py +77 -0
  21. homesec/models/storage.py +12 -0
  22. homesec/models/vlm.py +99 -0
  23. homesec/pipeline/__init__.py +6 -0
  24. homesec/pipeline/alert_policy.py +5 -0
  25. homesec/pipeline/core.py +639 -0
  26. homesec/plugins/__init__.py +62 -0
  27. homesec/plugins/alert_policies/__init__.py +80 -0
  28. homesec/plugins/alert_policies/default.py +111 -0
  29. homesec/plugins/alert_policies/noop.py +60 -0
  30. homesec/plugins/analyzers/__init__.py +126 -0
  31. homesec/plugins/analyzers/openai.py +446 -0
  32. homesec/plugins/filters/__init__.py +124 -0
  33. homesec/plugins/filters/yolo.py +317 -0
  34. homesec/plugins/notifiers/__init__.py +80 -0
  35. homesec/plugins/notifiers/mqtt.py +189 -0
  36. homesec/plugins/notifiers/multiplex.py +106 -0
  37. homesec/plugins/notifiers/sendgrid_email.py +228 -0
  38. homesec/plugins/storage/__init__.py +116 -0
  39. homesec/plugins/storage/dropbox.py +272 -0
  40. homesec/plugins/storage/local.py +108 -0
  41. homesec/plugins/utils.py +63 -0
  42. homesec/py.typed +0 -0
  43. homesec/repository/__init__.py +5 -0
  44. homesec/repository/clip_repository.py +552 -0
  45. homesec/sources/__init__.py +17 -0
  46. homesec/sources/base.py +224 -0
  47. homesec/sources/ftp.py +209 -0
  48. homesec/sources/local_folder.py +238 -0
  49. homesec/sources/rtsp.py +1251 -0
  50. homesec/state/__init__.py +10 -0
  51. homesec/state/postgres.py +501 -0
  52. homesec/storage_paths.py +46 -0
  53. homesec/telemetry/__init__.py +0 -0
  54. homesec/telemetry/db/__init__.py +1 -0
  55. homesec/telemetry/db/log_table.py +16 -0
  56. homesec/telemetry/db_log_handler.py +246 -0
  57. homesec/telemetry/postgres_settings.py +42 -0
  58. homesec-0.1.0.dist-info/METADATA +446 -0
  59. homesec-0.1.0.dist-info/RECORD +62 -0
  60. homesec-0.1.0.dist-info/WHEEL +4 -0
  61. homesec-0.1.0.dist-info/entry_points.txt +2 -0
  62. 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))