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,410 @@
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
+ Implementation of G90 cloud protocol notifications service.
23
+
24
+ Provides a server that listens for connections from G90 alarm devices and
25
+ handles cloud protocol notifications.
26
+ """
27
+ from typing import Optional, cast, Callable
28
+ import logging
29
+ import asyncio
30
+ from asyncio.transports import BaseTransport, Transport
31
+ from asyncio.protocols import Protocol, BaseProtocol
32
+ from asyncio import Future
33
+
34
+ from .protocol import (
35
+ G90CloudHeader, G90CloudError, G90CloudMessageNoMatch,
36
+ G90CloudMessageContext,
37
+ )
38
+ from .messages import (
39
+ CLOUD_MESSAGE_CLASSES, G90CloudNotificationMessage,
40
+ G90CloudStatusChangeAlarmReqMessage,
41
+ G90CloudStatusChangeSensorReqMessage,
42
+ G90CloudStatusChangeReqMessage,
43
+ G90CloudHelloReqMessage,
44
+ G90CloudHelloDiscoveryReqMessage,
45
+ )
46
+ from ..notifications.base import G90NotificationsBase
47
+ from ..notifications.protocol import G90NotificationProtocol
48
+ from ..const import (REMOTE_CLOUD_HOST, REMOTE_CLOUD_PORT)
49
+
50
+
51
+ _LOGGER = logging.getLogger(__name__)
52
+
53
+
54
+ # pylint:disable=too-many-instance-attributes
55
+ class G90CloudNotifications(G90NotificationsBase, asyncio.Protocol):
56
+ """
57
+ Cloud notifications service for G90 alarm systems.
58
+
59
+ Implements a server that listens for connections from G90 alarm devices
60
+ and processes cloud protocol messages, with optional forwarding to an
61
+ upstream server.
62
+
63
+ :param protocol_factory: Factory function to create notification
64
+ protocol handlers
65
+ :param local_host: Local host to bind the server to
66
+ :param local_port: Local port to bind the server to
67
+ :param upstream_host: Optional upstream host to forward messages to
68
+ :param upstream_port: Optional upstream port to forward messages to
69
+ :param keep_single_connection: Whether to keep only a single device
70
+ connection
71
+ """
72
+ # pylint:disable=too-many-positional-arguments,too-many-arguments
73
+ # pylint:disable=too-many-statements
74
+ def __init__(
75
+ self,
76
+ protocol_factory: Callable[[], G90NotificationProtocol],
77
+ local_host: str, local_port: int,
78
+ upstream_host: Optional[str] = None,
79
+ upstream_port: Optional[int] = None,
80
+ keep_single_connection: bool = True,
81
+ ) -> None:
82
+ super().__init__(protocol_factory)
83
+ self._transport: Optional[Transport] = None
84
+ self._server: Optional[asyncio.Server] = None
85
+ self._local_host = local_host
86
+ self._local_port = local_port
87
+ self._upstream_host = upstream_host
88
+ self._upstream_port = upstream_port
89
+ self._keep_single_connection = keep_single_connection
90
+ self._upstream_transport: Optional[Transport] = None
91
+ self._done_sending: Optional[Future[bool]] = None
92
+ self._upstream_task: Optional[asyncio.Task[None]] = None
93
+
94
+ def connection_made(self, transport: BaseTransport) -> None:
95
+ """
96
+ Handle a new connection from a device.
97
+
98
+ :param transport: The transport for the new connection
99
+ """
100
+ host, port = transport.get_extra_info('peername')
101
+ _LOGGER.debug('Connection from device %s:%s', host, port)
102
+ if self._keep_single_connection and self._transport:
103
+ _LOGGER.debug(
104
+ 'Closing connection previously opened from %s:%s',
105
+ *self._transport.get_extra_info('peername')
106
+ )
107
+ self._transport.close()
108
+ self._transport = cast(Transport, transport)
109
+
110
+ def connection_lost(self, exc: Optional[Exception]) -> None:
111
+ """
112
+ Handle connection loss from a device.
113
+
114
+ :param exc: Exception that caused the connection loss, if any
115
+ """
116
+ if exc:
117
+ _LOGGER.debug('Device connection error: %s', exc)
118
+
119
+ # Mark device ID as unknown when connection with alarm panel is lost
120
+ self.clear_device_id()
121
+
122
+ if self._transport:
123
+ self._transport.close()
124
+ self._transport = None
125
+
126
+ # pylint:disable=too-many-branches
127
+ def data_received(self, data: bytes) -> None:
128
+ """
129
+ Process data received from a device.
130
+
131
+ Parses messages from the device, handles them, and sends appropriate
132
+ responses back to the device simulating the cloud server, unless
133
+ upstream forwarding is configured - the data is passed thru to the
134
+ upstream server unmodified.
135
+
136
+ :param data: Bytes received from the device
137
+ """
138
+ if self._transport is None:
139
+ return
140
+
141
+ self.set_last_device_packet_time()
142
+
143
+ # If upstream connection is configured, pass thru all data from the
144
+ # panel. This is done in a separate task to avoid blocking the main
145
+ # processing - the passhtru mode is somewhat supplementary, hence the
146
+ # task is create-and-forget with no retries, error handling nor
147
+ # ordering guarantees, it is assumed the cloud service will handle
148
+ # those correctly.
149
+ if (
150
+ self._upstream_host is not None
151
+ and self._upstream_port is not None
152
+ ):
153
+ self._upstream_task = asyncio.create_task(self.send_upstream(data))
154
+
155
+ host, port = self._transport.get_extra_info('peername')
156
+ _LOGGER.debug(
157
+ 'Data received from device %s:%s: %s', host, port, data.hex(' ')
158
+ )
159
+
160
+ try:
161
+ while len(data):
162
+ # Instantiate a context for the messages
163
+ ctx = G90CloudMessageContext(
164
+ device_id=self.device_id,
165
+ local_host=self._local_host,
166
+ local_port=self._local_port,
167
+ cloud_host=REMOTE_CLOUD_HOST,
168
+ cloud_port=REMOTE_CLOUD_PORT,
169
+ upstream_host=self._upstream_host,
170
+ upstream_port=self._upstream_port,
171
+ remote_host=host,
172
+ remote_port=port
173
+ )
174
+ found = False
175
+
176
+ for cls in CLOUD_MESSAGE_CLASSES:
177
+ try:
178
+ msg = cls.from_wire(data, context=ctx)
179
+ except G90CloudMessageNoMatch:
180
+ continue
181
+
182
+ _LOGGER.debug("Cloud message received: %s", msg)
183
+
184
+ # Only these messages carry on the device ID, store it so
185
+ # vefirication will be performed by `_handle_alert()`
186
+ # method
187
+ if cls in [
188
+ G90CloudHelloReqMessage,
189
+ G90CloudHelloDiscoveryReqMessage,
190
+ ]:
191
+ self.device_id = msg.guid
192
+
193
+ if cls == G90CloudNotificationMessage:
194
+ self.handle(msg.as_notification_message)
195
+
196
+ if cls in [
197
+ G90CloudStatusChangeAlarmReqMessage,
198
+ G90CloudStatusChangeSensorReqMessage,
199
+ G90CloudStatusChangeReqMessage,
200
+ ]:
201
+ alert = msg.as_device_alert
202
+ if alert:
203
+ self.handle_alert(
204
+ alert,
205
+ # Verifying device ID only makes sense when
206
+ # connection isn't closed each time a message
207
+ # is received
208
+ verify_device_id=(
209
+ not self._keep_single_connection
210
+ )
211
+ )
212
+
213
+ found = True
214
+
215
+ # Sending message to upstream cloud service isn't
216
+ # configured, simulate the response and send those back to
217
+ # the panel
218
+ if not self._upstream_task:
219
+ self._done_sending = asyncio.Future()
220
+ for resp in msg.wire_responses(context=ctx):
221
+ self._transport.write(resp)
222
+ _LOGGER.debug(
223
+ 'Sending response to device %s:%s: %s',
224
+ host, port, resp.hex(' ')
225
+ )
226
+ # Signal the future that sending is done
227
+ self._done_sending.set_result(True)
228
+
229
+ hdr = msg.header
230
+ break
231
+
232
+ if not found:
233
+ _LOGGER.debug(
234
+ "Unknown command from device, wire data: '%s'",
235
+ data.hex(' ')
236
+ )
237
+ hdr = G90CloudHeader.from_wire(data, context=ctx)
238
+
239
+ # Advance to the next message
240
+ data = data[hdr.message_length:]
241
+ except G90CloudError as exc:
242
+ _LOGGER.error('Error processing data from device: %s', exc)
243
+
244
+ def upstream_connection_made(self, transport: BaseTransport) -> None:
245
+ """
246
+ Handle successful connection to the upstream server.
247
+
248
+ :param transport: The transport for the upstream connection
249
+ """
250
+ self._upstream_transport = cast(Transport, transport)
251
+
252
+ def upstream_connection_lost(self, exc: Optional[Exception]) -> None:
253
+ """
254
+ Handle connection loss to the upstream server.
255
+
256
+ :param exc: Exception that caused the connection loss, if any
257
+ """
258
+ if exc:
259
+ _LOGGER.debug('Upstream connection error: %s', exc)
260
+
261
+ if self._upstream_transport:
262
+ self._upstream_transport.close()
263
+ self._upstream_transport = None
264
+
265
+ def upstream_data_received(self, data: bytes) -> None:
266
+ """
267
+ Process data received from the upstream server.
268
+
269
+ Forwards the data to the connected device.
270
+
271
+ :param data: Bytes received from the upstream server
272
+ """
273
+ self.set_last_upstream_packet_time()
274
+
275
+ _LOGGER.debug('Data received from upstream: %s', data.hex(' '))
276
+
277
+ if self._transport:
278
+ host, port = self._transport.get_extra_info('peername')
279
+ _LOGGER.debug(
280
+ 'Sending upstream data to device %s:%s', host, port
281
+ )
282
+ self._transport.write(data)
283
+
284
+ def get_upstream_protocol(self) -> BaseProtocol:
285
+ """
286
+ Create and return a protocol for the upstream connection.
287
+
288
+ :return: Protocol for handling the upstream connection
289
+ """
290
+ class UpstreamProtocol(Protocol):
291
+ """
292
+ Protocol for handling the upstream connection.
293
+ """
294
+ def __init__(self, parent: G90CloudNotifications) -> None:
295
+ """
296
+ Initialize the upstream protocol.
297
+
298
+ :param parent: The parent notifications service
299
+ """
300
+ self._parent = parent
301
+
302
+ def connection_made(self, transport: BaseTransport) -> None:
303
+ """
304
+ Handle successful connection to the upstream server.
305
+
306
+ :param transport: The transport for the upstream connection
307
+ """
308
+ self._parent.upstream_connection_made(transport)
309
+
310
+ def connection_lost(self, exc: Optional[Exception]) -> None:
311
+ """
312
+ Handle connection loss to the upstream server.
313
+
314
+ :param exc: Exception that caused the connection loss, if any
315
+ """
316
+ self._parent.upstream_connection_lost(exc)
317
+
318
+ def data_received(self, data: bytes) -> None:
319
+ """
320
+ Process data received from the upstream server.
321
+
322
+ :param data: Bytes received from the upstream server
323
+ """
324
+ self._parent.upstream_data_received(data)
325
+
326
+ return UpstreamProtocol(self)
327
+
328
+ async def send_upstream(self, data: bytes) -> None:
329
+ """
330
+ Send data to the upstream server.
331
+
332
+ Creates a connection to the upstream server if one doesn't exist,
333
+ then sends the provided data.
334
+
335
+ :param data: Bytes to send to the upstream server
336
+ """
337
+ if not self._upstream_host or not self._upstream_port:
338
+ return
339
+
340
+ try:
341
+ if not self._upstream_transport:
342
+ _LOGGER.debug(
343
+ 'Creating upstream connection to %s:%s',
344
+ self._upstream_host, self._upstream_port
345
+ )
346
+ loop = asyncio.get_running_loop()
347
+
348
+ self._upstream_transport, _ = await loop.create_connection(
349
+ self.get_upstream_protocol,
350
+ host=self._upstream_host, port=self._upstream_port
351
+ )
352
+
353
+ if self._upstream_transport:
354
+ self._upstream_transport.write(data)
355
+ _LOGGER.debug(
356
+ 'Data sent to upstream %s:%s',
357
+ self._upstream_host, self._upstream_port
358
+ )
359
+ except (OSError, asyncio.TimeoutError) as exc:
360
+ _LOGGER.debug(
361
+ 'Error sending data to upstream %s:%s: %s',
362
+ self._upstream_host, self._upstream_port, exc
363
+ )
364
+
365
+ async def listen(self) -> None:
366
+ """
367
+ Start listening for connections from devices.
368
+
369
+ Creates a server bound to the configured local host and port.
370
+ """
371
+ loop = asyncio.get_running_loop()
372
+
373
+ _LOGGER.debug('Creating cloud endpoint for %s:%s',
374
+ self._local_host,
375
+ self._local_port)
376
+ self._server = await loop.create_server(
377
+ lambda: self,
378
+ self._local_host, self._local_port
379
+ )
380
+
381
+ async def close(self) -> None:
382
+ """
383
+ Close the server and any active connections.
384
+
385
+ Waits for any pending operations to complete, then closes the upstream
386
+ connection and the local server.
387
+ """
388
+ # Ensure all responses are sent to the panel
389
+ if self._done_sending:
390
+ await asyncio.wait([self._done_sending])
391
+
392
+ # Wait for the upstream task to finish if it exists
393
+ if self._upstream_task:
394
+ await asyncio.wait([self._upstream_task])
395
+
396
+ if self._upstream_transport:
397
+ _LOGGER.debug(
398
+ 'Closing upstream connection to %s:%s',
399
+ self._upstream_host, self._upstream_port
400
+ )
401
+ self._upstream_transport.close()
402
+ self._upstream_transport = None
403
+
404
+ if self._server:
405
+ _LOGGER.debug(
406
+ 'No longer listening for cloud connections on %s:%s',
407
+ self._local_host, self._local_port
408
+ )
409
+ self._server.close()
410
+ self._server = None