homesec 0.1.0__py3-none-any.whl → 1.0.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 (45) hide show
  1. homesec/__init__.py +1 -1
  2. homesec/app.py +34 -36
  3. homesec/cli.py +14 -11
  4. homesec/config/loader.py +11 -11
  5. homesec/config/validation.py +2 -5
  6. homesec/errors.py +2 -4
  7. homesec/health/server.py +29 -27
  8. homesec/interfaces.py +11 -6
  9. homesec/logging_setup.py +9 -5
  10. homesec/maintenance/cleanup_clips.py +2 -3
  11. homesec/models/__init__.py +1 -1
  12. homesec/models/alert.py +2 -0
  13. homesec/models/clip.py +8 -1
  14. homesec/models/config.py +9 -13
  15. homesec/models/events.py +14 -0
  16. homesec/models/filter.py +1 -3
  17. homesec/models/vlm.py +1 -2
  18. homesec/pipeline/core.py +15 -32
  19. homesec/plugins/alert_policies/__init__.py +3 -4
  20. homesec/plugins/alert_policies/default.py +3 -2
  21. homesec/plugins/alert_policies/noop.py +1 -2
  22. homesec/plugins/analyzers/__init__.py +3 -4
  23. homesec/plugins/analyzers/openai.py +34 -43
  24. homesec/plugins/filters/__init__.py +3 -4
  25. homesec/plugins/filters/yolo.py +27 -29
  26. homesec/plugins/notifiers/__init__.py +2 -1
  27. homesec/plugins/notifiers/mqtt.py +16 -17
  28. homesec/plugins/notifiers/multiplex.py +3 -2
  29. homesec/plugins/notifiers/sendgrid_email.py +6 -8
  30. homesec/plugins/storage/__init__.py +3 -4
  31. homesec/plugins/storage/dropbox.py +20 -17
  32. homesec/plugins/storage/local.py +3 -1
  33. homesec/plugins/utils.py +2 -1
  34. homesec/repository/clip_repository.py +5 -4
  35. homesec/sources/base.py +2 -2
  36. homesec/sources/local_folder.py +9 -7
  37. homesec/sources/rtsp.py +22 -10
  38. homesec/state/postgres.py +34 -35
  39. homesec/telemetry/db_log_handler.py +3 -2
  40. {homesec-0.1.0.dist-info → homesec-1.0.0.dist-info}/METADATA +66 -31
  41. homesec-1.0.0.dist-info/RECORD +62 -0
  42. homesec-0.1.0.dist-info/RECORD +0 -62
  43. {homesec-0.1.0.dist-info → homesec-1.0.0.dist-info}/WHEEL +0 -0
  44. {homesec-0.1.0.dist-info → homesec-1.0.0.dist-info}/entry_points.txt +0 -0
  45. {homesec-0.1.0.dist-info → homesec-1.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -14,6 +14,7 @@ import cv2
14
14
  from PIL import Image
15
15
  from pydantic import BaseModel
16
16
 
17
+ from homesec.interfaces import VLMAnalyzer
17
18
  from homesec.models.filter import FilterResult
18
19
  from homesec.models.vlm import (
19
20
  AnalysisResult,
@@ -22,7 +23,6 @@ from homesec.models.vlm import (
22
23
  VLMConfig,
23
24
  VLMPreprocessConfig,
24
25
  )
25
- from homesec.interfaces import VLMAnalyzer
26
26
 
27
27
  logger = logging.getLogger(__name__)
28
28
 
@@ -61,18 +61,18 @@ def _create_json_schema_format(
61
61
 
62
62
  class OpenAIVLM(VLMAnalyzer):
63
63
  """OpenAI-compatible VLM analyzer plugin.
64
-
64
+
65
65
  Uses aiohttp for async HTTP calls to OpenAI API.
66
66
  Supports structured output with Pydantic schemas.
67
67
  """
68
68
 
69
69
  def __init__(self, config: VLMConfig) -> None:
70
70
  """Initialize OpenAI VLM with config validation.
71
-
71
+
72
72
  Required config:
73
73
  llm.api_key_env: Env var name with API key
74
74
  llm.model: Model name (e.g., gpt-4o)
75
-
75
+
76
76
  Optional config:
77
77
  llm.base_url: API base URL (default: https://api.openai.com/v1)
78
78
  llm.token_param: max_tokens or max_completion_tokens
@@ -86,13 +86,13 @@ class OpenAIVLM(VLMAnalyzer):
86
86
  raise ValueError("OpenAIVLM requires llm=OpenAILLMConfig")
87
87
  llm = config.llm
88
88
  preprocess = config.preprocessing
89
-
89
+
90
90
  # Get API key from env
91
91
  self._api_key_env = llm.api_key_env
92
92
  self.api_key = os.getenv(self._api_key_env)
93
93
  if not self.api_key:
94
94
  raise ValueError(f"API key not found in env: {self._api_key_env}")
95
-
95
+
96
96
  self.model = llm.model
97
97
  self.base_url = llm.base_url
98
98
  self.system_prompt = DEFAULT_SYSTEM_PROMPT
@@ -100,11 +100,11 @@ class OpenAIVLM(VLMAnalyzer):
100
100
  self.token_param = llm.token_param
101
101
  self.max_tokens = self._resolve_token_limit(llm)
102
102
  self.request_timeout = float(llm.request_timeout)
103
-
103
+
104
104
  # Create HTTP session
105
105
  self._session: aiohttp.ClientSession | None = None
106
106
  self._shutdown_called = False
107
-
107
+
108
108
  logger.info(
109
109
  "OpenAIVLM initialized: model=%s, max_frames=%d, token_param=%s, temperature=%s",
110
110
  self.model,
@@ -127,18 +127,18 @@ class OpenAIVLM(VLMAnalyzer):
127
127
  config: VLMConfig,
128
128
  ) -> AnalysisResult:
129
129
  """Analyze video clip using OpenAI VLM.
130
-
130
+
131
131
  Extracts frames, encodes as base64, and calls OpenAI API
132
132
  with structured output schema.
133
133
  """
134
134
  if self._shutdown_called:
135
135
  raise RuntimeError("VLM has been shut down")
136
-
136
+
137
137
  start_time = asyncio.get_running_loop().time()
138
138
 
139
139
  # Extract frames
140
140
  frames = await self._extract_frames_async(video_path, config.preprocessing)
141
-
141
+
142
142
  if not frames:
143
143
  raise ValueError(f"No frames extracted from {video_path}")
144
144
 
@@ -154,10 +154,8 @@ class OpenAIVLM(VLMAnalyzer):
154
154
  prompt_tokens = usage.get("prompt_tokens")
155
155
  completion_tokens = usage.get("completion_tokens")
156
156
  prompt_token_count = prompt_tokens if isinstance(prompt_tokens, int) else None
157
- completion_token_count = (
158
- completion_tokens if isinstance(completion_tokens, int) else None
159
- )
160
-
157
+ completion_token_count = completion_tokens if isinstance(completion_tokens, int) else None
158
+
161
159
  # Parse response
162
160
  content = self._extract_content(data)
163
161
  analysis = self._parse_sequence_analysis(content)
@@ -236,9 +234,7 @@ class OpenAIVLM(VLMAnalyzer):
236
234
  payload: dict[str, object] = {
237
235
  "model": self.model,
238
236
  "messages": messages,
239
- "response_format": _create_json_schema_format(
240
- SequenceAnalysis, "sequence_analysis"
241
- ),
237
+ "response_format": _create_json_schema_format(SequenceAnalysis, "sequence_analysis"),
242
238
  }
243
239
  if self.temperature is not None:
244
240
  payload["temperature"] = self.temperature
@@ -260,18 +256,14 @@ class OpenAIVLM(VLMAnalyzer):
260
256
  async with session.post(url, json=payload, headers=headers) as resp:
261
257
  if resp.status != 200:
262
258
  error_text = await resp.text()
263
- raise RuntimeError(
264
- f"OpenAI API error {resp.status}: {error_text}"
265
- )
259
+ raise RuntimeError(f"OpenAI API error {resp.status}: {error_text}")
266
260
 
267
261
  data = await resp.json()
268
262
  if not isinstance(data, dict):
269
263
  raise TypeError("OpenAI API response is not a JSON object")
270
264
  return data
271
265
 
272
- def _log_usage(
273
- self, usage: dict[str, object], start_time: float, video_path: Path
274
- ) -> None:
266
+ def _log_usage(self, usage: dict[str, object], start_time: float, video_path: Path) -> None:
275
267
  elapsed_s = asyncio.get_running_loop().time() - start_time
276
268
  logger.info(
277
269
  "VLM token usage",
@@ -317,8 +309,7 @@ class OpenAIVLM(VLMAnalyzer):
317
309
  ) from e
318
310
  except ValueError as e:
319
311
  raise ValueError(
320
- f"VLM response does not match SequenceAnalysis schema: {e}. "
321
- f"Raw response: {content}"
312
+ f"VLM response does not match SequenceAnalysis schema: {e}. Raw response: {content}"
322
313
  ) from e
323
314
 
324
315
  def _resolve_token_limit(self, llm: OpenAILLMConfig) -> int:
@@ -336,25 +327,25 @@ class OpenAIVLM(VLMAnalyzer):
336
327
  quality: int,
337
328
  ) -> list[tuple[str, str]]:
338
329
  """Extract and encode frames from video.
339
-
330
+
340
331
  Returns list of (base64 JPEG, timestamp) tuples.
341
332
  """
342
333
  cap = cv2.VideoCapture(str(video_path))
343
334
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
344
-
335
+
345
336
  if total_frames == 0:
346
337
  cap.release()
347
338
  return []
348
-
339
+
349
340
  # Calculate frame indices to sample
350
341
  if total_frames <= max_frames:
351
342
  frame_indices = list(range(total_frames))
352
343
  else:
353
344
  step = total_frames / max_frames
354
345
  frame_indices = [int(i * step) for i in range(max_frames)]
355
-
346
+
356
347
  frames_b64: list[tuple[str, str]] = []
357
-
348
+
358
349
  for idx in frame_indices:
359
350
  cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
360
351
  ret, frame = cap.read()
@@ -363,25 +354,26 @@ class OpenAIVLM(VLMAnalyzer):
363
354
 
364
355
  timestamp_ms = cap.get(cv2.CAP_PROP_POS_MSEC)
365
356
  timestamp = self._format_timestamp(timestamp_ms)
366
-
357
+
367
358
  # Convert to PIL Image
368
359
  rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
369
360
  pil_img = Image.fromarray(rgb_frame)
370
-
361
+
371
362
  # Resize if needed
372
363
  if max(pil_img.size) > max_size:
373
364
  pil_img = self._resize_image(pil_img, max_size)
374
-
365
+
375
366
  # Encode as JPEG
376
367
  import io
368
+
377
369
  buffer = io.BytesIO()
378
370
  pil_img.save(buffer, format="JPEG", quality=quality)
379
371
  frame_bytes = buffer.getvalue()
380
-
372
+
381
373
  # Base64 encode
382
374
  frame_b64 = base64.b64encode(frame_bytes).decode("utf-8")
383
375
  frames_b64.append((frame_b64, timestamp))
384
-
376
+
385
377
  cap.release()
386
378
  return frames_b64
387
379
 
@@ -394,17 +386,17 @@ class OpenAIVLM(VLMAnalyzer):
394
386
  def _resize_image(self, img: Image.Image, max_size: int) -> Image.Image:
395
387
  """Resize image maintaining aspect ratio."""
396
388
  width, height = img.size
397
-
389
+
398
390
  if width <= max_size and height <= max_size:
399
391
  return img
400
-
392
+
401
393
  if width > height:
402
394
  new_width = max_size
403
395
  new_height = int(height * (max_size / width))
404
396
  else:
405
397
  new_height = max_size
406
398
  new_width = int(width * (max_size / height))
407
-
399
+
408
400
  return img.resize((new_width, new_height), Image.Resampling.LANCZOS)
409
401
 
410
402
  async def shutdown(self, timeout: float | None = None) -> None:
@@ -412,10 +404,10 @@ class OpenAIVLM(VLMAnalyzer):
412
404
  _ = timeout
413
405
  if self._shutdown_called:
414
406
  return
415
-
407
+
416
408
  self._shutdown_called = True
417
409
  logger.info("Shutting down OpenAIVLM...")
418
-
410
+
419
411
  if self._session:
420
412
  await self._session.close()
421
413
 
@@ -433,8 +425,7 @@ def openai_vlm_plugin() -> VLMPlugin:
433
425
  Returns:
434
426
  VLMPlugin for OpenAI vision-language model
435
427
  """
436
- from homesec.models.vlm import OpenAILLMConfig, VLMConfig
437
- from homesec.interfaces import VLMAnalyzer
428
+ from homesec.models.vlm import OpenAILLMConfig
438
429
 
439
430
  def factory(cfg: VLMConfig) -> VLMAnalyzer:
440
431
  return OpenAIVLM(cfg)
@@ -3,8 +3,9 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ from collections.abc import Callable
6
7
  from dataclasses import dataclass
7
- from typing import Callable, TYPE_CHECKING, TypeVar
8
+ from typing import TYPE_CHECKING, TypeVar
8
9
 
9
10
  from pydantic import BaseModel
10
11
 
@@ -93,9 +94,7 @@ def load_filter_plugin(config: FilterConfig) -> ObjectFilter:
93
94
 
94
95
  if plugin_name not in FILTER_REGISTRY:
95
96
  available = ", ".join(sorted(FILTER_REGISTRY.keys()))
96
- raise ValueError(
97
- f"Unknown filter plugin: '{plugin_name}'. Available: {available}"
98
- )
97
+ raise ValueError(f"Unknown filter plugin: '{plugin_name}'. Available: {available}")
99
98
 
100
99
  plugin = FILTER_REGISTRY[plugin_name]
101
100
 
@@ -5,16 +5,17 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import logging
7
7
  import shutil
8
+ from collections.abc import Callable
8
9
  from concurrent.futures import ProcessPoolExecutor
9
10
  from pathlib import Path
10
- from typing import Callable, cast
11
+ from typing import cast
11
12
 
12
13
  import cv2
13
14
  import torch
14
15
  from ultralytics import YOLO # type: ignore[attr-defined]
15
16
 
16
- from homesec.models.filter import FilterConfig, FilterOverrides, FilterResult, YoloFilterSettings
17
17
  from homesec.interfaces import ObjectFilter
18
+ from homesec.models.filter import FilterConfig, FilterOverrides, FilterResult, YoloFilterSettings
18
19
 
19
20
  logger = logging.getLogger(__name__)
20
21
 
@@ -100,7 +101,7 @@ def _get_model(model_path: str, device: str) -> YOLO:
100
101
 
101
102
  class YOLOv8Filter(ObjectFilter):
102
103
  """YOLO-based object detection filter.
103
-
104
+
104
105
  Uses ProcessPoolExecutor internally for CPU/GPU-bound inference.
105
106
  Supports frame sampling and early exit on detection.
106
107
  Bare model filenames resolve under ./yolo_cache and auto-download if missing.
@@ -108,10 +109,10 @@ class YOLOv8Filter(ObjectFilter):
108
109
 
109
110
  def __init__(self, config: FilterConfig) -> None:
110
111
  """Initialize YOLO filter with config validation.
111
-
112
+
112
113
  Required config:
113
114
  model_path: Path to .pt model file
114
-
115
+
115
116
  Optional config:
116
117
  classes: List of class names to detect (default: person)
117
118
  min_confidence: Minimum confidence threshold (default: 0.5)
@@ -131,11 +132,11 @@ class YOLOv8Filter(ObjectFilter):
131
132
  raise FileNotFoundError(f"Model not found: {self.model_path}")
132
133
 
133
134
  self._class_id_cache: dict[tuple[str, ...], list[int]] = {}
134
-
135
+
135
136
  # Initialize executor
136
137
  self._executor = ProcessPoolExecutor(max_workers=config.max_workers)
137
138
  self._shutdown_called = False
138
-
139
+
139
140
  logger.info(
140
141
  "YOLOv8Filter initialized: model=%s, classes=%s, confidence=%.2f",
141
142
  self.model_path,
@@ -149,13 +150,13 @@ class YOLOv8Filter(ObjectFilter):
149
150
  overrides: FilterOverrides | None = None,
150
151
  ) -> FilterResult:
151
152
  """Detect objects in video clip.
152
-
153
+
153
154
  Runs inference in ProcessPoolExecutor to avoid blocking event loop.
154
155
  Samples frames at configured rate and exits early on first detection.
155
156
  """
156
157
  if self._shutdown_called:
157
158
  raise RuntimeError("Filter has been shut down")
158
-
159
+
159
160
  # Run blocking work in executor
160
161
  loop = asyncio.get_running_loop()
161
162
  effective = self._apply_overrides(overrides)
@@ -172,7 +173,7 @@ class YOLOv8Filter(ObjectFilter):
172
173
  float(effective.min_box_h_ratio),
173
174
  int(effective.min_hits),
174
175
  )
175
-
176
+
176
177
  return result
177
178
 
178
179
  async def shutdown(self, timeout: float | None = None) -> None:
@@ -180,7 +181,7 @@ class YOLOv8Filter(ObjectFilter):
180
181
  _ = timeout
181
182
  if self._shutdown_called:
182
183
  return
183
-
184
+
184
185
  self._shutdown_called = True
185
186
  logger.info("Shutting down YOLOv8Filter...")
186
187
  self._executor.shutdown(wait=True, cancel_futures=False)
@@ -197,10 +198,7 @@ class YOLOv8Filter(ObjectFilter):
197
198
  cached = self._class_id_cache.get(key)
198
199
  if cached is not None:
199
200
  return cached
200
- target_class_ids = [
201
- cid for cid, name in HUMAN_ANIMAL_CLASSES.items()
202
- if name in classes
203
- ]
201
+ target_class_ids = [cid for cid, name in HUMAN_ANIMAL_CLASSES.items() if name in classes]
204
202
  if not target_class_ids:
205
203
  raise ValueError(f"No valid classes found in config: {classes}")
206
204
  self._class_id_cache[key] = target_class_ids
@@ -217,7 +215,7 @@ def _detect_worker(
217
215
  min_hits: int,
218
216
  ) -> FilterResult:
219
217
  """Worker function for video analysis (must be at module level for pickling).
220
-
218
+
221
219
  This runs in a separate process, so it needs to load the model fresh.
222
220
  """
223
221
  # Determine device
@@ -228,51 +226,51 @@ def _detect_worker(
228
226
  if torch.cuda.is_available()
229
227
  else "cpu"
230
228
  )
231
-
229
+
232
230
  # Load model (cached per process)
233
231
  model = _get_model(model_path, device)
234
-
232
+
235
233
  # Open video
236
234
  cap = cv2.VideoCapture(video_path)
237
235
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
238
236
  frame_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
239
237
  min_box_h = frame_h * min_box_h_ratio
240
-
238
+
241
239
  detected_classes: list[str] = []
242
240
  max_confidence = 0.0
243
241
  sampled_frames = 0
244
-
242
+
245
243
  frame_idx = 0
246
244
  while frame_idx < total_frames:
247
245
  cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
248
246
  ret, frame = cap.read()
249
247
  if not ret:
250
248
  break
251
-
249
+
252
250
  sampled_frames += 1
253
-
251
+
254
252
  # Run inference
255
253
  results = model(frame, verbose=False, conf=min_confidence, classes=target_class_ids)
256
-
254
+
257
255
  for result in results:
258
256
  for box in result.boxes:
259
257
  cls = int(box.cls[0])
260
258
  class_name = HUMAN_ANIMAL_CLASSES.get(cls)
261
259
  if not class_name:
262
260
  continue
263
-
261
+
264
262
  # Check box height
265
263
  xyxy = box.xyxy[0].tolist()
266
264
  box_h = xyxy[3] - xyxy[1]
267
265
  if box_h < min_box_h:
268
266
  continue
269
-
267
+
270
268
  # Track detection
271
269
  confidence = float(box.conf[0])
272
270
  if class_name not in detected_classes:
273
271
  detected_classes.append(class_name)
274
272
  max_confidence = max(max_confidence, confidence)
275
-
273
+
276
274
  # Early exit if we have enough hits
277
275
  if len(detected_classes) >= min_hits:
278
276
  cap.release()
@@ -282,11 +280,11 @@ def _detect_worker(
282
280
  model=Path(model_path).name,
283
281
  sampled_frames=sampled_frames,
284
282
  )
285
-
283
+
286
284
  frame_idx += sample_fps
287
-
285
+
288
286
  cap.release()
289
-
287
+
290
288
  return FilterResult(
291
289
  detected_classes=detected_classes,
292
290
  confidence=max_confidence if detected_classes else 0.0,
@@ -3,8 +3,9 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ from collections.abc import Callable
6
7
  from dataclasses import dataclass
7
- from typing import Callable, TypeVar
8
+ from typing import TypeVar
8
9
 
9
10
  from pydantic import BaseModel
10
11
 
@@ -3,29 +3,28 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
- import json
7
6
  import logging
8
7
  import os
9
8
  import threading
10
9
 
11
10
  import paho.mqtt.client as mqtt
12
11
 
12
+ from homesec.interfaces import Notifier
13
13
  from homesec.models.alert import Alert
14
14
  from homesec.models.config import MQTTConfig
15
- from homesec.interfaces import Notifier
16
15
 
17
16
  logger = logging.getLogger(__name__)
18
17
 
19
18
 
20
19
  class MQTTNotifier(Notifier):
21
20
  """MQTT notifier for Home Assistant alerts.
22
-
21
+
23
22
  Publishes alert messages to configured topics with QoS settings.
24
23
  """
25
24
 
26
25
  def __init__(self, config: MQTTConfig) -> None:
27
26
  """Initialize MQTT notifier with config validation.
28
-
27
+
29
28
  Args:
30
29
  config: MQTTConfig instance
31
30
  """
@@ -35,11 +34,11 @@ class MQTTNotifier(Notifier):
35
34
  self.qos = int(config.qos)
36
35
  self.retain = bool(config.retain)
37
36
  self.connection_timeout = float(config.connection_timeout)
38
-
37
+
39
38
  # Get credentials from env if provided
40
39
  self.username: str | None = None
41
40
  self.password: str | None = None
42
-
41
+
43
42
  if config.auth and config.auth.username_env:
44
43
  username_var = config.auth.username_env
45
44
  self.username = os.getenv(username_var)
@@ -51,13 +50,13 @@ class MQTTNotifier(Notifier):
51
50
  self.password = os.getenv(password_var)
52
51
  if not self.password:
53
52
  logger.warning("MQTT password not found in env: %s", password_var)
54
-
53
+
55
54
  # Initialize MQTT client
56
55
  self.client = mqtt.Client()
57
-
56
+
58
57
  if self.username and self.password:
59
58
  self.client.username_pw_set(self.username, self.password)
60
-
59
+
61
60
  # Connection state
62
61
  self._connected = False
63
62
  self._connected_event = threading.Event()
@@ -95,13 +94,13 @@ class MQTTNotifier(Notifier):
95
94
  async def send(self, alert: Alert) -> None:
96
95
  """Send alert notification to MQTT topic."""
97
96
  await self._ensure_connected()
98
-
97
+
99
98
  # Format topic
100
99
  topic = self.topic_template.format(camera_name=alert.camera_name)
101
-
100
+
102
101
  # Serialize alert to JSON
103
102
  payload = alert.model_dump_json()
104
-
103
+
105
104
  # Publish message
106
105
  await asyncio.to_thread(
107
106
  self._publish,
@@ -110,7 +109,7 @@ class MQTTNotifier(Notifier):
110
109
  self.qos,
111
110
  self.retain,
112
111
  )
113
-
112
+
114
113
  logger.info(
115
114
  "Published alert to MQTT: topic=%s, clip_id=%s",
116
115
  topic,
@@ -153,9 +152,7 @@ class MQTTNotifier(Notifier):
153
152
  if self._connected:
154
153
  return
155
154
  # Wait for connection with timeout
156
- connected = await asyncio.to_thread(
157
- self._connected_event.wait, self.connection_timeout
158
- )
155
+ connected = await asyncio.to_thread(self._connected_event.wait, self.connection_timeout)
159
156
  if not connected or not self._connected:
160
157
  raise RuntimeError(
161
158
  f"MQTT broker not connected after {self.connection_timeout}s timeout"
@@ -164,9 +161,11 @@ class MQTTNotifier(Notifier):
164
161
 
165
162
  # Plugin registration
166
163
  from typing import cast
164
+
167
165
  from pydantic import BaseModel
168
- from homesec.plugins.notifiers import NotifierPlugin, notifier_plugin
166
+
169
167
  from homesec.interfaces import Notifier
168
+ from homesec.plugins.notifiers import NotifierPlugin, notifier_plugin
170
169
 
171
170
 
172
171
  @notifier_plugin(name="mqtt")
@@ -4,11 +4,12 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import logging
7
+ from collections.abc import Callable, Coroutine
7
8
  from dataclasses import dataclass
8
- from typing import Any, Callable, Coroutine
9
+ from typing import Any
9
10
 
10
- from homesec.models.alert import Alert
11
11
  from homesec.interfaces import Notifier
12
+ from homesec.models.alert import Alert
12
13
 
13
14
  logger = logging.getLogger(__name__)
14
15
 
@@ -10,10 +10,10 @@ from collections import defaultdict
10
10
 
11
11
  import aiohttp
12
12
 
13
+ from homesec.interfaces import Notifier
13
14
  from homesec.models.alert import Alert
14
15
  from homesec.models.config import SendGridEmailConfig
15
16
  from homesec.models.vlm import SequenceAnalysis
16
- from homesec.interfaces import Notifier
17
17
 
18
18
  logger = logging.getLogger(__name__)
19
19
 
@@ -62,9 +62,7 @@ class SendGridEmailNotifier(Notifier):
62
62
  async with session.post(url, json=payload, headers=headers) as response:
63
63
  if response.status >= 400:
64
64
  details = await response.text()
65
- raise RuntimeError(
66
- f"SendGrid email send failed ({response.status}): {details}"
67
- )
65
+ raise RuntimeError(f"SendGrid email send failed ({response.status}): {details}")
68
66
 
69
67
  logger.info(
70
68
  "Sent SendGrid email alert: to=%s clip_id=%s",
@@ -181,9 +179,7 @@ class SendGridEmailNotifier(Notifier):
181
179
  items = []
182
180
  for key, value in mapping.items():
183
181
  rendered_value = self._render_value_html(value)
184
- items.append(
185
- f"<li><strong>{html.escape(str(key))}:</strong> {rendered_value}</li>"
186
- )
182
+ items.append(f"<li><strong>{html.escape(str(key))}:</strong> {rendered_value}</li>")
187
183
  return "<ul>" + "".join(items) + "</ul>"
188
184
 
189
185
  def _render_list_html(self, items: list[object]) -> str:
@@ -203,9 +199,11 @@ class SendGridEmailNotifier(Notifier):
203
199
 
204
200
  # Plugin registration
205
201
  from typing import cast
202
+
206
203
  from pydantic import BaseModel
207
- from homesec.plugins.notifiers import NotifierPlugin, notifier_plugin
204
+
208
205
  from homesec.interfaces import Notifier
206
+ from homesec.plugins.notifiers import NotifierPlugin, notifier_plugin
209
207
 
210
208
 
211
209
  @notifier_plugin(name="sendgrid_email")
@@ -3,8 +3,9 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ from collections.abc import Callable
6
7
  from dataclasses import dataclass
7
- from typing import Callable, TypeVar
8
+ from typing import TypeVar
8
9
 
9
10
  from pydantic import BaseModel
10
11
 
@@ -88,9 +89,7 @@ def create_storage(config: StorageConfig) -> StorageBackend:
88
89
 
89
90
  if backend_name not in STORAGE_REGISTRY:
90
91
  available = ", ".join(sorted(STORAGE_REGISTRY.keys()))
91
- raise RuntimeError(
92
- f"Unknown storage backend: '{backend_name}'. Available: {available}"
93
- )
92
+ raise RuntimeError(f"Unknown storage backend: '{backend_name}'. Available: {available}")
94
93
 
95
94
  plugin = STORAGE_REGISTRY[backend_name]
96
95