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