homesec 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- homesec/__init__.py +20 -0
- homesec/app.py +393 -0
- homesec/cli.py +159 -0
- homesec/config/__init__.py +18 -0
- homesec/config/loader.py +109 -0
- homesec/config/validation.py +82 -0
- homesec/errors.py +71 -0
- homesec/health/__init__.py +5 -0
- homesec/health/server.py +226 -0
- homesec/interfaces.py +249 -0
- homesec/logging_setup.py +176 -0
- homesec/maintenance/__init__.py +1 -0
- homesec/maintenance/cleanup_clips.py +632 -0
- homesec/models/__init__.py +79 -0
- homesec/models/alert.py +32 -0
- homesec/models/clip.py +71 -0
- homesec/models/config.py +362 -0
- homesec/models/events.py +184 -0
- homesec/models/filter.py +62 -0
- homesec/models/source.py +77 -0
- homesec/models/storage.py +12 -0
- homesec/models/vlm.py +99 -0
- homesec/pipeline/__init__.py +6 -0
- homesec/pipeline/alert_policy.py +5 -0
- homesec/pipeline/core.py +639 -0
- homesec/plugins/__init__.py +62 -0
- homesec/plugins/alert_policies/__init__.py +80 -0
- homesec/plugins/alert_policies/default.py +111 -0
- homesec/plugins/alert_policies/noop.py +60 -0
- homesec/plugins/analyzers/__init__.py +126 -0
- homesec/plugins/analyzers/openai.py +446 -0
- homesec/plugins/filters/__init__.py +124 -0
- homesec/plugins/filters/yolo.py +317 -0
- homesec/plugins/notifiers/__init__.py +80 -0
- homesec/plugins/notifiers/mqtt.py +189 -0
- homesec/plugins/notifiers/multiplex.py +106 -0
- homesec/plugins/notifiers/sendgrid_email.py +228 -0
- homesec/plugins/storage/__init__.py +116 -0
- homesec/plugins/storage/dropbox.py +272 -0
- homesec/plugins/storage/local.py +108 -0
- homesec/plugins/utils.py +63 -0
- homesec/py.typed +0 -0
- homesec/repository/__init__.py +5 -0
- homesec/repository/clip_repository.py +552 -0
- homesec/sources/__init__.py +17 -0
- homesec/sources/base.py +224 -0
- homesec/sources/ftp.py +209 -0
- homesec/sources/local_folder.py +238 -0
- homesec/sources/rtsp.py +1251 -0
- homesec/state/__init__.py +10 -0
- homesec/state/postgres.py +501 -0
- homesec/storage_paths.py +46 -0
- homesec/telemetry/__init__.py +0 -0
- homesec/telemetry/db/__init__.py +1 -0
- homesec/telemetry/db/log_table.py +16 -0
- homesec/telemetry/db_log_handler.py +246 -0
- homesec/telemetry/postgres_settings.py +42 -0
- homesec-0.1.0.dist-info/METADATA +446 -0
- homesec-0.1.0.dist-info/RECORD +62 -0
- homesec-0.1.0.dist-info/WHEEL +4 -0
- homesec-0.1.0.dist-info/entry_points.txt +2 -0
- homesec-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Alert policy plugins and registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Callable, TypeVar
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from homesec.interfaces import AlertPolicy
|
|
12
|
+
from homesec.models.config import AlertPolicyOverrides
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
AlertPolicyFactory = Callable[
|
|
18
|
+
[BaseModel, dict[str, AlertPolicyOverrides], list[str]], AlertPolicy
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class AlertPolicyPlugin:
|
|
24
|
+
name: str
|
|
25
|
+
config_model: type[BaseModel]
|
|
26
|
+
factory: AlertPolicyFactory
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
ALERT_POLICY_REGISTRY: dict[str, AlertPolicyPlugin] = {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def register_alert_policy(plugin: AlertPolicyPlugin) -> None:
|
|
33
|
+
"""Register an alert policy plugin with collision detection.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
plugin: Alert policy plugin to register
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
ValueError: If a plugin with the same name is already registered
|
|
40
|
+
"""
|
|
41
|
+
if plugin.name in ALERT_POLICY_REGISTRY:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"Alert policy plugin '{plugin.name}' is already registered. "
|
|
44
|
+
f"Plugin names must be unique across all alert policy plugins."
|
|
45
|
+
)
|
|
46
|
+
ALERT_POLICY_REGISTRY[plugin.name] = plugin
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
T = TypeVar("T", bound=Callable[[], AlertPolicyPlugin])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def alert_policy_plugin(name: str) -> Callable[[T], T]:
|
|
53
|
+
"""Decorator to register an alert policy plugin.
|
|
54
|
+
|
|
55
|
+
Usage:
|
|
56
|
+
@alert_policy_plugin(name="my_policy")
|
|
57
|
+
def my_policy_plugin() -> AlertPolicyPlugin:
|
|
58
|
+
return AlertPolicyPlugin(...)
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
name: Plugin name (for validation only - must match plugin.name)
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Decorator function that registers the plugin
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def decorator(factory_fn: T) -> T:
|
|
68
|
+
plugin = factory_fn()
|
|
69
|
+
register_alert_policy(plugin)
|
|
70
|
+
return factory_fn
|
|
71
|
+
|
|
72
|
+
return decorator
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
__all__ = [
|
|
76
|
+
"AlertPolicyPlugin",
|
|
77
|
+
"ALERT_POLICY_REGISTRY",
|
|
78
|
+
"register_alert_policy",
|
|
79
|
+
"alert_policy_plugin",
|
|
80
|
+
]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Default alert policy plugin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from homesec.interfaces import AlertPolicy
|
|
6
|
+
from homesec.models.alert import AlertDecision
|
|
7
|
+
from homesec.models.config import AlertPolicyOverrides, DefaultAlertPolicySettings
|
|
8
|
+
from homesec.models.filter import FilterResult
|
|
9
|
+
from homesec.models.vlm import AnalysisResult
|
|
10
|
+
|
|
11
|
+
# Risk level ordering for comparison
|
|
12
|
+
RISK_LEVELS = {"low": 0, "medium": 1, "high": 2, "critical": 3}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DefaultAlertPolicy(AlertPolicy):
|
|
16
|
+
"""Default alert policy implementation."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
settings: DefaultAlertPolicySettings,
|
|
21
|
+
overrides: dict[str, AlertPolicyOverrides],
|
|
22
|
+
trigger_classes: list[str],
|
|
23
|
+
) -> None:
|
|
24
|
+
self._settings = settings
|
|
25
|
+
self._overrides = overrides
|
|
26
|
+
self._trigger_classes = list(trigger_classes)
|
|
27
|
+
|
|
28
|
+
def should_notify(
|
|
29
|
+
self,
|
|
30
|
+
camera_name: str,
|
|
31
|
+
filter_result: FilterResult | None,
|
|
32
|
+
analysis: AnalysisResult | None,
|
|
33
|
+
) -> tuple[bool, str]:
|
|
34
|
+
policy = self._policy_for(camera_name)
|
|
35
|
+
|
|
36
|
+
# Check notify_on_motion first (bypasses VLM)
|
|
37
|
+
if policy.notify_on_motion:
|
|
38
|
+
return True, "notify_on_motion=true"
|
|
39
|
+
|
|
40
|
+
# If no analysis, check if filter detected trigger classes (VLM failure fallback)
|
|
41
|
+
if analysis is None:
|
|
42
|
+
if filter_result and self._filter_detected_trigger_classes(filter_result):
|
|
43
|
+
return True, "filter_detected_trigger_vlm_failed"
|
|
44
|
+
return False, "no_analysis"
|
|
45
|
+
|
|
46
|
+
# Check risk level threshold
|
|
47
|
+
if self._risk_meets_threshold(analysis.risk_level, policy.min_risk_level):
|
|
48
|
+
return True, f"risk_level={analysis.risk_level}"
|
|
49
|
+
|
|
50
|
+
# Check activity type list
|
|
51
|
+
if analysis.activity_type in policy.notify_on_activity_types:
|
|
52
|
+
return True, f"activity_type={analysis.activity_type} (per-camera)"
|
|
53
|
+
|
|
54
|
+
return False, "below_threshold"
|
|
55
|
+
|
|
56
|
+
def make_decision(
|
|
57
|
+
self,
|
|
58
|
+
camera_name: str,
|
|
59
|
+
filter_result: FilterResult | None,
|
|
60
|
+
analysis: AnalysisResult | None,
|
|
61
|
+
) -> AlertDecision:
|
|
62
|
+
notify, reason = self.should_notify(camera_name, filter_result, analysis)
|
|
63
|
+
return AlertDecision(notify=notify, notify_reason=reason)
|
|
64
|
+
|
|
65
|
+
def _policy_for(self, camera_name: str) -> DefaultAlertPolicySettings:
|
|
66
|
+
overrides = self._overrides.get(camera_name)
|
|
67
|
+
if overrides is None:
|
|
68
|
+
return self._settings
|
|
69
|
+
merged = {
|
|
70
|
+
**self._settings.model_dump(),
|
|
71
|
+
**overrides.model_dump(exclude_none=True),
|
|
72
|
+
}
|
|
73
|
+
return DefaultAlertPolicySettings.model_validate(merged)
|
|
74
|
+
|
|
75
|
+
def _risk_meets_threshold(self, actual: str, threshold: str) -> bool:
|
|
76
|
+
return RISK_LEVELS.get(actual, 0) >= RISK_LEVELS.get(threshold, 0)
|
|
77
|
+
|
|
78
|
+
def _filter_detected_trigger_classes(self, filter_result: FilterResult) -> bool:
|
|
79
|
+
detected = set(filter_result.detected_classes)
|
|
80
|
+
trigger = set(self._trigger_classes)
|
|
81
|
+
return bool(detected & trigger)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# Plugin registration
|
|
85
|
+
from pydantic import BaseModel
|
|
86
|
+
from homesec.plugins.alert_policies import AlertPolicyPlugin, alert_policy_plugin
|
|
87
|
+
from homesec.interfaces import AlertPolicy
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@alert_policy_plugin(name="default")
|
|
91
|
+
def default_alert_policy_plugin() -> AlertPolicyPlugin:
|
|
92
|
+
"""Default alert policy plugin factory.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
AlertPolicyPlugin for default risk-based alert policy
|
|
96
|
+
"""
|
|
97
|
+
from homesec.models.config import AlertPolicyOverrides, DefaultAlertPolicySettings
|
|
98
|
+
|
|
99
|
+
def factory(
|
|
100
|
+
cfg: BaseModel,
|
|
101
|
+
overrides: dict[str, AlertPolicyOverrides],
|
|
102
|
+
trigger_classes: list[str],
|
|
103
|
+
) -> AlertPolicy:
|
|
104
|
+
settings = DefaultAlertPolicySettings.model_validate(cfg)
|
|
105
|
+
return DefaultAlertPolicy(settings, overrides, trigger_classes)
|
|
106
|
+
|
|
107
|
+
return AlertPolicyPlugin(
|
|
108
|
+
name="default",
|
|
109
|
+
config_model=DefaultAlertPolicySettings,
|
|
110
|
+
factory=factory,
|
|
111
|
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""No-op alert policy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from homesec.interfaces import AlertPolicy
|
|
8
|
+
from homesec.models.alert import AlertDecision
|
|
9
|
+
from homesec.models.config import AlertPolicyOverrides
|
|
10
|
+
from homesec.models.filter import FilterResult
|
|
11
|
+
from homesec.models.vlm import AnalysisResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NoopAlertPolicy(AlertPolicy):
|
|
15
|
+
"""Alert policy that never notifies."""
|
|
16
|
+
|
|
17
|
+
def should_notify(
|
|
18
|
+
self,
|
|
19
|
+
camera_name: str,
|
|
20
|
+
filter_result: FilterResult | None,
|
|
21
|
+
analysis: AnalysisResult | None,
|
|
22
|
+
) -> tuple[bool, str]:
|
|
23
|
+
return False, "alert_policy_disabled"
|
|
24
|
+
|
|
25
|
+
def make_decision(
|
|
26
|
+
self,
|
|
27
|
+
camera_name: str,
|
|
28
|
+
filter_result: FilterResult | None,
|
|
29
|
+
analysis: AnalysisResult | None,
|
|
30
|
+
) -> AlertDecision:
|
|
31
|
+
return AlertDecision(notify=False, notify_reason="alert_policy_disabled")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Plugin registration
|
|
35
|
+
from pydantic import BaseModel
|
|
36
|
+
from homesec.plugins.alert_policies import AlertPolicyPlugin, alert_policy_plugin
|
|
37
|
+
from homesec.interfaces import AlertPolicy
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@alert_policy_plugin(name="noop")
|
|
41
|
+
def noop_alert_policy_plugin() -> AlertPolicyPlugin:
|
|
42
|
+
"""Noop alert policy plugin that never sends alerts.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
AlertPolicyPlugin for no-op alert policy
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def factory(
|
|
49
|
+
cfg: BaseModel,
|
|
50
|
+
overrides: dict[str, AlertPolicyOverrides],
|
|
51
|
+
trigger_classes: list[str],
|
|
52
|
+
) -> AlertPolicy:
|
|
53
|
+
# NoopAlertPolicy doesn't use any config
|
|
54
|
+
return NoopAlertPolicy()
|
|
55
|
+
|
|
56
|
+
return AlertPolicyPlugin(
|
|
57
|
+
name="noop",
|
|
58
|
+
config_model=BaseModel, # No config needed
|
|
59
|
+
factory=factory,
|
|
60
|
+
)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""VLM analyzer plugins and registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Callable, TYPE_CHECKING, TypeVar
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from homesec.interfaces import VLMAnalyzer
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from homesec.models.vlm import VLMConfig
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Type alias for clarity
|
|
19
|
+
VLMFactory = Callable[["VLMConfig"], VLMAnalyzer]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class VLMPlugin:
|
|
24
|
+
"""Metadata for a VLM analyzer plugin."""
|
|
25
|
+
|
|
26
|
+
name: str
|
|
27
|
+
config_model: type[BaseModel]
|
|
28
|
+
factory: VLMFactory
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
VLM_REGISTRY: dict[str, VLMPlugin] = {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def register_vlm(plugin: VLMPlugin) -> None:
|
|
35
|
+
"""Register a VLM plugin with collision detection.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
plugin: VLM plugin to register
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
ValueError: If a plugin with the same name is already registered
|
|
42
|
+
"""
|
|
43
|
+
if plugin.name in VLM_REGISTRY:
|
|
44
|
+
raise ValueError(
|
|
45
|
+
f"VLM plugin '{plugin.name}' is already registered. "
|
|
46
|
+
f"Plugin names must be unique across all VLM plugins."
|
|
47
|
+
)
|
|
48
|
+
VLM_REGISTRY[plugin.name] = plugin
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
T = TypeVar("T", bound=Callable[[], VLMPlugin])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def vlm_plugin(name: str) -> Callable[[T], T]:
|
|
55
|
+
"""Decorator to register a VLM analyzer plugin.
|
|
56
|
+
|
|
57
|
+
Usage:
|
|
58
|
+
@vlm_plugin(name="my_vlm")
|
|
59
|
+
def my_vlm_plugin() -> VLMPlugin:
|
|
60
|
+
return VLMPlugin(...)
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
name: Plugin name (for validation only - must match plugin.name)
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Decorator function that registers the plugin
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def decorator(factory_fn: T) -> T:
|
|
70
|
+
plugin = factory_fn()
|
|
71
|
+
register_vlm(plugin)
|
|
72
|
+
return factory_fn
|
|
73
|
+
|
|
74
|
+
return decorator
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_vlm_plugin(config: VLMConfig) -> VLMAnalyzer:
|
|
78
|
+
"""Load VLM plugin by name from config.
|
|
79
|
+
|
|
80
|
+
Validates the llm dict against the plugin's config_model and creates
|
|
81
|
+
a VLMConfig with the validated settings object.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
config: VLM configuration with backend name and raw llm dict
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Instantiated VLM plugin
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
ValueError: If plugin name is unknown or config validation fails
|
|
91
|
+
"""
|
|
92
|
+
plugin_name = config.backend.lower()
|
|
93
|
+
|
|
94
|
+
if plugin_name not in VLM_REGISTRY:
|
|
95
|
+
available = ", ".join(sorted(VLM_REGISTRY.keys()))
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"Unknown VLM plugin: '{plugin_name}'. Available: {available}"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
plugin = VLM_REGISTRY[plugin_name]
|
|
101
|
+
|
|
102
|
+
# Validate config.llm dict against plugin's config_model
|
|
103
|
+
validated_llm_settings = plugin.config_model.model_validate(config.llm)
|
|
104
|
+
|
|
105
|
+
# Create new VLMConfig with validated llm settings object
|
|
106
|
+
from homesec.models.vlm import VLMConfig as VLMConfigModel
|
|
107
|
+
|
|
108
|
+
validated_config = VLMConfigModel(
|
|
109
|
+
backend=config.backend,
|
|
110
|
+
trigger_classes=config.trigger_classes,
|
|
111
|
+
max_workers=config.max_workers,
|
|
112
|
+
llm=validated_llm_settings,
|
|
113
|
+
preprocessing=config.preprocessing,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
return plugin.factory(validated_config)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
__all__ = [
|
|
120
|
+
"VLMPlugin",
|
|
121
|
+
"VLMFactory",
|
|
122
|
+
"VLM_REGISTRY",
|
|
123
|
+
"register_vlm",
|
|
124
|
+
"vlm_plugin",
|
|
125
|
+
"load_vlm_plugin",
|
|
126
|
+
]
|