pyg90alarm 2.3.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 (42) hide show
  1. pyg90alarm/__init__.py +84 -0
  2. pyg90alarm/alarm.py +1274 -0
  3. pyg90alarm/callback.py +146 -0
  4. pyg90alarm/cloud/__init__.py +31 -0
  5. pyg90alarm/cloud/const.py +56 -0
  6. pyg90alarm/cloud/messages.py +593 -0
  7. pyg90alarm/cloud/notifications.py +410 -0
  8. pyg90alarm/cloud/protocol.py +518 -0
  9. pyg90alarm/const.py +273 -0
  10. pyg90alarm/definitions/__init__.py +3 -0
  11. pyg90alarm/definitions/base.py +247 -0
  12. pyg90alarm/definitions/devices.py +366 -0
  13. pyg90alarm/definitions/sensors.py +843 -0
  14. pyg90alarm/entities/__init__.py +3 -0
  15. pyg90alarm/entities/base_entity.py +93 -0
  16. pyg90alarm/entities/base_list.py +268 -0
  17. pyg90alarm/entities/device.py +97 -0
  18. pyg90alarm/entities/device_list.py +156 -0
  19. pyg90alarm/entities/sensor.py +891 -0
  20. pyg90alarm/entities/sensor_list.py +183 -0
  21. pyg90alarm/exceptions.py +63 -0
  22. pyg90alarm/local/__init__.py +0 -0
  23. pyg90alarm/local/base_cmd.py +293 -0
  24. pyg90alarm/local/config.py +157 -0
  25. pyg90alarm/local/discovery.py +103 -0
  26. pyg90alarm/local/history.py +272 -0
  27. pyg90alarm/local/host_info.py +89 -0
  28. pyg90alarm/local/host_status.py +52 -0
  29. pyg90alarm/local/notifications.py +117 -0
  30. pyg90alarm/local/paginated_cmd.py +132 -0
  31. pyg90alarm/local/paginated_result.py +135 -0
  32. pyg90alarm/local/targeted_discovery.py +162 -0
  33. pyg90alarm/local/user_data_crc.py +46 -0
  34. pyg90alarm/notifications/__init__.py +0 -0
  35. pyg90alarm/notifications/base.py +481 -0
  36. pyg90alarm/notifications/protocol.py +127 -0
  37. pyg90alarm/py.typed +0 -0
  38. pyg90alarm-2.3.0.dist-info/METADATA +277 -0
  39. pyg90alarm-2.3.0.dist-info/RECORD +42 -0
  40. pyg90alarm-2.3.0.dist-info/WHEEL +5 -0
  41. pyg90alarm-2.3.0.dist-info/licenses/LICENSE +21 -0
  42. pyg90alarm-2.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,518 @@
1
+ # Copyright (c) 2025 Ilia Sotnikov
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ # SOFTWARE.
20
+
21
+ """
22
+ Protocol implementation for G90 cloud communication.
23
+
24
+ This module defines the base classes and structures used for encoding and
25
+ decoding messages that flow between G90 alarm devices and their cloud servers.
26
+
27
+ Combination of Python `dataclasses` and `struct` is used to define the
28
+ protocol messages. The `dataclass` decorator is used to define the message
29
+ classes, while the `struct` module is used to define the binary format of
30
+ the messages.
31
+ """
32
+ from __future__ import annotations
33
+ from typing import ClassVar, cast, List, Type, TypeVar, Optional
34
+ from struct import unpack, calcsize, pack, error as StructError
35
+ from dataclasses import dataclass, astuple, asdict, InitVar
36
+ from datetime import datetime, timezone
37
+ import logging
38
+
39
+ from .const import G90CloudDirection, G90CloudCommand
40
+ from ..const import G90AlertTypes
41
+
42
+ _LOGGER = logging.getLogger(__name__)
43
+ CloudBaseT = TypeVar('CloudBaseT', bound='G90CloudBase')
44
+ CloudHeaderT = TypeVar('CloudHeaderT', bound='G90CloudHeader')
45
+ CloudMessageT = TypeVar('CloudMessageT', bound='G90CloudMessage')
46
+
47
+ PROTOCOL_VERSION = 1
48
+
49
+
50
+ class G90CloudError(Exception):
51
+ """
52
+ Base exception for G90 cloud protocol errors.
53
+ """
54
+
55
+
56
+ class G90CloudMessageNoMatch(G90CloudError):
57
+ """
58
+ Raised when a message does not match the expected format or type.
59
+ """
60
+
61
+
62
+ class G90CloudMessageInvalid(G90CloudError):
63
+ """
64
+ Raised when a message is invalid or cannot be processed.
65
+ """
66
+
67
+
68
+ @dataclass
69
+ class G90CloudMessageContext: # pylint:disable=too-many-instance-attributes
70
+ """
71
+ Context for G90 cloud messages.
72
+
73
+ This class holds information about the local and remote hosts and ports,
74
+ as well as the cloud server and upstream connection details.
75
+ """
76
+ local_host: str
77
+ local_port: int
78
+ remote_host: str
79
+ remote_port: int
80
+ cloud_host: str
81
+ cloud_port: int
82
+ upstream_host: Optional[str]
83
+ upstream_port: Optional[int]
84
+ device_id: Optional[str]
85
+
86
+
87
+ @dataclass
88
+ class G90CloudBase:
89
+ """
90
+ Base class for G90 cloud protocol messages.
91
+
92
+ Provides methods for encoding and decoding messages to and from their wire
93
+ representation.
94
+ """
95
+ # Format of the binary data representing the message, see `struct` module
96
+ # for the format supported
97
+ _format: ClassVar[str]
98
+ # Context for the message, should be provided when instsantiating the
99
+ # message class
100
+ context: InitVar[G90CloudMessageContext]
101
+ # Stored context
102
+ _context: ClassVar[G90CloudMessageContext]
103
+
104
+ @classmethod
105
+ def from_wire(
106
+ cls: Type[CloudBaseT], data: bytes, context: G90CloudMessageContext
107
+ ) -> CloudBaseT:
108
+ """
109
+ Decode a message from its wire representation.
110
+
111
+ :param data: The raw bytes of the message.
112
+ :param context: The message context.
113
+ :return: An instance of the message class.
114
+ """
115
+ assert cls.format() is not None
116
+ assert data is not None
117
+
118
+ cls._context = context
119
+
120
+ try:
121
+ elems = unpack(cls.format(), data[0:cls.size()])
122
+ except StructError as exc:
123
+ raise G90CloudError(
124
+ f"Failed to unpack data: {exc}"
125
+ f", supplied data length: {len(data)}, format: {cls.format()}"
126
+ ) from exc
127
+
128
+ try:
129
+ obj = cls(context, *elems)
130
+ except TypeError as exc:
131
+ raise G90CloudError(
132
+ f"Failed to create object: {exc}"
133
+ f", supplied data length: {len(data)}, format: {cls.format()}"
134
+ ) from exc
135
+
136
+ return obj
137
+
138
+ def to_wire(self) -> bytes:
139
+ """
140
+ Encode the message to its wire representation.
141
+
142
+ :return: The raw bytes of the message.
143
+ """
144
+ ret = pack(self.format(), *astuple(self))
145
+ return ret
146
+
147
+ @classmethod
148
+ def size(cls) -> int:
149
+ """
150
+ Get the size of the message in bytes.
151
+
152
+ :return: The size of the message.
153
+ """
154
+ return calcsize(cls.format())
155
+
156
+ @classmethod
157
+ def format(cls) -> str:
158
+ """
159
+ Get the format string for the message.
160
+
161
+ :return: The format string.
162
+ """
163
+ return cls._format
164
+
165
+ def __str__(self) -> str:
166
+ """
167
+ Get a string representation of the message.
168
+
169
+ :return: A string representation of the message.
170
+ """
171
+ return (
172
+ f"{type(self).__name__}("
173
+ f"wire_representation={self.to_wire().hex(' ')}"
174
+ )
175
+
176
+
177
+ @dataclass
178
+ class G90CloudHeader(G90CloudBase):
179
+ """
180
+ Header for G90 cloud protocol messages.
181
+
182
+ Contains metadata about the message, such as its command, source,
183
+ destination, and payload length.
184
+ """
185
+ _format = '<4Bi'
186
+
187
+ command: int
188
+ _source: int
189
+ flag1: int
190
+ _destination: int
191
+ message_length: int
192
+
193
+ def __post_init__(self, context: G90CloudMessageContext) -> None:
194
+ """
195
+ Initialize the header and its payload.
196
+
197
+ :param context: The message context.
198
+ """
199
+ super().__init__(context)
200
+ self._payload: bytes = bytes()
201
+
202
+ @property
203
+ def source(self) -> G90CloudDirection:
204
+ """
205
+ Get the source direction of the message.
206
+
207
+ :return: The source direction.
208
+ """
209
+ return G90CloudDirection(self._source)
210
+
211
+ @property
212
+ def destination(self) -> G90CloudDirection:
213
+ """
214
+ Get the destination direction of the message.
215
+
216
+ :return: The destination direction.
217
+ """
218
+ return G90CloudDirection(self._destination)
219
+
220
+ @classmethod
221
+ def from_wire(
222
+ cls: Type[CloudHeaderT], data: bytes, context: G90CloudMessageContext
223
+ ) -> CloudHeaderT:
224
+ """
225
+ Decode a header from its wire representation.
226
+
227
+ :param data: The raw bytes of the header.
228
+ :param context: The message context.
229
+ :return: An instance of the header class.
230
+ """
231
+ obj = super().from_wire(data, context)
232
+
233
+ message_length = obj.message_length # pylint:disable=no-member
234
+ if message_length > len(data):
235
+ raise G90CloudError(
236
+ f"Message length of {message_length} specified in header"
237
+ f" exceeds actual data length {len(data)}"
238
+ )
239
+
240
+ obj._payload = bytes() # pylint:disable=protected-access
241
+ if cls.size() < len(data):
242
+ # Payload length in header includes the header size, remove it from
243
+ # resulting payload
244
+ # pylint:disable=no-member,protected-access
245
+ obj._payload = data[cls.size():obj.message_length]
246
+ return obj
247
+
248
+ @property
249
+ def payload(self) -> bytes:
250
+ """
251
+ Get the payload of the message.
252
+
253
+ :return: The raw bytes of the payload.
254
+ """
255
+ return self._payload
256
+
257
+ @property
258
+ def payload_length(self) -> int:
259
+ """
260
+ Get the length of the payload in bytes.
261
+
262
+ :return: The payload length.
263
+ """
264
+ return self.message_length - self.size()
265
+
266
+ def matches(self, value: G90CloudHeader) -> bool:
267
+ """
268
+ Check if the header matches another header.
269
+
270
+ :param value: The header to compare against.
271
+ :return: True if the headers match, False otherwise.
272
+ """
273
+ try:
274
+ return (
275
+ self.command == value.command
276
+ and self._source == value._source
277
+ # pylint:disable=protected-access
278
+ and self._destination == value._destination
279
+ )
280
+ except AttributeError:
281
+ return False
282
+
283
+ def __str__(self) -> str:
284
+ """
285
+ Get a string representation of the header.
286
+
287
+ :return: A string representation of the header.
288
+ """
289
+ return (
290
+ f"{super().__str__()}"
291
+ f", source={repr(self.source)}"
292
+ f", destination={repr(self.destination)}"
293
+ f", payload({self.payload_length})={self.payload.hex(' ')})"
294
+ )
295
+
296
+
297
+ @dataclass
298
+ class G90CloudHeaderVersioned(G90CloudHeader):
299
+ """
300
+ Versioned header for G90 cloud protocol messages.
301
+
302
+ Adds version and sequence information to the header.
303
+ """
304
+ _format = '<4BiHH'
305
+
306
+ version: int = PROTOCOL_VERSION
307
+ # Sequence will be initialized by :class:`G90CloudMessage` class, has to
308
+ # have a default value as required by `dataclass` (non-default fields
309
+ # cannot follow ones with defaults)
310
+ sequence: int = 0
311
+
312
+ def __post_init__(self, context: G90CloudMessageContext) -> None:
313
+ super().__post_init__(context)
314
+ if self.version != PROTOCOL_VERSION:
315
+ raise G90CloudMessageInvalid(
316
+ f'Invalid version in header: {self.version}'
317
+ )
318
+
319
+ def __str__(self) -> str:
320
+ """
321
+ Get a string representation of the versioned header.
322
+
323
+ :return: A string representation of the versioned header.
324
+ """
325
+ return (
326
+ f"{super().__str__()}"
327
+ f", version={self.version}"
328
+ f", sequence={self.sequence}"
329
+ )
330
+
331
+
332
+ @dataclass
333
+ class G90CloudMessage(G90CloudBase):
334
+ """
335
+ Base class for G90 cloud protocol messages with headers.
336
+
337
+ Provides methods for encoding and decoding messages with headers, as well
338
+ as handling responses.
339
+ """
340
+ _command: ClassVar[G90CloudCommand]
341
+ _destination: ClassVar[G90CloudDirection]
342
+ _source: ClassVar[G90CloudDirection]
343
+ _responses: ClassVar[List[Type[G90CloudMessage]]] = []
344
+ _header_kls: ClassVar[Type[G90CloudHeader]] = G90CloudHeaderVersioned
345
+
346
+ def __post_init__(self, context: G90CloudMessageContext) -> None:
347
+ """
348
+ Initialize the message and its header.
349
+
350
+ :param context: The message context.
351
+ """
352
+ self.header = self._header_kls(
353
+ command=self._command, _source=self._source,
354
+ _destination=self._destination, flag1=0,
355
+ message_length=self._header_kls.size() + self.size(),
356
+ context=context
357
+ )
358
+
359
+ @classmethod
360
+ def from_wire(
361
+ cls: Type[CloudMessageT], data: bytes, context: G90CloudMessageContext
362
+ ) -> CloudMessageT:
363
+ """
364
+ Decode a message from its wire representation.
365
+
366
+ :param data: The raw bytes of the message.
367
+ :param context: The message context.
368
+ :return: An instance of the message class.
369
+ """
370
+ header = cls._header_kls.from_wire(data, context)
371
+
372
+ header_matches = cls.header_matches(header)
373
+ if not header_matches:
374
+ raise G90CloudMessageNoMatch('Header does not match')
375
+
376
+ try:
377
+ # pylint:disable=no-member
378
+ obj = super().from_wire(header.payload, context)
379
+ except ValueError as exc:
380
+ _LOGGER.error(
381
+ "Failed to create %s from wire: %s",
382
+ cls.__name__, exc
383
+ )
384
+ raise G90CloudMessageNoMatch('Failed to create object') from exc
385
+
386
+ obj_matches = cls.matches(obj)
387
+ if not obj_matches:
388
+ raise G90CloudMessageNoMatch('Message does not match')
389
+
390
+ obj.header = header
391
+ return obj
392
+
393
+ def to_wire(self) -> bytes:
394
+ """
395
+ Encode the message to its wire representation.
396
+
397
+ :return: The raw bytes of the message.
398
+ """
399
+ # Reconstruct the wire representation of the block using header plus
400
+ # payload
401
+ ret = self.header.to_wire() + super().to_wire()
402
+ return ret
403
+
404
+ def wire_responses(self, context: G90CloudMessageContext) -> List[bytes]:
405
+ """
406
+ Get the wire representations of the responses to this message.
407
+
408
+ :param context: The message context.
409
+ :return: A list of raw bytes for the responses.
410
+ """
411
+ result = []
412
+ for idx, response in enumerate(self._responses):
413
+ obj = response(context)
414
+ # Only messages with versioned headers can have sequence numbers
415
+ if isinstance(obj.header, G90CloudHeaderVersioned):
416
+ # Sequence numbers are either 0 for single message responses,
417
+ # or start from 1 if there are multiple ones
418
+ obj.header.sequence = 0
419
+ if len(self._responses) > 1:
420
+ obj.header.sequence = idx + 1
421
+ _LOGGER.debug(
422
+ "%s: Will send response: %s", type(self).__name__, obj
423
+ )
424
+ result.append(obj.to_wire())
425
+ return result
426
+
427
+ @classmethod
428
+ def matches(cls, value: G90CloudMessage) -> bool:
429
+ """
430
+ Check if the message matches another message.
431
+
432
+ :param value: The message to compare against.
433
+ :return: True if the messages match, False otherwise.
434
+ """
435
+ try:
436
+ return (
437
+ # pylint:disable=protected-access
438
+ cls._command == value._command
439
+ and cls._source == value._source
440
+ # pylint:disable=protected-access
441
+ and cls._destination == value._destination
442
+ )
443
+ except AttributeError:
444
+ return False
445
+
446
+ @classmethod
447
+ def header_matches(cls, value: G90CloudHeader) -> bool:
448
+ """
449
+ Check if the header matches the expected header for this message type.
450
+
451
+ :param value: The header to compare against.
452
+ :return: True if the headers match, False otherwise.
453
+ """
454
+ try:
455
+ return (
456
+ cls._command == value.command
457
+ and cls._source == value.source
458
+ and cls._destination == value.destination
459
+ )
460
+ except AttributeError:
461
+ return False
462
+
463
+ def __str__(self) -> str:
464
+ """
465
+ Get a string representation of the message.
466
+
467
+ :return: A string representation of the message.
468
+ """
469
+ b = [f"{k}={v}" for k, v in asdict(self).items()]
470
+ return (
471
+ f"{type(self).__name__}("
472
+ f"header={str(self.header)}"
473
+ f", wire_representation={self.to_wire().hex(' ')}"
474
+ f", {', '.join(b)})"
475
+ )
476
+
477
+
478
+ class G90CloudStatusChangeReqMessageBase(G90CloudMessage):
479
+ """
480
+ Base class for status change request messages in the G90 cloud protocol.
481
+
482
+ Provides methods for handling status change requests and their timestamps.
483
+ """
484
+ _type: ClassVar[G90AlertTypes]
485
+
486
+ type: int
487
+ _timestamp: int # Unix timestamp
488
+
489
+ @property
490
+ def timestamp(self) -> datetime:
491
+ """
492
+ Get the timestamp as a datetime object.
493
+
494
+ :return: The message timestamp converted to a datetime object with UTC
495
+ timezone.
496
+ """
497
+ return datetime.fromtimestamp(
498
+ self._timestamp, tz=timezone.utc
499
+ )
500
+
501
+ @classmethod
502
+ def matches(
503
+ cls, value: G90CloudMessage
504
+ ) -> bool:
505
+ """
506
+ Check if the message matches the expected type and format.
507
+
508
+ :param value: The message to compare against.
509
+ :return: True if the messages match, False otherwise.
510
+ """
511
+ try:
512
+ obj = cast(G90CloudStatusChangeReqMessageBase, value)
513
+ return (
514
+ super().matches(value)
515
+ and obj.type == cls._type
516
+ )
517
+ except AttributeError:
518
+ return False