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,82 @@
1
+ """Custom configuration validation helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from homesec.config.loader import ConfigError
6
+ from homesec.models.config import Config
7
+
8
+
9
+ def validate_camera_references(config: Config, camera_names: list[str] | None = None) -> None:
10
+ """Validate that per-camera config keys reference valid camera names.
11
+
12
+ Args:
13
+ config: Config instance to validate
14
+ camera_names: Optional list of valid camera names from cameras config
15
+
16
+ Raises:
17
+ ConfigError: If per-camera keys reference unknown cameras
18
+ """
19
+ if camera_names is None:
20
+ camera_names = [camera.name for camera in config.cameras]
21
+
22
+ camera_set = set(camera_names)
23
+ errors = []
24
+
25
+ for camera in config.per_camera_alert:
26
+ if camera not in camera_set:
27
+ errors.append(f"per_camera_alert references unknown camera: {camera}")
28
+
29
+ if errors:
30
+ raise ConfigError("Invalid camera references:\n " + "\n ".join(errors))
31
+
32
+
33
+ def validate_plugin_names(
34
+ config: Config,
35
+ valid_filters: list[str],
36
+ valid_vlms: list[str],
37
+ valid_storage: list[str] | None = None,
38
+ valid_notifiers: list[str] | None = None,
39
+ valid_alert_policies: list[str] | None = None,
40
+ ) -> None:
41
+ """Validate that plugin names are recognized.
42
+
43
+ Args:
44
+ config: Config instance to validate
45
+ valid_filters: List of valid filter plugin names
46
+ valid_vlms: List of valid VLM plugin names
47
+ valid_storage: Optional list of valid storage backends
48
+ valid_notifiers: Optional list of valid notifier backends
49
+
50
+ Raises:
51
+ ConfigError: If plugin names are not recognized
52
+ """
53
+ errors = []
54
+
55
+ if config.filter.plugin not in valid_filters:
56
+ errors.append(f"Unknown filter plugin: {config.filter.plugin} (valid: {valid_filters})")
57
+
58
+ if config.vlm.backend not in valid_vlms:
59
+ errors.append(f"Unknown VLM plugin: {config.vlm.backend} (valid: {valid_vlms})")
60
+
61
+ if valid_storage is not None and config.storage.backend not in valid_storage:
62
+ errors.append(
63
+ f"Unknown storage backend: {config.storage.backend} (valid: {valid_storage})"
64
+ )
65
+
66
+ if valid_notifiers is not None:
67
+ for notifier in config.notifiers:
68
+ if notifier.backend not in valid_notifiers:
69
+ errors.append(
70
+ "Unknown notifier backend: "
71
+ f"{notifier.backend} (valid: {valid_notifiers})"
72
+ )
73
+
74
+ if valid_alert_policies is not None:
75
+ if config.alert_policy.backend not in valid_alert_policies:
76
+ errors.append(
77
+ "Unknown alert policy backend: "
78
+ f"{config.alert_policy.backend} (valid: {valid_alert_policies})"
79
+ )
80
+
81
+ if errors:
82
+ raise ConfigError("Invalid plugin configuration:\n " + "\n ".join(errors))
homesec/errors.py ADDED
@@ -0,0 +1,71 @@
1
+ """Error hierarchy for HomeSec pipeline stages."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class PipelineError(Exception):
7
+ """Base exception for all pipeline errors.
8
+
9
+ Compatible with error-as-value pattern: instances can be returned as values
10
+ instead of raised. Preserves stack traces via exception chaining.
11
+ """
12
+
13
+ def __init__(
14
+ self, message: str, stage: str, clip_id: str, cause: Exception | None = None
15
+ ) -> None:
16
+ super().__init__(message)
17
+ self.stage = stage
18
+ self.clip_id = clip_id
19
+ self.cause = cause
20
+ self.__cause__ = cause # Python's exception chaining
21
+
22
+
23
+ class UploadError(PipelineError):
24
+ """Storage upload failed."""
25
+
26
+ def __init__(
27
+ self, clip_id: str, storage_uri: str | None, cause: Exception
28
+ ) -> None:
29
+ super().__init__(
30
+ f"Upload failed for {clip_id}", stage="upload", clip_id=clip_id, cause=cause
31
+ )
32
+ self.storage_uri = storage_uri
33
+
34
+
35
+ class FilterError(PipelineError):
36
+ """Object detection filter failed."""
37
+
38
+ def __init__(self, clip_id: str, plugin_name: str, cause: Exception) -> None:
39
+ super().__init__(
40
+ f"Filter failed for {clip_id} (plugin: {plugin_name})",
41
+ stage="filter",
42
+ clip_id=clip_id,
43
+ cause=cause,
44
+ )
45
+ self.plugin_name = plugin_name
46
+
47
+
48
+ class VLMError(PipelineError):
49
+ """VLM analysis failed."""
50
+
51
+ def __init__(self, clip_id: str, plugin_name: str, cause: Exception) -> None:
52
+ super().__init__(
53
+ f"VLM analysis failed for {clip_id} (plugin: {plugin_name})",
54
+ stage="vlm",
55
+ clip_id=clip_id,
56
+ cause=cause,
57
+ )
58
+ self.plugin_name = plugin_name
59
+
60
+
61
+ class NotifyError(PipelineError):
62
+ """Notification delivery failed."""
63
+
64
+ def __init__(self, clip_id: str, notifier_name: str, cause: Exception) -> None:
65
+ super().__init__(
66
+ f"Notify failed for {clip_id} (notifier: {notifier_name})",
67
+ stage="notify",
68
+ clip_id=clip_id,
69
+ cause=cause,
70
+ )
71
+ self.notifier_name = notifier_name
@@ -0,0 +1,5 @@
1
+ """Health check components."""
2
+
3
+ from homesec.health.server import HealthServer
4
+
5
+ __all__ = ["HealthServer"]
@@ -0,0 +1,226 @@
1
+ """HTTP health check endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from aiohttp import web
10
+
11
+ if TYPE_CHECKING:
12
+ from homesec.interfaces import ClipSource, Notifier, StateStore, StorageBackend
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class HealthServer:
18
+ """HTTP server for health checks.
19
+
20
+ Provides /health endpoint returning component status.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ host: str = "0.0.0.0",
26
+ port: int = 8080,
27
+ ) -> None:
28
+ """Initialize health server.
29
+
30
+ Args:
31
+ host: Host to bind to
32
+ port: Port to bind to
33
+ """
34
+ self.host = host
35
+ self.port = port
36
+
37
+ # Components to check (set via set_components)
38
+ self._state_store: StateStore | None = None
39
+ self._storage: StorageBackend | None = None
40
+ self._notifier: Notifier | None = None
41
+ self._sources: list[ClipSource] = []
42
+ self._mqtt_is_critical = False
43
+
44
+ # Server state
45
+ self._app: web.Application | None = None
46
+ self._runner: web.AppRunner | None = None
47
+ self._site: web.TCPSite | None = None
48
+
49
+ logger.info("HealthServer initialized: %s:%d", host, port)
50
+
51
+ def set_components(
52
+ self,
53
+ *,
54
+ state_store: StateStore | None = None,
55
+ storage: StorageBackend | None = None,
56
+ notifier: Notifier | None = None,
57
+ sources: list[ClipSource] | None = None,
58
+ mqtt_is_critical: bool = False,
59
+ ) -> None:
60
+ """Set components to check.
61
+
62
+ Args:
63
+ state_store: State store to ping
64
+ storage: Storage backend to ping
65
+ notifier: Notifier to ping
66
+ sources: List of clip sources to check
67
+ mqtt_is_critical: Whether MQTT failure is critical (unhealthy vs degraded)
68
+ """
69
+ self._state_store = state_store
70
+ self._storage = storage
71
+ self._notifier = notifier
72
+ self._sources = sources or []
73
+ self._mqtt_is_critical = mqtt_is_critical
74
+
75
+ async def start(self) -> None:
76
+ """Start HTTP server."""
77
+ self._app = web.Application()
78
+ self._app.router.add_get("/health", self._health_handler)
79
+
80
+ self._runner = web.AppRunner(self._app)
81
+ await self._runner.setup()
82
+
83
+ self._site = web.TCPSite(self._runner, self.host, self.port)
84
+ await self._site.start()
85
+
86
+ logger.info("HealthServer started: http://%s:%d/health", self.host, self.port)
87
+
88
+ async def stop(self) -> None:
89
+ """Stop HTTP server."""
90
+ if self._runner:
91
+ await self._runner.cleanup()
92
+
93
+ self._app = None
94
+ self._runner = None
95
+ self._site = None
96
+
97
+ logger.info("HealthServer stopped")
98
+
99
+ async def _health_handler(self, request: web.Request) -> web.Response:
100
+ """Handle GET /health request."""
101
+ health_data = await self.compute_health()
102
+ return web.json_response(health_data)
103
+
104
+ async def compute_health(self) -> dict[str, Any]:
105
+ """Compute health status and return JSON data.
106
+
107
+ Returns:
108
+ Health data dict with status, checks, warnings
109
+ """
110
+ sources = self._get_source_health()
111
+ # Run component checks
112
+ checks = {
113
+ "db": await self._check_db(),
114
+ "storage": await self._check_storage(),
115
+ "mqtt": await self._check_mqtt(),
116
+ "sources": self._check_sources(),
117
+ }
118
+
119
+ # Compute overall status
120
+ status = self._compute_status(checks)
121
+
122
+ # Generate warnings
123
+ warnings = self._compute_warnings()
124
+
125
+ return {
126
+ "status": status,
127
+ "checks": checks,
128
+ "warnings": warnings,
129
+ "sources": sources,
130
+ }
131
+
132
+ async def _check_db(self) -> bool:
133
+ """Check if state store is healthy."""
134
+ return await self._check_component(self._state_store, "State store")
135
+
136
+ async def _check_storage(self) -> bool:
137
+ """Check if storage backend is healthy."""
138
+ return await self._check_component(self._storage, "Storage")
139
+
140
+ async def _check_mqtt(self) -> bool:
141
+ """Check if notifier is healthy."""
142
+ return await self._check_component(self._notifier, "Notifier")
143
+
144
+ def _check_sources(self) -> bool:
145
+ """Check if all clip sources are healthy."""
146
+ if not self._sources:
147
+ return True # No sources configured
148
+
149
+ # All sources must be healthy
150
+ return all(source.is_healthy() for source in self._sources)
151
+
152
+ def _get_source_health(self) -> list[dict[str, object]]:
153
+ """Return per-source health detail."""
154
+ if not self._sources:
155
+ return []
156
+
157
+ current_time = time.monotonic()
158
+ details: list[dict[str, object]] = []
159
+ for source in self._sources:
160
+ camera_name = getattr(source, "camera_name", "unknown")
161
+ last_heartbeat = source.last_heartbeat()
162
+ details.append({
163
+ "name": camera_name,
164
+ "healthy": source.is_healthy(),
165
+ "last_heartbeat": last_heartbeat,
166
+ "last_heartbeat_age_s": round(current_time - last_heartbeat, 3),
167
+ })
168
+ return details
169
+
170
+ async def _check_component(
171
+ self,
172
+ component: StateStore | StorageBackend | Notifier | None,
173
+ label: str,
174
+ ) -> bool:
175
+ if component is None:
176
+ return True
177
+
178
+ try:
179
+ return await component.ping()
180
+ except Exception as e:
181
+ logger.warning("%s health check failed: %s", label, e, exc_info=True)
182
+ return False
183
+
184
+ def _compute_status(self, checks: dict[str, bool]) -> str:
185
+ """Compute overall health status.
186
+
187
+ Args:
188
+ checks: Dict of component check results
189
+
190
+ Returns:
191
+ "healthy", "degraded", or "unhealthy"
192
+ """
193
+ # Critical checks (unhealthy if fail)
194
+ if not checks["sources"]:
195
+ return "unhealthy"
196
+
197
+ if not checks["storage"]:
198
+ return "unhealthy"
199
+
200
+ # MQTT can be critical (configurable)
201
+ if not checks["mqtt"] and self._mqtt_is_critical:
202
+ return "unhealthy"
203
+
204
+ # Non-critical checks (degraded if fail)
205
+ if not checks["db"] or not checks["mqtt"]:
206
+ return "degraded"
207
+
208
+ return "healthy"
209
+
210
+ def _compute_warnings(self) -> list[str]:
211
+ """Generate warnings for stale heartbeats.
212
+
213
+ Returns:
214
+ List of warning messages
215
+ """
216
+ warnings: list[str] = []
217
+
218
+ # Check source heartbeats (warn if > 2 minutes)
219
+ current_time = time.monotonic()
220
+ for source in self._sources:
221
+ heartbeat_age = current_time - source.last_heartbeat()
222
+ if heartbeat_age > 120: # 2 minutes
223
+ camera_name = getattr(source, "camera_name", "unknown")
224
+ warnings.append(f"source_{camera_name}_heartbeat_stale")
225
+
226
+ return warnings
homesec/interfaces.py ADDED
@@ -0,0 +1,249 @@
1
+ """Interface definitions for HomeSec pipeline components."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Callable
9
+
10
+ if TYPE_CHECKING:
11
+ from homesec.models.alert import Alert, AlertDecision
12
+ from homesec.models.clip import Clip, ClipStateData
13
+ from homesec.models.events import ClipLifecycleEvent
14
+ from homesec.models.filter import FilterOverrides, FilterResult
15
+ from homesec.models.storage import StorageUploadResult
16
+ from homesec.models.vlm import AnalysisResult, VLMConfig
17
+
18
+
19
+ class Shutdownable(ABC):
20
+ """Async shutdown interface for managed components."""
21
+
22
+ @abstractmethod
23
+ async def shutdown(self, timeout: float | None = None) -> None:
24
+ """Release resources and stop background work."""
25
+ raise NotImplementedError
26
+
27
+
28
+ class ClipSource(Shutdownable, ABC):
29
+ """Produces finalized clips and notifies pipeline via callback."""
30
+
31
+ @abstractmethod
32
+ def register_callback(self, callback: Callable[[Clip], None]) -> None:
33
+ """Register callback to be invoked when a new clip is finalized."""
34
+ raise NotImplementedError
35
+
36
+ @abstractmethod
37
+ async def start(self) -> None:
38
+ """Start producing clips (long-running, blocks or runs in background)."""
39
+ raise NotImplementedError
40
+
41
+ @abstractmethod
42
+ def is_healthy(self) -> bool:
43
+ """Check if source is actively able to receive clips.
44
+
45
+ Implementation should check:
46
+ - Process/thread is alive
47
+ - Receiving data recently (e.g., RTSP frames within timeout)
48
+ - NOT dependent on motion/clip activity
49
+
50
+ Examples:
51
+ - RTSP: frame pipeline running + receiving frames recently
52
+ - FTP: server thread alive + accepting connections
53
+
54
+ Returns False if source has failed and needs restart.
55
+ """
56
+ raise NotImplementedError
57
+
58
+ @abstractmethod
59
+ def last_heartbeat(self) -> float:
60
+ """Return timestamp (monotonic) of last successful operation.
61
+
62
+ Examples:
63
+ - RTSP: timestamp of last frame received
64
+ - FTP: timestamp of last connection check
65
+
66
+ Updated continuously (every ~60s), independent of motion/clips.
67
+ Used for observability, not health status.
68
+ """
69
+ raise NotImplementedError
70
+
71
+
72
+ class StorageBackend(Shutdownable, ABC):
73
+ """Stores raw clips and derived artifacts."""
74
+
75
+ @abstractmethod
76
+ async def put_file(self, local_path: Path, dest_path: str) -> "StorageUploadResult":
77
+ """Upload file to storage. Returns storage result."""
78
+ raise NotImplementedError
79
+
80
+ @abstractmethod
81
+ async def get_view_url(self, storage_uri: str) -> str | None:
82
+ """Return a web-accessible view URL for the given storage URI."""
83
+ raise NotImplementedError
84
+
85
+ @abstractmethod
86
+ async def get(self, storage_uri: str, local_path: Path) -> None:
87
+ """Download file from storage to local path."""
88
+ raise NotImplementedError
89
+
90
+ @abstractmethod
91
+ async def exists(self, storage_uri: str) -> bool:
92
+ """Check if file exists in storage."""
93
+ raise NotImplementedError
94
+
95
+ @abstractmethod
96
+ async def delete(self, storage_uri: str) -> None:
97
+ """Delete an object from storage.
98
+
99
+ Must be idempotent: deleting a missing object should succeed.
100
+ """
101
+ raise NotImplementedError
102
+
103
+ @abstractmethod
104
+ async def ping(self) -> bool:
105
+ """Health check. Returns True if storage is reachable."""
106
+ raise NotImplementedError
107
+
108
+
109
+ class StateStore(Shutdownable, ABC):
110
+ """Manages clip workflow state in Postgres."""
111
+
112
+ @abstractmethod
113
+ async def upsert(self, clip_id: str, data: ClipStateData) -> None:
114
+ """Insert or update clip state."""
115
+ raise NotImplementedError
116
+
117
+ @abstractmethod
118
+ async def get(self, clip_id: str) -> ClipStateData | None:
119
+ """Retrieve clip state. Returns None if not found."""
120
+ raise NotImplementedError
121
+
122
+ @abstractmethod
123
+ async def list_candidate_clips_for_cleanup(
124
+ self,
125
+ *,
126
+ older_than_days: int | None,
127
+ camera_name: str | None,
128
+ batch_size: int,
129
+ cursor: tuple[datetime, str] | None = None,
130
+ ) -> list[tuple[str, ClipStateData, datetime]]:
131
+ """List clip states for cleanup scanning."""
132
+ raise NotImplementedError
133
+
134
+ @abstractmethod
135
+ async def ping(self) -> bool:
136
+ """Health check. Returns True if database is reachable."""
137
+ raise NotImplementedError
138
+
139
+
140
+ class EventStore(ABC):
141
+ """Manages clip lifecycle events in Postgres."""
142
+
143
+ @abstractmethod
144
+ async def append(self, event: ClipLifecycleEvent) -> None:
145
+ """Append a lifecycle event.
146
+
147
+ Events are appended with database-assigned ids.
148
+ Raises on database errors (should be retried by caller).
149
+ """
150
+ raise NotImplementedError
151
+
152
+ @abstractmethod
153
+ async def get_events(
154
+ self,
155
+ clip_id: str,
156
+ after_id: int | None = None,
157
+ ) -> list[ClipLifecycleEvent]:
158
+ """Get all events for a clip, optionally after an event id.
159
+
160
+ Returns events ordered by id. Returns empty list on error.
161
+ """
162
+ raise NotImplementedError
163
+
164
+
165
+ class Notifier(Shutdownable, ABC):
166
+ """Sends notifications (e.g., MQTT, email, SMS)."""
167
+
168
+ @abstractmethod
169
+ async def send(self, alert: Alert) -> None:
170
+ """Send notification. Raises on failure."""
171
+ raise NotImplementedError
172
+
173
+ @abstractmethod
174
+ async def ping(self) -> bool:
175
+ """Health check. Returns True if notifier is reachable."""
176
+ raise NotImplementedError
177
+
178
+ class AlertPolicy(ABC):
179
+ """Decides whether to notify based on analysis results."""
180
+
181
+ @abstractmethod
182
+ def should_notify(
183
+ self,
184
+ camera_name: str,
185
+ filter_result: FilterResult | None,
186
+ analysis: AnalysisResult | None,
187
+ ) -> tuple[bool, str]:
188
+ """Determine if notification should be sent.
189
+
190
+ Returns:
191
+ (notify, reason) tuple where reason explains the decision.
192
+ """
193
+ raise NotImplementedError
194
+
195
+ def make_decision(
196
+ self,
197
+ camera_name: str,
198
+ filter_result: FilterResult | None,
199
+ analysis: AnalysisResult | None,
200
+ ) -> "AlertDecision":
201
+ """Build an AlertDecision from should_notify output."""
202
+ from homesec.models.alert import AlertDecision
203
+
204
+ notify, reason = self.should_notify(camera_name, filter_result, analysis)
205
+ return AlertDecision(notify=notify, notify_reason=reason)
206
+
207
+
208
+ class ObjectFilter(Shutdownable, ABC):
209
+ """Plugin interface for object detection in video clips."""
210
+
211
+ @abstractmethod
212
+ async def detect(self, video_path: Path, overrides: FilterOverrides | None = None) -> FilterResult:
213
+ """Detect objects in video clip.
214
+
215
+ Implementation notes:
216
+ - MUST be async (use asyncio.to_thread or run_in_executor for blocking code)
217
+ - CPU/GPU-bound plugins should manage their own ProcessPoolExecutor internally
218
+ - I/O-bound plugins can use async HTTP clients directly
219
+ - Should respect the instance config max_workers if managing worker pool
220
+ - Should support early exit on first detection for efficiency
221
+ - overrides apply per-call (model path cannot be overridden)
222
+
223
+ Returns:
224
+ FilterResult with detected_classes, confidence, sampled_frames, model name
225
+ """
226
+ raise NotImplementedError
227
+
228
+ class VLMAnalyzer(Shutdownable, ABC):
229
+ """Plugin interface for VLM-based clip analysis."""
230
+
231
+ @abstractmethod
232
+ async def analyze(
233
+ self, video_path: Path, filter_result: FilterResult, config: VLMConfig
234
+ ) -> AnalysisResult:
235
+ """Analyze clip and produce structured assessment.
236
+
237
+ Implementation notes:
238
+ - MUST be async (use asyncio.to_thread or run_in_executor for blocking code)
239
+ - Local models: manage ProcessPoolExecutor internally
240
+ - API-based: use async HTTP clients (aiohttp, httpx)
241
+ - Should respect config.max_workers if managing worker pool
242
+ - Should use filter_result to focus analysis (e.g., detected person at timestamp X)
243
+
244
+ Returns:
245
+ AnalysisResult with risk_level, activity_type, summary, etc.
246
+ """
247
+ raise NotImplementedError
248
+
249
+ # shutdown() defined in Shutdownable