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.
Files changed (62) hide show
  1. homesec/__init__.py +20 -0
  2. homesec/app.py +393 -0
  3. homesec/cli.py +159 -0
  4. homesec/config/__init__.py +18 -0
  5. homesec/config/loader.py +109 -0
  6. homesec/config/validation.py +82 -0
  7. homesec/errors.py +71 -0
  8. homesec/health/__init__.py +5 -0
  9. homesec/health/server.py +226 -0
  10. homesec/interfaces.py +249 -0
  11. homesec/logging_setup.py +176 -0
  12. homesec/maintenance/__init__.py +1 -0
  13. homesec/maintenance/cleanup_clips.py +632 -0
  14. homesec/models/__init__.py +79 -0
  15. homesec/models/alert.py +32 -0
  16. homesec/models/clip.py +71 -0
  17. homesec/models/config.py +362 -0
  18. homesec/models/events.py +184 -0
  19. homesec/models/filter.py +62 -0
  20. homesec/models/source.py +77 -0
  21. homesec/models/storage.py +12 -0
  22. homesec/models/vlm.py +99 -0
  23. homesec/pipeline/__init__.py +6 -0
  24. homesec/pipeline/alert_policy.py +5 -0
  25. homesec/pipeline/core.py +639 -0
  26. homesec/plugins/__init__.py +62 -0
  27. homesec/plugins/alert_policies/__init__.py +80 -0
  28. homesec/plugins/alert_policies/default.py +111 -0
  29. homesec/plugins/alert_policies/noop.py +60 -0
  30. homesec/plugins/analyzers/__init__.py +126 -0
  31. homesec/plugins/analyzers/openai.py +446 -0
  32. homesec/plugins/filters/__init__.py +124 -0
  33. homesec/plugins/filters/yolo.py +317 -0
  34. homesec/plugins/notifiers/__init__.py +80 -0
  35. homesec/plugins/notifiers/mqtt.py +189 -0
  36. homesec/plugins/notifiers/multiplex.py +106 -0
  37. homesec/plugins/notifiers/sendgrid_email.py +228 -0
  38. homesec/plugins/storage/__init__.py +116 -0
  39. homesec/plugins/storage/dropbox.py +272 -0
  40. homesec/plugins/storage/local.py +108 -0
  41. homesec/plugins/utils.py +63 -0
  42. homesec/py.typed +0 -0
  43. homesec/repository/__init__.py +5 -0
  44. homesec/repository/clip_repository.py +552 -0
  45. homesec/sources/__init__.py +17 -0
  46. homesec/sources/base.py +224 -0
  47. homesec/sources/ftp.py +209 -0
  48. homesec/sources/local_folder.py +238 -0
  49. homesec/sources/rtsp.py +1251 -0
  50. homesec/state/__init__.py +10 -0
  51. homesec/state/postgres.py +501 -0
  52. homesec/storage_paths.py +46 -0
  53. homesec/telemetry/__init__.py +0 -0
  54. homesec/telemetry/db/__init__.py +1 -0
  55. homesec/telemetry/db/log_table.py +16 -0
  56. homesec/telemetry/db_log_handler.py +246 -0
  57. homesec/telemetry/postgres_settings.py +42 -0
  58. homesec-0.1.0.dist-info/METADATA +446 -0
  59. homesec-0.1.0.dist-info/RECORD +62 -0
  60. homesec-0.1.0.dist-info/WHEEL +4 -0
  61. homesec-0.1.0.dist-info/entry_points.txt +2 -0
  62. homesec-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,228 @@
1
+ """SendGrid email notifier plugin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import html
7
+ import logging
8
+ import os
9
+ from collections import defaultdict
10
+
11
+ import aiohttp
12
+
13
+ from homesec.models.alert import Alert
14
+ from homesec.models.config import SendGridEmailConfig
15
+ from homesec.models.vlm import SequenceAnalysis
16
+ from homesec.interfaces import Notifier
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class SendGridEmailNotifier(Notifier):
22
+ """SendGrid email notifier for HomeSec alerts."""
23
+
24
+ def __init__(self, config: SendGridEmailConfig) -> None:
25
+ self._api_key_env = config.api_key_env
26
+ self._from_email = config.from_email
27
+ self._from_name = config.from_name
28
+ self._to_emails = list(config.to_emails)
29
+ self._cc_emails = list(config.cc_emails)
30
+ self._bcc_emails = list(config.bcc_emails)
31
+ self._subject_template = config.subject_template
32
+ self._text_template = config.text_template
33
+ self._html_template = config.html_template
34
+ self._timeout_s = float(config.request_timeout_s)
35
+ self._api_base = config.api_base.rstrip("/")
36
+
37
+ self._api_key = os.getenv(self._api_key_env)
38
+ if not self._api_key:
39
+ logger.warning("SendGrid API key not found in env: %s", self._api_key_env)
40
+
41
+ self._session: aiohttp.ClientSession | None = None
42
+ self._shutdown_called = False
43
+
44
+ async def send(self, alert: Alert) -> None:
45
+ """Send alert notification via SendGrid."""
46
+ if self._shutdown_called:
47
+ raise RuntimeError("Notifier has been shut down")
48
+ if not self._api_key:
49
+ raise RuntimeError("SendGrid API key missing from environment")
50
+
51
+ subject = self._render_subject(alert)
52
+ text_body = self._render_text(alert) if self._text_template else ""
53
+ html_body = self._render_html(alert) if self._html_template else ""
54
+ if not text_body and not html_body:
55
+ raise RuntimeError("SendGrid email requires text or html content")
56
+
57
+ payload = self._build_payload(subject, text_body, html_body)
58
+ headers = {"Authorization": f"Bearer {self._api_key}"}
59
+ url = f"{self._api_base}/mail/send"
60
+ session = await self._get_session()
61
+
62
+ async with session.post(url, json=payload, headers=headers) as response:
63
+ if response.status >= 400:
64
+ details = await response.text()
65
+ raise RuntimeError(
66
+ f"SendGrid email send failed ({response.status}): {details}"
67
+ )
68
+
69
+ logger.info(
70
+ "Sent SendGrid email alert: to=%s clip_id=%s",
71
+ ",".join(self._to_emails),
72
+ alert.clip_id,
73
+ )
74
+
75
+ async def ping(self) -> bool:
76
+ """Health check - verify SendGrid credentials and connectivity."""
77
+ if self._shutdown_called or not self._api_key:
78
+ return False
79
+
80
+ url = f"{self._api_base}/user/profile"
81
+ headers = {"Authorization": f"Bearer {self._api_key}"}
82
+ session = await self._get_session()
83
+ try:
84
+ async with session.get(url, headers=headers) as response:
85
+ if response.status >= 400:
86
+ return False
87
+ await response.read()
88
+ except (aiohttp.ClientError, asyncio.TimeoutError) as e:
89
+ logger.warning("SendGrid ping failed: %s", e)
90
+ return False
91
+ return True
92
+
93
+ async def shutdown(self, timeout: float | None = None) -> None:
94
+ """Cleanup resources - close HTTP session."""
95
+ _ = timeout
96
+ if self._shutdown_called:
97
+ return
98
+ self._shutdown_called = True
99
+
100
+ if self._session and not self._session.closed:
101
+ await self._session.close()
102
+
103
+ async def _get_session(self) -> aiohttp.ClientSession:
104
+ if self._session is None or self._session.closed:
105
+ timeout = aiohttp.ClientTimeout(total=self._timeout_s)
106
+ self._session = aiohttp.ClientSession(timeout=timeout)
107
+ return self._session
108
+
109
+ def _build_payload(self, subject: str, text_body: str, html_body: str) -> dict[str, object]:
110
+ personalization: dict[str, object] = {
111
+ "to": [{"email": email} for email in self._to_emails],
112
+ "subject": subject,
113
+ }
114
+ if self._cc_emails:
115
+ personalization["cc"] = [{"email": email} for email in self._cc_emails]
116
+ if self._bcc_emails:
117
+ personalization["bcc"] = [{"email": email} for email in self._bcc_emails]
118
+
119
+ sender: dict[str, str] = {"email": self._from_email}
120
+ if self._from_name:
121
+ sender["name"] = self._from_name
122
+
123
+ content: list[dict[str, str]] = []
124
+ if text_body:
125
+ content.append({"type": "text/plain", "value": text_body})
126
+ if html_body:
127
+ content.append({"type": "text/html", "value": html_body})
128
+
129
+ return {
130
+ "personalizations": [personalization],
131
+ "from": sender,
132
+ "content": content,
133
+ }
134
+
135
+ def _build_context(self, alert: Alert) -> defaultdict[str, str]:
136
+ view_url = alert.view_url or alert.storage_uri or "n/a"
137
+ analysis_html = self._render_analysis_html(alert.analysis)
138
+ return defaultdict(
139
+ str,
140
+ {
141
+ "camera_name": alert.camera_name,
142
+ "clip_id": alert.clip_id,
143
+ "risk_level": alert.risk_level or "unknown",
144
+ "activity_type": alert.activity_type or "unknown",
145
+ "notify_reason": alert.notify_reason,
146
+ "summary": alert.summary or "",
147
+ "view_url": view_url,
148
+ "storage_uri": alert.storage_uri or "",
149
+ "ts": alert.ts.isoformat(),
150
+ "upload_failed": str(alert.upload_failed),
151
+ "analysis_html": analysis_html,
152
+ },
153
+ )
154
+
155
+ def _render_subject(self, alert: Alert) -> str:
156
+ return self._subject_template.format_map(self._build_context(alert)).strip()
157
+
158
+ def _render_text(self, alert: Alert) -> str:
159
+ return self._text_template.format_map(self._build_context(alert)).strip()
160
+
161
+ def _render_html(self, alert: Alert) -> str:
162
+ return self._html_template.format_map(self._build_context(alert)).strip()
163
+
164
+ def _render_analysis_html(self, analysis: SequenceAnalysis | None) -> str:
165
+ if analysis is None:
166
+ return ""
167
+ return self._render_value_html(analysis.model_dump())
168
+
169
+ def _render_value_html(self, value: object) -> str:
170
+ match value:
171
+ case None:
172
+ return "<em>n/a</em>"
173
+ case dict() as mapping:
174
+ return self._render_dict_html(mapping)
175
+ case list() as items:
176
+ return self._render_list_html(items)
177
+ case _:
178
+ return html.escape(str(value))
179
+
180
+ def _render_dict_html(self, mapping: dict[str, object]) -> str:
181
+ items = []
182
+ for key, value in mapping.items():
183
+ rendered_value = self._render_value_html(value)
184
+ items.append(
185
+ f"<li><strong>{html.escape(str(key))}:</strong> {rendered_value}</li>"
186
+ )
187
+ return "<ul>" + "".join(items) + "</ul>"
188
+
189
+ def _render_list_html(self, items: list[object]) -> str:
190
+ if not items:
191
+ return "<ul><li>none</li></ul>"
192
+
193
+ rendered_items = []
194
+ for idx, item in enumerate(items, start=1):
195
+ rendered_value = self._render_value_html(item)
196
+ label = f"Item {idx}"
197
+ if isinstance(item, dict):
198
+ rendered_items.append(f"<li>{html.escape(label)}{rendered_value}</li>")
199
+ else:
200
+ rendered_items.append(f"<li>{rendered_value}</li>")
201
+ return "<ul>" + "".join(rendered_items) + "</ul>"
202
+
203
+
204
+ # Plugin registration
205
+ from typing import cast
206
+ from pydantic import BaseModel
207
+ from homesec.plugins.notifiers import NotifierPlugin, notifier_plugin
208
+ from homesec.interfaces import Notifier
209
+
210
+
211
+ @notifier_plugin(name="sendgrid_email")
212
+ def sendgrid_email_plugin() -> NotifierPlugin:
213
+ """SendGrid email notifier plugin factory.
214
+
215
+ Returns:
216
+ NotifierPlugin for SendGrid email notifications
217
+ """
218
+ from homesec.models.config import SendGridEmailConfig
219
+
220
+ def factory(cfg: BaseModel) -> Notifier:
221
+ # Config is already validated by app.py, just cast
222
+ return SendGridEmailNotifier(cast(SendGridEmailConfig, cfg))
223
+
224
+ return NotifierPlugin(
225
+ name="sendgrid_email",
226
+ config_model=SendGridEmailConfig,
227
+ factory=factory,
228
+ )
@@ -0,0 +1,116 @@
1
+ """Storage backend 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 StorageBackend
12
+ from homesec.models.config import StorageConfig
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Type alias for clarity
17
+ StorageFactory = Callable[[BaseModel], StorageBackend]
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class StoragePlugin:
22
+ """Metadata for a storage backend plugin."""
23
+
24
+ name: str
25
+ config_model: type[BaseModel]
26
+ factory: StorageFactory
27
+
28
+
29
+ STORAGE_REGISTRY: dict[str, StoragePlugin] = {}
30
+
31
+
32
+ def register_storage(plugin: StoragePlugin) -> None:
33
+ """Register a storage plugin with collision detection.
34
+
35
+ Args:
36
+ plugin: Storage plugin to register
37
+
38
+ Raises:
39
+ ValueError: If a plugin with the same name is already registered
40
+ """
41
+ if plugin.name in STORAGE_REGISTRY:
42
+ raise ValueError(
43
+ f"Storage plugin '{plugin.name}' is already registered. "
44
+ f"Plugin names must be unique across all storage plugins."
45
+ )
46
+ STORAGE_REGISTRY[plugin.name] = plugin
47
+
48
+
49
+ T = TypeVar("T", bound=Callable[[], StoragePlugin])
50
+
51
+
52
+ def storage_plugin(name: str) -> Callable[[T], T]:
53
+ """Decorator to register a storage backend plugin.
54
+
55
+ Usage:
56
+ @storage_plugin(name="my_storage")
57
+ def my_storage_plugin() -> StoragePlugin:
58
+ return StoragePlugin(...)
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_storage(plugin)
70
+ return factory_fn
71
+
72
+ return decorator
73
+
74
+
75
+ def create_storage(config: StorageConfig) -> StorageBackend:
76
+ """Create storage backend from config using plugin registry.
77
+
78
+ Args:
79
+ config: Storage configuration with backend name and backend-specific settings
80
+
81
+ Returns:
82
+ Instantiated storage backend
83
+
84
+ Raises:
85
+ RuntimeError: If backend is unknown or backend-specific config is missing
86
+ """
87
+ backend_name = config.backend.lower()
88
+
89
+ if backend_name not in STORAGE_REGISTRY:
90
+ available = ", ".join(sorted(STORAGE_REGISTRY.keys()))
91
+ raise RuntimeError(
92
+ f"Unknown storage backend: '{backend_name}'. Available: {available}"
93
+ )
94
+
95
+ plugin = STORAGE_REGISTRY[backend_name]
96
+
97
+ # Extract backend-specific config using attribute access
98
+ # e.g., config.dropbox, config.local
99
+ specific_config = getattr(config, backend_name, None)
100
+ if specific_config is None:
101
+ raise RuntimeError(
102
+ f"Missing '{backend_name}' config in storage section. "
103
+ f"Add 'storage.{backend_name}' to your config."
104
+ )
105
+
106
+ return plugin.factory(specific_config)
107
+
108
+
109
+ __all__ = [
110
+ "StoragePlugin",
111
+ "StorageFactory",
112
+ "STORAGE_REGISTRY",
113
+ "register_storage",
114
+ "storage_plugin",
115
+ "create_storage",
116
+ ]
@@ -0,0 +1,272 @@
1
+ """Dropbox storage backend plugin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ from pathlib import Path, PurePosixPath
9
+ from typing import BinaryIO
10
+ import dropbox # type: ignore
11
+
12
+ from homesec.models.config import DropboxStorageConfig
13
+ from homesec.models.storage import StorageUploadResult
14
+ from homesec.interfaces import StorageBackend
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ CHUNK_SIZE = 4 * 1024 * 1024
19
+
20
+
21
+ class DropboxStorage(StorageBackend):
22
+ """Dropbox storage backend.
23
+
24
+ Uses dropbox SDK for file operations.
25
+ Implements idempotent uploads with overwrite mode.
26
+
27
+ Supports two auth modes:
28
+ 1. Simple token: Set DROPBOX_TOKEN env var
29
+ 2. Refresh token flow: Set DROPBOX_APP_KEY, DROPBOX_APP_SECRET, DROPBOX_REFRESH_TOKEN
30
+ """
31
+
32
+ def __init__(self, config: DropboxStorageConfig) -> None:
33
+ """Initialize Dropbox storage with config validation.
34
+
35
+ Required config:
36
+ root: Root path in Dropbox (e.g., /homecam)
37
+
38
+ Optional config:
39
+ token_env: Env var name for simple token auth (default: DROPBOX_TOKEN)
40
+ app_key_env: Env var name for app key (default: DROPBOX_APP_KEY)
41
+ app_secret_env: Env var name for app secret (default: DROPBOX_APP_SECRET)
42
+ refresh_token_env: Env var name for refresh token (default: DROPBOX_REFRESH_TOKEN)
43
+ web_url_prefix: URL prefix for view links (default: https://www.dropbox.com/home)
44
+ """
45
+ self.root = str(config.root).rstrip("/")
46
+ self.web_url_prefix = str(config.web_url_prefix)
47
+
48
+ # Initialize Dropbox client using env vars
49
+ self.client = self._create_client(config)
50
+ self._shutdown_called = False
51
+
52
+ logger.info("DropboxStorage initialized: root=%s", self.root)
53
+
54
+ def _create_client(self, config: DropboxStorageConfig) -> dropbox.Dropbox:
55
+ """Create Dropbox client from env vars.
56
+
57
+ Tries simple token first, then falls back to refresh token flow.
58
+ """
59
+ # Try simple token auth first
60
+ token_var = str(config.token_env)
61
+ token = os.getenv(token_var)
62
+ if token:
63
+ logger.info("Using Dropbox simple token auth")
64
+ return dropbox.Dropbox(token)
65
+
66
+ # Try refresh token flow
67
+ app_key_var = str(config.app_key_env)
68
+ app_secret_var = str(config.app_secret_env)
69
+ refresh_token_var = str(config.refresh_token_env)
70
+
71
+ app_key = os.getenv(app_key_var)
72
+ app_secret = os.getenv(app_secret_var)
73
+ refresh_token = os.getenv(refresh_token_var)
74
+
75
+ if app_key and app_secret and refresh_token:
76
+ logger.info("Using Dropbox refresh token auth")
77
+ return dropbox.Dropbox(
78
+ app_key=app_key,
79
+ app_secret=app_secret,
80
+ oauth2_refresh_token=refresh_token,
81
+ )
82
+
83
+ raise ValueError(
84
+ f"Missing Dropbox credentials. Set {token_var} or "
85
+ f"({app_key_var}, {app_secret_var}, {refresh_token_var})."
86
+ )
87
+
88
+ async def put_file(self, local_path: Path, dest_path: str) -> StorageUploadResult:
89
+ """Upload file to Dropbox."""
90
+ self._ensure_open()
91
+
92
+ dest_path = self._full_dest_path(dest_path)
93
+
94
+ # Run blocking upload in executor
95
+ await asyncio.to_thread(self._upload_file, local_path, dest_path)
96
+
97
+ storage_uri = f"dropbox:{dest_path}"
98
+ view_url = await self.get_view_url(storage_uri)
99
+ return StorageUploadResult(storage_uri=storage_uri, view_url=view_url)
100
+
101
+ async def get_view_url(self, storage_uri: str) -> str | None:
102
+ """Compute a web-accessible URL for a Dropbox storage URI."""
103
+ self._ensure_open()
104
+ if not storage_uri.startswith("dropbox:"):
105
+ return None
106
+ path = storage_uri[8:]
107
+ prefix = self.web_url_prefix.rstrip("/")
108
+ return f"{prefix}{path}"
109
+
110
+ def _upload_file(self, local_path: Path, dest_path: str) -> None:
111
+ """Upload file (blocking operation)."""
112
+ file_size = local_path.stat().st_size
113
+ with open(local_path, "rb") as f:
114
+ if file_size <= CHUNK_SIZE:
115
+ self.client.files_upload(
116
+ f.read(),
117
+ dest_path,
118
+ mode=dropbox.files.WriteMode.overwrite,
119
+ )
120
+ else:
121
+ self._upload_file_chunked(f, dest_path, file_size)
122
+ logger.debug("Uploaded to Dropbox: %s", dest_path)
123
+
124
+ def _upload_file_chunked(self, file_handle: BinaryIO, dest_path: str, file_size: int) -> None:
125
+ """Upload file in chunks using a Dropbox upload session."""
126
+ chunk = file_handle.read(CHUNK_SIZE)
127
+ if not chunk:
128
+ raise ValueError("Cannot upload empty file")
129
+
130
+ session = self.client.files_upload_session_start(chunk)
131
+ cursor = dropbox.files.UploadSessionCursor(
132
+ session_id=session.session_id,
133
+ offset=file_handle.tell(),
134
+ )
135
+ commit = dropbox.files.CommitInfo(
136
+ path=dest_path,
137
+ mode=dropbox.files.WriteMode.overwrite,
138
+ )
139
+
140
+ while file_handle.tell() < file_size:
141
+ chunk = file_handle.read(CHUNK_SIZE)
142
+ if not chunk:
143
+ raise RuntimeError("Unexpected end of file during chunked upload")
144
+ if file_handle.tell() >= file_size:
145
+ self.client.files_upload_session_finish(chunk, cursor, commit)
146
+ return
147
+ self.client.files_upload_session_append_v2(chunk, cursor)
148
+ cursor.offset = file_handle.tell()
149
+
150
+ async def get(self, storage_uri: str, local_path: Path) -> None:
151
+ """Download file from Dropbox."""
152
+ self._ensure_open()
153
+
154
+ # Parse storage_uri
155
+ if not storage_uri.startswith("dropbox:"):
156
+ raise ValueError(f"Invalid storage_uri: {storage_uri}")
157
+
158
+ remote_path = storage_uri[8:] # Strip "dropbox:"
159
+
160
+ # Run blocking download in executor
161
+ await asyncio.to_thread(self._download_file, remote_path, local_path)
162
+
163
+ def _download_file(self, remote_path: str, local_path: Path) -> None:
164
+ """Download file (blocking operation)."""
165
+ local_path.parent.mkdir(parents=True, exist_ok=True)
166
+ self.client.files_download_to_file(str(local_path), remote_path)
167
+ logger.debug("Downloaded from Dropbox: %s", remote_path)
168
+
169
+ async def exists(self, storage_uri: str) -> bool:
170
+ """Check if file exists in Dropbox."""
171
+ self._ensure_open()
172
+
173
+ # Parse storage_uri
174
+ if not storage_uri.startswith("dropbox:"):
175
+ return False
176
+
177
+ remote_path = storage_uri[8:]
178
+
179
+ # Run blocking check in executor
180
+ return await asyncio.to_thread(self._check_exists, remote_path)
181
+
182
+ async def delete(self, storage_uri: str) -> None:
183
+ """Delete file from Dropbox.
184
+
185
+ Idempotent: missing files are treated as success.
186
+ """
187
+ self._ensure_open()
188
+
189
+ if not storage_uri.startswith("dropbox:"):
190
+ raise ValueError(f"Invalid storage_uri: {storage_uri}")
191
+
192
+ remote_path = storage_uri[8:]
193
+
194
+ try:
195
+ await asyncio.to_thread(self.client.files_delete_v2, remote_path)
196
+ except dropbox.exceptions.ApiError as exc:
197
+ # Treat missing file as success
198
+ err = getattr(exc, "error", None)
199
+ try:
200
+ if err is not None and getattr(err, "is_path_lookup", lambda: False)():
201
+ lookup = err.get_path_lookup()
202
+ if getattr(lookup, "is_not_found", lambda: False)():
203
+ return
204
+ except Exception:
205
+ pass
206
+ raise
207
+
208
+ def _check_exists(self, remote_path: str) -> bool:
209
+ """Check if file exists (blocking operation)."""
210
+ try:
211
+ self.client.files_get_metadata(remote_path)
212
+ return True
213
+ except dropbox.exceptions.ApiError:
214
+ return False
215
+
216
+ async def ping(self) -> bool:
217
+ """Health check - verify Dropbox connection."""
218
+ try:
219
+ await asyncio.to_thread(self.client.users_get_current_account)
220
+ return True
221
+ except Exception as e:
222
+ logger.warning("Dropbox ping failed: %s", e, exc_info=True)
223
+ return False
224
+
225
+ async def shutdown(self, timeout: float | None = None) -> None:
226
+ """Cleanup resources."""
227
+ _ = timeout
228
+ if self._shutdown_called:
229
+ return
230
+
231
+ self._shutdown_called = True
232
+ logger.info("DropboxStorage closed")
233
+
234
+ def _ensure_open(self) -> None:
235
+ if self._shutdown_called:
236
+ raise RuntimeError("Storage has been shut down")
237
+
238
+ def _full_dest_path(self, dest_path: str) -> str:
239
+ cleaned = str(dest_path).lstrip("/")
240
+ if not cleaned or "\\" in cleaned:
241
+ raise ValueError(f"Invalid dest_path: {dest_path}")
242
+ path = PurePosixPath(cleaned)
243
+ if path.is_absolute() or ".." in path.parts:
244
+ raise ValueError(f"Invalid dest_path: {dest_path}")
245
+ return f"{self.root}/{path}"
246
+
247
+
248
+ # Plugin registration
249
+ from typing import cast
250
+ from pydantic import BaseModel
251
+ from homesec.plugins.storage import StoragePlugin, storage_plugin
252
+ from homesec.interfaces import StorageBackend
253
+
254
+
255
+ @storage_plugin(name="dropbox")
256
+ def dropbox_storage_plugin() -> StoragePlugin:
257
+ """Dropbox storage plugin factory.
258
+
259
+ Returns:
260
+ StoragePlugin for Dropbox cloud storage
261
+ """
262
+ from homesec.models.config import DropboxStorageConfig
263
+
264
+ def factory(cfg: BaseModel) -> StorageBackend:
265
+ # Config is already validated by pydantic when loaded
266
+ return DropboxStorage(cast(DropboxStorageConfig, cfg))
267
+
268
+ return StoragePlugin(
269
+ name="dropbox",
270
+ config_model=DropboxStorageConfig,
271
+ factory=factory,
272
+ )