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.
- homesec/__init__.py +1 -1
- 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.0.dist-info → homesec-1.0.0.dist-info}/METADATA +66 -31
- homesec-1.0.0.dist-info/RECORD +62 -0
- homesec-0.1.0.dist-info/RECORD +0 -62
- {homesec-0.1.0.dist-info → homesec-1.0.0.dist-info}/WHEEL +0 -0
- {homesec-0.1.0.dist-info → homesec-1.0.0.dist-info}/entry_points.txt +0 -0
- {homesec-0.1.0.dist-info → homesec-1.0.0.dist-info}/licenses/LICENSE +0 -0
homesec/__init__.py
CHANGED
homesec/app.py
CHANGED
|
@@ -5,8 +5,9 @@ from __future__ import annotations
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import logging
|
|
7
7
|
import signal
|
|
8
|
+
from collections.abc import Callable
|
|
8
9
|
from pathlib import Path
|
|
9
|
-
from typing import TYPE_CHECKING,
|
|
10
|
+
from typing import TYPE_CHECKING, cast
|
|
10
11
|
|
|
11
12
|
from pydantic import BaseModel
|
|
12
13
|
|
|
@@ -17,9 +18,12 @@ from homesec.config import (
|
|
|
17
18
|
validate_plugin_names,
|
|
18
19
|
)
|
|
19
20
|
from homesec.health import HealthServer
|
|
21
|
+
from homesec.interfaces import EventStore
|
|
22
|
+
from homesec.models.config import NotifierConfig
|
|
23
|
+
from homesec.models.source import FtpSourceConfig, LocalFolderSourceConfig, RTSPSourceConfig
|
|
20
24
|
from homesec.pipeline import ClipPipeline
|
|
21
|
-
from homesec.plugins.analyzers import VLM_REGISTRY, load_vlm_plugin
|
|
22
25
|
from homesec.plugins.alert_policies import ALERT_POLICY_REGISTRY
|
|
26
|
+
from homesec.plugins.analyzers import VLM_REGISTRY, load_vlm_plugin
|
|
23
27
|
from homesec.plugins.filters import FILTER_REGISTRY, load_filter_plugin
|
|
24
28
|
from homesec.plugins.notifiers import (
|
|
25
29
|
NOTIFIER_REGISTRY,
|
|
@@ -28,15 +32,11 @@ from homesec.plugins.notifiers import (
|
|
|
28
32
|
NotifierPlugin,
|
|
29
33
|
)
|
|
30
34
|
from homesec.plugins.storage import STORAGE_REGISTRY, create_storage
|
|
31
|
-
from homesec.sources import FtpSource, LocalFolderSource, RTSPSource
|
|
32
|
-
from homesec.models.config import NotifierConfig
|
|
33
|
-
from homesec.models.source import FtpSourceConfig, LocalFolderSourceConfig, RTSPSourceConfig
|
|
34
|
-
from homesec.interfaces import EventStore
|
|
35
35
|
from homesec.repository import ClipRepository
|
|
36
|
+
from homesec.sources import FtpSource, LocalFolderSource, RTSPSource
|
|
36
37
|
from homesec.state import NoopEventStore, NoopStateStore, PostgresStateStore
|
|
37
38
|
|
|
38
39
|
if TYPE_CHECKING:
|
|
39
|
-
from homesec.models.config import Config
|
|
40
40
|
from homesec.interfaces import (
|
|
41
41
|
AlertPolicy,
|
|
42
42
|
ClipSource,
|
|
@@ -46,25 +46,26 @@ if TYPE_CHECKING:
|
|
|
46
46
|
StorageBackend,
|
|
47
47
|
VLMAnalyzer,
|
|
48
48
|
)
|
|
49
|
+
from homesec.models.config import Config
|
|
49
50
|
|
|
50
51
|
logger = logging.getLogger(__name__)
|
|
51
52
|
|
|
52
53
|
|
|
53
54
|
class Application:
|
|
54
55
|
"""Main application that orchestrates all components.
|
|
55
|
-
|
|
56
|
+
|
|
56
57
|
Handles component creation, lifecycle, and graceful shutdown.
|
|
57
58
|
"""
|
|
58
59
|
|
|
59
60
|
def __init__(self, config_path: Path) -> None:
|
|
60
61
|
"""Initialize application with config file path.
|
|
61
|
-
|
|
62
|
+
|
|
62
63
|
Args:
|
|
63
64
|
config_path: Path to YAML config file
|
|
64
65
|
"""
|
|
65
66
|
self._config_path = config_path
|
|
66
67
|
self._config: Config | None = None
|
|
67
|
-
|
|
68
|
+
|
|
68
69
|
# Components (created in _create_components)
|
|
69
70
|
self._storage: StorageBackend | None = None
|
|
70
71
|
self._state_store: StateStore = NoopStateStore()
|
|
@@ -77,41 +78,41 @@ class Application:
|
|
|
77
78
|
self._sources: list[ClipSource] = []
|
|
78
79
|
self._pipeline: ClipPipeline | None = None
|
|
79
80
|
self._health_server: HealthServer | None = None
|
|
80
|
-
|
|
81
|
+
|
|
81
82
|
# Shutdown state
|
|
82
83
|
self._shutdown_event = asyncio.Event()
|
|
83
84
|
self._shutdown_started = False
|
|
84
85
|
|
|
85
86
|
async def run(self) -> None:
|
|
86
87
|
"""Run the application.
|
|
87
|
-
|
|
88
|
+
|
|
88
89
|
Loads config, creates components, and runs until shutdown signal.
|
|
89
90
|
"""
|
|
90
91
|
logger.info("Starting HomeSec application...")
|
|
91
|
-
|
|
92
|
+
|
|
92
93
|
# Load config
|
|
93
94
|
self._config = load_config(self._config_path)
|
|
94
95
|
logger.info("Config loaded from %s", self._config_path)
|
|
95
|
-
|
|
96
|
+
|
|
96
97
|
# Create components
|
|
97
98
|
await self._create_components()
|
|
98
|
-
|
|
99
|
+
|
|
99
100
|
# Set up signal handlers
|
|
100
101
|
self._setup_signal_handlers()
|
|
101
|
-
|
|
102
|
+
|
|
102
103
|
# Start health server
|
|
103
104
|
if self._health_server:
|
|
104
105
|
await self._health_server.start()
|
|
105
|
-
|
|
106
|
+
|
|
106
107
|
# Start sources
|
|
107
108
|
for source in self._sources:
|
|
108
109
|
await source.start()
|
|
109
|
-
|
|
110
|
+
|
|
110
111
|
logger.info("Application started. Waiting for clips...")
|
|
111
|
-
|
|
112
|
+
|
|
112
113
|
# Wait for shutdown signal
|
|
113
114
|
await self._shutdown_event.wait()
|
|
114
|
-
|
|
115
|
+
|
|
115
116
|
# Graceful shutdown
|
|
116
117
|
await self.shutdown()
|
|
117
118
|
|
|
@@ -152,7 +153,7 @@ class Application:
|
|
|
152
153
|
self._filter = filter_plugin
|
|
153
154
|
self._vlm = vlm_plugin
|
|
154
155
|
alert_policy = self._create_alert_policy(config)
|
|
155
|
-
|
|
156
|
+
|
|
156
157
|
# Create pipeline
|
|
157
158
|
self._pipeline = ClipPipeline(
|
|
158
159
|
config=config,
|
|
@@ -166,12 +167,12 @@ class Application:
|
|
|
166
167
|
)
|
|
167
168
|
# Set event loop for thread-safe callbacks from sources
|
|
168
169
|
self._pipeline.set_event_loop(asyncio.get_running_loop())
|
|
169
|
-
|
|
170
|
+
|
|
170
171
|
# Create sources and register callback
|
|
171
172
|
self._sources = self._create_sources(config)
|
|
172
173
|
for source in self._sources:
|
|
173
174
|
source.register_callback(self._pipeline.on_new_clip)
|
|
174
|
-
|
|
175
|
+
|
|
175
176
|
# Create health server
|
|
176
177
|
health_cfg = config.health
|
|
177
178
|
self._health_server = HealthServer(
|
|
@@ -185,7 +186,7 @@ class Application:
|
|
|
185
186
|
sources=self._sources,
|
|
186
187
|
mqtt_is_critical=health_cfg.mqtt_is_critical,
|
|
187
188
|
)
|
|
188
|
-
|
|
189
|
+
|
|
189
190
|
logger.info("All components created")
|
|
190
191
|
|
|
191
192
|
def _create_storage(self, config: Config) -> StorageBackend:
|
|
@@ -237,10 +238,7 @@ class Application:
|
|
|
237
238
|
if not self._notifier_entries:
|
|
238
239
|
return
|
|
239
240
|
|
|
240
|
-
tasks = [
|
|
241
|
-
asyncio.create_task(entry.notifier.ping())
|
|
242
|
-
for entry in self._notifier_entries
|
|
243
|
-
]
|
|
241
|
+
tasks = [asyncio.create_task(entry.notifier.ping()) for entry in self._notifier_entries]
|
|
244
242
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
245
243
|
|
|
246
244
|
for entry, result in zip(self._notifier_entries, results, strict=True):
|
|
@@ -339,7 +337,7 @@ class Application:
|
|
|
339
337
|
def _setup_signal_handlers(self) -> None:
|
|
340
338
|
"""Set up signal handlers for graceful shutdown."""
|
|
341
339
|
loop = asyncio.get_running_loop()
|
|
342
|
-
|
|
340
|
+
|
|
343
341
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
344
342
|
loop.add_signal_handler(sig, self._handle_signal, sig)
|
|
345
343
|
|
|
@@ -348,7 +346,7 @@ class Application:
|
|
|
348
346
|
if self._shutdown_started:
|
|
349
347
|
logger.warning("Shutdown already in progress, ignoring signal")
|
|
350
348
|
return
|
|
351
|
-
|
|
349
|
+
|
|
352
350
|
logger.info("Received signal %s, initiating shutdown...", sig.name)
|
|
353
351
|
self._shutdown_started = True
|
|
354
352
|
self._shutdown_event.set()
|
|
@@ -356,18 +354,18 @@ class Application:
|
|
|
356
354
|
async def shutdown(self) -> None:
|
|
357
355
|
"""Graceful shutdown of all components."""
|
|
358
356
|
logger.info("Shutting down application...")
|
|
359
|
-
|
|
357
|
+
|
|
360
358
|
# Stop sources first
|
|
361
359
|
if self._sources:
|
|
362
360
|
await asyncio.gather(
|
|
363
361
|
*(source.shutdown() for source in self._sources),
|
|
364
362
|
return_exceptions=True,
|
|
365
363
|
)
|
|
366
|
-
|
|
364
|
+
|
|
367
365
|
# Shutdown pipeline (waits for in-flight clips)
|
|
368
366
|
if self._pipeline:
|
|
369
367
|
await self._pipeline.shutdown()
|
|
370
|
-
|
|
368
|
+
|
|
371
369
|
# Stop health server
|
|
372
370
|
if self._health_server:
|
|
373
371
|
await self._health_server.stop()
|
|
@@ -381,13 +379,13 @@ class Application:
|
|
|
381
379
|
# Close state store
|
|
382
380
|
if self._state_store:
|
|
383
381
|
await self._state_store.shutdown()
|
|
384
|
-
|
|
382
|
+
|
|
385
383
|
# Close storage
|
|
386
384
|
if self._storage:
|
|
387
385
|
await self._storage.shutdown()
|
|
388
|
-
|
|
386
|
+
|
|
389
387
|
# Close notifier
|
|
390
388
|
if self._notifier:
|
|
391
389
|
await self._notifier.shutdown()
|
|
392
|
-
|
|
390
|
+
|
|
393
391
|
logger.info("Application shutdown complete")
|
homesec/cli.py
CHANGED
|
@@ -6,15 +6,19 @@ import asyncio
|
|
|
6
6
|
import sys
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
|
+
from dotenv import load_dotenv
|
|
10
|
+
|
|
11
|
+
load_dotenv()
|
|
12
|
+
|
|
9
13
|
import fire # type: ignore[import-untyped]
|
|
10
14
|
|
|
11
|
-
from homesec.logging_setup import configure_logging
|
|
12
15
|
from homesec.app import Application
|
|
13
16
|
from homesec.config import ConfigError, load_config
|
|
14
17
|
from homesec.config.validation import validate_camera_references, validate_plugin_names
|
|
18
|
+
from homesec.logging_setup import configure_logging
|
|
15
19
|
from homesec.maintenance.cleanup_clips import CleanupOptions, run_cleanup
|
|
16
|
-
from homesec.plugins.analyzers import VLM_REGISTRY
|
|
17
20
|
from homesec.plugins.alert_policies import ALERT_POLICY_REGISTRY
|
|
21
|
+
from homesec.plugins.analyzers import VLM_REGISTRY
|
|
18
22
|
from homesec.plugins.filters import FILTER_REGISTRY
|
|
19
23
|
from homesec.plugins.notifiers import NOTIFIER_REGISTRY
|
|
20
24
|
from homesec.plugins.storage import STORAGE_REGISTRY
|
|
@@ -30,17 +34,17 @@ class HomeSec:
|
|
|
30
34
|
|
|
31
35
|
def run(self, config: str, log_level: str = "INFO") -> None:
|
|
32
36
|
"""Run the HomeSec pipeline.
|
|
33
|
-
|
|
37
|
+
|
|
34
38
|
Args:
|
|
35
39
|
config: Path to YAML config file
|
|
36
40
|
log_level: Logging level (DEBUG, INFO, WARNING, ERROR)
|
|
37
41
|
"""
|
|
38
42
|
setup_logging(log_level)
|
|
39
|
-
|
|
43
|
+
|
|
40
44
|
config_path = Path(config)
|
|
41
|
-
|
|
45
|
+
|
|
42
46
|
app = Application(config_path)
|
|
43
|
-
|
|
47
|
+
|
|
44
48
|
try:
|
|
45
49
|
asyncio.run(app.run())
|
|
46
50
|
except ConfigError as e:
|
|
@@ -51,12 +55,12 @@ class HomeSec:
|
|
|
51
55
|
|
|
52
56
|
def validate(self, config: str) -> None:
|
|
53
57
|
"""Validate config file without running.
|
|
54
|
-
|
|
58
|
+
|
|
55
59
|
Args:
|
|
56
60
|
config: Path to YAML config file
|
|
57
61
|
"""
|
|
58
62
|
config_path = Path(config)
|
|
59
|
-
|
|
63
|
+
|
|
60
64
|
try:
|
|
61
65
|
cfg = load_config(config_path)
|
|
62
66
|
|
|
@@ -75,13 +79,12 @@ class HomeSec:
|
|
|
75
79
|
valid_notifiers=sorted(NOTIFIER_REGISTRY.keys()),
|
|
76
80
|
valid_alert_policies=sorted(ALERT_POLICY_REGISTRY.keys()),
|
|
77
81
|
)
|
|
78
|
-
|
|
82
|
+
|
|
79
83
|
print(f"✓ Config valid: {config_path}")
|
|
80
84
|
camera_names = [camera.name for camera in cfg.cameras]
|
|
81
85
|
print(f" Cameras: {camera_names}")
|
|
82
86
|
notifier_backends = [
|
|
83
|
-
f"{notifier.backend} (enabled={notifier.enabled})"
|
|
84
|
-
for notifier in cfg.notifiers
|
|
87
|
+
f"{notifier.backend} (enabled={notifier.enabled})" for notifier in cfg.notifiers
|
|
85
88
|
]
|
|
86
89
|
print(f" Storage backend: {cfg.storage.backend}")
|
|
87
90
|
print(f" Notifiers: {notifier_backends}")
|
homesec/config/loader.py
CHANGED
|
@@ -22,13 +22,13 @@ class ConfigError(Exception):
|
|
|
22
22
|
|
|
23
23
|
def load_config(path: Path) -> Config:
|
|
24
24
|
"""Load and validate configuration from YAML file.
|
|
25
|
-
|
|
25
|
+
|
|
26
26
|
Args:
|
|
27
27
|
path: Path to YAML config file
|
|
28
|
-
|
|
28
|
+
|
|
29
29
|
Returns:
|
|
30
30
|
Validated Config instance
|
|
31
|
-
|
|
31
|
+
|
|
32
32
|
Raises:
|
|
33
33
|
ConfigError: If file not found, YAML invalid, or validation fails
|
|
34
34
|
"""
|
|
@@ -55,13 +55,13 @@ def load_config(path: Path) -> Config:
|
|
|
55
55
|
|
|
56
56
|
def load_config_from_dict(data: dict[str, Any]) -> Config:
|
|
57
57
|
"""Load and validate configuration from a dict (useful for testing).
|
|
58
|
-
|
|
58
|
+
|
|
59
59
|
Args:
|
|
60
60
|
data: Configuration dictionary
|
|
61
|
-
|
|
61
|
+
|
|
62
62
|
Returns:
|
|
63
63
|
Validated Config instance
|
|
64
|
-
|
|
64
|
+
|
|
65
65
|
Raises:
|
|
66
66
|
ConfigError: If validation fails
|
|
67
67
|
"""
|
|
@@ -73,14 +73,14 @@ def load_config_from_dict(data: dict[str, Any]) -> Config:
|
|
|
73
73
|
|
|
74
74
|
def resolve_env_var(env_var_name: str, required: bool = True) -> str | None:
|
|
75
75
|
"""Resolve environment variable by name.
|
|
76
|
-
|
|
76
|
+
|
|
77
77
|
Args:
|
|
78
78
|
env_var_name: Name of the environment variable
|
|
79
79
|
required: If True, raise if not found
|
|
80
|
-
|
|
80
|
+
|
|
81
81
|
Returns:
|
|
82
82
|
Environment variable value, or None if not required and not found
|
|
83
|
-
|
|
83
|
+
|
|
84
84
|
Raises:
|
|
85
85
|
ConfigError: If required and not found
|
|
86
86
|
"""
|
|
@@ -92,11 +92,11 @@ def resolve_env_var(env_var_name: str, required: bool = True) -> str | None:
|
|
|
92
92
|
|
|
93
93
|
def format_validation_error(e: ValidationError, path: Path | None = None) -> str:
|
|
94
94
|
"""Format Pydantic validation error for human readability.
|
|
95
|
-
|
|
95
|
+
|
|
96
96
|
Args:
|
|
97
97
|
e: Pydantic ValidationError
|
|
98
98
|
path: Optional config file path for context
|
|
99
|
-
|
|
99
|
+
|
|
100
100
|
Returns:
|
|
101
101
|
Human-readable error message
|
|
102
102
|
"""
|
homesec/config/validation.py
CHANGED
|
@@ -59,16 +59,13 @@ def validate_plugin_names(
|
|
|
59
59
|
errors.append(f"Unknown VLM plugin: {config.vlm.backend} (valid: {valid_vlms})")
|
|
60
60
|
|
|
61
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
|
-
)
|
|
62
|
+
errors.append(f"Unknown storage backend: {config.storage.backend} (valid: {valid_storage})")
|
|
65
63
|
|
|
66
64
|
if valid_notifiers is not None:
|
|
67
65
|
for notifier in config.notifiers:
|
|
68
66
|
if notifier.backend not in valid_notifiers:
|
|
69
67
|
errors.append(
|
|
70
|
-
"Unknown notifier backend: "
|
|
71
|
-
f"{notifier.backend} (valid: {valid_notifiers})"
|
|
68
|
+
f"Unknown notifier backend: {notifier.backend} (valid: {valid_notifiers})"
|
|
72
69
|
)
|
|
73
70
|
|
|
74
71
|
if valid_alert_policies is not None:
|
homesec/errors.py
CHANGED
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
|
|
6
6
|
class PipelineError(Exception):
|
|
7
7
|
"""Base exception for all pipeline errors.
|
|
8
|
-
|
|
8
|
+
|
|
9
9
|
Compatible with error-as-value pattern: instances can be returned as values
|
|
10
10
|
instead of raised. Preserves stack traces via exception chaining.
|
|
11
11
|
"""
|
|
@@ -23,9 +23,7 @@ class PipelineError(Exception):
|
|
|
23
23
|
class UploadError(PipelineError):
|
|
24
24
|
"""Storage upload failed."""
|
|
25
25
|
|
|
26
|
-
def __init__(
|
|
27
|
-
self, clip_id: str, storage_uri: str | None, cause: Exception
|
|
28
|
-
) -> None:
|
|
26
|
+
def __init__(self, clip_id: str, storage_uri: str | None, cause: Exception) -> None:
|
|
29
27
|
super().__init__(
|
|
30
28
|
f"Upload failed for {clip_id}", stage="upload", clip_id=clip_id, cause=cause
|
|
31
29
|
)
|
homesec/health/server.py
CHANGED
|
@@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
|
|
|
16
16
|
|
|
17
17
|
class HealthServer:
|
|
18
18
|
"""HTTP server for health checks.
|
|
19
|
-
|
|
19
|
+
|
|
20
20
|
Provides /health endpoint returning component status.
|
|
21
21
|
"""
|
|
22
22
|
|
|
@@ -26,26 +26,26 @@ class HealthServer:
|
|
|
26
26
|
port: int = 8080,
|
|
27
27
|
) -> None:
|
|
28
28
|
"""Initialize health server.
|
|
29
|
-
|
|
29
|
+
|
|
30
30
|
Args:
|
|
31
31
|
host: Host to bind to
|
|
32
32
|
port: Port to bind to
|
|
33
33
|
"""
|
|
34
34
|
self.host = host
|
|
35
35
|
self.port = port
|
|
36
|
-
|
|
36
|
+
|
|
37
37
|
# Components to check (set via set_components)
|
|
38
38
|
self._state_store: StateStore | None = None
|
|
39
39
|
self._storage: StorageBackend | None = None
|
|
40
40
|
self._notifier: Notifier | None = None
|
|
41
41
|
self._sources: list[ClipSource] = []
|
|
42
42
|
self._mqtt_is_critical = False
|
|
43
|
-
|
|
43
|
+
|
|
44
44
|
# Server state
|
|
45
45
|
self._app: web.Application | None = None
|
|
46
46
|
self._runner: web.AppRunner | None = None
|
|
47
47
|
self._site: web.TCPSite | None = None
|
|
48
|
-
|
|
48
|
+
|
|
49
49
|
logger.info("HealthServer initialized: %s:%d", host, port)
|
|
50
50
|
|
|
51
51
|
def set_components(
|
|
@@ -58,7 +58,7 @@ class HealthServer:
|
|
|
58
58
|
mqtt_is_critical: bool = False,
|
|
59
59
|
) -> None:
|
|
60
60
|
"""Set components to check.
|
|
61
|
-
|
|
61
|
+
|
|
62
62
|
Args:
|
|
63
63
|
state_store: State store to ping
|
|
64
64
|
storage: Storage backend to ping
|
|
@@ -76,24 +76,24 @@ class HealthServer:
|
|
|
76
76
|
"""Start HTTP server."""
|
|
77
77
|
self._app = web.Application()
|
|
78
78
|
self._app.router.add_get("/health", self._health_handler)
|
|
79
|
-
|
|
79
|
+
|
|
80
80
|
self._runner = web.AppRunner(self._app)
|
|
81
81
|
await self._runner.setup()
|
|
82
|
-
|
|
82
|
+
|
|
83
83
|
self._site = web.TCPSite(self._runner, self.host, self.port)
|
|
84
84
|
await self._site.start()
|
|
85
|
-
|
|
85
|
+
|
|
86
86
|
logger.info("HealthServer started: http://%s:%d/health", self.host, self.port)
|
|
87
87
|
|
|
88
88
|
async def stop(self) -> None:
|
|
89
89
|
"""Stop HTTP server."""
|
|
90
90
|
if self._runner:
|
|
91
91
|
await self._runner.cleanup()
|
|
92
|
-
|
|
92
|
+
|
|
93
93
|
self._app = None
|
|
94
94
|
self._runner = None
|
|
95
95
|
self._site = None
|
|
96
|
-
|
|
96
|
+
|
|
97
97
|
logger.info("HealthServer stopped")
|
|
98
98
|
|
|
99
99
|
async def _health_handler(self, request: web.Request) -> web.Response:
|
|
@@ -145,7 +145,7 @@ class HealthServer:
|
|
|
145
145
|
"""Check if all clip sources are healthy."""
|
|
146
146
|
if not self._sources:
|
|
147
147
|
return True # No sources configured
|
|
148
|
-
|
|
148
|
+
|
|
149
149
|
# All sources must be healthy
|
|
150
150
|
return all(source.is_healthy() for source in self._sources)
|
|
151
151
|
|
|
@@ -159,12 +159,14 @@ class HealthServer:
|
|
|
159
159
|
for source in self._sources:
|
|
160
160
|
camera_name = getattr(source, "camera_name", "unknown")
|
|
161
161
|
last_heartbeat = source.last_heartbeat()
|
|
162
|
-
details.append(
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
162
|
+
details.append(
|
|
163
|
+
{
|
|
164
|
+
"name": camera_name,
|
|
165
|
+
"healthy": source.is_healthy(),
|
|
166
|
+
"last_heartbeat": last_heartbeat,
|
|
167
|
+
"last_heartbeat_age_s": round(current_time - last_heartbeat, 3),
|
|
168
|
+
}
|
|
169
|
+
)
|
|
168
170
|
return details
|
|
169
171
|
|
|
170
172
|
async def _check_component(
|
|
@@ -183,38 +185,38 @@ class HealthServer:
|
|
|
183
185
|
|
|
184
186
|
def _compute_status(self, checks: dict[str, bool]) -> str:
|
|
185
187
|
"""Compute overall health status.
|
|
186
|
-
|
|
188
|
+
|
|
187
189
|
Args:
|
|
188
190
|
checks: Dict of component check results
|
|
189
|
-
|
|
191
|
+
|
|
190
192
|
Returns:
|
|
191
193
|
"healthy", "degraded", or "unhealthy"
|
|
192
194
|
"""
|
|
193
195
|
# Critical checks (unhealthy if fail)
|
|
194
196
|
if not checks["sources"]:
|
|
195
197
|
return "unhealthy"
|
|
196
|
-
|
|
198
|
+
|
|
197
199
|
if not checks["storage"]:
|
|
198
200
|
return "unhealthy"
|
|
199
|
-
|
|
201
|
+
|
|
200
202
|
# MQTT can be critical (configurable)
|
|
201
203
|
if not checks["mqtt"] and self._mqtt_is_critical:
|
|
202
204
|
return "unhealthy"
|
|
203
|
-
|
|
205
|
+
|
|
204
206
|
# Non-critical checks (degraded if fail)
|
|
205
207
|
if not checks["db"] or not checks["mqtt"]:
|
|
206
208
|
return "degraded"
|
|
207
|
-
|
|
209
|
+
|
|
208
210
|
return "healthy"
|
|
209
211
|
|
|
210
212
|
def _compute_warnings(self) -> list[str]:
|
|
211
213
|
"""Generate warnings for stale heartbeats.
|
|
212
|
-
|
|
214
|
+
|
|
213
215
|
Returns:
|
|
214
216
|
List of warning messages
|
|
215
217
|
"""
|
|
216
218
|
warnings: list[str] = []
|
|
217
|
-
|
|
219
|
+
|
|
218
220
|
# Check source heartbeats (warn if > 2 minutes)
|
|
219
221
|
current_time = time.monotonic()
|
|
220
222
|
for source in self._sources:
|
|
@@ -222,5 +224,5 @@ class HealthServer:
|
|
|
222
224
|
if heartbeat_age > 120: # 2 minutes
|
|
223
225
|
camera_name = getattr(source, "camera_name", "unknown")
|
|
224
226
|
warnings.append(f"source_{camera_name}_heartbeat_stale")
|
|
225
|
-
|
|
227
|
+
|
|
226
228
|
return warnings
|
homesec/interfaces.py
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Callable
|
|
6
7
|
from datetime import datetime
|
|
7
8
|
from pathlib import Path
|
|
8
|
-
from typing import TYPE_CHECKING
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
9
10
|
|
|
10
11
|
if TYPE_CHECKING:
|
|
11
12
|
from homesec.models.alert import Alert, AlertDecision
|
|
@@ -73,7 +74,7 @@ class StorageBackend(Shutdownable, ABC):
|
|
|
73
74
|
"""Stores raw clips and derived artifacts."""
|
|
74
75
|
|
|
75
76
|
@abstractmethod
|
|
76
|
-
async def put_file(self, local_path: Path, dest_path: str) ->
|
|
77
|
+
async def put_file(self, local_path: Path, dest_path: str) -> StorageUploadResult:
|
|
77
78
|
"""Upload file to storage. Returns storage result."""
|
|
78
79
|
raise NotImplementedError
|
|
79
80
|
|
|
@@ -143,7 +144,7 @@ class EventStore(ABC):
|
|
|
143
144
|
@abstractmethod
|
|
144
145
|
async def append(self, event: ClipLifecycleEvent) -> None:
|
|
145
146
|
"""Append a lifecycle event.
|
|
146
|
-
|
|
147
|
+
|
|
147
148
|
Events are appended with database-assigned ids.
|
|
148
149
|
Raises on database errors (should be retried by caller).
|
|
149
150
|
"""
|
|
@@ -156,7 +157,7 @@ class EventStore(ABC):
|
|
|
156
157
|
after_id: int | None = None,
|
|
157
158
|
) -> list[ClipLifecycleEvent]:
|
|
158
159
|
"""Get all events for a clip, optionally after an event id.
|
|
159
|
-
|
|
160
|
+
|
|
160
161
|
Returns events ordered by id. Returns empty list on error.
|
|
161
162
|
"""
|
|
162
163
|
raise NotImplementedError
|
|
@@ -175,6 +176,7 @@ class Notifier(Shutdownable, ABC):
|
|
|
175
176
|
"""Health check. Returns True if notifier is reachable."""
|
|
176
177
|
raise NotImplementedError
|
|
177
178
|
|
|
179
|
+
|
|
178
180
|
class AlertPolicy(ABC):
|
|
179
181
|
"""Decides whether to notify based on analysis results."""
|
|
180
182
|
|
|
@@ -197,7 +199,7 @@ class AlertPolicy(ABC):
|
|
|
197
199
|
camera_name: str,
|
|
198
200
|
filter_result: FilterResult | None,
|
|
199
201
|
analysis: AnalysisResult | None,
|
|
200
|
-
) ->
|
|
202
|
+
) -> AlertDecision:
|
|
201
203
|
"""Build an AlertDecision from should_notify output."""
|
|
202
204
|
from homesec.models.alert import AlertDecision
|
|
203
205
|
|
|
@@ -209,7 +211,9 @@ class ObjectFilter(Shutdownable, ABC):
|
|
|
209
211
|
"""Plugin interface for object detection in video clips."""
|
|
210
212
|
|
|
211
213
|
@abstractmethod
|
|
212
|
-
async def detect(
|
|
214
|
+
async def detect(
|
|
215
|
+
self, video_path: Path, overrides: FilterOverrides | None = None
|
|
216
|
+
) -> FilterResult:
|
|
213
217
|
"""Detect objects in video clip.
|
|
214
218
|
|
|
215
219
|
Implementation notes:
|
|
@@ -225,6 +229,7 @@ class ObjectFilter(Shutdownable, ABC):
|
|
|
225
229
|
"""
|
|
226
230
|
raise NotImplementedError
|
|
227
231
|
|
|
232
|
+
|
|
228
233
|
class VLMAnalyzer(Shutdownable, ABC):
|
|
229
234
|
"""Plugin interface for VLM-based clip analysis."""
|
|
230
235
|
|