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.
Files changed (46) hide show
  1. homesec/__init__.py +1 -1
  2. homesec/app.py +38 -84
  3. homesec/cli.py +6 -10
  4. homesec/config/validation.py +38 -12
  5. homesec/interfaces.py +50 -2
  6. homesec/maintenance/cleanup_clips.py +4 -4
  7. homesec/models/__init__.py +6 -5
  8. homesec/models/alert.py +3 -2
  9. homesec/models/clip.py +4 -2
  10. homesec/models/config.py +62 -17
  11. homesec/models/enums.py +114 -0
  12. homesec/models/events.py +19 -18
  13. homesec/models/filter.py +13 -3
  14. homesec/models/source.py +4 -0
  15. homesec/models/vlm.py +18 -7
  16. homesec/plugins/__init__.py +7 -33
  17. homesec/plugins/alert_policies/__init__.py +34 -59
  18. homesec/plugins/alert_policies/default.py +20 -45
  19. homesec/plugins/alert_policies/noop.py +14 -29
  20. homesec/plugins/analyzers/__init__.py +20 -105
  21. homesec/plugins/analyzers/openai.py +70 -53
  22. homesec/plugins/filters/__init__.py +18 -102
  23. homesec/plugins/filters/yolo.py +103 -66
  24. homesec/plugins/notifiers/__init__.py +20 -56
  25. homesec/plugins/notifiers/mqtt.py +22 -30
  26. homesec/plugins/notifiers/sendgrid_email.py +34 -32
  27. homesec/plugins/registry.py +160 -0
  28. homesec/plugins/sources/__init__.py +45 -0
  29. homesec/plugins/sources/ftp.py +25 -0
  30. homesec/plugins/sources/local_folder.py +30 -0
  31. homesec/plugins/sources/rtsp.py +27 -0
  32. homesec/plugins/storage/__init__.py +18 -88
  33. homesec/plugins/storage/dropbox.py +36 -37
  34. homesec/plugins/storage/local.py +8 -29
  35. homesec/plugins/utils.py +8 -4
  36. homesec/repository/clip_repository.py +20 -14
  37. homesec/sources/base.py +24 -2
  38. homesec/sources/local_folder.py +57 -78
  39. homesec/sources/rtsp.py +45 -4
  40. homesec/state/postgres.py +46 -17
  41. {homesec-1.1.1.dist-info → homesec-1.2.0.dist-info}/METADATA +1 -1
  42. homesec-1.2.0.dist-info/RECORD +68 -0
  43. homesec-1.1.1.dist-info/RECORD +0 -62
  44. {homesec-1.1.1.dist-info → homesec-1.2.0.dist-info}/WHEEL +0 -0
  45. {homesec-1.1.1.dist-info → homesec-1.2.0.dist-info}/entry_points.txt +0 -0
  46. {homesec-1.1.1.dist-info → homesec-1.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -3,121 +3,37 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
- from collections.abc import Callable
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
- if TYPE_CHECKING:
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 filter_plugin(name: str) -> Callable[[T], T]:
56
- """Decorator to register a filter plugin.
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
- name: Plugin name (for validation only - must match plugin.name)
19
+ config: Filter configuration
65
20
 
66
21
  Returns:
67
- Decorator function that registers the plugin
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 name is unknown or config validation fails
25
+ ValueError: If plugin not found in registry
26
+ ValidationError: If config validation fails
92
27
  """
93
- plugin_name = config.plugin.lower()
94
-
95
- if plugin_name not in FILTER_REGISTRY:
96
- available = ", ".join(sorted(FILTER_REGISTRY.keys()))
97
- raise ValueError(f"Unknown filter plugin: '{plugin_name}'. Available: {available}")
98
-
99
- plugin = FILTER_REGISTRY[plugin_name]
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"]
@@ -5,17 +5,31 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import logging
7
7
  import shutil
8
- from collections.abc import Callable
8
+ import threading
9
9
  from concurrent.futures import ProcessPoolExecutor
10
10
  from pathlib import Path
11
- from typing import cast
12
-
13
- import cv2
14
- import torch
15
- from ultralytics import YOLO # type: ignore[attr-defined]
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 FilterConfig, FilterOverrides, FilterResult, YoloFilterSettings
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], YOLO] = {}
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
- try:
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
- _ = YOLO(requested_path.name)
71
- candidate = check_file_fn(requested_path.name)
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) -> YOLO:
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 = YOLO(model_path).to(device)
98
- _MODEL_CACHE[key] = model
99
- return model
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
- def __init__(self, config: FilterConfig) -> None:
111
- """Initialize YOLO filter with config validation.
162
+ config_cls = YoloFilterSettings
112
163
 
113
- Required config:
114
- model_path: Path to .pt model file
164
+ @classmethod
165
+ def create(cls, config: YoloFilterSettings) -> ObjectFilter:
166
+ return cls(config)
115
167
 
116
- Optional config:
117
- classes: List of class names to detect (default: person)
118
- min_confidence: Minimum confidence threshold (default: 0.5)
119
- sample_fps: Frame sampling rate (default: 2)
120
- min_box_h_ratio: Minimum box height ratio (default: 0.1)
121
- min_hits: Minimum detections to confirm (default: 1)
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
- match config.config:
124
- case YoloFilterSettings() as settings:
125
- cfg = settings
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=config.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 collections.abc import Callable
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
- NotifierFactory = Callable[[BaseModel], Notifier]
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
- name: Plugin name (for validation only - must match plugin.name)
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
- Decorator function that registers the plugin
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
- return decorator
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
- "NotifierPlugin",
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
- import paho.mqtt.client as mqtt
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
- raise RuntimeError(f"SendGrid email send failed ({response.status}): {details}")
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 or "unknown",
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
- )