homesec 0.1.1__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.
- homesec/app.py +34 -36
- homesec/cli.py +14 -11
- homesec/config/loader.py +11 -11
- homesec/config/validation.py +2 -5
- homesec/errors.py +2 -4
- homesec/health/server.py +29 -27
- homesec/interfaces.py +11 -6
- homesec/logging_setup.py +9 -5
- homesec/maintenance/cleanup_clips.py +2 -3
- homesec/models/__init__.py +1 -1
- homesec/models/alert.py +2 -0
- homesec/models/clip.py +8 -1
- homesec/models/config.py +9 -13
- homesec/models/events.py +14 -0
- homesec/models/filter.py +1 -3
- homesec/models/vlm.py +1 -2
- homesec/pipeline/core.py +15 -32
- homesec/plugins/alert_policies/__init__.py +3 -4
- homesec/plugins/alert_policies/default.py +3 -2
- homesec/plugins/alert_policies/noop.py +1 -2
- homesec/plugins/analyzers/__init__.py +3 -4
- homesec/plugins/analyzers/openai.py +34 -43
- homesec/plugins/filters/__init__.py +3 -4
- homesec/plugins/filters/yolo.py +27 -29
- homesec/plugins/notifiers/__init__.py +2 -1
- homesec/plugins/notifiers/mqtt.py +16 -17
- homesec/plugins/notifiers/multiplex.py +3 -2
- homesec/plugins/notifiers/sendgrid_email.py +6 -8
- homesec/plugins/storage/__init__.py +3 -4
- homesec/plugins/storage/dropbox.py +20 -17
- homesec/plugins/storage/local.py +3 -1
- homesec/plugins/utils.py +2 -1
- homesec/repository/clip_repository.py +5 -4
- homesec/sources/base.py +2 -2
- homesec/sources/local_folder.py +9 -7
- homesec/sources/rtsp.py +22 -10
- homesec/state/postgres.py +34 -35
- homesec/telemetry/db_log_handler.py +3 -2
- {homesec-0.1.1.dist-info → homesec-1.0.0.dist-info}/METADATA +39 -31
- homesec-1.0.0.dist-info/RECORD +62 -0
- homesec-0.1.1.dist-info/RECORD +0 -62
- {homesec-0.1.1.dist-info → homesec-1.0.0.dist-info}/WHEEL +0 -0
- {homesec-0.1.1.dist-info → homesec-1.0.0.dist-info}/entry_points.txt +0 -0
- {homesec-0.1.1.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
|
-
|
|
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
|
|
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
|
|
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
|
|
homesec/plugins/filters/yolo.py
CHANGED
|
@@ -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
|
|
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,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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|