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
@@ -8,7 +8,7 @@ import json
8
8
  import logging
9
9
  import os
10
10
  from pathlib import Path
11
- from typing import Any
11
+ from typing import Any, Literal
12
12
 
13
13
  aiohttp: Any
14
14
  cv2: Any
@@ -31,13 +31,7 @@ from pydantic import BaseModel
31
31
 
32
32
  from homesec.interfaces import VLMAnalyzer
33
33
  from homesec.models.filter import FilterResult
34
- from homesec.models.vlm import (
35
- AnalysisResult,
36
- OpenAILLMConfig,
37
- SequenceAnalysis,
38
- VLMConfig,
39
- VLMPreprocessConfig,
40
- )
34
+ from homesec.models.vlm import AnalysisResult, SequenceAnalysis, VLMConfig, VLMPreprocessConfig
41
35
  from homesec.plugins.registry import PluginType, plugin
42
36
 
43
37
  logger = logging.getLogger(__name__)
@@ -61,6 +55,20 @@ Focus on KEY EVENTS ONLY:
61
55
  Keep observations list concise (short bullet points of security-relevant actions)."""
62
56
 
63
57
 
58
+ class OpenAIConfig(BaseModel):
59
+ """OpenAI-compatible LLM configuration."""
60
+
61
+ model_config = {"extra": "forbid"}
62
+ api_key_env: str
63
+ model: str
64
+ base_url: str = "https://api.openai.com/v1"
65
+ token_param: Literal["max_tokens", "max_completion_tokens"] = "max_completion_tokens"
66
+ max_completion_tokens: int = 10_000
67
+ max_tokens: int | None = None
68
+ temperature: float | None = 0.0
69
+ request_timeout: float = 60.0
70
+
71
+
64
72
  def _ensure_openai_dependencies() -> None:
65
73
  """Fail fast with a clear error if OpenAI VLM dependencies are missing."""
66
74
  if aiohttp is None or cv2 is None or Image is None:
@@ -92,18 +100,17 @@ class OpenAIVLM(VLMAnalyzer):
92
100
  Supports structured output with Pydantic schemas.
93
101
  """
94
102
 
95
- config_cls = OpenAILLMConfig
103
+ config_cls = OpenAIConfig
96
104
 
97
105
  @classmethod
98
- def create(cls, config: OpenAILLMConfig) -> VLMAnalyzer:
106
+ def create(cls, config: OpenAIConfig) -> VLMAnalyzer:
99
107
  return cls(config)
100
108
 
101
- def __init__(self, llm_config: OpenAILLMConfig) -> None:
109
+ def __init__(self, llm_config: OpenAIConfig) -> None:
102
110
  """Initialize OpenAI VLM with validated LLM config.
103
111
 
104
112
  Args:
105
113
  llm_config: OpenAI-specific configuration (API key, model, etc.)
106
- Also assumes injected runtime fields (trigger_classes, max_workers)
107
114
  """
108
115
  _ensure_openai_dependencies()
109
116
  self._config = llm_config
@@ -335,7 +342,7 @@ class OpenAIVLM(VLMAnalyzer):
335
342
  f"VLM response does not match SequenceAnalysis schema: {e}. Raw response: {content}"
336
343
  ) from e
337
344
 
338
- def _resolve_token_limit(self, llm: OpenAILLMConfig) -> int:
345
+ def _resolve_token_limit(self, llm: OpenAIConfig) -> int:
339
346
  if self.token_param == "max_completion_tokens":
340
347
  value = llm.max_completion_tokens or llm.max_tokens or 1000
341
348
  else:
@@ -29,9 +29,8 @@ def load_filter(config: FilterConfig) -> ObjectFilter:
29
29
  ObjectFilter,
30
30
  load_plugin(
31
31
  PluginType.FILTER,
32
- config.plugin,
32
+ config.backend,
33
33
  config.config,
34
- max_workers=config.max_workers,
35
34
  ),
36
35
  )
37
36
 
@@ -27,12 +27,32 @@ else:
27
27
  torch = _torch
28
28
  YOLO_CLASS = _YOLO
29
29
 
30
+ from pydantic import BaseModel, Field
31
+
30
32
  from homesec.interfaces import ObjectFilter
31
- from homesec.models.filter import FilterOverrides, FilterResult, YoloFilterSettings
33
+ from homesec.models.filter import FilterOverrides, FilterResult
32
34
  from homesec.plugins.registry import PluginType, plugin
33
35
 
34
36
  logger = logging.getLogger(__name__)
35
37
 
38
+
39
+ class YoloFilterConfig(BaseModel):
40
+ """YOLO filter settings.
41
+
42
+ model_path accepts a filename; bare names resolve under ./yolo_cache.
43
+ """
44
+
45
+ model_config = {"extra": "forbid"}
46
+
47
+ model_path: str = "yolo11n.pt"
48
+ classes: list[str] = Field(default_factory=lambda: ["person"], min_length=1)
49
+ min_confidence: float = Field(default=0.5, ge=0.0, le=1.0)
50
+ sample_fps: int = Field(default=2, ge=1)
51
+ min_box_h_ratio: float = Field(default=0.1, ge=0.0, le=1.0)
52
+ min_hits: int = Field(default=1, ge=1)
53
+ max_workers: int = Field(default=4, ge=1)
54
+
55
+
36
56
  # COCO classes for humans and animals
37
57
  HUMAN_ANIMAL_CLASSES = {
38
58
  0: "person",
@@ -159,13 +179,13 @@ class YOLOFilter(ObjectFilter):
159
179
  Bare model filenames resolve under ./yolo_cache and auto-download if missing.
160
180
  """
161
181
 
162
- config_cls = YoloFilterSettings
182
+ config_cls = YoloFilterConfig
163
183
 
164
184
  @classmethod
165
- def create(cls, config: YoloFilterSettings) -> ObjectFilter:
185
+ def create(cls, config: YoloFilterConfig) -> ObjectFilter:
166
186
  return cls(config)
167
187
 
168
- def __init__(self, settings: YoloFilterSettings) -> None:
188
+ def __init__(self, settings: YoloFilterConfig) -> None:
169
189
  """Initialize YOLO filter with validated settings.
170
190
 
171
191
  Args:
@@ -243,7 +263,7 @@ class YOLOFilter(ObjectFilter):
243
263
  # Executor is considered healthy if not shut down
244
264
  return True
245
265
 
246
- def _apply_overrides(self, overrides: FilterOverrides | None) -> YoloFilterSettings:
266
+ def _apply_overrides(self, overrides: FilterOverrides | None) -> YoloFilterConfig:
247
267
  if overrides is None:
248
268
  return self._settings
249
269
  update = overrides.model_dump(exclude_none=True)
@@ -8,7 +8,6 @@ from typing import cast
8
8
  from pydantic import BaseModel
9
9
 
10
10
  from homesec.interfaces import Notifier
11
- from homesec.plugins.notifiers.multiplex import MultiplexNotifier, NotifierEntry
12
11
  from homesec.plugins.registry import PluginType, load_plugin
13
12
 
14
13
  logger = logging.getLogger(__name__)
@@ -38,8 +37,4 @@ def load_notifier_plugin(backend: str, config: dict[str, object] | BaseModel) ->
38
37
  )
39
38
 
40
39
 
41
- __all__ = [
42
- "MultiplexNotifier",
43
- "NotifierEntry",
44
- "load_notifier_plugin",
45
- ]
40
+ __all__ = ["load_notifier_plugin"]
@@ -17,14 +17,34 @@ except Exception:
17
17
  else:
18
18
  mqtt = _mqtt
19
19
 
20
+ from pydantic import BaseModel
21
+
20
22
  from homesec.interfaces import Notifier
21
23
  from homesec.models.alert import Alert
22
- from homesec.models.config import MQTTConfig
23
24
  from homesec.plugins.registry import PluginType, plugin
24
25
 
25
26
  logger = logging.getLogger(__name__)
26
27
 
27
28
 
29
+ class MQTTAuthConfig(BaseModel):
30
+ """MQTT auth configuration using env var names."""
31
+
32
+ username_env: str | None = None
33
+ password_env: str | None = None
34
+
35
+
36
+ class MQTTConfig(BaseModel):
37
+ """MQTT notifier configuration."""
38
+
39
+ host: str
40
+ port: int = 1883
41
+ auth: MQTTAuthConfig | None = None
42
+ topic_template: str = "homecam/alerts/{camera_name}"
43
+ qos: int = 1
44
+ retain: bool = False
45
+ connection_timeout: float = 10.0
46
+
47
+
28
48
  @plugin(plugin_type=PluginType.NOTIFIER, name="mqtt")
29
49
  class MQTTNotifier(Notifier):
30
50
  """MQTT notifier for Home Assistant alerts.
@@ -19,15 +19,66 @@ else:
19
19
  aiohttp = _aiohttp
20
20
 
21
21
 
22
+ from pydantic import BaseModel, Field, model_validator
23
+
22
24
  from homesec.interfaces import Notifier
23
25
  from homesec.models.alert import Alert
24
- from homesec.models.config import SendGridEmailConfig
25
26
  from homesec.models.vlm import SequenceAnalysis
26
27
  from homesec.plugins.registry import PluginType, plugin
27
28
 
28
29
  logger = logging.getLogger(__name__)
29
30
 
30
31
 
32
+ class SendGridEmailConfig(BaseModel):
33
+ """SendGrid email notifier configuration."""
34
+
35
+ api_key_env: str = "SENDGRID_API_KEY"
36
+ from_email: str
37
+ from_name: str | None = None
38
+ to_emails: list[str] = Field(min_length=1)
39
+ cc_emails: list[str] = Field(default_factory=list)
40
+ bcc_emails: list[str] = Field(default_factory=list)
41
+ subject_template: str = "[HomeSec] {camera_name}: {activity_type} ({risk_level})"
42
+ text_template: str = (
43
+ "HomeSec alert\n"
44
+ "Camera: {camera_name}\n"
45
+ "Clip: {clip_id}\n"
46
+ "Risk: {risk_level}\n"
47
+ "Activity: {activity_type}\n"
48
+ "Reason: {notify_reason}\n"
49
+ "Summary: {summary}\n"
50
+ "View: {view_url}\n"
51
+ "Storage: {storage_uri}\n"
52
+ "Time: {ts}\n"
53
+ "Upload failed: {upload_failed}\n"
54
+ )
55
+ html_template: str = (
56
+ "<html><body>"
57
+ "<h2>HomeSec alert</h2>"
58
+ "<p><strong>Camera:</strong> {camera_name}</p>"
59
+ "<p><strong>Clip:</strong> {clip_id}</p>"
60
+ "<p><strong>Risk:</strong> {risk_level}</p>"
61
+ "<p><strong>Activity:</strong> {activity_type}</p>"
62
+ "<p><strong>Reason:</strong> {notify_reason}</p>"
63
+ "<p><strong>Summary:</strong> {summary}</p>"
64
+ '<p><strong>View:</strong> <a href="{view_url}">{view_url}</a></p>'
65
+ "<p><strong>Storage:</strong> {storage_uri}</p>"
66
+ "<p><strong>Time:</strong> {ts}</p>"
67
+ "<p><strong>Upload failed:</strong> {upload_failed}</p>"
68
+ "<h3>Structured analysis</h3>"
69
+ "{analysis_html}"
70
+ "</body></html>"
71
+ )
72
+ request_timeout_s: float = 10.0
73
+ api_base: str = "https://api.sendgrid.com/v3"
74
+
75
+ @model_validator(mode="after")
76
+ def _validate_templates(self) -> SendGridEmailConfig:
77
+ if not self.text_template and not self.html_template:
78
+ raise ValueError("sendgrid_email requires at least one of text_template/html_template")
79
+ return self
80
+
81
+
31
82
  def _ensure_sendgrid_dependencies() -> None:
32
83
  """Fail fast with a clear error if SendGrid dependencies are missing."""
33
84
  if aiohttp is None:
@@ -95,6 +95,19 @@ class PluginRegistry(Generic[ConfigT, PluginInterfaceT]):
95
95
  # 3. Create instance
96
96
  return plugin_cls.create(validated_config)
97
97
 
98
+ def validate(self, name: str, config_dict: dict[str, Any], **runtime_context: Any) -> BaseModel:
99
+ """Validate configuration for a plugin without instantiating it."""
100
+ if name not in self._plugins:
101
+ available = ", ".join(sorted(self._plugins.keys()))
102
+ raise ValueError(f"Unknown {self.plugin_type} plugin: '{name}'. Available: {available}")
103
+
104
+ plugin_cls = self._plugins[name]
105
+
106
+ merged_config = config_dict.copy()
107
+ merged_config.update(runtime_context)
108
+
109
+ return plugin_cls.config_cls.model_validate(merged_config)
110
+
98
111
  def get_all(self) -> dict[str, type[PluginProtocol[ConfigT, PluginInterfaceT]]]:
99
112
  """Return all registered plugins."""
100
113
  return self._plugins.copy()
@@ -155,6 +168,20 @@ def load_plugin(
155
168
  return registry.load(name, config_dict, **runtime_context)
156
169
 
157
170
 
171
+ def validate_plugin(
172
+ plugin_type: PluginType, name: str, config: dict[str, Any] | BaseModel, **runtime_context: Any
173
+ ) -> BaseModel:
174
+ """Validate plugin configuration without instantiating it."""
175
+ registry = _REGISTRIES[plugin_type]
176
+
177
+ if isinstance(config, BaseModel):
178
+ config_dict = config.model_dump()
179
+ else:
180
+ config_dict = config
181
+
182
+ return registry.validate(name, config_dict, **runtime_context)
183
+
184
+
158
185
  def get_plugin_names(plugin_type: PluginType) -> list[str]:
159
186
  """Get list of registered plugin names for a given type."""
160
187
  return sorted(_REGISTRIES[plugin_type].get_all().keys())
@@ -14,14 +14,14 @@ logger = logging.getLogger(__name__)
14
14
 
15
15
 
16
16
  def load_source_plugin(
17
- source_type: str,
17
+ source_backend: str,
18
18
  config: dict[str, object] | BaseModel,
19
19
  camera_name: str,
20
20
  ) -> ClipSource:
21
21
  """Load and instantiate a source plugin.
22
22
 
23
23
  Args:
24
- source_type: Name of the source plugin (e.g., "rtsp", "local_folder")
24
+ source_backend: Name of the source plugin (e.g., "rtsp", "local_folder")
25
25
  config: Raw config dict or validated BaseModel
26
26
  camera_name: Name of the camera (runtime context)
27
27
 
@@ -29,13 +29,13 @@ def load_source_plugin(
29
29
  Instantiated ClipSource
30
30
 
31
31
  Raises:
32
- ValueError: If source_type is unknown or config validation fails
32
+ ValueError: If source_backend is unknown or config validation fails
33
33
  """
34
34
  return cast(
35
35
  ClipSource,
36
36
  load_plugin(
37
37
  PluginType.SOURCE,
38
- source_type,
38
+ source_backend,
39
39
  config,
40
40
  camera_name=camera_name,
41
41
  ),
@@ -3,11 +3,11 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from homesec.interfaces import ClipSource
6
- from homesec.models.source.ftp import FtpSourceConfig
7
6
  from homesec.plugins.registry import PluginType, plugin
8
7
 
9
8
  # Import the actual implementation from sources module
10
9
  from homesec.sources.ftp import FtpSource as FtpSourceImpl
10
+ from homesec.sources.ftp import FtpSourceConfig
11
11
 
12
12
 
13
13
  @plugin(plugin_type=PluginType.SOURCE, name="ftp")
@@ -3,9 +3,9 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from homesec.interfaces import ClipSource
6
- from homesec.models.source.local_folder import LocalFolderSourceConfig
7
6
  from homesec.plugins.registry import PluginType, plugin
8
7
  from homesec.sources.local_folder import LocalFolderSource as LocalFolderSourceImpl
8
+ from homesec.sources.local_folder import LocalFolderSourceConfig
9
9
 
10
10
 
11
11
  @plugin(plugin_type=PluginType.SOURCE, name="local_folder")
@@ -5,9 +5,9 @@ from __future__ import annotations
5
5
  from typing import TYPE_CHECKING
6
6
 
7
7
  from homesec.interfaces import ClipSource
8
- from homesec.models.source.rtsp import RTSPSourceConfig
9
8
  from homesec.plugins.registry import PluginType, plugin
10
9
  from homesec.sources.rtsp.core import RTSPSource as RTSPSourceImpl
10
+ from homesec.sources.rtsp.core import RTSPSourceConfig
11
11
 
12
12
  if TYPE_CHECKING:
13
13
  pass
@@ -24,20 +24,12 @@ def load_storage_plugin(config: StorageConfig) -> StorageBackend:
24
24
  Raises:
25
25
  ValueError: If backend is unknown or backend-specific config is missing
26
26
  """
27
- # Extract backend-specific config using attribute access
28
- specific_config = getattr(config, config.backend.lower(), None)
29
- if specific_config is None:
30
- raise ValueError(
31
- f"Missing '{config.backend.lower()}' config in storage section. "
32
- f"Add 'storage.{config.backend.lower()}' to your config."
33
- )
34
-
35
27
  return cast(
36
28
  StorageBackend,
37
29
  load_plugin(
38
30
  PluginType.STORAGE,
39
31
  config.backend,
40
- specific_config,
32
+ config.config,
41
33
  ),
42
34
  )
43
35
 
@@ -18,8 +18,9 @@ else:
18
18
  dropbox = _dropbox
19
19
 
20
20
 
21
+ from pydantic import BaseModel
22
+
21
23
  from homesec.interfaces import StorageBackend
22
- from homesec.models.config import DropboxStorageConfig
23
24
  from homesec.models.storage import StorageUploadResult
24
25
  from homesec.plugins.registry import PluginType, plugin
25
26
 
@@ -28,6 +29,17 @@ logger = logging.getLogger(__name__)
28
29
  CHUNK_SIZE = 4 * 1024 * 1024
29
30
 
30
31
 
32
+ class DropboxStorageConfig(BaseModel):
33
+ """Dropbox storage configuration."""
34
+
35
+ root: str
36
+ token_env: str = "DROPBOX_TOKEN"
37
+ app_key_env: str = "DROPBOX_APP_KEY"
38
+ app_secret_env: str = "DROPBOX_APP_SECRET"
39
+ refresh_token_env: str = "DROPBOX_REFRESH_TOKEN"
40
+ web_url_prefix: str = "https://www.dropbox.com/home"
41
+
42
+
31
43
  @plugin(plugin_type=PluginType.STORAGE, name="dropbox")
32
44
  class DropboxStorage(StorageBackend):
33
45
  """Dropbox storage backend.
@@ -7,14 +7,21 @@ import logging
7
7
  import shutil
8
8
  from pathlib import Path, PurePosixPath
9
9
 
10
+ from pydantic import BaseModel
11
+
10
12
  from homesec.interfaces import StorageBackend
11
- from homesec.models.config import LocalStorageConfig
12
13
  from homesec.models.storage import StorageUploadResult
13
14
  from homesec.plugins.registry import PluginType, plugin
14
15
 
15
16
  logger = logging.getLogger(__name__)
16
17
 
17
18
 
19
+ class LocalStorageConfig(BaseModel):
20
+ """Local storage configuration."""
21
+
22
+ root: str = "./storage"
23
+
24
+
18
25
  @plugin(plugin_type=PluginType.STORAGE, name="local")
19
26
  class LocalStorage(StorageBackend):
20
27
  """Local storage backend for development and tests."""
@@ -73,7 +73,7 @@ class ClipRepository:
73
73
  timestamp=datetime.now(),
74
74
  camera_name=clip.camera_name,
75
75
  duration_s=clip.duration_s,
76
- source_type=clip.source_type,
76
+ source_backend=clip.source_backend,
77
77
  )
78
78
 
79
79
  await self._safe_upsert(clip.clip_id, state)
@@ -1,12 +1,9 @@
1
1
  """Clip source implementations."""
2
2
 
3
- from homesec.models.source.ftp import FtpSourceConfig
4
- from homesec.models.source.local_folder import LocalFolderSourceConfig
5
- from homesec.models.source.rtsp import RTSPSourceConfig
6
3
  from homesec.sources.base import ThreadedClipSource
7
- from homesec.sources.ftp import FtpSource
8
- from homesec.sources.local_folder import LocalFolderSource
9
- from homesec.sources.rtsp.core import RTSPSource
4
+ from homesec.sources.ftp import FtpSource, FtpSourceConfig
5
+ from homesec.sources.local_folder import LocalFolderSource, LocalFolderSourceConfig
6
+ from homesec.sources.rtsp.core import RTSPSource, RTSPSourceConfig
10
7
 
11
8
  __all__ = [
12
9
  "FtpSource",
homesec/sources/ftp.py CHANGED
@@ -9,13 +9,106 @@ from pathlib import Path
9
9
  from threading import Thread
10
10
  from typing import Any
11
11
 
12
+ from pydantic import BaseModel, Field, field_validator
13
+
12
14
  from homesec.models.clip import Clip
13
- from homesec.models.source.ftp import FtpSourceConfig
14
15
  from homesec.sources.base import ThreadedClipSource
15
16
 
16
17
  logger = logging.getLogger(__name__)
17
18
 
18
19
 
20
+ class FtpSourceConfig(BaseModel):
21
+ """FTP source configuration."""
22
+
23
+ model_config = {"extra": "forbid"}
24
+
25
+ camera_name: str | None = Field(
26
+ default=None,
27
+ description="Optional human-friendly camera name.",
28
+ )
29
+ host: str = Field(
30
+ default="0.0.0.0",
31
+ description="FTP bind address.",
32
+ )
33
+ port: int = Field(
34
+ default=2121,
35
+ ge=0,
36
+ le=65535,
37
+ description="FTP listen port (0 lets the OS choose an ephemeral port).",
38
+ )
39
+ root_dir: str = Field(
40
+ default="./ftp_incoming",
41
+ description="FTP root directory for uploads.",
42
+ )
43
+ ftp_subdir: str | None = Field(
44
+ default=None,
45
+ description="Optional subdirectory under root_dir.",
46
+ )
47
+ anonymous: bool = Field(
48
+ default=True,
49
+ description="Allow anonymous FTP uploads.",
50
+ )
51
+ username_env: str | None = Field(
52
+ default=None,
53
+ description="Environment variable containing FTP username.",
54
+ )
55
+ password_env: str | None = Field(
56
+ default=None,
57
+ description="Environment variable containing FTP password.",
58
+ )
59
+ perms: str = Field(
60
+ default="elw",
61
+ description="pyftpdlib permissions string.",
62
+ )
63
+ passive_ports: str | None = Field(
64
+ default=None,
65
+ description="Passive ports range (e.g., '60000-60100' or '60000,60010').",
66
+ )
67
+ masquerade_address: str | None = Field(
68
+ default=None,
69
+ description="Optional masquerade address for passive mode.",
70
+ )
71
+ heartbeat_s: float = Field(
72
+ default=30.0,
73
+ ge=0.0,
74
+ description="Seconds between FTP health checks.",
75
+ )
76
+ allowed_extensions: list[str] = Field(
77
+ default_factory=lambda: [".mp4"],
78
+ description="Allowed file extensions for uploaded clips.",
79
+ )
80
+ delete_non_matching: bool = Field(
81
+ default=True,
82
+ description="Delete files with disallowed extensions.",
83
+ )
84
+ delete_incomplete: bool = Field(
85
+ default=True,
86
+ description="Delete incomplete uploads when enabled.",
87
+ )
88
+ default_duration_s: float = Field(
89
+ default=10.0,
90
+ ge=0.0,
91
+ description="Fallback clip duration when timestamps are missing.",
92
+ )
93
+ log_level: str = Field(
94
+ default="INFO",
95
+ description="FTP server log level.",
96
+ )
97
+
98
+ @field_validator("allowed_extensions")
99
+ @classmethod
100
+ def _normalize_extensions(cls, value: list[str]) -> list[str]:
101
+ cleaned: list[str] = []
102
+ for item in value:
103
+ ext = str(item).strip().lower()
104
+ if not ext:
105
+ continue
106
+ if not ext.startswith("."):
107
+ ext = f".{ext}"
108
+ cleaned.append(ext)
109
+ return cleaned
110
+
111
+
19
112
  def _parse_passive_ports(spec: str | None) -> list[int] | None:
20
113
  if not spec:
21
114
  return None
@@ -164,7 +257,7 @@ class FtpSource(ThreadedClipSource):
164
257
  start_ts=mtime - timedelta(seconds=duration_s),
165
258
  end_ts=mtime,
166
259
  duration_s=duration_s,
167
- source_type="ftp",
260
+ source_backend="ftp",
168
261
  )
169
262
 
170
263
  def _resolve_env(self, name: str | None) -> str | None:
@@ -12,14 +12,39 @@ from datetime import datetime, timedelta
12
12
  from pathlib import Path
13
13
 
14
14
  from anyio import Path as AsyncPath
15
+ from pydantic import BaseModel, Field
15
16
 
16
17
  from homesec.models.clip import Clip
17
- from homesec.models.source.local_folder import LocalFolderSourceConfig
18
18
  from homesec.sources.base import AsyncClipSource
19
19
 
20
20
  logger = logging.getLogger(__name__)
21
21
 
22
22
 
23
+ class LocalFolderSourceConfig(BaseModel):
24
+ """Local folder source configuration."""
25
+
26
+ model_config = {"extra": "forbid"}
27
+
28
+ camera_name: str | None = Field(
29
+ default=None,
30
+ description="Optional human-friendly camera name.",
31
+ )
32
+ watch_dir: str = Field(
33
+ default="recordings",
34
+ description="Directory to watch for new clips.",
35
+ )
36
+ poll_interval: float = Field(
37
+ default=1.0,
38
+ ge=0.0,
39
+ description="Polling interval in seconds.",
40
+ )
41
+ stability_threshold_s: float = Field(
42
+ default=3.0,
43
+ ge=0.0,
44
+ description="Seconds to wait for file size to stabilize before accepting a clip.",
45
+ )
46
+
47
+
23
48
  class LocalFolderSource(AsyncClipSource):
24
49
  """Watches a local folder for new video clips.
25
50
 
@@ -215,5 +240,5 @@ class LocalFolderSource(AsyncClipSource):
215
240
  start_ts=mtime_dt - timedelta(seconds=duration_s),
216
241
  end_ts=mtime_dt,
217
242
  duration_s=duration_s,
218
- source_type="local_folder",
243
+ source_backend="local_folder",
219
244
  )