pyg90alarm 1.19.0__py3-none-any.whl → 2.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.
Files changed (34) hide show
  1. pyg90alarm/__init__.py +5 -5
  2. pyg90alarm/alarm.py +159 -114
  3. pyg90alarm/cloud/__init__.py +31 -0
  4. pyg90alarm/cloud/const.py +56 -0
  5. pyg90alarm/cloud/messages.py +593 -0
  6. pyg90alarm/cloud/notifications.py +409 -0
  7. pyg90alarm/cloud/protocol.py +518 -0
  8. pyg90alarm/const.py +5 -0
  9. pyg90alarm/entities/base_entity.py +83 -0
  10. pyg90alarm/entities/base_list.py +165 -0
  11. pyg90alarm/entities/device_list.py +58 -0
  12. pyg90alarm/entities/sensor.py +63 -3
  13. pyg90alarm/entities/sensor_list.py +50 -0
  14. pyg90alarm/local/__init__.py +0 -0
  15. pyg90alarm/{base_cmd.py → local/base_cmd.py} +3 -6
  16. pyg90alarm/{discovery.py → local/discovery.py} +1 -1
  17. pyg90alarm/{history.py → local/history.py} +4 -2
  18. pyg90alarm/{host_status.py → local/host_status.py} +1 -1
  19. pyg90alarm/local/notifications.py +116 -0
  20. pyg90alarm/{paginated_cmd.py → local/paginated_cmd.py} +2 -2
  21. pyg90alarm/{paginated_result.py → local/paginated_result.py} +1 -1
  22. pyg90alarm/{targeted_discovery.py → local/targeted_discovery.py} +2 -2
  23. pyg90alarm/notifications/__init__.py +0 -0
  24. pyg90alarm/{device_notifications.py → notifications/base.py} +115 -173
  25. pyg90alarm/notifications/protocol.py +116 -0
  26. {pyg90alarm-1.19.0.dist-info → pyg90alarm-2.0.0.dist-info}/METADATA +112 -18
  27. pyg90alarm-2.0.0.dist-info/RECORD +40 -0
  28. {pyg90alarm-1.19.0.dist-info → pyg90alarm-2.0.0.dist-info}/WHEEL +1 -1
  29. pyg90alarm-1.19.0.dist-info/RECORD +0 -27
  30. /pyg90alarm/{config.py → local/config.py} +0 -0
  31. /pyg90alarm/{host_info.py → local/host_info.py} +0 -0
  32. /pyg90alarm/{user_data_crc.py → local/user_data_crc.py} +0 -0
  33. {pyg90alarm-1.19.0.dist-info → pyg90alarm-2.0.0.dist-info/licenses}/LICENSE +0 -0
  34. {pyg90alarm-1.19.0.dist-info → pyg90alarm-2.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,593 @@
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
+ Cloud message implementations for G90 alarm systems.
23
+
24
+ This module provides concrete message classes for cloud communication with G90
25
+ alarm systems, including ping messages, discovery messages, status change
26
+ notifications, and alarm notifications.
27
+ """
28
+ from typing import List, Type, cast, ClassVar, Any, TypeVar
29
+ import logging
30
+ from dataclasses import dataclass
31
+ from datetime import datetime, timezone
32
+
33
+ from pyg90alarm.cloud.protocol import G90CloudMessageContext
34
+
35
+ from .protocol import (
36
+ G90CloudMessage, G90CloudStatusChangeReqMessageBase, G90CloudHeader
37
+ )
38
+ from .const import G90CloudDirection, G90CloudCommand
39
+ from ..const import (
40
+ G90AlertStateChangeTypes, REMOTE_CLOUD_HOST, REMOTE_CLOUD_PORT,
41
+ G90AlertTypes, G90AlertSources, G90AlertStates,
42
+ )
43
+ from ..entities.sensor import G90SensorTypes
44
+ from ..notifications.base import G90DeviceAlert
45
+
46
+
47
+ _LOGGER = logging.getLogger(__name__)
48
+ CLOUD_MESSAGE_CLASSES: List[Type[Any]] = []
49
+ CloudMessageT = TypeVar('CloudMessageT', bound='G90CloudMessage')
50
+
51
+
52
+ def cloud_message(obj: Type[CloudMessageT]) -> Type[CloudMessageT]:
53
+ """
54
+ Register a cloud message class.
55
+
56
+ This decorator registers the cloud message class in the global registry
57
+ and ensures there are no duplicate registrations for the same command/
58
+ source/destination combination.
59
+
60
+ :param obj: The cloud message class to register
61
+ :return: The registered cloud message class
62
+ :raises ValueError: If a class with the same command/source/destination is
63
+ already registered
64
+ """
65
+ for cls in CLOUD_MESSAGE_CLASSES:
66
+ if cls.matches(obj):
67
+ # pylint:disable=protected-access
68
+ raise ValueError(
69
+ f"Duplicate command={obj._command}"
70
+ f"/source={obj._source}/destination={obj._destination}"
71
+ f" in {cls} and {obj}"
72
+ )
73
+ CLOUD_MESSAGE_CLASSES.append(obj)
74
+ return obj
75
+
76
+
77
+ @dataclass
78
+ class G90CloudPingRespMessage(G90CloudMessage):
79
+ """
80
+ Response message for ping requests.
81
+
82
+ A message sent in response to a ping request from the device.
83
+ """
84
+ _format = ''
85
+ _command = G90CloudCommand.HELLO
86
+ _source = G90CloudDirection.DEVICE
87
+ _destination = G90CloudDirection.UNSPECIFIED
88
+ _header_kls = G90CloudHeader
89
+
90
+
91
+ @cloud_message
92
+ @dataclass
93
+ class G90CloudPingReqMessage(G90CloudMessage):
94
+ """
95
+ Ping request message sent by the device to the cloud server.
96
+
97
+ This message is sent every minute as a keepalive mechanism.
98
+
99
+ :attr _responses: The possible response message classes
100
+ """
101
+ _format = ''
102
+ _command = G90CloudCommand.HELLO
103
+ _source = G90CloudDirection.DEVICE
104
+ _destination = G90CloudDirection.UNSPECIFIED
105
+ _responses = [G90CloudPingRespMessage]
106
+ _header_kls = G90CloudHeader
107
+
108
+
109
+ @dataclass
110
+ class G90CloudHelloAckMessage(G90CloudMessage):
111
+ """
112
+ Acknowledgement message sent by the cloud server in response to a hello
113
+ message.
114
+
115
+ This message confirms receipt of the hello request from the device.
116
+ """
117
+ _format = '<B'
118
+ _command = G90CloudCommand.HELLO_ACK
119
+ _source = G90CloudDirection.CLOUD
120
+ _destination = G90CloudDirection.DEVICE
121
+
122
+ flag: int = 1
123
+
124
+
125
+ @dataclass
126
+ class G90CloudHelloRespMessage(G90CloudMessage):
127
+ """
128
+ Response message from the cloud server to a hello request from a device.
129
+ """
130
+ _format = '<B'
131
+ _command = G90CloudCommand.HELLO
132
+ _source = G90CloudDirection.CLOUD
133
+ _destination = G90CloudDirection.DEVICE
134
+
135
+ flag: int = 0x1f
136
+
137
+
138
+ @dataclass
139
+ class G90CloudHelloInfoRespMessage(G90CloudMessage):
140
+ """
141
+ Information response message from the cloud server to a device's hello
142
+ request.
143
+
144
+ This message contains information about the port the device should use for
145
+ communication.
146
+ """
147
+ _format = '<i'
148
+ _command = G90CloudCommand.HELLO_INFO
149
+ _source = G90CloudDirection.CLOUD
150
+ _destination = G90CloudDirection.DEVICE
151
+
152
+ # Actual port is set from context in `__post_init__()` method below
153
+ port: int = 0
154
+
155
+ def __post_init__(self, context: G90CloudMessageContext) -> None:
156
+ super().__post_init__(context)
157
+ self.port = context.local_port
158
+
159
+
160
+ @cloud_message
161
+ @dataclass
162
+ # pylint:disable=too-many-instance-attributes
163
+ class G90CloudHelloReqMessage(G90CloudMessage):
164
+ """
165
+ Hello request message sent by the device to the cloud server.
166
+
167
+ This message is sent every minute as part of the device's heartbeat
168
+ mechanism.
169
+ """
170
+ _format = '<15sx4i3sx6i'
171
+ _command = G90CloudCommand.HELLO
172
+ _source = G90CloudDirection.DEVICE
173
+ _destination = G90CloudDirection.CLOUD
174
+ _responses = [
175
+ G90CloudHelloAckMessage, G90CloudHelloRespMessage,
176
+ G90CloudHelloInfoRespMessage
177
+ ]
178
+
179
+ _guid: bytes
180
+ flag1: int # Typically is 1
181
+ flag2: int # Typically is 0
182
+ flag3: int # Typically is 2
183
+ flag4: int # Typically is 28672 (0x7000)
184
+ fw_ver: str
185
+ flag5: int # Typically is 0x2000NNNN
186
+ flag6: int # Typically is 48 (0x30)
187
+ flag7: int # Typically is 0
188
+ flag8: int # Typically is 7
189
+ flag9: int # Typically is 30 (0x1E)
190
+ flag10: int # Typically is 30 (0x1E)
191
+
192
+ @property
193
+ def guid(self) -> str:
194
+ """
195
+ Get the device GUID as a string.
196
+
197
+ :return: The device's GUID decoded from bytes to string
198
+ """
199
+ return self._guid.decode()
200
+
201
+
202
+ @dataclass
203
+ class G90CloudHelloDiscoveryRespMessage(G90CloudMessage):
204
+ """
205
+ Discovery response message from the cloud to the device.
206
+
207
+ This message contains information about the cloud server's IP, port, and
208
+ timestamp.
209
+ """
210
+ _format = '<16s4i'
211
+ _command = G90CloudCommand.HELLO
212
+ _source = G90CloudDirection.CLOUD_DISCOVERY
213
+ _destination = G90CloudDirection.DEVICE
214
+
215
+ # Simulated cloud response always contains known IP address of the vendor's
216
+ # cloud service - that is, all interactions between alarm panel and
217
+ # simulated cloud service will use same IP address for unification (i.e.
218
+ # traffic redicrection will always be used to divert panel's cloud traffic
219
+ # to the simulated cloud service)
220
+ ip_addr: bytes = REMOTE_CLOUD_HOST.encode()
221
+ flag2: int = 0
222
+ flag3: int = 0
223
+ port: int = REMOTE_CLOUD_PORT
224
+ _timestamp: int = 0 # unix timestamp
225
+
226
+ def __post_init__(self, context: G90CloudMessageContext) -> None:
227
+ super().__post_init__(context)
228
+
229
+ self._timestamp = int(datetime.now(timezone.utc).timestamp())
230
+ _LOGGER.debug(
231
+ "%s: Timestamp added: %s", type(self).__name__, str(self)
232
+ )
233
+ self.ip_addr = context.cloud_host.encode()
234
+ self.port = context.cloud_port
235
+
236
+ @property
237
+ def timestamp(self) -> datetime:
238
+ """
239
+ Get the timestamp as a datetime object.
240
+
241
+ :return: The message timestamp converted to a datetime object with UTC
242
+ timezone
243
+ """
244
+ return datetime.fromtimestamp(
245
+ self._timestamp, tz=timezone.utc
246
+ )
247
+
248
+ def __str__(self) -> str:
249
+ return (
250
+ f"{type(self).__name__}"
251
+ f"({super().__str__()}"
252
+ f", timestamp={self.timestamp})"
253
+ )
254
+
255
+
256
+ @cloud_message
257
+ @dataclass
258
+ # pylint:disable=too-many-instance-attributes
259
+ class G90CloudHelloDiscoveryReqMessage(G90CloudMessage):
260
+ """
261
+ Hello discovery request message sent by the device.
262
+
263
+ This message is used during the device discovery process to locate the
264
+ cloud server.
265
+ """
266
+ _format = '<15sx4i3sx3i'
267
+ _command = G90CloudCommand.HELLO
268
+ _source = G90CloudDirection.DEVICE_DISCOVERY
269
+ _destination = G90CloudDirection.CLOUD
270
+ _responses = [G90CloudHelloDiscoveryRespMessage]
271
+
272
+ _guid: bytes
273
+ flag1: int # Typically is 0
274
+ flag2: int # Typically is 0
275
+ flag3: int # Typically is 1
276
+ flag4: int # Typically is 28672 (0x7000)
277
+ fw_ver: str
278
+ flag5: int # Typically is 0x05050505
279
+ flag6: int # Typically is 0x06060030
280
+ flag7: int # Typically is 0x07070707
281
+
282
+ @property
283
+ def guid(self) -> str:
284
+ """
285
+ Get the device GUID as a string.
286
+
287
+ :return: The device's GUID decoded from bytes to string
288
+ """
289
+ return self._guid.decode()
290
+
291
+
292
+ @cloud_message
293
+ @dataclass
294
+ # pylint:disable=too-many-instance-attributes
295
+ class G90CloudStatusChangeReqMessage(G90CloudStatusChangeReqMessageBase):
296
+ """
297
+ Status change request message from the device to the cloud.
298
+
299
+ This message is sent when the device's status changes, such as arming or
300
+ disarming.
301
+ """
302
+ # 68x are typically zeros, while 34x is some garbage from previous
303
+ # notification message (0x22) with its head overwritten with old/new status
304
+ # values
305
+ _format = '<2B34xi68x'
306
+ _command = G90CloudCommand.STATUS_CHANGE
307
+ _source = G90CloudDirection.DEVICE
308
+ _destination = G90CloudDirection.CLOUD
309
+ _type = G90AlertTypes.STATE_CHANGE
310
+
311
+ type: int
312
+ _state: G90AlertStateChangeTypes
313
+ _timestamp: int # Unix timestamp
314
+
315
+ @property
316
+ def state(self) -> G90AlertStateChangeTypes:
317
+ """
318
+ Get the state change type.
319
+
320
+ :return: The alert state change type
321
+ """
322
+ return G90AlertStateChangeTypes(self._state)
323
+
324
+ @property
325
+ def as_device_alert(self) -> G90DeviceAlert:
326
+ """
327
+ Convert the message to a device alert object.
328
+
329
+ :return: A G90DeviceAlert object constructed from the message
330
+ properties
331
+ """
332
+ return G90DeviceAlert(
333
+ device_id=self._context.device_id or '',
334
+ state=self.state,
335
+ event_id=self.state,
336
+ zone_name='',
337
+ type=self._type,
338
+ source=G90AlertSources.DEVICE,
339
+ unix_time=self._timestamp,
340
+ resv4=0,
341
+ other='',
342
+ )
343
+
344
+ def __str__(self) -> str:
345
+ return (
346
+ f"{type(self).__name__}"
347
+ f"({super().__str__()}"
348
+ f", type={self.type}"
349
+ f", state={repr(self.state)}"
350
+ f", timestamp={self.timestamp})"
351
+ )
352
+
353
+
354
+ @cloud_message
355
+ @dataclass
356
+ # pylint:disable=too-many-instance-attributes
357
+ class G90CloudStatusChangeSensorReqMessage(G90CloudStatusChangeReqMessageBase):
358
+ """
359
+ Status change sensor request message from the device to the cloud.
360
+
361
+ This message is sent when a sensor's status changes, such as when motion is
362
+ detected or a door/window is opened.
363
+ """
364
+ _format = '<4B32si68x'
365
+ _command = G90CloudCommand.STATUS_CHANGE
366
+ _source = G90CloudDirection.DEVICE
367
+ _destination = G90CloudDirection.CLOUD
368
+ _type: ClassVar[G90AlertTypes] = G90AlertTypes.SENSOR_ACTIVITY
369
+
370
+ type: int
371
+ sensor_id: int
372
+ _sensor_type: G90SensorTypes
373
+ _sensor_state: int
374
+ _sensor: bytes
375
+ _timestamp: int # Unix timestamp
376
+
377
+ @property
378
+ def sensor_type(self) -> G90SensorTypes:
379
+ """
380
+ Get the sensor type.
381
+
382
+ :return: The type of the sensor that triggered the event
383
+ """
384
+ return G90SensorTypes(self._sensor_type)
385
+
386
+ @property
387
+ def sensor_state(self) -> G90AlertStates:
388
+ """
389
+ Get the sensor state.
390
+
391
+ :return: The state of the sensor that triggered the event
392
+ """
393
+ return G90AlertStates(self._sensor_state)
394
+
395
+ @property
396
+ def sensor(self) -> str:
397
+ """
398
+ Get the sensor name as a string.
399
+
400
+ :return: The sensor name decoded from bytes with null characters
401
+ removed
402
+ """
403
+ return self._sensor.decode().rstrip('\x00')
404
+
405
+ @property
406
+ def as_device_alert(self) -> G90DeviceAlert:
407
+ """
408
+ Convert the message to a device alert object.
409
+
410
+ :return: A G90DeviceAlert object constructed from the message
411
+ properties
412
+ """
413
+ return G90DeviceAlert(
414
+ device_id=self._context.device_id or '',
415
+ state=self.sensor_state,
416
+ event_id=cast(G90AlertStateChangeTypes, self.sensor_id),
417
+ zone_name=self.sensor,
418
+ type=self._type,
419
+ source=G90AlertSources.SENSOR,
420
+ unix_time=self._timestamp,
421
+ resv4=0,
422
+ other='',
423
+ )
424
+
425
+ def __str__(self) -> str:
426
+ return (
427
+ f"{type(self).__name__}"
428
+ f"({super().__str__()}"
429
+ f", type={self.type}"
430
+ f", sensor={repr(self.sensor)}"
431
+ f", sensor id ={self.sensor_id}"
432
+ f", sensor type={repr(self.sensor_type)}"
433
+ f", sensor state={repr(self.sensor_state)}"
434
+ f", timestamp={self.timestamp})"
435
+ )
436
+
437
+
438
+ @cloud_message
439
+ @dataclass
440
+ # pylint:disable=too-many-instance-attributes
441
+ class G90CloudStatusChangeAlarmReqMessage(G90CloudStatusChangeReqMessageBase):
442
+ """
443
+ Status change alarm request message from the device to the cloud.
444
+
445
+ This message is sent when an alarm is triggered on the device, such as when
446
+ an intrusion is detected or when the panic button is pressed.
447
+ """
448
+ _format = '<4B32si68x'
449
+ _command = G90CloudCommand.STATUS_CHANGE
450
+ _source = G90CloudDirection.DEVICE
451
+ _destination = G90CloudDirection.CLOUD
452
+ _type: ClassVar[G90AlertTypes] = G90AlertTypes.ALARM
453
+
454
+ type: int
455
+ sensor_id: int
456
+ _sensor_type: G90SensorTypes
457
+ _sensor_state: int
458
+ _sensor: bytes
459
+ _timestamp: int # Unix timestamp
460
+
461
+ @property
462
+ def sensor_state(self) -> G90AlertStates:
463
+ """
464
+ Get the sensor state for the alarm event.
465
+
466
+ :return: The state of the sensor that triggered the alarm
467
+ """
468
+ return G90AlertStates(self._sensor_state)
469
+
470
+ @property
471
+ def sensor(self) -> str:
472
+ """
473
+ Get the sensor name as a string.
474
+
475
+ :return: The sensor name decoded from bytes with null characters
476
+ removed
477
+ """
478
+ return self._sensor.decode().rstrip('\x00')
479
+
480
+ @property
481
+ def sensor_type(self) -> G90SensorTypes:
482
+ """
483
+ Get the sensor type for the alarm event.
484
+
485
+ :return: The type of sensor that triggered the alarm
486
+ """
487
+ return G90SensorTypes(self._sensor_type)
488
+
489
+ @property
490
+ def as_device_alert(self) -> G90DeviceAlert:
491
+ """
492
+ Convert the message to a device alert object.
493
+
494
+ :return: A G90DeviceAlert object constructed from the message
495
+ properties
496
+ """
497
+ return G90DeviceAlert(
498
+ device_id=self._context.device_id or '',
499
+ state=self.sensor_state,
500
+ event_id=cast(G90AlertStateChangeTypes, self.sensor_id),
501
+ zone_name=self.sensor,
502
+ type=self._type,
503
+ source=G90AlertSources.DEVICE,
504
+ unix_time=self._timestamp,
505
+ resv4=0,
506
+ other='',
507
+ )
508
+
509
+ def __str__(self) -> str:
510
+ return (
511
+ f"{type(self).__name__}"
512
+ f"({super().__str__()}"
513
+ f", type={self.type}"
514
+ f", _type={self._type}"
515
+ f", sensor={repr(self.sensor)}"
516
+ f", sensor id ={self.sensor_id}"
517
+ f", sensor type={repr(self.sensor_type)}"
518
+ f", sensor state={repr(self.sensor_state)}"
519
+ f", timestamp={self.timestamp})"
520
+ )
521
+
522
+
523
+ @cloud_message
524
+ @dataclass
525
+ class G90CloudNotificationMessage(G90CloudMessage):
526
+ """
527
+ Notification message from the device to the cloud server.
528
+
529
+ This message carries notification data from the device that may include
530
+ sensor data or other information.
531
+ """
532
+ _format = ''
533
+ _command = G90CloudCommand.NOTIFICATION
534
+ _source = G90CloudDirection.DEVICE
535
+ _destination = G90CloudDirection.CLOUD
536
+
537
+ @property
538
+ def as_notification_message(self) -> bytes:
539
+ """
540
+ Extract the notification message payload.
541
+
542
+ :return: The raw notification message bytes extracted from the header
543
+ payload
544
+ """
545
+ return self.header.payload[self.size():]
546
+
547
+ def __str__(self) -> str:
548
+ return (
549
+ f"{type(self).__name__}"
550
+ f"({super().__str__()}"
551
+ f", notification_message={self.as_notification_message.decode()})"
552
+ )
553
+
554
+
555
+ @cloud_message
556
+ @dataclass
557
+ class G90CloudCmdRespMessage(G90CloudMessage):
558
+ """
559
+ Command response message sent by the device.
560
+
561
+ This message contains command and sequence information.
562
+ """
563
+ _format = '<HiHi2H'
564
+ _command = G90CloudCommand.HELLO
565
+ _source = G90CloudDirection.UNSPECIFIED
566
+ _destination = G90CloudDirection.CLOUD
567
+ _header_kls = G90CloudHeader
568
+
569
+ flag1: int
570
+ seq_num1: int
571
+ flag3: int
572
+ seq_num2: int
573
+ cmd: int
574
+ subcmd: int
575
+
576
+ @property
577
+ def body(self) -> bytes:
578
+ """
579
+ Extract the response body payload.
580
+
581
+ :return: The raw response body bytes extracted from the header payload
582
+ """
583
+ return self.header.payload[self.size():]
584
+
585
+ def __str__(self) -> str:
586
+ return (
587
+ f"{type(self).__name__}"
588
+ f"({super().__str__()}"
589
+ f", cmd={self.cmd}"
590
+ f", subcmd={self.subcmd}"
591
+ f", seq_num={self.seq_num1}"
592
+ f", body={self.body.decode()})"
593
+ )