crow-security-ng 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.
- crow_security_ng/__init__.py +42 -0
- crow_security_ng/client.py +571 -0
- crow_security_ng/exceptions.py +88 -0
- crow_security_ng/models.py +313 -0
- crow_security_ng/py.typed +0 -0
- crow_security_ng/session.py +145 -0
- crow_security_ng/utils.py +66 -0
- crow_security_ng-0.1.0.dist-info/METADATA +290 -0
- crow_security_ng-0.1.0.dist-info/RECORD +12 -0
- crow_security_ng-0.1.0.dist-info/WHEEL +5 -0
- crow_security_ng-0.1.0.dist-info/licenses/LICENSE +21 -0
- crow_security_ng-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Crow Security NG - A modern Python library for the Crow Cloud API.
|
|
3
|
+
|
|
4
|
+
This library provides async access to Crow Cloud security systems,
|
|
5
|
+
including the Shepherd alarm panel series.
|
|
6
|
+
"""
|
|
7
|
+
from .client import CrowClient
|
|
8
|
+
from .models import Panel, Area, Zone, Output, Measurement
|
|
9
|
+
from .exceptions import (
|
|
10
|
+
CrowError,
|
|
11
|
+
AuthenticationError,
|
|
12
|
+
ConnectionError,
|
|
13
|
+
ResponseError,
|
|
14
|
+
PanelNotFoundError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
TimeoutError,
|
|
17
|
+
)
|
|
18
|
+
from .session import Session
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
__author__ = "Crow Security NG Contributors"
|
|
22
|
+
__license__ = "MIT"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
# Main classes
|
|
26
|
+
"CrowClient",
|
|
27
|
+
"Session",
|
|
28
|
+
"Panel",
|
|
29
|
+
# Models
|
|
30
|
+
"Area",
|
|
31
|
+
"Zone",
|
|
32
|
+
"Output",
|
|
33
|
+
"Measurement",
|
|
34
|
+
# Exceptions
|
|
35
|
+
"CrowError",
|
|
36
|
+
"AuthenticationError",
|
|
37
|
+
"ConnectionError",
|
|
38
|
+
"ResponseError",
|
|
39
|
+
"PanelNotFoundError",
|
|
40
|
+
"RateLimitError",
|
|
41
|
+
"TimeoutError",
|
|
42
|
+
]
|
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
"""HTTP client for the Crow Cloud API."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
|
|
10
|
+
from .exceptions import (
|
|
11
|
+
AuthenticationError,
|
|
12
|
+
ConnectionError,
|
|
13
|
+
PanelNotFoundError,
|
|
14
|
+
RateLimitError,
|
|
15
|
+
ResponseError,
|
|
16
|
+
TimeoutError,
|
|
17
|
+
WebSocketError,
|
|
18
|
+
)
|
|
19
|
+
from .models import Area, Measurement, Output, Panel, Zone
|
|
20
|
+
from .utils import normalize_mac
|
|
21
|
+
|
|
22
|
+
_LOGGER = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
# API Configuration
|
|
25
|
+
DEFAULT_API_BASE = "https://api.crowcloud.com"
|
|
26
|
+
DEFAULT_TIMEOUT = 30
|
|
27
|
+
DEFAULT_RETRY_COUNT = 3
|
|
28
|
+
DEFAULT_RETRY_DELAY = 1.0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CrowClient:
|
|
32
|
+
"""
|
|
33
|
+
Async HTTP client for the Crow Cloud API.
|
|
34
|
+
|
|
35
|
+
This client handles authentication, request retries, and
|
|
36
|
+
provides methods for all Crow Cloud API operations.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
email: str,
|
|
42
|
+
password: str,
|
|
43
|
+
*,
|
|
44
|
+
api_base: str = DEFAULT_API_BASE,
|
|
45
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
46
|
+
retry_count: int = DEFAULT_RETRY_COUNT,
|
|
47
|
+
retry_delay: float = DEFAULT_RETRY_DELAY,
|
|
48
|
+
session: aiohttp.ClientSession | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Initialize the Crow Cloud client.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
email: Crow Cloud account email.
|
|
55
|
+
password: Crow Cloud account password.
|
|
56
|
+
api_base: Base URL for the API (default: https://api.crowcloud.com).
|
|
57
|
+
timeout: Request timeout in seconds.
|
|
58
|
+
retry_count: Number of retries for failed requests.
|
|
59
|
+
retry_delay: Delay between retries in seconds.
|
|
60
|
+
session: Optional aiohttp session to use.
|
|
61
|
+
"""
|
|
62
|
+
self._email = email
|
|
63
|
+
self._password = password
|
|
64
|
+
self._api_base = api_base.rstrip("/")
|
|
65
|
+
self._timeout = aiohttp.ClientTimeout(total=timeout)
|
|
66
|
+
self._retry_count = retry_count
|
|
67
|
+
self._retry_delay = retry_delay
|
|
68
|
+
self._session = session
|
|
69
|
+
self._owns_session = session is None
|
|
70
|
+
self._token: str | None = None
|
|
71
|
+
self._token_expiry: float | None = None
|
|
72
|
+
self._ws_connections: dict[str, asyncio.Task] = {}
|
|
73
|
+
|
|
74
|
+
async def _ensure_session(self) -> aiohttp.ClientSession:
|
|
75
|
+
"""Ensure we have an active session."""
|
|
76
|
+
if self._session is None or self._session.closed:
|
|
77
|
+
self._session = aiohttp.ClientSession(timeout=self._timeout)
|
|
78
|
+
self._owns_session = True
|
|
79
|
+
return self._session
|
|
80
|
+
|
|
81
|
+
async def _authenticate(self) -> None:
|
|
82
|
+
"""Authenticate with the Crow Cloud API."""
|
|
83
|
+
session = await self._ensure_session()
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
async with session.post(
|
|
87
|
+
f"{self._api_base}/api/auth/login",
|
|
88
|
+
json={"email": self._email, "password": self._password},
|
|
89
|
+
) as response:
|
|
90
|
+
if response.status == 401 or response.status == 403:
|
|
91
|
+
raise AuthenticationError()
|
|
92
|
+
|
|
93
|
+
await self._validate_response(response)
|
|
94
|
+
data = await response.json()
|
|
95
|
+
|
|
96
|
+
self._token = data.get("token") or data.get("accessToken") or data.get("access_token")
|
|
97
|
+
if not self._token:
|
|
98
|
+
raise AuthenticationError("No token received in authentication response")
|
|
99
|
+
|
|
100
|
+
except aiohttp.ClientError as err:
|
|
101
|
+
raise ConnectionError(f"Failed to connect: {err}") from err
|
|
102
|
+
|
|
103
|
+
async def _ensure_authenticated(self) -> None:
|
|
104
|
+
"""Ensure we have a valid authentication token."""
|
|
105
|
+
if not self._token:
|
|
106
|
+
await self._authenticate()
|
|
107
|
+
|
|
108
|
+
def _get_headers(self) -> dict[str, str]:
|
|
109
|
+
"""Get headers for API requests."""
|
|
110
|
+
headers = {
|
|
111
|
+
"Content-Type": "application/json",
|
|
112
|
+
"Accept": "application/json",
|
|
113
|
+
}
|
|
114
|
+
if self._token:
|
|
115
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
|
116
|
+
return headers
|
|
117
|
+
|
|
118
|
+
async def _validate_response(self, response: aiohttp.ClientResponse) -> None:
|
|
119
|
+
"""Validate an API response and raise appropriate exceptions."""
|
|
120
|
+
if response.status == 200 or response.status == 201:
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
if response.status == 401 or response.status == 403:
|
|
124
|
+
self._token = None # Clear token to force re-auth
|
|
125
|
+
raise AuthenticationError()
|
|
126
|
+
|
|
127
|
+
if response.status == 404:
|
|
128
|
+
text = await response.text()
|
|
129
|
+
raise ResponseError(404, "Resource not found", text)
|
|
130
|
+
|
|
131
|
+
if response.status == 408:
|
|
132
|
+
# 408 is expected for some operations (arm state changes)
|
|
133
|
+
_LOGGER.debug("Received 408 response (expected for some operations)")
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
if response.status == 429:
|
|
137
|
+
retry_after = response.headers.get("Retry-After")
|
|
138
|
+
raise RateLimitError(int(retry_after) if retry_after else None)
|
|
139
|
+
|
|
140
|
+
text = await response.text()
|
|
141
|
+
raise ResponseError(response.status, "API error", text)
|
|
142
|
+
|
|
143
|
+
async def _request(
|
|
144
|
+
self,
|
|
145
|
+
method: str,
|
|
146
|
+
endpoint: str,
|
|
147
|
+
*,
|
|
148
|
+
json: dict[str, Any] | None = None,
|
|
149
|
+
params: dict[str, Any] | None = None,
|
|
150
|
+
authenticate: bool = True,
|
|
151
|
+
) -> dict[str, Any] | list[Any] | None:
|
|
152
|
+
"""
|
|
153
|
+
Make an API request with retry logic.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
method: HTTP method (GET, POST, PUT, DELETE).
|
|
157
|
+
endpoint: API endpoint (without base URL).
|
|
158
|
+
json: JSON body data.
|
|
159
|
+
params: Query parameters.
|
|
160
|
+
authenticate: Whether to require authentication.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Parsed JSON response or None.
|
|
164
|
+
"""
|
|
165
|
+
if authenticate:
|
|
166
|
+
await self._ensure_authenticated()
|
|
167
|
+
|
|
168
|
+
session = await self._ensure_session()
|
|
169
|
+
url = f"{self._api_base}/{endpoint.lstrip('/')}"
|
|
170
|
+
|
|
171
|
+
last_error: Exception | None = None
|
|
172
|
+
|
|
173
|
+
for attempt in range(self._retry_count):
|
|
174
|
+
try:
|
|
175
|
+
async with session.request(
|
|
176
|
+
method,
|
|
177
|
+
url,
|
|
178
|
+
headers=self._get_headers(),
|
|
179
|
+
json=json,
|
|
180
|
+
params=params,
|
|
181
|
+
) as response:
|
|
182
|
+
await self._validate_response(response)
|
|
183
|
+
|
|
184
|
+
if response.status == 408:
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
# Try to parse JSON, return None if empty
|
|
188
|
+
try:
|
|
189
|
+
return await response.json()
|
|
190
|
+
except aiohttp.ContentTypeError:
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
except AuthenticationError:
|
|
194
|
+
# Re-authenticate and retry
|
|
195
|
+
if attempt < self._retry_count - 1:
|
|
196
|
+
await self._authenticate()
|
|
197
|
+
continue
|
|
198
|
+
raise
|
|
199
|
+
|
|
200
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as err:
|
|
201
|
+
last_error = err
|
|
202
|
+
if attempt < self._retry_count - 1:
|
|
203
|
+
await asyncio.sleep(self._retry_delay * (attempt + 1))
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
if last_error:
|
|
207
|
+
if isinstance(last_error, asyncio.TimeoutError):
|
|
208
|
+
raise TimeoutError() from last_error
|
|
209
|
+
raise ConnectionError(str(last_error)) from last_error
|
|
210
|
+
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
async def _get(
|
|
214
|
+
self,
|
|
215
|
+
endpoint: str,
|
|
216
|
+
params: dict[str, Any] | None = None
|
|
217
|
+
) -> dict[str, Any] | list[Any] | None:
|
|
218
|
+
"""Make a GET request."""
|
|
219
|
+
return await self._request("GET", endpoint, params=params)
|
|
220
|
+
|
|
221
|
+
async def _post(
|
|
222
|
+
self,
|
|
223
|
+
endpoint: str,
|
|
224
|
+
json: dict[str, Any] | None = None,
|
|
225
|
+
) -> dict[str, Any] | list[Any] | None:
|
|
226
|
+
"""Make a POST request."""
|
|
227
|
+
return await self._request("POST", endpoint, json=json)
|
|
228
|
+
|
|
229
|
+
async def _put(
|
|
230
|
+
self,
|
|
231
|
+
endpoint: str,
|
|
232
|
+
json: dict[str, Any] | None = None,
|
|
233
|
+
) -> dict[str, Any] | list[Any] | None:
|
|
234
|
+
"""Make a PUT request."""
|
|
235
|
+
return await self._request("PUT", endpoint, json=json)
|
|
236
|
+
|
|
237
|
+
# Panel operations
|
|
238
|
+
|
|
239
|
+
async def get_panel(self, mac: str) -> Panel:
|
|
240
|
+
"""
|
|
241
|
+
Get a panel by MAC address.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
mac: Panel MAC address (any format accepted).
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
Panel object.
|
|
248
|
+
|
|
249
|
+
Raises:
|
|
250
|
+
PanelNotFoundError: If panel not found.
|
|
251
|
+
"""
|
|
252
|
+
normalized_mac = normalize_mac(mac)
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
data = await self._get(f"/api/panels/{normalized_mac}")
|
|
256
|
+
if not data:
|
|
257
|
+
raise PanelNotFoundError(mac)
|
|
258
|
+
return Panel.from_api(data, normalized_mac, client=self)
|
|
259
|
+
except ResponseError as err:
|
|
260
|
+
if err.status_code == 404:
|
|
261
|
+
raise PanelNotFoundError(mac) from err
|
|
262
|
+
raise
|
|
263
|
+
|
|
264
|
+
async def get_panels(self) -> list[Panel]:
|
|
265
|
+
"""
|
|
266
|
+
Get all panels accessible to this account.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
List of Panel objects.
|
|
270
|
+
"""
|
|
271
|
+
data = await self._get("/api/panels")
|
|
272
|
+
if not data or not isinstance(data, list):
|
|
273
|
+
return []
|
|
274
|
+
return [
|
|
275
|
+
Panel.from_api(
|
|
276
|
+
p,
|
|
277
|
+
normalize_mac(p.get("mac", p.get("macAddress", ""))),
|
|
278
|
+
client=self
|
|
279
|
+
)
|
|
280
|
+
for p in data
|
|
281
|
+
]
|
|
282
|
+
|
|
283
|
+
# Area operations
|
|
284
|
+
|
|
285
|
+
async def get_areas(self, mac: str) -> list[Area]:
|
|
286
|
+
"""
|
|
287
|
+
Get all areas for a panel.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
mac: Panel MAC address.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
List of Area objects.
|
|
294
|
+
"""
|
|
295
|
+
normalized_mac = normalize_mac(mac)
|
|
296
|
+
data = await self._get(f"/api/panels/{normalized_mac}/areas")
|
|
297
|
+
if not data or not isinstance(data, list):
|
|
298
|
+
return []
|
|
299
|
+
return [Area.from_api(a) for a in data]
|
|
300
|
+
|
|
301
|
+
async def get_area(self, mac: str, area_id: str) -> Area | None:
|
|
302
|
+
"""
|
|
303
|
+
Get a specific area.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
mac: Panel MAC address.
|
|
307
|
+
area_id: Area ID.
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
Area object or None if not found.
|
|
311
|
+
"""
|
|
312
|
+
normalized_mac = normalize_mac(mac)
|
|
313
|
+
try:
|
|
314
|
+
data = await self._get(f"/api/panels/{normalized_mac}/areas/{area_id}")
|
|
315
|
+
if not data:
|
|
316
|
+
return None
|
|
317
|
+
return Area.from_api(data)
|
|
318
|
+
except ResponseError as err:
|
|
319
|
+
if err.status_code == 404:
|
|
320
|
+
return None
|
|
321
|
+
raise
|
|
322
|
+
|
|
323
|
+
async def set_area_state(
|
|
324
|
+
self,
|
|
325
|
+
mac: str,
|
|
326
|
+
area_id: str,
|
|
327
|
+
command: str
|
|
328
|
+
) -> Area | None:
|
|
329
|
+
"""
|
|
330
|
+
Set the arm state of an area.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
mac: Panel MAC address.
|
|
334
|
+
area_id: Area ID.
|
|
335
|
+
command: Command ('arm', 'stay', 'disarm').
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
Updated Area object or None.
|
|
339
|
+
"""
|
|
340
|
+
normalized_mac = normalize_mac(mac)
|
|
341
|
+
_LOGGER.info("Setting area %s state to %s", area_id, command)
|
|
342
|
+
|
|
343
|
+
try:
|
|
344
|
+
data = await self._post(
|
|
345
|
+
f"/api/panels/{normalized_mac}/areas/{area_id}/state",
|
|
346
|
+
json={"state": command}
|
|
347
|
+
)
|
|
348
|
+
if data:
|
|
349
|
+
return Area.from_api(data)
|
|
350
|
+
return None
|
|
351
|
+
except ResponseError as err:
|
|
352
|
+
if err.status_code == 408:
|
|
353
|
+
_LOGGER.debug("Received expected 408 for arm state change")
|
|
354
|
+
return None
|
|
355
|
+
raise
|
|
356
|
+
|
|
357
|
+
# Zone operations
|
|
358
|
+
|
|
359
|
+
async def get_zones(self, mac: str) -> list[Zone]:
|
|
360
|
+
"""
|
|
361
|
+
Get all zones for a panel.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
mac: Panel MAC address.
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
List of Zone objects.
|
|
368
|
+
"""
|
|
369
|
+
normalized_mac = normalize_mac(mac)
|
|
370
|
+
data = await self._get(f"/api/panels/{normalized_mac}/zones")
|
|
371
|
+
if not data or not isinstance(data, list):
|
|
372
|
+
return []
|
|
373
|
+
return [Zone.from_api(z) for z in data]
|
|
374
|
+
|
|
375
|
+
# Output operations
|
|
376
|
+
|
|
377
|
+
async def get_outputs(self, mac: str) -> list[Output]:
|
|
378
|
+
"""
|
|
379
|
+
Get all outputs for a panel.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
mac: Panel MAC address.
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
List of Output objects.
|
|
386
|
+
"""
|
|
387
|
+
normalized_mac = normalize_mac(mac)
|
|
388
|
+
data = await self._get(f"/api/panels/{normalized_mac}/outputs")
|
|
389
|
+
if not data or not isinstance(data, list):
|
|
390
|
+
return []
|
|
391
|
+
return [Output.from_api(o) for o in data]
|
|
392
|
+
|
|
393
|
+
async def set_output_state(
|
|
394
|
+
self,
|
|
395
|
+
mac: str,
|
|
396
|
+
output_id: str,
|
|
397
|
+
state: bool
|
|
398
|
+
) -> bool:
|
|
399
|
+
"""
|
|
400
|
+
Set the state of an output.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
mac: Panel MAC address.
|
|
404
|
+
output_id: Output ID.
|
|
405
|
+
state: True for on, False for off.
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
True if successful.
|
|
409
|
+
"""
|
|
410
|
+
normalized_mac = normalize_mac(mac)
|
|
411
|
+
_LOGGER.info("Setting output %s to %s", output_id, state)
|
|
412
|
+
|
|
413
|
+
try:
|
|
414
|
+
await self._post(
|
|
415
|
+
f"/api/panels/{normalized_mac}/outputs/{output_id}",
|
|
416
|
+
json={"state": state}
|
|
417
|
+
)
|
|
418
|
+
return True
|
|
419
|
+
except ResponseError:
|
|
420
|
+
return False
|
|
421
|
+
|
|
422
|
+
# Measurement operations
|
|
423
|
+
|
|
424
|
+
async def get_measurements(self, mac: str) -> list[Measurement]:
|
|
425
|
+
"""
|
|
426
|
+
Get all measurements for a panel.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
mac: Panel MAC address.
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
List of Measurement objects.
|
|
433
|
+
"""
|
|
434
|
+
normalized_mac = normalize_mac(mac)
|
|
435
|
+
data = await self._get(f"/api/panels/{normalized_mac}/measurements")
|
|
436
|
+
if not data or not isinstance(data, list):
|
|
437
|
+
return []
|
|
438
|
+
return [Measurement.from_api(m) for m in data]
|
|
439
|
+
|
|
440
|
+
# Camera operations
|
|
441
|
+
|
|
442
|
+
async def capture_cam_image(self, mac: str, zone_id: str) -> bytes | None:
|
|
443
|
+
"""
|
|
444
|
+
Capture an image from a camera zone.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
mac: Panel MAC address.
|
|
448
|
+
zone_id: Camera zone ID.
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
Image bytes or None.
|
|
452
|
+
"""
|
|
453
|
+
normalized_mac = normalize_mac(mac)
|
|
454
|
+
|
|
455
|
+
try:
|
|
456
|
+
session = await self._ensure_session()
|
|
457
|
+
await self._ensure_authenticated()
|
|
458
|
+
|
|
459
|
+
async with session.post(
|
|
460
|
+
f"{self._api_base}/api/panels/{normalized_mac}/cameras/{zone_id}/capture",
|
|
461
|
+
headers=self._get_headers(),
|
|
462
|
+
) as response:
|
|
463
|
+
if response.status == 200:
|
|
464
|
+
return await response.read()
|
|
465
|
+
return None
|
|
466
|
+
except Exception as err:
|
|
467
|
+
_LOGGER.error("Failed to capture camera image: %s", err)
|
|
468
|
+
return None
|
|
469
|
+
|
|
470
|
+
# WebSocket operations
|
|
471
|
+
|
|
472
|
+
async def ws_connect(
|
|
473
|
+
self,
|
|
474
|
+
mac: str,
|
|
475
|
+
callback: Callable[[dict[str, Any]], None],
|
|
476
|
+
*,
|
|
477
|
+
auto_reconnect: bool = True,
|
|
478
|
+
reconnect_delay: float = 5.0,
|
|
479
|
+
) -> None:
|
|
480
|
+
"""
|
|
481
|
+
Connect to WebSocket for real-time updates.
|
|
482
|
+
|
|
483
|
+
Args:
|
|
484
|
+
mac: Panel MAC address.
|
|
485
|
+
callback: Async callback function for messages.
|
|
486
|
+
auto_reconnect: Whether to automatically reconnect on disconnect.
|
|
487
|
+
reconnect_delay: Delay between reconnection attempts.
|
|
488
|
+
"""
|
|
489
|
+
normalized_mac = normalize_mac(mac)
|
|
490
|
+
await self._ensure_authenticated()
|
|
491
|
+
|
|
492
|
+
ws_url = f"{self._api_base.replace('http', 'ws')}/ws/panels/{normalized_mac}"
|
|
493
|
+
|
|
494
|
+
while True:
|
|
495
|
+
try:
|
|
496
|
+
session = await self._ensure_session()
|
|
497
|
+
|
|
498
|
+
async with session.ws_connect(
|
|
499
|
+
ws_url,
|
|
500
|
+
headers=self._get_headers(),
|
|
501
|
+
) as ws:
|
|
502
|
+
_LOGGER.info("WebSocket connected for panel %s", normalized_mac)
|
|
503
|
+
|
|
504
|
+
async for msg in ws:
|
|
505
|
+
if msg.type == aiohttp.WSMsgType.TEXT:
|
|
506
|
+
try:
|
|
507
|
+
data = msg.json()
|
|
508
|
+
await self._handle_ws_message(data, callback)
|
|
509
|
+
except Exception as err:
|
|
510
|
+
_LOGGER.error("Error handling WebSocket message: %s", err)
|
|
511
|
+
|
|
512
|
+
elif msg.type == aiohttp.WSMsgType.ERROR:
|
|
513
|
+
_LOGGER.error("WebSocket error: %s", ws.exception())
|
|
514
|
+
break
|
|
515
|
+
|
|
516
|
+
elif msg.type == aiohttp.WSMsgType.CLOSED:
|
|
517
|
+
_LOGGER.info("WebSocket closed")
|
|
518
|
+
break
|
|
519
|
+
|
|
520
|
+
except Exception as err:
|
|
521
|
+
_LOGGER.error("WebSocket connection error: %s", err)
|
|
522
|
+
|
|
523
|
+
if not auto_reconnect:
|
|
524
|
+
break
|
|
525
|
+
|
|
526
|
+
_LOGGER.info("Reconnecting WebSocket in %s seconds...", reconnect_delay)
|
|
527
|
+
await asyncio.sleep(reconnect_delay)
|
|
528
|
+
|
|
529
|
+
async def _handle_ws_message(
|
|
530
|
+
self,
|
|
531
|
+
data: dict[str, Any],
|
|
532
|
+
callback: Callable[[dict[str, Any]], None],
|
|
533
|
+
) -> None:
|
|
534
|
+
"""Handle an incoming WebSocket message."""
|
|
535
|
+
# Skip certain info messages
|
|
536
|
+
if (data.get("type") == "info" and
|
|
537
|
+
data.get("data", {}).get("_id", {}).get("dect_interface") == 32768):
|
|
538
|
+
_LOGGER.debug("Skipping DECT info message")
|
|
539
|
+
return
|
|
540
|
+
|
|
541
|
+
_LOGGER.debug("WebSocket message: %s", data)
|
|
542
|
+
|
|
543
|
+
try:
|
|
544
|
+
if asyncio.iscoroutinefunction(callback):
|
|
545
|
+
await callback(data)
|
|
546
|
+
else:
|
|
547
|
+
callback(data)
|
|
548
|
+
except Exception as err:
|
|
549
|
+
_LOGGER.error("Error in WebSocket callback: %s", err)
|
|
550
|
+
|
|
551
|
+
# Cleanup
|
|
552
|
+
|
|
553
|
+
async def close(self) -> None:
|
|
554
|
+
"""Close the client and clean up resources."""
|
|
555
|
+
# Cancel WebSocket connections
|
|
556
|
+
for task in self._ws_connections.values():
|
|
557
|
+
task.cancel()
|
|
558
|
+
self._ws_connections.clear()
|
|
559
|
+
|
|
560
|
+
# Close session if we own it
|
|
561
|
+
if self._owns_session and self._session and not self._session.closed:
|
|
562
|
+
await self._session.close()
|
|
563
|
+
self._session = None
|
|
564
|
+
|
|
565
|
+
async def __aenter__(self) -> "CrowClient":
|
|
566
|
+
"""Async context manager entry."""
|
|
567
|
+
return self
|
|
568
|
+
|
|
569
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
570
|
+
"""Async context manager exit."""
|
|
571
|
+
await self.close()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Custom exceptions for the Crow Security NG library."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class CrowError(Exception):
|
|
6
|
+
"""Base exception for all Crow Security errors."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, message: str = "An error occurred with the Crow API") -> None:
|
|
9
|
+
self.message = message
|
|
10
|
+
super().__init__(self.message)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuthenticationError(CrowError):
|
|
14
|
+
"""Raised when authentication fails."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, message: str = "Authentication failed. Check your credentials.") -> None:
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConnectionError(CrowError):
|
|
21
|
+
"""Raised when connection to the API fails."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, message: str = "Failed to connect to Crow Cloud.") -> None:
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ResponseError(CrowError):
|
|
28
|
+
"""Raised when the API returns an error response."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
status_code: int,
|
|
33
|
+
message: str = "API returned an error response",
|
|
34
|
+
response_text: str | None = None
|
|
35
|
+
) -> None:
|
|
36
|
+
self.status_code = status_code
|
|
37
|
+
self.response_text = response_text
|
|
38
|
+
full_message = f"{message} (status: {status_code})"
|
|
39
|
+
if response_text:
|
|
40
|
+
full_message += f": {response_text[:200]}" # Limit response text length
|
|
41
|
+
super().__init__(full_message)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class PanelNotFoundError(CrowError):
|
|
45
|
+
"""Raised when the requested panel is not found."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, mac: str) -> None:
|
|
48
|
+
self.mac = mac
|
|
49
|
+
super().__init__(f"Panel with MAC address '{mac}' not found.")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RateLimitError(CrowError):
|
|
53
|
+
"""Raised when rate limit is exceeded."""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
retry_after: int | None = None,
|
|
58
|
+
message: str = "Rate limit exceeded"
|
|
59
|
+
) -> None:
|
|
60
|
+
self.retry_after = retry_after
|
|
61
|
+
if retry_after:
|
|
62
|
+
message = f"{message}. Retry after {retry_after} seconds."
|
|
63
|
+
super().__init__(message)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TimeoutError(CrowError):
|
|
67
|
+
"""Raised when a request times out."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, message: str = "Request timed out") -> None:
|
|
70
|
+
super().__init__(message)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class WebSocketError(CrowError):
|
|
74
|
+
"""Raised when WebSocket connection fails."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, message: str = "WebSocket connection error") -> None:
|
|
77
|
+
super().__init__(message)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class InvalidMacError(CrowError):
|
|
81
|
+
"""Raised when an invalid MAC address is provided."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, mac: str) -> None:
|
|
84
|
+
self.mac = mac
|
|
85
|
+
super().__init__(
|
|
86
|
+
f"Invalid MAC address format: '{mac}'. "
|
|
87
|
+
"Expected 12 hexadecimal characters."
|
|
88
|
+
)
|