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.
- homesec/app.py +5 -14
- homesec/cli.py +5 -4
- homesec/config/__init__.py +8 -1
- homesec/config/loader.py +17 -2
- homesec/config/validation.py +99 -6
- homesec/interfaces.py +2 -2
- homesec/maintenance/cleanup_clips.py +17 -4
- homesec/models/__init__.py +3 -25
- homesec/models/clip.py +1 -1
- homesec/models/config.py +10 -261
- homesec/models/enums.py +8 -0
- homesec/models/events.py +1 -1
- homesec/models/filter.py +3 -21
- homesec/models/vlm.py +11 -20
- homesec/pipeline/__init__.py +1 -2
- homesec/pipeline/core.py +9 -10
- homesec/plugins/alert_policies/__init__.py +5 -5
- homesec/plugins/alert_policies/default.py +21 -2
- homesec/plugins/analyzers/__init__.py +1 -3
- homesec/plugins/analyzers/openai.py +20 -13
- homesec/plugins/filters/__init__.py +1 -2
- homesec/plugins/filters/yolo.py +25 -5
- homesec/plugins/notifiers/__init__.py +1 -6
- homesec/plugins/notifiers/mqtt.py +21 -1
- homesec/plugins/notifiers/sendgrid_email.py +52 -1
- homesec/plugins/registry.py +27 -0
- homesec/plugins/sources/__init__.py +4 -4
- homesec/plugins/sources/ftp.py +1 -1
- homesec/plugins/sources/local_folder.py +1 -1
- homesec/plugins/sources/rtsp.py +1 -1
- homesec/plugins/storage/__init__.py +1 -9
- homesec/plugins/storage/dropbox.py +13 -1
- homesec/plugins/storage/local.py +8 -1
- homesec/repository/clip_repository.py +1 -1
- homesec/sources/__init__.py +3 -6
- homesec/sources/ftp.py +95 -2
- homesec/sources/local_folder.py +27 -2
- homesec/sources/rtsp/core.py +162 -2
- {homesec-1.2.2.dist-info → homesec-1.2.3.dist-info}/METADATA +7 -12
- homesec-1.2.3.dist-info/RECORD +73 -0
- homesec/models/source/__init__.py +0 -3
- homesec/models/source/ftp.py +0 -97
- homesec/models/source/local_folder.py +0 -30
- homesec/models/source/rtsp.py +0 -165
- homesec/pipeline/alert_policy.py +0 -5
- homesec-1.2.2.dist-info/RECORD +0 -78
- /homesec/{plugins/notifiers → notifiers}/multiplex.py +0 -0
- {homesec-1.2.2.dist-info → homesec-1.2.3.dist-info}/WHEEL +0 -0
- {homesec-1.2.2.dist-info → homesec-1.2.3.dist-info}/entry_points.txt +0 -0
- {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 =
|
|
103
|
+
config_cls = OpenAIConfig
|
|
96
104
|
|
|
97
105
|
@classmethod
|
|
98
|
-
def create(cls, config:
|
|
106
|
+
def create(cls, config: OpenAIConfig) -> VLMAnalyzer:
|
|
99
107
|
return cls(config)
|
|
100
108
|
|
|
101
|
-
def __init__(self, llm_config:
|
|
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:
|
|
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:
|
homesec/plugins/filters/yolo.py
CHANGED
|
@@ -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
|
|
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 =
|
|
182
|
+
config_cls = YoloFilterConfig
|
|
163
183
|
|
|
164
184
|
@classmethod
|
|
165
|
-
def create(cls, config:
|
|
185
|
+
def create(cls, config: YoloFilterConfig) -> ObjectFilter:
|
|
166
186
|
return cls(config)
|
|
167
187
|
|
|
168
|
-
def __init__(self, settings:
|
|
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) ->
|
|
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:
|
homesec/plugins/registry.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
38
|
+
source_backend,
|
|
39
39
|
config,
|
|
40
40
|
camera_name=camera_name,
|
|
41
41
|
),
|
homesec/plugins/sources/ftp.py
CHANGED
|
@@ -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")
|
homesec/plugins/sources/rtsp.py
CHANGED
|
@@ -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
|
-
|
|
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.
|
homesec/plugins/storage/local.py
CHANGED
|
@@ -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."""
|
homesec/sources/__init__.py
CHANGED
|
@@ -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
|
-
|
|
260
|
+
source_backend="ftp",
|
|
168
261
|
)
|
|
169
262
|
|
|
170
263
|
def _resolve_env(self, name: str | None) -> str | None:
|
homesec/sources/local_folder.py
CHANGED
|
@@ -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
|
-
|
|
243
|
+
source_backend="local_folder",
|
|
219
244
|
)
|