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
homesec/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""HomeSec camera pipeline."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
# Export commonly used types
|
|
6
|
+
from homesec.errors import PipelineError
|
|
7
|
+
from homesec.models.alert import Alert
|
|
8
|
+
from homesec.models.clip import Clip, ClipStateData
|
|
9
|
+
from homesec.models.filter import FilterResult
|
|
10
|
+
from homesec.models.vlm import AnalysisResult
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"__version__",
|
|
14
|
+
"Alert",
|
|
15
|
+
"AnalysisResult",
|
|
16
|
+
"Clip",
|
|
17
|
+
"ClipStateData",
|
|
18
|
+
"FilterResult",
|
|
19
|
+
"PipelineError",
|
|
20
|
+
]
|
homesec/app.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Main application that wires all components together."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import signal
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Callable, cast
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from homesec.config import (
|
|
14
|
+
load_config,
|
|
15
|
+
resolve_env_var,
|
|
16
|
+
validate_camera_references,
|
|
17
|
+
validate_plugin_names,
|
|
18
|
+
)
|
|
19
|
+
from homesec.health import HealthServer
|
|
20
|
+
from homesec.pipeline import ClipPipeline
|
|
21
|
+
from homesec.plugins.analyzers import VLM_REGISTRY, load_vlm_plugin
|
|
22
|
+
from homesec.plugins.alert_policies import ALERT_POLICY_REGISTRY
|
|
23
|
+
from homesec.plugins.filters import FILTER_REGISTRY, load_filter_plugin
|
|
24
|
+
from homesec.plugins.notifiers import (
|
|
25
|
+
NOTIFIER_REGISTRY,
|
|
26
|
+
MultiplexNotifier,
|
|
27
|
+
NotifierEntry,
|
|
28
|
+
NotifierPlugin,
|
|
29
|
+
)
|
|
30
|
+
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
|
+
from homesec.repository import ClipRepository
|
|
36
|
+
from homesec.state import NoopEventStore, NoopStateStore, PostgresStateStore
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from homesec.models.config import Config
|
|
40
|
+
from homesec.interfaces import (
|
|
41
|
+
AlertPolicy,
|
|
42
|
+
ClipSource,
|
|
43
|
+
Notifier,
|
|
44
|
+
ObjectFilter,
|
|
45
|
+
StateStore,
|
|
46
|
+
StorageBackend,
|
|
47
|
+
VLMAnalyzer,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
logger = logging.getLogger(__name__)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Application:
|
|
54
|
+
"""Main application that orchestrates all components.
|
|
55
|
+
|
|
56
|
+
Handles component creation, lifecycle, and graceful shutdown.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, config_path: Path) -> None:
|
|
60
|
+
"""Initialize application with config file path.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
config_path: Path to YAML config file
|
|
64
|
+
"""
|
|
65
|
+
self._config_path = config_path
|
|
66
|
+
self._config: Config | None = None
|
|
67
|
+
|
|
68
|
+
# Components (created in _create_components)
|
|
69
|
+
self._storage: StorageBackend | None = None
|
|
70
|
+
self._state_store: StateStore = NoopStateStore()
|
|
71
|
+
self._event_store: EventStore = NoopEventStore()
|
|
72
|
+
self._repository: ClipRepository | None = None
|
|
73
|
+
self._notifier: Notifier | None = None
|
|
74
|
+
self._notifier_entries: list[NotifierEntry] = []
|
|
75
|
+
self._filter: ObjectFilter | None = None
|
|
76
|
+
self._vlm: VLMAnalyzer | None = None
|
|
77
|
+
self._sources: list[ClipSource] = []
|
|
78
|
+
self._pipeline: ClipPipeline | None = None
|
|
79
|
+
self._health_server: HealthServer | None = None
|
|
80
|
+
|
|
81
|
+
# Shutdown state
|
|
82
|
+
self._shutdown_event = asyncio.Event()
|
|
83
|
+
self._shutdown_started = False
|
|
84
|
+
|
|
85
|
+
async def run(self) -> None:
|
|
86
|
+
"""Run the application.
|
|
87
|
+
|
|
88
|
+
Loads config, creates components, and runs until shutdown signal.
|
|
89
|
+
"""
|
|
90
|
+
logger.info("Starting HomeSec application...")
|
|
91
|
+
|
|
92
|
+
# Load config
|
|
93
|
+
self._config = load_config(self._config_path)
|
|
94
|
+
logger.info("Config loaded from %s", self._config_path)
|
|
95
|
+
|
|
96
|
+
# Create components
|
|
97
|
+
await self._create_components()
|
|
98
|
+
|
|
99
|
+
# Set up signal handlers
|
|
100
|
+
self._setup_signal_handlers()
|
|
101
|
+
|
|
102
|
+
# Start health server
|
|
103
|
+
if self._health_server:
|
|
104
|
+
await self._health_server.start()
|
|
105
|
+
|
|
106
|
+
# Start sources
|
|
107
|
+
for source in self._sources:
|
|
108
|
+
await source.start()
|
|
109
|
+
|
|
110
|
+
logger.info("Application started. Waiting for clips...")
|
|
111
|
+
|
|
112
|
+
# Wait for shutdown signal
|
|
113
|
+
await self._shutdown_event.wait()
|
|
114
|
+
|
|
115
|
+
# Graceful shutdown
|
|
116
|
+
await self.shutdown()
|
|
117
|
+
|
|
118
|
+
async def _create_components(self) -> None:
|
|
119
|
+
"""Create all components based on config."""
|
|
120
|
+
config = self._require_config()
|
|
121
|
+
|
|
122
|
+
# Discover plugins before validation
|
|
123
|
+
from homesec.plugins import discover_all_plugins
|
|
124
|
+
|
|
125
|
+
discover_all_plugins()
|
|
126
|
+
|
|
127
|
+
# Validate config references and plugin names before instantiating components
|
|
128
|
+
self._validate_config(config)
|
|
129
|
+
|
|
130
|
+
# Create storage backend
|
|
131
|
+
self._storage = self._create_storage(config)
|
|
132
|
+
|
|
133
|
+
# Create state store
|
|
134
|
+
self._state_store = await self._create_state_store(config)
|
|
135
|
+
self._event_store = self._create_event_store(self._state_store)
|
|
136
|
+
self._repository = ClipRepository(
|
|
137
|
+
self._state_store,
|
|
138
|
+
self._event_store,
|
|
139
|
+
retry=config.retry,
|
|
140
|
+
)
|
|
141
|
+
assert self._storage is not None
|
|
142
|
+
assert self._repository is not None
|
|
143
|
+
|
|
144
|
+
# Create notifier
|
|
145
|
+
self._notifier = self._create_notifier(config)
|
|
146
|
+
assert self._notifier is not None
|
|
147
|
+
await self._log_notifier_health()
|
|
148
|
+
|
|
149
|
+
# Create filter, VLM, and alert policy plugins
|
|
150
|
+
filter_plugin = load_filter_plugin(config.filter)
|
|
151
|
+
vlm_plugin = load_vlm_plugin(config.vlm)
|
|
152
|
+
self._filter = filter_plugin
|
|
153
|
+
self._vlm = vlm_plugin
|
|
154
|
+
alert_policy = self._create_alert_policy(config)
|
|
155
|
+
|
|
156
|
+
# Create pipeline
|
|
157
|
+
self._pipeline = ClipPipeline(
|
|
158
|
+
config=config,
|
|
159
|
+
storage=self._storage,
|
|
160
|
+
repository=self._repository,
|
|
161
|
+
filter_plugin=filter_plugin,
|
|
162
|
+
vlm_plugin=vlm_plugin,
|
|
163
|
+
notifier=self._notifier,
|
|
164
|
+
alert_policy=alert_policy,
|
|
165
|
+
notifier_entries=self._notifier_entries,
|
|
166
|
+
)
|
|
167
|
+
# Set event loop for thread-safe callbacks from sources
|
|
168
|
+
self._pipeline.set_event_loop(asyncio.get_running_loop())
|
|
169
|
+
|
|
170
|
+
# Create sources and register callback
|
|
171
|
+
self._sources = self._create_sources(config)
|
|
172
|
+
for source in self._sources:
|
|
173
|
+
source.register_callback(self._pipeline.on_new_clip)
|
|
174
|
+
|
|
175
|
+
# Create health server
|
|
176
|
+
health_cfg = config.health
|
|
177
|
+
self._health_server = HealthServer(
|
|
178
|
+
host=health_cfg.host,
|
|
179
|
+
port=health_cfg.port,
|
|
180
|
+
)
|
|
181
|
+
self._health_server.set_components(
|
|
182
|
+
state_store=self._state_store,
|
|
183
|
+
storage=self._storage,
|
|
184
|
+
notifier=self._notifier,
|
|
185
|
+
sources=self._sources,
|
|
186
|
+
mqtt_is_critical=health_cfg.mqtt_is_critical,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
logger.info("All components created")
|
|
190
|
+
|
|
191
|
+
def _create_storage(self, config: Config) -> StorageBackend:
|
|
192
|
+
"""Create storage backend based on config."""
|
|
193
|
+
return create_storage(config.storage)
|
|
194
|
+
|
|
195
|
+
async def _create_state_store(self, config: Config) -> StateStore:
|
|
196
|
+
"""Create state store based on config."""
|
|
197
|
+
state_cfg = config.state_store
|
|
198
|
+
dsn = state_cfg.dsn
|
|
199
|
+
if state_cfg.dsn_env:
|
|
200
|
+
dsn = resolve_env_var(state_cfg.dsn_env)
|
|
201
|
+
if not dsn:
|
|
202
|
+
raise RuntimeError("Postgres DSN is required for state_store backend")
|
|
203
|
+
store = PostgresStateStore(dsn)
|
|
204
|
+
await store.initialize()
|
|
205
|
+
return store
|
|
206
|
+
|
|
207
|
+
def _create_event_store(self, state_store: StateStore) -> EventStore:
|
|
208
|
+
"""Create event store based on state store backend."""
|
|
209
|
+
create_event_store = getattr(state_store, "create_event_store", None)
|
|
210
|
+
if callable(create_event_store):
|
|
211
|
+
event_store = cast(Callable[[], EventStore], create_event_store)()
|
|
212
|
+
if isinstance(event_store, NoopEventStore):
|
|
213
|
+
logger.warning("Event store unavailable; events will be dropped")
|
|
214
|
+
return event_store
|
|
215
|
+
logger.warning("Unsupported state store for events; events will be dropped")
|
|
216
|
+
return NoopEventStore()
|
|
217
|
+
|
|
218
|
+
def _create_notifier(self, config: Config) -> Notifier:
|
|
219
|
+
"""Create notifier(s) based on config."""
|
|
220
|
+
entries: list[NotifierEntry] = []
|
|
221
|
+
for index, notifier_cfg in enumerate(config.notifiers):
|
|
222
|
+
plugin, validated_cfg = self._validate_notifier_config(notifier_cfg)
|
|
223
|
+
if not notifier_cfg.enabled:
|
|
224
|
+
continue
|
|
225
|
+
notifier = plugin.factory(validated_cfg)
|
|
226
|
+
name = f"{notifier_cfg.backend}[{index}]"
|
|
227
|
+
entries.append(NotifierEntry(name=name, notifier=notifier))
|
|
228
|
+
|
|
229
|
+
self._notifier_entries = entries
|
|
230
|
+
if not entries:
|
|
231
|
+
raise RuntimeError("No enabled notifiers configured")
|
|
232
|
+
if len(entries) == 1:
|
|
233
|
+
return entries[0].notifier
|
|
234
|
+
return MultiplexNotifier(entries)
|
|
235
|
+
|
|
236
|
+
async def _log_notifier_health(self) -> None:
|
|
237
|
+
if not self._notifier_entries:
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
tasks = [
|
|
241
|
+
asyncio.create_task(entry.notifier.ping())
|
|
242
|
+
for entry in self._notifier_entries
|
|
243
|
+
]
|
|
244
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
245
|
+
|
|
246
|
+
for entry, result in zip(self._notifier_entries, results, strict=True):
|
|
247
|
+
match result:
|
|
248
|
+
case bool() as ok:
|
|
249
|
+
if ok:
|
|
250
|
+
logger.info("Notifier reachable at startup: %s", entry.name)
|
|
251
|
+
else:
|
|
252
|
+
logger.error("Notifier unreachable at startup: %s", entry.name)
|
|
253
|
+
case BaseException() as err:
|
|
254
|
+
logger.error(
|
|
255
|
+
"Notifier ping failed at startup: %s error=%s",
|
|
256
|
+
entry.name,
|
|
257
|
+
err,
|
|
258
|
+
exc_info=err,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def _validate_notifier_config(
|
|
262
|
+
self, notifier_cfg: NotifierConfig
|
|
263
|
+
) -> tuple[NotifierPlugin, BaseModel]:
|
|
264
|
+
"""Validate notifier config and return the plugin and validated config."""
|
|
265
|
+
plugin = NOTIFIER_REGISTRY.get(notifier_cfg.backend)
|
|
266
|
+
if plugin is None:
|
|
267
|
+
raise RuntimeError(f"Unsupported notifier backend: {notifier_cfg.backend}")
|
|
268
|
+
validated_cfg = plugin.config_model.model_validate(notifier_cfg.config)
|
|
269
|
+
return plugin, validated_cfg
|
|
270
|
+
|
|
271
|
+
def _create_alert_policy(self, config: Config) -> AlertPolicy:
|
|
272
|
+
policy_cfg = config.alert_policy
|
|
273
|
+
|
|
274
|
+
# Use noop backend when alert policy is disabled
|
|
275
|
+
backend = "noop" if not policy_cfg.enabled else policy_cfg.backend
|
|
276
|
+
|
|
277
|
+
plugin = ALERT_POLICY_REGISTRY.get(backend)
|
|
278
|
+
if plugin is None:
|
|
279
|
+
raise RuntimeError(f"Unsupported alert policy backend: {backend}")
|
|
280
|
+
|
|
281
|
+
# Always validate to ensure proper BaseModel contract
|
|
282
|
+
if policy_cfg.enabled:
|
|
283
|
+
settings = plugin.config_model.model_validate(policy_cfg.config)
|
|
284
|
+
else:
|
|
285
|
+
# Noop uses empty BaseModel, validate empty dict to get BaseModel instance
|
|
286
|
+
settings = plugin.config_model.model_validate({})
|
|
287
|
+
|
|
288
|
+
return plugin.factory(settings, config.per_camera_alert, config.vlm.trigger_classes)
|
|
289
|
+
|
|
290
|
+
def _create_sources(self, config: Config) -> list[ClipSource]:
|
|
291
|
+
"""Create clip sources based on config."""
|
|
292
|
+
sources: list[ClipSource] = []
|
|
293
|
+
|
|
294
|
+
for camera in config.cameras:
|
|
295
|
+
source_cfg = camera.source
|
|
296
|
+
match source_cfg.type:
|
|
297
|
+
case "local_folder":
|
|
298
|
+
local_cfg = source_cfg.config
|
|
299
|
+
assert isinstance(local_cfg, LocalFolderSourceConfig)
|
|
300
|
+
sources.append(
|
|
301
|
+
LocalFolderSource(
|
|
302
|
+
local_cfg,
|
|
303
|
+
camera_name=camera.name,
|
|
304
|
+
state_store=self._state_store,
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
case "rtsp":
|
|
309
|
+
rtsp_cfg = source_cfg.config
|
|
310
|
+
assert isinstance(rtsp_cfg, RTSPSourceConfig)
|
|
311
|
+
sources.append(RTSPSource(rtsp_cfg, camera_name=camera.name))
|
|
312
|
+
|
|
313
|
+
case "ftp":
|
|
314
|
+
ftp_cfg = source_cfg.config
|
|
315
|
+
assert isinstance(ftp_cfg, FtpSourceConfig)
|
|
316
|
+
sources.append(FtpSource(ftp_cfg, camera_name=camera.name))
|
|
317
|
+
|
|
318
|
+
case _:
|
|
319
|
+
raise RuntimeError(f"Unsupported source type: {source_cfg.type}")
|
|
320
|
+
|
|
321
|
+
return sources
|
|
322
|
+
|
|
323
|
+
def _require_config(self) -> Config:
|
|
324
|
+
if self._config is None:
|
|
325
|
+
raise RuntimeError("Config not loaded")
|
|
326
|
+
return self._config
|
|
327
|
+
|
|
328
|
+
def _validate_config(self, config: Config) -> None:
|
|
329
|
+
validate_camera_references(config)
|
|
330
|
+
validate_plugin_names(
|
|
331
|
+
config,
|
|
332
|
+
valid_filters=sorted(FILTER_REGISTRY.keys()),
|
|
333
|
+
valid_vlms=sorted(VLM_REGISTRY.keys()),
|
|
334
|
+
valid_storage=sorted(STORAGE_REGISTRY.keys()),
|
|
335
|
+
valid_notifiers=sorted(NOTIFIER_REGISTRY.keys()),
|
|
336
|
+
valid_alert_policies=sorted(ALERT_POLICY_REGISTRY.keys()),
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
def _setup_signal_handlers(self) -> None:
|
|
340
|
+
"""Set up signal handlers for graceful shutdown."""
|
|
341
|
+
loop = asyncio.get_running_loop()
|
|
342
|
+
|
|
343
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
344
|
+
loop.add_signal_handler(sig, self._handle_signal, sig)
|
|
345
|
+
|
|
346
|
+
def _handle_signal(self, sig: signal.Signals) -> None:
|
|
347
|
+
"""Handle shutdown signal."""
|
|
348
|
+
if self._shutdown_started:
|
|
349
|
+
logger.warning("Shutdown already in progress, ignoring signal")
|
|
350
|
+
return
|
|
351
|
+
|
|
352
|
+
logger.info("Received signal %s, initiating shutdown...", sig.name)
|
|
353
|
+
self._shutdown_started = True
|
|
354
|
+
self._shutdown_event.set()
|
|
355
|
+
|
|
356
|
+
async def shutdown(self) -> None:
|
|
357
|
+
"""Graceful shutdown of all components."""
|
|
358
|
+
logger.info("Shutting down application...")
|
|
359
|
+
|
|
360
|
+
# Stop sources first
|
|
361
|
+
if self._sources:
|
|
362
|
+
await asyncio.gather(
|
|
363
|
+
*(source.shutdown() for source in self._sources),
|
|
364
|
+
return_exceptions=True,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
# Shutdown pipeline (waits for in-flight clips)
|
|
368
|
+
if self._pipeline:
|
|
369
|
+
await self._pipeline.shutdown()
|
|
370
|
+
|
|
371
|
+
# Stop health server
|
|
372
|
+
if self._health_server:
|
|
373
|
+
await self._health_server.stop()
|
|
374
|
+
|
|
375
|
+
# Close filter and VLM plugins
|
|
376
|
+
if self._filter:
|
|
377
|
+
await self._filter.shutdown()
|
|
378
|
+
if self._vlm:
|
|
379
|
+
await self._vlm.shutdown()
|
|
380
|
+
|
|
381
|
+
# Close state store
|
|
382
|
+
if self._state_store:
|
|
383
|
+
await self._state_store.shutdown()
|
|
384
|
+
|
|
385
|
+
# Close storage
|
|
386
|
+
if self._storage:
|
|
387
|
+
await self._storage.shutdown()
|
|
388
|
+
|
|
389
|
+
# Close notifier
|
|
390
|
+
if self._notifier:
|
|
391
|
+
await self._notifier.shutdown()
|
|
392
|
+
|
|
393
|
+
logger.info("Application shutdown complete")
|
homesec/cli.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""CLI entrypoint for HomeSec application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import fire # type: ignore[import-untyped]
|
|
10
|
+
|
|
11
|
+
from homesec.logging_setup import configure_logging
|
|
12
|
+
from homesec.app import Application
|
|
13
|
+
from homesec.config import ConfigError, load_config
|
|
14
|
+
from homesec.config.validation import validate_camera_references, validate_plugin_names
|
|
15
|
+
from homesec.maintenance.cleanup_clips import CleanupOptions, run_cleanup
|
|
16
|
+
from homesec.plugins.analyzers import VLM_REGISTRY
|
|
17
|
+
from homesec.plugins.alert_policies import ALERT_POLICY_REGISTRY
|
|
18
|
+
from homesec.plugins.filters import FILTER_REGISTRY
|
|
19
|
+
from homesec.plugins.notifiers import NOTIFIER_REGISTRY
|
|
20
|
+
from homesec.plugins.storage import STORAGE_REGISTRY
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def setup_logging(level: str = "INFO") -> None:
|
|
24
|
+
"""Configure logging for CLI."""
|
|
25
|
+
configure_logging(log_level=level)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class HomeSec:
|
|
29
|
+
"""HomeSec CLI - Home Security Camera Pipeline."""
|
|
30
|
+
|
|
31
|
+
def run(self, config: str, log_level: str = "INFO") -> None:
|
|
32
|
+
"""Run the HomeSec pipeline.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
config: Path to YAML config file
|
|
36
|
+
log_level: Logging level (DEBUG, INFO, WARNING, ERROR)
|
|
37
|
+
"""
|
|
38
|
+
setup_logging(log_level)
|
|
39
|
+
|
|
40
|
+
config_path = Path(config)
|
|
41
|
+
|
|
42
|
+
app = Application(config_path)
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
asyncio.run(app.run())
|
|
46
|
+
except ConfigError as e:
|
|
47
|
+
print(f"✗ Config invalid: {e}", file=sys.stderr)
|
|
48
|
+
sys.exit(1)
|
|
49
|
+
except KeyboardInterrupt:
|
|
50
|
+
pass # Handled by signal handlers
|
|
51
|
+
|
|
52
|
+
def validate(self, config: str) -> None:
|
|
53
|
+
"""Validate config file without running.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
config: Path to YAML config file
|
|
57
|
+
"""
|
|
58
|
+
config_path = Path(config)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
cfg = load_config(config_path)
|
|
62
|
+
|
|
63
|
+
# Discover all plugins
|
|
64
|
+
from homesec.plugins import discover_all_plugins
|
|
65
|
+
|
|
66
|
+
discover_all_plugins()
|
|
67
|
+
|
|
68
|
+
# Additional validation checks
|
|
69
|
+
validate_camera_references(cfg)
|
|
70
|
+
validate_plugin_names(
|
|
71
|
+
cfg,
|
|
72
|
+
sorted(FILTER_REGISTRY.keys()),
|
|
73
|
+
sorted(VLM_REGISTRY.keys()),
|
|
74
|
+
valid_storage=sorted(STORAGE_REGISTRY.keys()),
|
|
75
|
+
valid_notifiers=sorted(NOTIFIER_REGISTRY.keys()),
|
|
76
|
+
valid_alert_policies=sorted(ALERT_POLICY_REGISTRY.keys()),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
print(f"✓ Config valid: {config_path}")
|
|
80
|
+
camera_names = [camera.name for camera in cfg.cameras]
|
|
81
|
+
print(f" Cameras: {camera_names}")
|
|
82
|
+
notifier_backends = [
|
|
83
|
+
f"{notifier.backend} (enabled={notifier.enabled})"
|
|
84
|
+
for notifier in cfg.notifiers
|
|
85
|
+
]
|
|
86
|
+
print(f" Storage backend: {cfg.storage.backend}")
|
|
87
|
+
print(f" Notifiers: {notifier_backends}")
|
|
88
|
+
print(f" Filter plugin: {cfg.filter.plugin}")
|
|
89
|
+
print(f" VLM backend: {cfg.vlm.backend}")
|
|
90
|
+
print(f" VLM trigger classes: {cfg.vlm.trigger_classes}")
|
|
91
|
+
print(f" Alert policy backend: {cfg.alert_policy.backend}")
|
|
92
|
+
print(f" Alert policy enabled: {cfg.alert_policy.enabled}")
|
|
93
|
+
except ConfigError as e:
|
|
94
|
+
print(f"✗ Config invalid: {e}", file=sys.stderr)
|
|
95
|
+
sys.exit(1)
|
|
96
|
+
|
|
97
|
+
def cleanup(
|
|
98
|
+
self,
|
|
99
|
+
config: str,
|
|
100
|
+
older_than_days: int | None = None,
|
|
101
|
+
camera_name: str | None = None,
|
|
102
|
+
batch_size: int = 100,
|
|
103
|
+
workers: int = 2,
|
|
104
|
+
dry_run: bool = True,
|
|
105
|
+
recheck_model_path: str | None = None,
|
|
106
|
+
recheck_min_confidence: float | None = None,
|
|
107
|
+
recheck_sample_fps: int | None = None,
|
|
108
|
+
recheck_min_box_h_ratio: float | None = None,
|
|
109
|
+
recheck_min_hits: int | None = None,
|
|
110
|
+
log_level: str = "INFO",
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Re-analyze and optionally delete clips that appear empty.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
config: Path to YAML config file
|
|
116
|
+
older_than_days: Only consider clips older than this many days
|
|
117
|
+
camera_name: Optional camera name filter
|
|
118
|
+
batch_size: Postgres paging size
|
|
119
|
+
workers: Concurrency for re-analysis/deletion
|
|
120
|
+
dry_run: If True, log actions but do not delete or mutate state
|
|
121
|
+
recheck_model_path: Override YOLO model path for recheck (default: yolo11x.pt)
|
|
122
|
+
recheck_min_confidence: Override YOLO confidence for recheck
|
|
123
|
+
recheck_sample_fps: Override frame sampling step for recheck
|
|
124
|
+
recheck_min_box_h_ratio: Override minimum box height ratio
|
|
125
|
+
recheck_min_hits: Override minimum hits setting
|
|
126
|
+
log_level: Logging level
|
|
127
|
+
"""
|
|
128
|
+
setup_logging(log_level)
|
|
129
|
+
|
|
130
|
+
opts = CleanupOptions(
|
|
131
|
+
config_path=Path(config),
|
|
132
|
+
older_than_days=older_than_days,
|
|
133
|
+
camera_name=camera_name,
|
|
134
|
+
batch_size=batch_size,
|
|
135
|
+
workers=workers,
|
|
136
|
+
dry_run=dry_run,
|
|
137
|
+
recheck_model_path=recheck_model_path,
|
|
138
|
+
recheck_min_confidence=recheck_min_confidence,
|
|
139
|
+
recheck_sample_fps=recheck_sample_fps,
|
|
140
|
+
recheck_min_box_h_ratio=recheck_min_box_h_ratio,
|
|
141
|
+
recheck_min_hits=recheck_min_hits,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
asyncio.run(run_cleanup(opts))
|
|
146
|
+
except ConfigError as e:
|
|
147
|
+
print(f"✗ Config invalid: {e}", file=sys.stderr)
|
|
148
|
+
sys.exit(1)
|
|
149
|
+
except KeyboardInterrupt:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def main() -> None:
|
|
154
|
+
"""Main CLI entrypoint."""
|
|
155
|
+
fire.Fire(HomeSec)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
main()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Configuration loading and validation."""
|
|
2
|
+
|
|
3
|
+
from homesec.config.loader import (
|
|
4
|
+
ConfigError,
|
|
5
|
+
load_config,
|
|
6
|
+
load_config_from_dict,
|
|
7
|
+
resolve_env_var,
|
|
8
|
+
)
|
|
9
|
+
from homesec.config.validation import validate_camera_references, validate_plugin_names
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"ConfigError",
|
|
13
|
+
"load_config",
|
|
14
|
+
"load_config_from_dict",
|
|
15
|
+
"resolve_env_var",
|
|
16
|
+
"validate_camera_references",
|
|
17
|
+
"validate_plugin_names",
|
|
18
|
+
]
|
homesec/config/loader.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Configuration loading and validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
|
|
12
|
+
from homesec.models.config import Config
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConfigError(Exception):
|
|
16
|
+
"""Configuration loading or validation error."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, message: str, path: Path | None = None) -> None:
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.path = path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_config(path: Path) -> Config:
|
|
24
|
+
"""Load and validate configuration from YAML file.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
path: Path to YAML config file
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Validated Config instance
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
ConfigError: If file not found, YAML invalid, or validation fails
|
|
34
|
+
"""
|
|
35
|
+
if not path.exists():
|
|
36
|
+
raise ConfigError(f"Config file not found: {path}", path=path)
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
with path.open() as f:
|
|
40
|
+
raw = yaml.safe_load(f)
|
|
41
|
+
except yaml.YAMLError as e:
|
|
42
|
+
raise ConfigError(f"Invalid YAML in {path}: {e}", path=path) from e
|
|
43
|
+
|
|
44
|
+
if raw is None:
|
|
45
|
+
raise ConfigError(f"Config file is empty: {path}", path=path)
|
|
46
|
+
|
|
47
|
+
if not isinstance(raw, dict):
|
|
48
|
+
raise ConfigError(f"Config must be a YAML mapping, got {type(raw).__name__}", path=path)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
return Config.model_validate(raw)
|
|
52
|
+
except ValidationError as e:
|
|
53
|
+
raise ConfigError(format_validation_error(e, path), path=path) from e
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_config_from_dict(data: dict[str, Any]) -> Config:
|
|
57
|
+
"""Load and validate configuration from a dict (useful for testing).
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
data: Configuration dictionary
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Validated Config instance
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
ConfigError: If validation fails
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
return Config.model_validate(data)
|
|
70
|
+
except ValidationError as e:
|
|
71
|
+
raise ConfigError(format_validation_error(e, path=None)) from e
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def resolve_env_var(env_var_name: str, required: bool = True) -> str | None:
|
|
75
|
+
"""Resolve environment variable by name.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
env_var_name: Name of the environment variable
|
|
79
|
+
required: If True, raise if not found
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Environment variable value, or None if not required and not found
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
ConfigError: If required and not found
|
|
86
|
+
"""
|
|
87
|
+
value = os.environ.get(env_var_name)
|
|
88
|
+
if value is None and required:
|
|
89
|
+
raise ConfigError(f"Required environment variable not set: {env_var_name}")
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def format_validation_error(e: ValidationError, path: Path | None = None) -> str:
|
|
94
|
+
"""Format Pydantic validation error for human readability.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
e: Pydantic ValidationError
|
|
98
|
+
path: Optional config file path for context
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Human-readable error message
|
|
102
|
+
"""
|
|
103
|
+
prefix = f"Config validation failed ({path}):" if path else "Config validation failed:"
|
|
104
|
+
errors = []
|
|
105
|
+
for err in e.errors():
|
|
106
|
+
loc = " -> ".join(str(x) for x in err["loc"])
|
|
107
|
+
msg = err["msg"]
|
|
108
|
+
errors.append(f" {loc}: {msg}")
|
|
109
|
+
return prefix + "\n" + "\n".join(errors)
|