homesec 0.1.1__py3-none-any.whl → 1.0.1__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 (44) hide show
  1. homesec/app.py +34 -36
  2. homesec/cli.py +14 -11
  3. homesec/config/loader.py +11 -11
  4. homesec/config/validation.py +2 -5
  5. homesec/errors.py +2 -4
  6. homesec/health/server.py +29 -27
  7. homesec/interfaces.py +11 -6
  8. homesec/logging_setup.py +9 -5
  9. homesec/maintenance/cleanup_clips.py +2 -3
  10. homesec/models/__init__.py +1 -1
  11. homesec/models/alert.py +2 -0
  12. homesec/models/clip.py +8 -1
  13. homesec/models/config.py +9 -13
  14. homesec/models/events.py +14 -0
  15. homesec/models/filter.py +1 -3
  16. homesec/models/vlm.py +1 -2
  17. homesec/pipeline/core.py +15 -32
  18. homesec/plugins/alert_policies/__init__.py +3 -4
  19. homesec/plugins/alert_policies/default.py +3 -2
  20. homesec/plugins/alert_policies/noop.py +1 -2
  21. homesec/plugins/analyzers/__init__.py +3 -4
  22. homesec/plugins/analyzers/openai.py +34 -43
  23. homesec/plugins/filters/__init__.py +3 -4
  24. homesec/plugins/filters/yolo.py +27 -29
  25. homesec/plugins/notifiers/__init__.py +2 -1
  26. homesec/plugins/notifiers/mqtt.py +16 -17
  27. homesec/plugins/notifiers/multiplex.py +3 -2
  28. homesec/plugins/notifiers/sendgrid_email.py +6 -8
  29. homesec/plugins/storage/__init__.py +3 -4
  30. homesec/plugins/storage/dropbox.py +20 -17
  31. homesec/plugins/storage/local.py +3 -1
  32. homesec/plugins/utils.py +2 -1
  33. homesec/repository/clip_repository.py +5 -4
  34. homesec/sources/base.py +2 -2
  35. homesec/sources/local_folder.py +9 -7
  36. homesec/sources/rtsp.py +22 -10
  37. homesec/state/postgres.py +34 -35
  38. homesec/telemetry/db_log_handler.py +3 -2
  39. {homesec-0.1.1.dist-info → homesec-1.0.1.dist-info}/METADATA +39 -31
  40. homesec-1.0.1.dist-info/RECORD +62 -0
  41. homesec-0.1.1.dist-info/RECORD +0 -62
  42. {homesec-0.1.1.dist-info → homesec-1.0.1.dist-info}/WHEEL +0 -0
  43. {homesec-0.1.1.dist-info → homesec-1.0.1.dist-info}/entry_points.txt +0 -0
  44. {homesec-0.1.1.dist-info → homesec-1.0.1.dist-info}/licenses/LICENSE +0 -0
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, Callable, cast
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
  """
@@ -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
- "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
- })
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, Callable
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) -> "StorageUploadResult":
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
- ) -> "AlertDecision":
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(self, video_path: Path, overrides: FilterOverrides | None = None) -> FilterResult:
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
 
homesec/logging_setup.py CHANGED
@@ -35,16 +35,17 @@ _STANDARD_LOGRECORD_ATTRS = {
35
35
  "taskName",
36
36
  }
37
37
 
38
+
38
39
  class _CameraNameFilter(logging.Filter):
39
40
  def filter(self, record: logging.LogRecord) -> bool:
40
- if not hasattr(record, "camera_name") or getattr(record, "camera_name") in (None, ""):
41
+ if not hasattr(record, "camera_name") or record.camera_name in (None, ""):
41
42
  record.camera_name = _CURRENT_CAMERA_NAME
42
43
  return True
43
44
 
44
45
 
45
46
  class _RecordingIdFilter(logging.Filter):
46
47
  def filter(self, record: logging.LogRecord) -> bool:
47
- if not hasattr(record, "recording_id") or getattr(record, "recording_id") in (None, ""):
48
+ if not hasattr(record, "recording_id") or record.recording_id in (None, ""):
48
49
  record.recording_id = _CURRENT_RECORDING_ID
49
50
  return True
50
51
 
@@ -86,6 +87,7 @@ def set_camera_name(name: str | None) -> None:
86
87
  global _CURRENT_CAMERA_NAME
87
88
  _CURRENT_CAMERA_NAME = name or "-"
88
89
 
90
+
89
91
  def set_recording_id(recording_id: str | None) -> None:
90
92
  """Set the `recording_id` value injected into log records."""
91
93
  global _CURRENT_RECORDING_ID
@@ -99,6 +101,7 @@ def _install_camera_filter() -> None:
99
101
  continue
100
102
  handler.addFilter(_CameraNameFilter())
101
103
 
104
+
102
105
  def _install_recording_filter() -> None:
103
106
  root = logging.getLogger()
104
107
  for handler in root.handlers:
@@ -115,8 +118,7 @@ def configure_logging(*, log_level: str = "INFO", camera_name: str | None = None
115
118
  """
116
119
  console_level_name = str(log_level).upper()
117
120
  default_console_fmt = (
118
- "%(asctime)s %(levelname)s [%(camera_name)s] "
119
- "%(module)s %(pathname)s:%(lineno)d %(message)s"
121
+ "%(asctime)s %(levelname)s [%(camera_name)s] %(module)s %(pathname)s:%(lineno)d %(message)s"
120
122
  )
121
123
  console_fmt = os.getenv("CONSOLE_LOG_FORMAT", default_console_fmt)
122
124
 
@@ -160,7 +162,9 @@ def configure_logging(*, log_level: str = "INFO", camera_name: str | None = None
160
162
  from homesec.telemetry.db_log_handler import AsyncPostgresJsonLogHandler
161
163
  except Exception as exc:
162
164
  # Keep the app running even if DB logging deps aren't installed.
163
- logging.getLogger(__name__).warning("DB_DSN is set but DB log handler failed to import: %s", exc)
165
+ logging.getLogger(__name__).warning(
166
+ "DB_DSN is set but DB log handler failed to import: %s", exc
167
+ )
164
168
  return
165
169
 
166
170
  for handler in list(root.handlers):