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.
- homesec/__init__.py +20 -0
- homesec/app.py +393 -0
- homesec/cli.py +159 -0
- homesec/config/__init__.py +18 -0
- homesec/config/loader.py +109 -0
- homesec/config/validation.py +82 -0
- homesec/errors.py +71 -0
- homesec/health/__init__.py +5 -0
- homesec/health/server.py +226 -0
- homesec/interfaces.py +249 -0
- homesec/logging_setup.py +176 -0
- homesec/maintenance/__init__.py +1 -0
- homesec/maintenance/cleanup_clips.py +632 -0
- homesec/models/__init__.py +79 -0
- homesec/models/alert.py +32 -0
- homesec/models/clip.py +71 -0
- homesec/models/config.py +362 -0
- homesec/models/events.py +184 -0
- homesec/models/filter.py +62 -0
- homesec/models/source.py +77 -0
- homesec/models/storage.py +12 -0
- homesec/models/vlm.py +99 -0
- homesec/pipeline/__init__.py +6 -0
- homesec/pipeline/alert_policy.py +5 -0
- homesec/pipeline/core.py +639 -0
- homesec/plugins/__init__.py +62 -0
- homesec/plugins/alert_policies/__init__.py +80 -0
- homesec/plugins/alert_policies/default.py +111 -0
- homesec/plugins/alert_policies/noop.py +60 -0
- homesec/plugins/analyzers/__init__.py +126 -0
- homesec/plugins/analyzers/openai.py +446 -0
- homesec/plugins/filters/__init__.py +124 -0
- homesec/plugins/filters/yolo.py +317 -0
- homesec/plugins/notifiers/__init__.py +80 -0
- homesec/plugins/notifiers/mqtt.py +189 -0
- homesec/plugins/notifiers/multiplex.py +106 -0
- homesec/plugins/notifiers/sendgrid_email.py +228 -0
- homesec/plugins/storage/__init__.py +116 -0
- homesec/plugins/storage/dropbox.py +272 -0
- homesec/plugins/storage/local.py +108 -0
- homesec/plugins/utils.py +63 -0
- homesec/py.typed +0 -0
- homesec/repository/__init__.py +5 -0
- homesec/repository/clip_repository.py +552 -0
- homesec/sources/__init__.py +17 -0
- homesec/sources/base.py +224 -0
- homesec/sources/ftp.py +209 -0
- homesec/sources/local_folder.py +238 -0
- homesec/sources/rtsp.py +1251 -0
- homesec/state/__init__.py +10 -0
- homesec/state/postgres.py +501 -0
- homesec/storage_paths.py +46 -0
- homesec/telemetry/__init__.py +0 -0
- homesec/telemetry/db/__init__.py +1 -0
- homesec/telemetry/db/log_table.py +16 -0
- homesec/telemetry/db_log_handler.py +246 -0
- homesec/telemetry/postgres_settings.py +42 -0
- homesec-0.1.0.dist-info/METADATA +446 -0
- homesec-0.1.0.dist-info/RECORD +62 -0
- homesec-0.1.0.dist-info/WHEEL +4 -0
- homesec-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
homesec/health/server.py
ADDED
|
@@ -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
|