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
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
+ ]
@@ -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)