pydiagral 1.0.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.
pydiagral/api.py ADDED
@@ -0,0 +1,1249 @@
1
+ """Module for interacting with the Diagral API.
2
+
3
+ This module provides a DiagralAPI class that encapsulates all the functionality
4
+ for communicating with the Diagral alarm system API, including authentication,
5
+ retrieving system status, and controlling various aspects of the alarm system.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime
11
+ import logging
12
+ import re
13
+ import time
14
+ from typing import Any, Self
15
+
16
+ import aiohttp
17
+
18
+ from .constants import API_VERSION, BASE_URL
19
+ from .exceptions import (
20
+ AuthenticationError,
21
+ ClientError,
22
+ ConfigurationError,
23
+ DiagralAPIError,
24
+ ServerError,
25
+ SessionError,
26
+ ValidationError,
27
+ )
28
+ from .models import (
29
+ AlarmConfiguration,
30
+ Anomalies,
31
+ ApiKeys,
32
+ ApiKeyWithSecret,
33
+ DeviceList,
34
+ HTTPErrorResponse,
35
+ HTTPValidationError,
36
+ LoginResponse,
37
+ Rudes,
38
+ SystemDetails,
39
+ SystemStatus,
40
+ Webhook,
41
+ )
42
+ from .utils import generate_hmac_signature
43
+
44
+ _LOGGER = logging.getLogger(__name__)
45
+
46
+ # Minimum Python version: 3.10
47
+
48
+
49
+ class DiagralAPI:
50
+ """Provide interface for interacting with the Diagral API.
51
+
52
+ This class encapsulates all the functionality for communicating with
53
+ the Diagral alarm system API, including authentication, retrieving
54
+ system status, and controlling various aspects of the alarm system.
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ username: str,
60
+ password: str,
61
+ serial_id: str,
62
+ apikey: str | None = None,
63
+ secret_key: str | None = None,
64
+ pincode: int | None = None,
65
+ ) -> None:
66
+ """Initialize the DiagralAPI instance.
67
+
68
+ Args:
69
+ username (str): The email address for Diagral API authentication.
70
+ password (str): The password for Diagral API authentication.
71
+ serial_id (str): The serial ID of the Diagral system.
72
+ apikey (str | None, optional): The API key for additional authentication. Defaults to None.
73
+ secret_key (str | None, optional): The secret key for additional authentication. Defaults to None.
74
+ pincode (int | None, optional): The PIN code for the Diagral system. Defaults to None.
75
+
76
+ Raises:
77
+ ConfigurationError: If any required field is empty or invalid.
78
+
79
+ """
80
+ # Validate username as an email
81
+ if (
82
+ not username
83
+ or not isinstance(username, str)
84
+ or not self.__is_valid_email(username)
85
+ ):
86
+ raise ConfigurationError("username must be a valid non-empty email address")
87
+ self.username = username
88
+
89
+ # Validate password
90
+ if not password or not isinstance(password, str):
91
+ raise ConfigurationError("password must be a non-empty string")
92
+ self.__password = password
93
+
94
+ # Validate serial_id
95
+ if not serial_id or not isinstance(serial_id, str):
96
+ raise ConfigurationError("serial_id must be a non-empty string")
97
+ self.serial_id = serial_id
98
+
99
+ # Set apikey and secret_key
100
+ self.__apikey = apikey
101
+ self.__secret_key = secret_key
102
+
103
+ # Validate pincode
104
+ if pincode is not None:
105
+ if not isinstance(pincode, int):
106
+ raise ConfigurationError("pincode must be an integer")
107
+ self.__pincode = pincode
108
+
109
+ # Initialize session and access_token
110
+ self.session: aiohttp.ClientSession | None = None
111
+ self.__access_token: str | None = None
112
+ self.access_token_expires: datetime | None = None
113
+
114
+ # Set default values for other attributes
115
+ self.alarm_configuration: AlarmConfiguration | None = None
116
+
117
+ async def __aenter__(self) -> Self:
118
+ """Initialize the aiohttp ClientSession."""
119
+ self.session = aiohttp.ClientSession()
120
+ _LOGGER.info("Successfully initialized DiagralAPI session")
121
+ return self
122
+
123
+ async def __aexit__(self, exc_type, exc, tb):
124
+ """Close the aiohttp ClientSession."""
125
+ if self.session:
126
+ await self.session.close()
127
+
128
+ async def _request(
129
+ self, method: str, endpoint: str, timeout: float = 30, **kwargs
130
+ ) -> tuple[dict[str, Any], int]:
131
+ """Make an asynchronous HTTP request to the specified endpoint.
132
+
133
+ Args:
134
+ method (str): The HTTP method to use for the request (e.g., 'GET', 'POST').
135
+ endpoint (str): The API endpoint to send the request to.
136
+ timeout (float, optional): The timeout for the request in seconds. Defaults to 30.
137
+ **kwargs: Additional keyword arguments to pass to the request.
138
+
139
+ Returns:
140
+ tuple[dict[str, Any], int]: A tuple containing:
141
+ - dict[str, Any]: The JSON response from the API.
142
+ - int: The HTTP status code of the response.
143
+
144
+ Raises:
145
+ SessionError: If the session is not initialized.
146
+ DiagralAPIError: If the request results in a 400 status code or other API errors.
147
+ AuthenticationError: If the request results in a 401 or 403 status code.
148
+ ValidationError: If the request results in a 422 status code.
149
+ ServerError: If the request results in a 500 or 503 status code.
150
+ ClientError: If there is a network error.
151
+ aiohttp.ContentTypeError: If the response is not valid JSON.
152
+
153
+ """
154
+ if not self.session:
155
+ error_msg = "Session not initialized from __aenter__."
156
+ _LOGGER.error(error_msg)
157
+ raise SessionError(error_msg)
158
+
159
+ url = f"{BASE_URL}/{API_VERSION}/{endpoint}"
160
+ headers = kwargs.pop("headers", {})
161
+ _LOGGER.debug(
162
+ "Sending %s request to %s with headers %s and data %s",
163
+ method,
164
+ url,
165
+ headers,
166
+ kwargs.get("json", {}),
167
+ )
168
+
169
+ try:
170
+ async with self.session.request(
171
+ method, url, headers=headers, timeout=timeout, **kwargs
172
+ ) as response:
173
+ response_data = await response.json()
174
+ if response.status == 400:
175
+ response = HTTPErrorResponse(**response_data)
176
+ raise DiagralAPIError(
177
+ f"Bad request - Detail : {response.detail}",
178
+ )
179
+ if response.status == 401:
180
+ response = HTTPErrorResponse(**response_data)
181
+ raise AuthenticationError(
182
+ f"Unauthorized - Invalid or expired token - Detail : {response.detail}"
183
+ )
184
+ if response.status == 403:
185
+ response = HTTPErrorResponse(**response_data)
186
+ raise AuthenticationError(
187
+ f"Forbidden - The user does not have the right permissions - Detail : {response.detail}"
188
+ )
189
+ if response.status == 422:
190
+ _LOGGER.debug("test: %s", response_data)
191
+ response = HTTPValidationError(**response_data)
192
+ raise ValidationError(
193
+ f"Validation Error - Detail : {response.detail}"
194
+ )
195
+ if response.status == 500:
196
+ response = HTTPErrorResponse(**response_data)
197
+ raise ServerError(
198
+ f"Internal Server Error - Detail : {response.detail}"
199
+ )
200
+ if response.status == 503:
201
+ response = HTTPErrorResponse(**response_data)
202
+ raise ServerError(
203
+ f"Service temporarily unavailable - Detail : {response.detail}"
204
+ )
205
+ if response.status >= 400:
206
+ _LOGGER.debug(
207
+ "Received response with status code: %d and content %s",
208
+ response.status,
209
+ await response.json(),
210
+ )
211
+ raise DiagralAPIError(
212
+ f"API error: {response.status} - Error Message : {await response.text()}",
213
+ status_code=response.status,
214
+ )
215
+ return await response.json(), response.status
216
+ except aiohttp.ContentTypeError as e:
217
+ raise ValidationError(f"Invalid JSON response: {e}") from e
218
+ except aiohttp.ClientError as e:
219
+ raise ClientError(f"Network error: {e}") from e
220
+
221
+ async def login(self) -> None:
222
+ """Asynchronously logs in to the Diagral API using the provided username and password.
223
+
224
+ This method sends a POST request to the authentication endpoint with the necessary credentials.
225
+ If the login is successful, it retrieves and stores the access token.
226
+ If the login fails, it raises an appropriate error.
227
+
228
+ Raises:
229
+ AuthenticationError: If the login fails or the access token cannot be obtained.
230
+
231
+ """
232
+
233
+ if not self.session:
234
+ error_msg = "Session not initialized from __aenter__."
235
+ _LOGGER.error(error_msg)
236
+ raise SessionError(error_msg)
237
+
238
+ _LOGGER.debug("Attempting to login to Diagral API")
239
+ _DATA = {"username": self.username, "password": self.__password}
240
+ try:
241
+ response_data, *_ = await self._request(
242
+ "POST", "users/authenticate/login?vendor=DIAGRAL", json=_DATA
243
+ )
244
+ _LOGGER.debug("Login Response data: %s", response_data)
245
+ login_response = LoginResponse.from_dict(response_data)
246
+ _LOGGER.debug("Login response: %s", login_response)
247
+
248
+ self.__access_token = login_response.access_token
249
+ if not self.__access_token:
250
+ error_msg = "Failed to obtain authentication access_token"
251
+ _LOGGER.error(error_msg)
252
+ raise AuthenticationError(error_msg)
253
+
254
+ _LOGGER.info("Successfully logged in to Diagral API")
255
+ except DiagralAPIError as e:
256
+ error_msg = f"Failed to login : {e!s}"
257
+ _LOGGER.error(error_msg)
258
+ raise AuthenticationError(error_msg) from e
259
+
260
+ async def set_apikey(self) -> ApiKeyWithSecret:
261
+ """Asynchronously set the API key for the Diagral API.
262
+
263
+ This method first ensures that the access token is valid and not expired.
264
+ If the access token is expired, it attempts to log in again to refresh it.
265
+ Then, it sends a request to create a new API key using the current access token.
266
+ If the API key is successfully created, it verifies the API key to ensure its validity.
267
+
268
+ Returns:
269
+ ApiKeyWithSecret: An instance of ApiKeyWithSecret containing the created API key and secret key.
270
+
271
+ Raises:
272
+ AuthenticationError: If the API key is not found in the response or if the created API key fails validation.
273
+
274
+ """
275
+
276
+ if not self.__access_token:
277
+ await self.login()
278
+
279
+ if (
280
+ self.__access_token
281
+ and self.access_token_expires
282
+ and datetime.now() >= self.access_token_expires
283
+ ):
284
+ _LOGGER.warning("Access token has expired, attempting to login again")
285
+ await self.login()
286
+
287
+ _DATA = {"serial_id": self.serial_id}
288
+ _HEADERS = {
289
+ "Authorization": f"Bearer {self.__access_token}",
290
+ }
291
+
292
+ try:
293
+ response_data, *_ = await self._request(
294
+ "POST", "users/api_key", json=_DATA, headers=_HEADERS
295
+ )
296
+ set_apikey_response = ApiKeyWithSecret.from_dict(response_data)
297
+ self.__apikey = set_apikey_response.api_key
298
+ if not self.__apikey:
299
+ error_msg = "API key not found in response"
300
+ _LOGGER.error(error_msg)
301
+ raise AuthenticationError(error_msg)
302
+ self.__secret_key = set_apikey_response.secret_key
303
+ if not self.__secret_key:
304
+ error_msg = "Secret key not found in response"
305
+ _LOGGER.error(error_msg)
306
+ raise AuthenticationError(error_msg)
307
+
308
+ _LOGGER.info("Successfully created new API key: ...%s", self.__apikey[-4:])
309
+ # Verify if the API key is valid
310
+ try:
311
+ await self.validate_apikey()
312
+ _LOGGER.info(
313
+ "Successfully verified new API key: ...%s", self.__apikey[-4:]
314
+ )
315
+ except AuthenticationError as e:
316
+ _LOGGER.error("Created API key failed validation: %s", e)
317
+ self.__apikey = None
318
+ raise
319
+ except DiagralAPIError as e:
320
+ error_msg = f"Failed to create API key: {e!s}"
321
+ _LOGGER.error(error_msg)
322
+ raise AuthenticationError(error_msg) from e
323
+
324
+ return ApiKeyWithSecret(api_key=self.__apikey, secret_key=self.__secret_key)
325
+
326
+ async def validate_apikey(self, apikey: str | None = None) -> None:
327
+ """Validate the current or provided API key by checking it against the list of valid keys.
328
+
329
+ This method performs the following steps:
330
+ 1. Checks if the API key is available. If not, logs a warning and raises an AuthenticationError.
331
+ 2. Ensures that an access token is available by calling the login method if necessary.
332
+ 3. Sends a GET request to retrieve the list of valid API keys associated with the user's system.
333
+ 4. Checks if the current or provided API key is in the list of valid keys.
334
+ 5. Logs a success message if the API key is valid, otherwise raises an AuthenticationError.
335
+
336
+ Args:
337
+ apikey (str | None, optional): The API key to validate. If not provided, the instance's API key is used. Defaults to None.
338
+
339
+ Raises:
340
+ ConfigurationError: If no API key is provided or if the API key is invalid.
341
+
342
+ """
343
+
344
+ apikey_to_validate = apikey or self.__apikey
345
+
346
+ if not apikey_to_validate:
347
+ _LOGGER.warning("No API key provided to validate")
348
+ raise ConfigurationError("No API key provided to validate")
349
+
350
+ if not self.__access_token:
351
+ await self.login()
352
+
353
+ _HEADERS = {
354
+ "Authorization": f"Bearer {self.__access_token}",
355
+ }
356
+ response_data, *_ = await self._request(
357
+ "GET",
358
+ f"users/systems/{self.serial_id}/api_keys",
359
+ headers=_HEADERS,
360
+ )
361
+ validate_apikey_response = ApiKeys.from_dict(response_data)
362
+ is_valid = any(
363
+ key_info.api_key == apikey_to_validate
364
+ for key_info in validate_apikey_response.api_keys
365
+ )
366
+ if is_valid:
367
+ _LOGGER.info("API key successfully validated")
368
+ else:
369
+ raise AuthenticationError(
370
+ "API key is invalid or not found in the list of valid keys"
371
+ )
372
+
373
+ async def delete_apikey(self, apikey: str | None = None) -> None:
374
+ """Asynchronously delete the specified or current API key.
375
+
376
+ This method deletes the API key associated with the instance or the provided API key.
377
+ If the API key is not available, it raises an AuthenticationError. If the access token
378
+ is not available, it attempts to log in to obtain one. The method then sends a DELETE
379
+ request to the appropriate endpoint to delete the API key. Upon successful deletion,
380
+ it logs an informational message and sets the `apikey` and `secret_key` attributes to None.
381
+
382
+ Args:
383
+ apikey (str | None, optional): The API key to delete. If not provided, the instance's API key is used. Defaults to None.
384
+
385
+ Raises:
386
+ AuthenticationError: If the API key is not available.
387
+
388
+ """
389
+
390
+ apikey_to_delete = apikey or self.__apikey
391
+
392
+ if not apikey_to_delete:
393
+ raise AuthenticationError("An API key is required to delete it")
394
+
395
+ if not self.__access_token:
396
+ await self.login()
397
+
398
+ _HEADERS = {
399
+ "Authorization": f"Bearer {self.__access_token}",
400
+ }
401
+ await self._request(
402
+ "DELETE",
403
+ f"users/systems/{self.serial_id}/api_keys/{apikey_to_delete}",
404
+ headers=_HEADERS,
405
+ )
406
+ _LOGGER.info("Successfully deleted API key: ...%s", apikey_to_delete[-4:])
407
+
408
+ if apikey is None:
409
+ self.__apikey = None
410
+ self.__secret_key = None
411
+
412
+ async def get_configuration(self) -> None:
413
+ """Asynchronously retrieve the configuration of the Diagral system.
414
+
415
+ This method retrieves the configuration of the Diagral system by sending a GET
416
+ request to the appropriate endpoint. If the access token is not available, it
417
+ attempts to log in to obtain one. Upon successful retrieval, it logs an informational
418
+ message with the configuration details. The retrieved configuration is stored in
419
+ the self.alarm_configuration attribute, allowing it to be reused within the same
420
+ session without needing to collect it multiple times.
421
+
422
+ Returns:
423
+ AlarmConfiguration: The configuration details of the Diagral system.
424
+
425
+ Raises:
426
+ AuthenticationError: If the access token is not available.
427
+
428
+ """
429
+
430
+ if not self.__apikey or not self.__secret_key:
431
+ raise AuthenticationError(
432
+ "API key and secret key required to get configuration"
433
+ )
434
+
435
+ _TIMESTAMP = str(int(time.time()))
436
+ _HMAC = generate_hmac_signature(
437
+ timestamp=_TIMESTAMP,
438
+ serial_id=self.serial_id,
439
+ api_key=self.__apikey,
440
+ secret_key=self.__secret_key,
441
+ )
442
+
443
+ _HEADERS = {
444
+ "X-HMAC": _HMAC,
445
+ "X-TIMESTAMP": _TIMESTAMP,
446
+ "X-APIKEY": self.__apikey,
447
+ }
448
+ response_data, *_ = await self._request(
449
+ "GET", f"systems/{self.serial_id}/configurations", headers=_HEADERS
450
+ )
451
+ self.alarm_configuration = AlarmConfiguration.from_dict(response_data)
452
+ _LOGGER.debug(
453
+ "Successfully retrieved configuration: %s", self.alarm_configuration
454
+ )
455
+ return self.alarm_configuration
456
+
457
+ async def get_devices_info(self) -> DeviceList:
458
+ """Asynchronously retrieves information about various device types from the alarm configuration.
459
+
460
+ The method retrieve information for each device type (cameras, commands, sensors, sirens,
461
+ transmitters) from the alarm configuration, and compiles this information into a dictionary.
462
+
463
+ Returns:
464
+ dict: A dictionary where the keys are device types and the values are lists of dictionaries
465
+ containing device information (index and label).
466
+
467
+ Raises:
468
+ ConfigurationError: If the alarm configuration cannot be retrieved.
469
+
470
+ """
471
+
472
+ if not self.alarm_configuration:
473
+ await self.get_configuration()
474
+
475
+ if not self.alarm_configuration:
476
+ raise ConfigurationError("Failed to retrieve alarm configuration")
477
+
478
+ device_types = sorted(
479
+ ["cameras", "commands", "sensors", "sirens", "transmitters"]
480
+ )
481
+ devices_infos = {}
482
+ for device_type in device_types:
483
+ _LOGGER.debug("Retrieving devices information for %s", device_type)
484
+ devices = getattr(self.alarm_configuration, device_type, None)
485
+ if devices is not None:
486
+ devices_infos[device_type] = [
487
+ {"index": device.index, "label": device.label} for device in devices
488
+ ]
489
+ else:
490
+ devices_infos[device_type] = []
491
+ _LOGGER.debug("Successfully retrieved devices information: %s", devices_infos)
492
+ return DeviceList.from_dict(devices_infos)
493
+
494
+ async def get_system_details(self) -> SystemDetails:
495
+ """Asynchronously retrieves the system details.
496
+
497
+ This method fetches the system details using the provided API key, secret key,
498
+ and PIN code. It generates the necessary HMAC signature and includes it in the
499
+ request headers.
500
+
501
+ Returns:
502
+ SystemDetails: An instance of SystemDetails containing the retrieved system information.
503
+
504
+ Raises:
505
+ AuthenticationError: If the API key, secret key, or PIN code is not provided.
506
+
507
+ """
508
+
509
+ if not self.__apikey or not self.__secret_key:
510
+ raise AuthenticationError(
511
+ "API key and secret key required to get system details"
512
+ )
513
+
514
+ if not self.__pincode:
515
+ raise AuthenticationError("PIN code required to get system details")
516
+
517
+ _TIMESTAMP = str(int(time.time()))
518
+ _HMAC = generate_hmac_signature(
519
+ timestamp=_TIMESTAMP,
520
+ serial_id=self.serial_id,
521
+ api_key=self.__apikey,
522
+ secret_key=self.__secret_key,
523
+ )
524
+
525
+ _HEADERS = {
526
+ "X-PIN-CODE": str(self.__pincode),
527
+ "X-HMAC": _HMAC,
528
+ "X-TIMESTAMP": _TIMESTAMP,
529
+ "X-APIKEY": self.__apikey,
530
+ }
531
+ response_data, *_ = await self._request(
532
+ "GET", f"systems/{self.serial_id}", headers=_HEADERS
533
+ )
534
+ _LOGGER.debug("Successfully retrieved system details: %s", response_data)
535
+ return SystemDetails.from_dict(response_data)
536
+
537
+ async def get_system_status(self) -> SystemStatus:
538
+ """Asynchronously retrieves the system status.
539
+
540
+ This method fetches the current status of the system using the provided API key,
541
+ secret key, and PIN code. It generates an HMAC signature for authentication and
542
+ sends a GET request to the system status endpoint.
543
+
544
+ Returns:
545
+ SystemStatus: An instance of SystemStatus containing the retrieved system status.
546
+
547
+ Raises:
548
+ AuthenticationError: If the API key, secret key, or PIN code is not provided.
549
+
550
+ """
551
+
552
+ if not self.__apikey or not self.__secret_key:
553
+ raise AuthenticationError(
554
+ "API key and secret key required to get system details"
555
+ )
556
+
557
+ if not self.__pincode:
558
+ raise AuthenticationError("PIN code required to get system details")
559
+
560
+ _TIMESTAMP = str(int(time.time()))
561
+ _HMAC = generate_hmac_signature(
562
+ timestamp=_TIMESTAMP,
563
+ serial_id=self.serial_id,
564
+ api_key=self.__apikey,
565
+ secret_key=self.__secret_key,
566
+ )
567
+
568
+ _HEADERS = {
569
+ "X-PIN-CODE": str(self.__pincode),
570
+ "X-HMAC": _HMAC,
571
+ "X-TIMESTAMP": _TIMESTAMP,
572
+ "X-APIKEY": self.__apikey,
573
+ }
574
+ response_data, *_ = await self._request(
575
+ "GET", f"systems/{self.serial_id}/status", headers=_HEADERS
576
+ )
577
+ _LOGGER.debug("Successfully retrieved system status: %s", response_data)
578
+ return SystemStatus.from_dict(response_data)
579
+
580
+ async def __system_action(self, action: str) -> SystemStatus:
581
+ """Perform a system action such as start, stop, presence, partial start 1, or partial start 2.
582
+
583
+ Args:
584
+ action (str): The action to perform. Must be one of 'start', 'stop', 'presence', 'partial_start_1', or 'partial_start_2'.
585
+
586
+ Returns:
587
+ SystemStatus: An instance of SystemStatus containing the system status after performing the action.
588
+
589
+ Raises:
590
+ ConfigurationError: If the action is not one of the allowed actions.
591
+ AuthenticationError: If the API key, secret key, or PIN code is missing.
592
+ All other exceptions from _request function are propagated.
593
+
594
+ """
595
+
596
+ if action not in [
597
+ "start",
598
+ "stop",
599
+ "presence",
600
+ "partial_start_1",
601
+ "partial_start_2",
602
+ ]:
603
+ raise ConfigurationError(
604
+ "Action must be one of 'start', 'stop', 'presence', 'partial_start_1', or 'partial_start_2'"
605
+ )
606
+
607
+ if not self.__apikey or not self.__secret_key:
608
+ raise AuthenticationError(
609
+ "API key and secret key required to get system details"
610
+ )
611
+
612
+ if not self.__pincode:
613
+ raise AuthenticationError(f"PIN code required to do system action {action}")
614
+
615
+ _TIMESTAMP = str(int(time.time()))
616
+ _HMAC = generate_hmac_signature(
617
+ timestamp=_TIMESTAMP,
618
+ serial_id=self.serial_id,
619
+ api_key=self.__apikey,
620
+ secret_key=self.__secret_key,
621
+ )
622
+
623
+ _HEADERS = {
624
+ "X-PIN-CODE": str(self.__pincode),
625
+ "X-HMAC": _HMAC,
626
+ "X-TIMESTAMP": _TIMESTAMP,
627
+ "X-APIKEY": self.__apikey,
628
+ }
629
+ response_data, *_ = await self._request(
630
+ "POST", f"systems/{self.serial_id}/{action}", headers=_HEADERS
631
+ )
632
+ _LOGGER.debug(
633
+ "Successfully performed action %s: %s", action.upper(), response_data
634
+ )
635
+ return SystemStatus.from_dict(response_data)
636
+
637
+ async def start_system(self) -> SystemStatus:
638
+ """Asynchronously starts the system.
639
+
640
+ This method sends a request to start the system and returns the system status.
641
+
642
+ Returns:
643
+ SystemStatus: An instance of SystemStatus containing the system status after starting the system.
644
+
645
+ Raises:
646
+ All exceptions from __system_action function are propagated.
647
+
648
+ """
649
+
650
+ return await self.__system_action("start")
651
+
652
+ async def stop_system(self) -> SystemStatus:
653
+ """Asynchronously stops the system.
654
+
655
+ This method sends a request to stop the system and returns the system status.
656
+
657
+ Returns:
658
+ SystemStatus: An instance of SystemStatus containing the system status after stopping the system.
659
+
660
+ Raises:
661
+ All exceptions from __system_action function are propagated.
662
+
663
+ """
664
+
665
+ return await self.__system_action("stop")
666
+
667
+ async def presence(self) -> SystemStatus:
668
+ """Asynchronously starts the system in presence mode.
669
+
670
+ Returns:
671
+ SystemStatus: An instance of SystemStatus containing the system status after starting the system in presence mode.
672
+
673
+ Raises:
674
+ All exceptions from __system_action function are propagated.
675
+
676
+ """
677
+
678
+ return await self.__system_action("presence")
679
+
680
+ async def partial_start_system(self, id: int = 1) -> SystemStatus: # NOT-TESTED
681
+ """Initiate a partial start of the system.
682
+
683
+ Args:
684
+ id (int, optional): The ID of the partial start. Must be either 1 or 2. Defaults to 1.
685
+
686
+ Returns:
687
+ SystemStatus: An instance of SystemStatus containing the system status after performing the partial start.
688
+
689
+ Raises:
690
+ ConfigurationError: If the provided ID is not 1 or 2.
691
+ All other exceptions from __system_action function are propagated.
692
+
693
+ """
694
+
695
+ if id not in [1, 2]:
696
+ raise ConfigurationError("Partial Start Id must be 1 or 2")
697
+
698
+ return await self.__system_action(f"partial_start_{id}")
699
+
700
+ async def __action_group_system(
701
+ self, action: str, groups: list[int]
702
+ ) -> SystemStatus: # TO-TEST
703
+ """Perform an action on a group of systems.
704
+
705
+ This method activates or disables a group of systems based on the provided action.
706
+
707
+ Args:
708
+ action (str): The action to perform. Must be either 'activate_group' or 'disable_group'.
709
+ groups (list[int]): A list of group indices to perform the action on.
710
+
711
+ Returns:
712
+ SystemStatus: An instance of SystemStatus containing the system status after performing the action.
713
+
714
+ Raises:
715
+ ConfigurationError: If the action is not 'activate_group' or 'disable_group', or if the groups are invalid.
716
+ AuthenticationError: If the API key, secret key, or PIN code is not provided.
717
+ All other exceptions from _request function are propagated.
718
+
719
+ """
720
+
721
+ if action not in ["activate_group", "disable_group"]:
722
+ raise ConfigurationError(
723
+ "Action must be either 'activate_group' or 'disable_group'"
724
+ )
725
+
726
+ if not self.__apikey or not self.__secret_key:
727
+ raise AuthenticationError(
728
+ "API key and secret key required to get system details"
729
+ )
730
+
731
+ if not self.__pincode:
732
+ raise AuthenticationError("PIN code required to get system details")
733
+
734
+ # Get the configuration if it is not already available
735
+ if not self.alarm_configuration:
736
+ await self.get_configuration()
737
+
738
+ # Check if the groups are valid
739
+ invalid_groups = [
740
+ group
741
+ for group in groups
742
+ if group not in [g.index for g in self.alarm_configuration.groups]
743
+ ]
744
+ if invalid_groups:
745
+ raise ConfigurationError(
746
+ f"The following groups do not exist in your system: {invalid_groups}"
747
+ )
748
+
749
+ _TIMESTAMP = str(int(time.time()))
750
+ _HMAC = generate_hmac_signature(
751
+ timestamp=_TIMESTAMP,
752
+ serial_id=self.serial_id,
753
+ api_key=self.__apikey,
754
+ secret_key=self.__secret_key,
755
+ )
756
+
757
+ _HEADERS = {
758
+ "X-PIN-CODE": str(self.__pincode),
759
+ "X-HMAC": _HMAC,
760
+ "X-TIMESTAMP": _TIMESTAMP,
761
+ "X-APIKEY": self.__apikey,
762
+ }
763
+ data = {"groups": groups}
764
+ response_data, *_ = await self._request(
765
+ "POST",
766
+ f"systems/{self.serial_id}/{action}",
767
+ headers=_HEADERS,
768
+ json=data,
769
+ )
770
+ _LOGGER.debug(
771
+ "Successfully %s %s: %s", action.replace("_", " "), groups, response_data
772
+ )
773
+ return SystemStatus.from_dict(response_data)
774
+
775
+ async def activate_group(self, groups: list[int]) -> SystemStatus:
776
+ """Asynchronously activates a group of systems.
777
+
778
+ Args:
779
+ groups (list[int]): A list of integers representing the groups to be activated.
780
+
781
+ Returns:
782
+ SystemStatus: An instance of SystemStatus containing the system status after activating the groups.
783
+
784
+ Raises:
785
+ ConfigurationError: If the action is not one of the allowed actions.
786
+ All other exceptions from __action_group_system function are propagated.
787
+
788
+ """
789
+
790
+ if not isinstance(groups, list) or not all(
791
+ isinstance(item, int) for item in groups
792
+ ):
793
+ raise ConfigurationError("Groups must be a list of integers")
794
+
795
+ return await self.__action_group_system("activate_group", groups)
796
+
797
+ async def disable_group(self, groups: list[int]) -> SystemStatus:
798
+ """Asynchronously disables a group of systems.
799
+
800
+ Args:
801
+ groups (list[int]): A list of integers representing the groups to disable.
802
+
803
+ Returns:
804
+ SystemStatus: An instance of SystemStatus containing the system status after disabling the groups.
805
+
806
+ Raises:
807
+ ConfigurationError: If the action is not one of the allowed actions.
808
+ All other exceptions from __action_group_system function are propagated.
809
+
810
+ """
811
+
812
+ if not isinstance(groups, list) or not all(
813
+ isinstance(item, int) for item in groups
814
+ ):
815
+ raise ConfigurationError("Groups must be a list of integers")
816
+
817
+ return await self.__action_group_system("disable_group", groups)
818
+
819
+ async def __action_product(
820
+ self, action: str, type: str, product_id: int
821
+ ) -> SystemStatus: # NOT-TESTED
822
+ """Perform an action on a product in the Diagral system.
823
+
824
+ This method enables or disables a product based on the provided action.
825
+
826
+ Args:
827
+ action (str): The action to perform. Must be either 'enable' or 'disable'.
828
+ type (str): The type of product to perform the action on.
829
+ product_id (int): The ID of the product to perform the action on.
830
+
831
+ Returns:
832
+ SystemStatus: An instance of SystemStatus containing the system status after performing the action.
833
+
834
+ Raises:
835
+ ConfigurationError: If the action is not 'enable' or 'disable', or if the product type is invalid.
836
+ AuthenticationError: If the API key, secret key, or PIN code is not provided.
837
+ All other exceptions from _request function are propagated.
838
+
839
+ """
840
+
841
+ if action not in ["enable", "disable"]:
842
+ raise ConfigurationError("Action must be either 'enable' or 'disable'")
843
+
844
+ if type not in ["CENTRAL", "SENSOR", "COMMAND", "ALARM", "BOX", "PLUG"]:
845
+ raise ConfigurationError(
846
+ "Product type must be one of 'CENTRAL', 'SENSOR', 'COMMAND', 'ALARM', 'BOX', or 'PLUG'"
847
+ )
848
+
849
+ if not self.__apikey or not self.__secret_key:
850
+ raise AuthenticationError(
851
+ "API key and secret key required to get system details"
852
+ )
853
+
854
+ if not self.__pincode:
855
+ raise AuthenticationError("PIN code required to get system details")
856
+
857
+ _TIMESTAMP = str(int(time.time()))
858
+ _HMAC = generate_hmac_signature(
859
+ timestamp=_TIMESTAMP,
860
+ serial_id=self.serial_id,
861
+ api_key=self.__apikey,
862
+ secret_key=self.__secret_key,
863
+ )
864
+
865
+ _HEADERS = {
866
+ "X-PIN-CODE": str(self.__pincode),
867
+ "X-HMAC": _HMAC,
868
+ "X-TIMESTAMP": _TIMESTAMP,
869
+ "X-APIKEY": self.__apikey,
870
+ }
871
+ response_data, *_ = await self._request(
872
+ "POST",
873
+ f"systems/{self.serial_id}/{type}/{product_id}/{action}",
874
+ headers=_HEADERS,
875
+ )
876
+ return SystemStatus.from_dict(response_data)
877
+
878
+ async def enable_product(
879
+ self, type: str, product_id: int
880
+ ) -> SystemStatus: # NOT-TESTED
881
+ """Asynchronously enables a product in the system.
882
+
883
+ Args:
884
+ type (str): The type of the product to enable.
885
+ product_id (int): The unique identifier of the product to enable.
886
+
887
+ Returns:
888
+ SystemStatus: An instance of SystemStatus containing the system status after enabling the product.
889
+
890
+ Raises:
891
+ All exceptions from __action_product function are propagated.
892
+
893
+ """
894
+ return await self.__action_product(
895
+ action="enable", type=type, product_id=product_id
896
+ )
897
+
898
+ async def disable_product(
899
+ self, type: str, product_id: int
900
+ ) -> SystemStatus: # NOT-TESTED
901
+ """Asynchronously enables a product in the system.
902
+
903
+ Args:
904
+ type (str): The type of the product to enable.
905
+ product_id (int): The unique identifier of the product to enable.
906
+
907
+ Returns:
908
+ SystemStatus: An instance of SystemStatus containing the system status after disabling the product.
909
+
910
+ Raises:
911
+ All exceptions from __action_product function are propagated.
912
+
913
+ """
914
+ return await self.__action_product(
915
+ action="disable", type=type, product_id=product_id
916
+ )
917
+
918
+ async def get_anomalies(self) -> Anomalies | dict:
919
+ """Asynchronously retrieves anomalies for the system.
920
+
921
+ This method fetches the anomalies associated with the system identified by the serial ID. It requires valid API key and secret key for authentication.
922
+
923
+ Returns:
924
+ Anomalies | dict: An instance of the Anomalies class populated with the data retrieved from the API, or an empty dictionary if no anomalies are found.
925
+
926
+ Raises:
927
+ AuthenticationError: If the API key or secret key is not provided.
928
+
929
+ """
930
+
931
+ if not self.__apikey or not self.__secret_key:
932
+ raise AuthenticationError(
933
+ "API key and secret key required to get system details"
934
+ )
935
+
936
+ _TIMESTAMP = str(int(time.time()))
937
+ _HMAC = generate_hmac_signature(
938
+ timestamp=_TIMESTAMP,
939
+ serial_id=self.serial_id,
940
+ api_key=self.__apikey,
941
+ secret_key=self.__secret_key,
942
+ )
943
+
944
+ _HEADERS = {
945
+ "X-HMAC": _HMAC,
946
+ "X-TIMESTAMP": _TIMESTAMP,
947
+ "X-APIKEY": self.__apikey,
948
+ }
949
+ try:
950
+ response_data, *_ = await self._request(
951
+ "GET",
952
+ f"systems/{self.serial_id}/anomalies",
953
+ headers=_HEADERS,
954
+ )
955
+ except DiagralAPIError as e:
956
+ if e.status_code == 404 and "No anomalies found" in e.message:
957
+ _LOGGER.info("No anomalies found for the system")
958
+ return {}
959
+ return Anomalies.from_dict(response_data)
960
+
961
+ async def delete_anomalies(self, anomaly_id: int) -> None: # NOT-IMPLEMENTED
962
+ """Asynchronously delete the list of anomalies.
963
+
964
+ This method is currently not implemented.
965
+
966
+ Raises:
967
+ NotImplementedError: This method is not yet implemented.
968
+
969
+ """
970
+ raise NotImplementedError("Method not yet implemented")
971
+
972
+ async def get_automatism_rudes(self) -> Rudes: # NOT-TESTED
973
+ """Asynchronously retrieves the automatism Rudes for the system.
974
+
975
+ This method fetches the Rudes data for the system identified by the serial ID.
976
+ It requires valid API and secret keys for authentication.
977
+
978
+ Returns:
979
+ Rudes: An instance of the Rudes class populated with the data from the response.
980
+
981
+ Raises:
982
+ AuthenticationError: If the API key or secret key is not provided.
983
+ All other exceptions from _request function are propagated.
984
+
985
+ """
986
+
987
+ if not self.__apikey or not self.__secret_key:
988
+ raise AuthenticationError(
989
+ "API key and secret key required to get system details"
990
+ )
991
+
992
+ _TIMESTAMP = str(int(time.time()))
993
+ _HMAC = generate_hmac_signature(
994
+ timestamp=_TIMESTAMP,
995
+ serial_id=self.serial_id,
996
+ api_key=self.__apikey,
997
+ secret_key=self.__secret_key,
998
+ )
999
+
1000
+ _HEADERS = {
1001
+ "X-HMAC": _HMAC,
1002
+ "X-TIMESTAMP": _TIMESTAMP,
1003
+ "X-APIKEY": self.__apikey,
1004
+ }
1005
+ response_data, *_ = await self._request(
1006
+ "GET",
1007
+ f"systems/{self.serial_id}/rudes",
1008
+ headers=_HEADERS,
1009
+ )
1010
+ return Rudes.from_dict(response_data)
1011
+
1012
+ async def set_automatism_action(
1013
+ self, canal: str, action: str
1014
+ ) -> None: # NOT-IMPLEMENTED
1015
+ """Set the automatism action.
1016
+
1017
+ This method is currently not implemented.
1018
+
1019
+ Args:
1020
+ canal (str): The canal for the automatism action.
1021
+ action (str): The action to be set for the automatism.
1022
+
1023
+ Raises:
1024
+ NotImplementedError: Always, as this method is not yet implemented.
1025
+
1026
+ """
1027
+ raise NotImplementedError("Method not yet implemented")
1028
+
1029
+ async def get_webhook(self) -> Webhook:
1030
+ """Retrieve the webhook subscription details for the system.
1031
+
1032
+ Returns:
1033
+ Webhook: An instance of the Webhook class containing the subscription details.
1034
+
1035
+ Raises:
1036
+ AuthenticationError: If the API key or secret key is not provided.
1037
+
1038
+ """
1039
+
1040
+ if not self.__apikey or not self.__secret_key:
1041
+ raise AuthenticationError(
1042
+ "API key and secret key required to get system details"
1043
+ )
1044
+
1045
+ _TIMESTAMP = str(int(time.time()))
1046
+ _HMAC = generate_hmac_signature(
1047
+ timestamp=_TIMESTAMP,
1048
+ serial_id=self.serial_id,
1049
+ api_key=self.__apikey,
1050
+ secret_key=self.__secret_key,
1051
+ )
1052
+
1053
+ _HEADERS = {
1054
+ "X-HMAC": _HMAC,
1055
+ "X-TIMESTAMP": _TIMESTAMP,
1056
+ "X-APIKEY": self.__apikey,
1057
+ }
1058
+ response_data, *_ = await self._request(
1059
+ "GET",
1060
+ f"webhooks/{self.serial_id}/subscription",
1061
+ headers=_HEADERS,
1062
+ )
1063
+ return Webhook.from_dict(response_data)
1064
+
1065
+ async def __webhook_action_create_update(
1066
+ self,
1067
+ action: str,
1068
+ webhook_url: str,
1069
+ subscribe_to_anomaly: bool = False,
1070
+ subscribe_to_alert: bool = False,
1071
+ subscribe_to_state: bool = False,
1072
+ ) -> Webhook | None:
1073
+ """Create or update a webhook subscription.
1074
+
1075
+ Args:
1076
+ action (str): The action to perform, either 'register' or 'update'.
1077
+ webhook_url (str): The URL of the webhook to register or update.
1078
+ subscribe_to_anomaly (bool, optional): Whether to subscribe to anomaly notifications. Defaults to False.
1079
+ subscribe_to_alert (bool, optional): Whether to subscribe to alert notifications. Defaults to False.
1080
+ subscribe_to_state (bool, optional): Whether to subscribe to state notifications. Defaults to False.
1081
+
1082
+ Returns:
1083
+ Webhook | None: The created or updated Webhook object, or None if registration is skipped.
1084
+
1085
+ Raises:
1086
+ ConfigurationError: If the action is not 'register' or 'update'.
1087
+ AuthenticationError: If the API key or secret key is missing.
1088
+ ValidationError: If the webhook URL is invalid or if the subscription already exists.
1089
+
1090
+ """
1091
+ if action not in ["register", "update"]:
1092
+ raise ConfigurationError("Action must be either 'register' or 'update'")
1093
+
1094
+ if not self.__apikey or not self.__secret_key:
1095
+ raise AuthenticationError(
1096
+ "API key and secret key required to get system details"
1097
+ )
1098
+
1099
+ if action == "register" and not any(
1100
+ [subscribe_to_anomaly, subscribe_to_alert, subscribe_to_state]
1101
+ ):
1102
+ _LOGGER.warning("No subscriptions selected, skipping webhook registration")
1103
+ return None
1104
+
1105
+ if not re.match(r"^https?://", webhook_url):
1106
+ raise ValidationError(
1107
+ "Invalid webhook URL. Must start with http:// or https://"
1108
+ )
1109
+
1110
+ _TIMESTAMP = str(int(time.time()))
1111
+ _HMAC = generate_hmac_signature(
1112
+ timestamp=_TIMESTAMP,
1113
+ serial_id=self.serial_id,
1114
+ api_key=self.__apikey,
1115
+ secret_key=self.__secret_key,
1116
+ )
1117
+
1118
+ _HEADERS = {
1119
+ "X-HMAC": _HMAC,
1120
+ "X-TIMESTAMP": _TIMESTAMP,
1121
+ "X-APIKEY": self.__apikey,
1122
+ }
1123
+ data = {
1124
+ "webhook_url": webhook_url,
1125
+ "subscribe_to_anomaly": subscribe_to_anomaly,
1126
+ "subscribe_to_alert": subscribe_to_alert,
1127
+ "subscribe_to_state": subscribe_to_state,
1128
+ }
1129
+ method = "POST" if action == "register" else "PUT"
1130
+ try:
1131
+ response_data, *_ = await self._request(
1132
+ method,
1133
+ f"webhooks/{self.serial_id}/subscription",
1134
+ headers=_HEADERS,
1135
+ json=data,
1136
+ )
1137
+ except DiagralAPIError as e:
1138
+ if "Subscription already exists" in str(e):
1139
+ raise DiagralAPIError(
1140
+ "Webhook subscription already exists. Please use the 'update' action to modify it."
1141
+ ) from e
1142
+ if "There is no subscription for" in str(e):
1143
+ raise DiagralAPIError(
1144
+ "No subscription found for the specified serial ID"
1145
+ ) from e
1146
+ return Webhook.from_dict(response_data)
1147
+
1148
+ async def register_webhook(
1149
+ self,
1150
+ webhook_url: str,
1151
+ subscribe_to_anomaly: bool = False,
1152
+ subscribe_to_alert: bool = False,
1153
+ subscribe_to_state: bool = False,
1154
+ ) -> Webhook | None:
1155
+ """Register a webhook with the specified URL and subscription options.
1156
+
1157
+ Args:
1158
+ webhook_url (str): The URL to which the webhook will send data.
1159
+ subscribe_to_anomaly (bool, optional): If True, subscribe to anomaly events. Defaults to False.
1160
+ subscribe_to_alert (bool, optional): If True, subscribe to alert events. Defaults to False.
1161
+ subscribe_to_state (bool, optional): If True, subscribe to state events. Defaults to False.
1162
+
1163
+ Returns:
1164
+ Webhook | None: The registered Webhook object if successful, otherwise None.
1165
+
1166
+ """
1167
+ return await self.__webhook_action_create_update(
1168
+ "register",
1169
+ webhook_url,
1170
+ subscribe_to_anomaly,
1171
+ subscribe_to_alert,
1172
+ subscribe_to_state,
1173
+ )
1174
+
1175
+ async def update_webhook(
1176
+ self,
1177
+ webhook_url: str,
1178
+ subscribe_to_anomaly: bool = False,
1179
+ subscribe_to_alert: bool = False,
1180
+ subscribe_to_state: bool = False,
1181
+ ) -> Webhook | None:
1182
+ """Update the webhook configuration with the specified parameters.
1183
+
1184
+ Args:
1185
+ webhook_url (str): The URL of the webhook to update.
1186
+ subscribe_to_anomaly (bool, optional): Whether to subscribe to anomaly notifications. Defaults to False.
1187
+ subscribe_to_alert (bool, optional): Whether to subscribe to alert notifications. Defaults to False.
1188
+ subscribe_to_state (bool, optional): Whether to subscribe to state notifications. Defaults to False.
1189
+
1190
+ Returns:
1191
+ Webhook | None: The registered Webhook object if successful, otherwise None.
1192
+
1193
+ """
1194
+ return await self.__webhook_action_create_update(
1195
+ "update",
1196
+ webhook_url,
1197
+ subscribe_to_anomaly,
1198
+ subscribe_to_alert,
1199
+ subscribe_to_state,
1200
+ )
1201
+
1202
+ async def delete_webhook(self) -> None:
1203
+ """Asynchronously deletes the webhook subscription for the current system.
1204
+
1205
+ Raises:
1206
+ AuthenticationError: If the API key or secret key is not provided.
1207
+
1208
+ Returns:
1209
+ None
1210
+
1211
+ """
1212
+
1213
+ if not self.__apikey or not self.__secret_key:
1214
+ raise AuthenticationError(
1215
+ "API key and secret key required to get system details"
1216
+ )
1217
+
1218
+ _TIMESTAMP = str(int(time.time()))
1219
+ _HMAC = generate_hmac_signature(
1220
+ timestamp=_TIMESTAMP,
1221
+ serial_id=self.serial_id,
1222
+ api_key=self.__apikey,
1223
+ secret_key=self.__secret_key,
1224
+ )
1225
+
1226
+ _HEADERS = {
1227
+ "X-HMAC": _HMAC,
1228
+ "X-TIMESTAMP": _TIMESTAMP,
1229
+ "X-APIKEY": self.__apikey,
1230
+ }
1231
+ response_data, *_ = await self._request(
1232
+ "DELETE",
1233
+ f"webhooks/{self.serial_id}/subscription",
1234
+ headers=_HEADERS,
1235
+ )
1236
+
1237
+ @staticmethod
1238
+ def __is_valid_email(email: str) -> bool:
1239
+ """Validate the format of an email address.
1240
+
1241
+ Args:
1242
+ email (str): The email address to validate.
1243
+
1244
+ Returns:
1245
+ bool: True if the email is valid, False otherwise.
1246
+
1247
+ """
1248
+ email_regex = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
1249
+ return re.match(email_regex, email) is not None