homesec 1.2.2__py3-none-any.whl → 1.2.3__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 (50) hide show
  1. homesec/app.py +5 -14
  2. homesec/cli.py +5 -4
  3. homesec/config/__init__.py +8 -1
  4. homesec/config/loader.py +17 -2
  5. homesec/config/validation.py +99 -6
  6. homesec/interfaces.py +2 -2
  7. homesec/maintenance/cleanup_clips.py +17 -4
  8. homesec/models/__init__.py +3 -25
  9. homesec/models/clip.py +1 -1
  10. homesec/models/config.py +10 -261
  11. homesec/models/enums.py +8 -0
  12. homesec/models/events.py +1 -1
  13. homesec/models/filter.py +3 -21
  14. homesec/models/vlm.py +11 -20
  15. homesec/pipeline/__init__.py +1 -2
  16. homesec/pipeline/core.py +9 -10
  17. homesec/plugins/alert_policies/__init__.py +5 -5
  18. homesec/plugins/alert_policies/default.py +21 -2
  19. homesec/plugins/analyzers/__init__.py +1 -3
  20. homesec/plugins/analyzers/openai.py +20 -13
  21. homesec/plugins/filters/__init__.py +1 -2
  22. homesec/plugins/filters/yolo.py +25 -5
  23. homesec/plugins/notifiers/__init__.py +1 -6
  24. homesec/plugins/notifiers/mqtt.py +21 -1
  25. homesec/plugins/notifiers/sendgrid_email.py +52 -1
  26. homesec/plugins/registry.py +27 -0
  27. homesec/plugins/sources/__init__.py +4 -4
  28. homesec/plugins/sources/ftp.py +1 -1
  29. homesec/plugins/sources/local_folder.py +1 -1
  30. homesec/plugins/sources/rtsp.py +1 -1
  31. homesec/plugins/storage/__init__.py +1 -9
  32. homesec/plugins/storage/dropbox.py +13 -1
  33. homesec/plugins/storage/local.py +8 -1
  34. homesec/repository/clip_repository.py +1 -1
  35. homesec/sources/__init__.py +3 -6
  36. homesec/sources/ftp.py +95 -2
  37. homesec/sources/local_folder.py +27 -2
  38. homesec/sources/rtsp/core.py +162 -2
  39. {homesec-1.2.2.dist-info → homesec-1.2.3.dist-info}/METADATA +7 -12
  40. homesec-1.2.3.dist-info/RECORD +73 -0
  41. homesec/models/source/__init__.py +0 -3
  42. homesec/models/source/ftp.py +0 -97
  43. homesec/models/source/local_folder.py +0 -30
  44. homesec/models/source/rtsp.py +0 -165
  45. homesec/pipeline/alert_policy.py +0 -5
  46. homesec-1.2.2.dist-info/RECORD +0 -78
  47. /homesec/{plugins/notifiers → notifiers}/multiplex.py +0 -0
  48. {homesec-1.2.2.dist-info → homesec-1.2.3.dist-info}/WHEEL +0 -0
  49. {homesec-1.2.2.dist-info → homesec-1.2.3.dist-info}/entry_points.txt +0 -0
  50. {homesec-1.2.2.dist-info → homesec-1.2.3.dist-info}/licenses/LICENSE +0 -0
homesec/app.py CHANGED
@@ -8,23 +8,15 @@ import signal
8
8
  from pathlib import Path
9
9
  from typing import TYPE_CHECKING
10
10
 
11
- from homesec.config import (
12
- load_config,
13
- resolve_env_var,
14
- validate_camera_references,
15
- validate_plugin_names,
16
- )
11
+ from homesec.config import load_config, resolve_env_var, validate_config, validate_plugin_names
17
12
  from homesec.health import HealthServer
18
13
  from homesec.interfaces import EventStore
14
+ from homesec.notifiers.multiplex import MultiplexNotifier, NotifierEntry
19
15
  from homesec.pipeline import ClipPipeline
20
16
  from homesec.plugins.alert_policies import load_alert_policy
21
17
  from homesec.plugins.analyzers import load_analyzer
22
18
  from homesec.plugins.filters import load_filter
23
- from homesec.plugins.notifiers import (
24
- MultiplexNotifier,
25
- NotifierEntry,
26
- load_notifier_plugin,
27
- )
19
+ from homesec.plugins.notifiers import load_notifier_plugin
28
20
  from homesec.plugins.registry import PluginType, get_plugin_names
29
21
  from homesec.plugins.sources import load_source_plugin
30
22
  from homesec.plugins.storage import load_storage_plugin
@@ -252,7 +244,6 @@ class Application:
252
244
  """Create alert policy using the plugin registry."""
253
245
  return load_alert_policy(
254
246
  config.alert_policy,
255
- per_camera_overrides=config.per_camera_alert,
256
247
  trigger_classes=config.vlm.trigger_classes,
257
248
  )
258
249
 
@@ -263,7 +254,7 @@ class Application:
263
254
  for camera in config.cameras:
264
255
  source_cfg = camera.source
265
256
  source = load_source_plugin(
266
- source_type=source_cfg.type,
257
+ source_backend=source_cfg.backend,
267
258
  config=source_cfg.config,
268
259
  camera_name=camera.name,
269
260
  )
@@ -277,7 +268,6 @@ class Application:
277
268
  return self._config
278
269
 
279
270
  def _validate_config(self, config: Config) -> None:
280
- validate_camera_references(config)
281
271
  validate_plugin_names(
282
272
  config,
283
273
  valid_filters=get_plugin_names(PluginType.FILTER),
@@ -287,6 +277,7 @@ class Application:
287
277
  valid_alert_policies=get_plugin_names(PluginType.ALERT_POLICY),
288
278
  valid_sources=get_plugin_names(PluginType.SOURCE),
289
279
  )
280
+ validate_config(config)
290
281
 
291
282
  def _setup_signal_handlers(self) -> None:
292
283
  """Set up signal handlers for graceful shutdown."""
homesec/cli.py CHANGED
@@ -13,8 +13,8 @@ load_dotenv()
13
13
  import fire # type: ignore[import-untyped]
14
14
 
15
15
  from homesec.app import Application
16
- from homesec.config import ConfigError, load_config
17
- from homesec.config.validation import validate_camera_references, validate_plugin_names
16
+ from homesec.config import ConfigError, load_config, validate_config
17
+ from homesec.config.validation import validate_plugin_names
18
18
  from homesec.logging_setup import configure_logging
19
19
  from homesec.maintenance.cleanup_clips import CleanupOptions, run_cleanup
20
20
  from homesec.plugins.registry import PluginType, get_plugin_names
@@ -66,7 +66,6 @@ class HomeSec:
66
66
  discover_all_plugins()
67
67
 
68
68
  # Additional validation checks
69
- validate_camera_references(cfg)
70
69
  validate_plugin_names(
71
70
  cfg,
72
71
  sorted(get_plugin_names(PluginType.FILTER)),
@@ -74,7 +73,9 @@ class HomeSec:
74
73
  valid_storage=sorted(get_plugin_names(PluginType.STORAGE)),
75
74
  valid_notifiers=sorted(get_plugin_names(PluginType.NOTIFIER)),
76
75
  valid_alert_policies=sorted(get_plugin_names(PluginType.ALERT_POLICY)),
76
+ valid_sources=sorted(get_plugin_names(PluginType.SOURCE)),
77
77
  )
78
+ validate_config(cfg)
78
79
 
79
80
  print(f"✓ Config valid: {config_path}")
80
81
  camera_names = [camera.name for camera in cfg.cameras]
@@ -84,7 +85,7 @@ class HomeSec:
84
85
  ]
85
86
  print(f" Storage backend: {cfg.storage.backend}")
86
87
  print(f" Notifiers: {notifier_backends}")
87
- print(f" Filter plugin: {cfg.filter.plugin}")
88
+ print(f" Filter backend: {cfg.filter.backend}")
88
89
  print(f" VLM backend: {cfg.vlm.backend}")
89
90
  print(f" VLM trigger classes: {cfg.vlm.trigger_classes}")
90
91
  print(f" Alert policy backend: {cfg.alert_policy.backend}")
@@ -6,7 +6,12 @@ from homesec.config.loader import (
6
6
  load_config_from_dict,
7
7
  resolve_env_var,
8
8
  )
9
- from homesec.config.validation import validate_camera_references, validate_plugin_names
9
+ from homesec.config.validation import (
10
+ validate_camera_references,
11
+ validate_config,
12
+ validate_plugin_configs,
13
+ validate_plugin_names,
14
+ )
10
15
 
11
16
  __all__ = [
12
17
  "ConfigError",
@@ -14,5 +19,7 @@ __all__ = [
14
19
  "load_config_from_dict",
15
20
  "resolve_env_var",
16
21
  "validate_camera_references",
22
+ "validate_config",
23
+ "validate_plugin_configs",
17
24
  "validate_plugin_names",
18
25
  ]
homesec/config/loader.py CHANGED
@@ -48,10 +48,18 @@ def load_config(path: Path) -> Config:
48
48
  raise ConfigError(f"Config must be a YAML mapping, got {type(raw).__name__}", path=path)
49
49
 
50
50
  try:
51
- return Config.model_validate(raw)
51
+ config = Config.model_validate(raw)
52
52
  except ValidationError as e:
53
53
  raise ConfigError(format_validation_error(e, path), path=path) from e
54
54
 
55
+ # Discover plugins and validate plugin-specific config
56
+ from homesec.config.validation import validate_config
57
+ from homesec.plugins import discover_all_plugins
58
+
59
+ discover_all_plugins()
60
+ validate_config(config)
61
+ return config
62
+
55
63
 
56
64
  def load_config_from_dict(data: dict[str, Any]) -> Config:
57
65
  """Load and validate configuration from a dict (useful for testing).
@@ -66,10 +74,17 @@ def load_config_from_dict(data: dict[str, Any]) -> Config:
66
74
  ConfigError: If validation fails
67
75
  """
68
76
  try:
69
- return Config.model_validate(data)
77
+ config = Config.model_validate(data)
70
78
  except ValidationError as e:
71
79
  raise ConfigError(format_validation_error(e, path=None)) from e
72
80
 
81
+ from homesec.config.validation import validate_config
82
+ from homesec.plugins import discover_all_plugins
83
+
84
+ discover_all_plugins()
85
+ validate_config(config)
86
+ return config
87
+
73
88
 
74
89
  def resolve_env_var(env_var_name: str, required: bool = True) -> str | None:
75
90
  """Resolve environment variable by name.
@@ -2,8 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from pydantic import BaseModel, ValidationError
6
+
5
7
  from homesec.config.loader import ConfigError
6
8
  from homesec.models.config import Config
9
+ from homesec.plugins.registry import PluginType, validate_plugin
7
10
 
8
11
 
9
12
  def validate_camera_references(config: Config, camera_names: list[str] | None = None) -> None:
@@ -22,9 +25,17 @@ def validate_camera_references(config: Config, camera_names: list[str] | None =
22
25
  camera_set = set(camera_names)
23
26
  errors = []
24
27
 
25
- for camera in config.per_camera_alert:
28
+ overrides: dict[str, object] = {}
29
+ if config.alert_policy.backend == "default":
30
+ raw = config.alert_policy.config
31
+ if isinstance(raw, BaseModel):
32
+ overrides = getattr(raw, "overrides", {}) or {}
33
+ elif isinstance(raw, dict):
34
+ overrides = raw.get("overrides", {}) or {}
35
+
36
+ for camera in overrides:
26
37
  if camera not in camera_set:
27
- errors.append(f"per_camera_alert references unknown camera: {camera}")
38
+ errors.append(f"alert_policy.overrides references unknown camera: {camera}")
28
39
 
29
40
  if errors:
30
41
  raise ConfigError("Invalid camera references:\n " + "\n ".join(errors))
@@ -56,9 +67,9 @@ def validate_plugin_names(
56
67
  errors = []
57
68
 
58
69
  valid_filters_lower = {name.lower() for name in valid_filters}
59
- if config.filter.plugin.lower() not in valid_filters_lower:
70
+ if config.filter.backend.lower() not in valid_filters_lower:
60
71
  errors.append(
61
- f"Unknown filter plugin: {config.filter.plugin} (valid: {sorted(valid_filters_lower)})"
72
+ f"Unknown filter plugin: {config.filter.backend} (valid: {sorted(valid_filters_lower)})"
62
73
  )
63
74
 
64
75
  valid_vlms_lower = {name.lower() for name in valid_vlms}
@@ -95,11 +106,93 @@ def validate_plugin_names(
95
106
  if valid_sources is not None:
96
107
  valid_sources_lower = {name.lower() for name in valid_sources}
97
108
  for camera in config.cameras:
98
- if camera.source.type.lower() not in valid_sources_lower:
109
+ if camera.source.backend.lower() not in valid_sources_lower:
99
110
  errors.append(
100
111
  f"Unknown source type for camera '{camera.name}': "
101
- f"{camera.source.type} (valid: {sorted(valid_sources_lower)})"
112
+ f"{camera.source.backend} (valid: {sorted(valid_sources_lower)})"
102
113
  )
103
114
 
104
115
  if errors:
105
116
  raise ConfigError("Invalid plugin configuration:\n " + "\n ".join(errors))
117
+
118
+
119
+ def validate_plugin_configs(config: Config) -> None:
120
+ """Validate plugin configs against registered plugin config models."""
121
+ errors: list[str] = []
122
+
123
+ def _add_error(prefix: str, err: Exception) -> None:
124
+ if isinstance(err, ValidationError):
125
+ errors.append(f"{prefix}: {err}")
126
+ else:
127
+ errors.append(f"{prefix}: {err}")
128
+
129
+ try:
130
+ validate_plugin(
131
+ PluginType.STORAGE,
132
+ config.storage.backend,
133
+ config.storage.config,
134
+ )
135
+ except Exception as exc:
136
+ _add_error(f"storage[{config.storage.backend}]", exc)
137
+
138
+ try:
139
+ validate_plugin(
140
+ PluginType.FILTER,
141
+ config.filter.backend,
142
+ config.filter.config,
143
+ )
144
+ except Exception as exc:
145
+ _add_error(f"filter[{config.filter.backend}]", exc)
146
+
147
+ try:
148
+ validate_plugin(
149
+ PluginType.ANALYZER,
150
+ config.vlm.backend,
151
+ config.vlm.config,
152
+ )
153
+ except Exception as exc:
154
+ _add_error(f"vlm[{config.vlm.backend}]", exc)
155
+
156
+ if config.alert_policy.enabled:
157
+ try:
158
+ runtime_context = {}
159
+ if config.alert_policy.backend == "default":
160
+ runtime_context["trigger_classes"] = list(config.vlm.trigger_classes)
161
+ validate_plugin(
162
+ PluginType.ALERT_POLICY,
163
+ config.alert_policy.backend,
164
+ config.alert_policy.config,
165
+ **runtime_context,
166
+ )
167
+ except Exception as exc:
168
+ _add_error(f"alert_policy[{config.alert_policy.backend}]", exc)
169
+
170
+ for index, notifier in enumerate(config.notifiers):
171
+ try:
172
+ validate_plugin(
173
+ PluginType.NOTIFIER,
174
+ notifier.backend,
175
+ notifier.config,
176
+ )
177
+ except Exception as exc:
178
+ _add_error(f"notifier[{index}:{notifier.backend}]", exc)
179
+
180
+ for camera in config.cameras:
181
+ try:
182
+ validate_plugin(
183
+ PluginType.SOURCE,
184
+ camera.source.backend,
185
+ camera.source.config,
186
+ camera_name=camera.name,
187
+ )
188
+ except Exception as exc:
189
+ _add_error(f"source[{camera.name}:{camera.source.backend}]", exc)
190
+
191
+ if errors:
192
+ raise ConfigError("Invalid plugin config:\n " + "\n ".join(errors))
193
+
194
+
195
+ def validate_config(config: Config, camera_names: list[str] | None = None) -> None:
196
+ """Validate config boundaries and plugin configs."""
197
+ validate_camera_references(config, camera_names)
198
+ validate_plugin_configs(config)
homesec/interfaces.py CHANGED
@@ -248,7 +248,7 @@ class ObjectFilter(Shutdownable, ABC):
248
248
  - MUST be async (use asyncio.to_thread or run_in_executor for blocking code)
249
249
  - CPU/GPU-bound plugins should manage their own ProcessPoolExecutor internally
250
250
  - I/O-bound plugins can use async HTTP clients directly
251
- - Should respect the instance config max_workers if managing worker pool
251
+ - If managing a worker pool, use concurrency settings from the plugin's config model
252
252
  - Should support early exit on first detection for efficiency
253
253
  - overrides apply per-call (model path cannot be overridden)
254
254
 
@@ -282,7 +282,7 @@ class VLMAnalyzer(Shutdownable, ABC):
282
282
  - MUST be async (use asyncio.to_thread or run_in_executor for blocking code)
283
283
  - Local models: manage ProcessPoolExecutor internally
284
284
  - API-based: use async HTTP clients (aiohttp, httpx)
285
- - Should respect config.max_workers if managing worker pool
285
+ - If managing a worker pool, use concurrency settings from the plugin's config model
286
286
  - Should use filter_result to focus analysis (e.g., detected person at timestamp X)
287
287
 
288
288
  Returns:
@@ -19,9 +19,10 @@ from pydantic import BaseModel, Field
19
19
  from homesec.config import load_config, resolve_env_var
20
20
  from homesec.interfaces import ObjectFilter, StorageBackend
21
21
  from homesec.models.clip import ClipStateData
22
- from homesec.models.filter import FilterConfig, YoloFilterSettings
22
+ from homesec.models.filter import FilterConfig
23
23
  from homesec.plugins import discover_all_plugins
24
24
  from homesec.plugins.filters import load_filter
25
+ from homesec.plugins.filters.yolo import YoloFilterConfig
25
26
  from homesec.plugins.storage import load_storage_plugin
26
27
  from homesec.repository.clip_repository import ClipRepository
27
28
  from homesec.state.postgres import PostgresStateStore
@@ -115,7 +116,17 @@ def _base_payload(
115
116
 
116
117
  def _recheck_settings(config: FilterConfig) -> dict[str, object]:
117
118
  match config.config:
118
- case YoloFilterSettings() as settings:
119
+ case YoloFilterConfig() as settings:
120
+ return {
121
+ "model_path": str(settings.model_path),
122
+ "min_confidence": float(settings.min_confidence),
123
+ "sample_fps": int(settings.sample_fps),
124
+ "min_box_h_ratio": float(settings.min_box_h_ratio),
125
+ "min_hits": int(settings.min_hits),
126
+ "classes": list(settings.classes),
127
+ }
128
+ case dict() as raw:
129
+ settings = YoloFilterConfig.model_validate(raw)
119
130
  return {
120
131
  "model_path": str(settings.model_path),
121
132
  "min_confidence": float(settings.min_confidence),
@@ -130,8 +141,10 @@ def _recheck_settings(config: FilterConfig) -> dict[str, object]:
130
141
 
131
142
  def _build_recheck_filter_config(base: FilterConfig, opts: CleanupOptions) -> FilterConfig:
132
143
  match base.config:
133
- case YoloFilterSettings() as yolo:
144
+ case YoloFilterConfig() as yolo:
134
145
  settings = yolo.model_copy(deep=True)
146
+ case dict() as raw:
147
+ settings = YoloFilterConfig.model_validate(raw)
135
148
  case _:
136
149
  raise ValueError(f"Unsupported filter config type: {type(base.config).__name__}")
137
150
 
@@ -144,9 +157,9 @@ def _build_recheck_filter_config(base: FilterConfig, opts: CleanupOptions) -> Fi
144
157
  settings.min_box_h_ratio = opts.recheck_min_box_h_ratio
145
158
  if opts.recheck_min_hits is not None:
146
159
  settings.min_hits = opts.recheck_min_hits
160
+ settings.max_workers = int(opts.workers)
147
161
 
148
162
  merged = base.model_copy(deep=True)
149
- merged.max_workers = int(opts.workers)
150
163
  merged.config = settings
151
164
  return merged
152
165
 
@@ -4,34 +4,23 @@ from homesec.models.alert import Alert, AlertDecision
4
4
  from homesec.models.clip import Clip, ClipStateData, _resolve_forward_refs
5
5
  from homesec.models.config import (
6
6
  AlertPolicyConfig,
7
- AlertPolicyOverrides,
8
7
  CameraConfig,
9
8
  CameraSourceConfig,
10
9
  ConcurrencyConfig,
11
10
  Config,
12
- DefaultAlertPolicySettings,
13
- DropboxStorageConfig,
14
11
  HealthConfig,
15
- LocalStorageConfig,
16
- MQTTAuthConfig,
17
- MQTTConfig,
18
12
  NotifierConfig,
19
13
  RetentionConfig,
20
14
  RetryConfig,
21
- SendGridEmailConfig,
22
15
  StateStoreConfig,
23
16
  StorageConfig,
24
17
  StoragePathsConfig,
25
18
  )
26
- from homesec.models.enums import RiskLevel, RiskLevelField
27
- from homesec.models.filter import FilterConfig, FilterOverrides, FilterResult, YoloFilterSettings
28
- from homesec.models.source.ftp import FtpSourceConfig
29
- from homesec.models.source.local_folder import LocalFolderSourceConfig
30
- from homesec.models.source.rtsp import RTSPSourceConfig
19
+ from homesec.models.enums import RiskLevel, RiskLevelField, VLMRunMode
20
+ from homesec.models.filter import FilterConfig, FilterOverrides, FilterResult
31
21
  from homesec.models.vlm import (
32
22
  AnalysisResult,
33
23
  EntityTimeline,
34
- OpenAILLMConfig,
35
24
  SequenceAnalysis,
36
25
  VLMConfig,
37
26
  VLMPreprocessConfig,
@@ -44,7 +33,6 @@ __all__ = [
44
33
  "Alert",
45
34
  "AlertDecision",
46
35
  "AlertPolicyConfig",
47
- "AlertPolicyOverrides",
48
36
  "AnalysisResult",
49
37
  "CameraConfig",
50
38
  "CameraSourceConfig",
@@ -52,31 +40,21 @@ __all__ = [
52
40
  "ClipStateData",
53
41
  "ConcurrencyConfig",
54
42
  "Config",
55
- "DefaultAlertPolicySettings",
56
- "DropboxStorageConfig",
57
43
  "EntityTimeline",
58
44
  "FilterConfig",
59
45
  "FilterOverrides",
60
46
  "FilterResult",
61
- "FtpSourceConfig",
62
47
  "HealthConfig",
63
- "LocalFolderSourceConfig",
64
- "LocalStorageConfig",
65
- "MQTTAuthConfig",
66
- "MQTTConfig",
67
48
  "NotifierConfig",
68
- "OpenAILLMConfig",
69
- "RTSPSourceConfig",
70
49
  "RetentionConfig",
71
50
  "RetryConfig",
72
51
  "RiskLevel",
73
52
  "RiskLevelField",
74
- "SendGridEmailConfig",
75
53
  "SequenceAnalysis",
76
54
  "StateStoreConfig",
77
55
  "StorageConfig",
78
56
  "StoragePathsConfig",
79
57
  "VLMConfig",
80
58
  "VLMPreprocessConfig",
81
- "YoloFilterSettings",
59
+ "VLMRunMode",
82
60
  ]
homesec/models/clip.py CHANGED
@@ -25,7 +25,7 @@ class Clip(BaseModel):
25
25
  start_ts: datetime
26
26
  end_ts: datetime
27
27
  duration_s: float
28
- source_type: str # "rtsp", "ftp", etc.
28
+ source_backend: str # "rtsp", "ftp", etc.
29
29
 
30
30
 
31
31
  class ClipStateData(BaseModel):