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.
@@ -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
+ )