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.
- pyg90alarm/__init__.py +84 -0
- pyg90alarm/alarm.py +1274 -0
- pyg90alarm/callback.py +146 -0
- pyg90alarm/cloud/__init__.py +31 -0
- pyg90alarm/cloud/const.py +56 -0
- pyg90alarm/cloud/messages.py +593 -0
- pyg90alarm/cloud/notifications.py +410 -0
- pyg90alarm/cloud/protocol.py +518 -0
- pyg90alarm/const.py +273 -0
- pyg90alarm/definitions/__init__.py +3 -0
- pyg90alarm/definitions/base.py +247 -0
- pyg90alarm/definitions/devices.py +366 -0
- pyg90alarm/definitions/sensors.py +843 -0
- pyg90alarm/entities/__init__.py +3 -0
- pyg90alarm/entities/base_entity.py +93 -0
- pyg90alarm/entities/base_list.py +268 -0
- pyg90alarm/entities/device.py +97 -0
- pyg90alarm/entities/device_list.py +156 -0
- pyg90alarm/entities/sensor.py +891 -0
- pyg90alarm/entities/sensor_list.py +183 -0
- pyg90alarm/exceptions.py +63 -0
- pyg90alarm/local/__init__.py +0 -0
- pyg90alarm/local/base_cmd.py +293 -0
- pyg90alarm/local/config.py +157 -0
- pyg90alarm/local/discovery.py +103 -0
- pyg90alarm/local/history.py +272 -0
- pyg90alarm/local/host_info.py +89 -0
- pyg90alarm/local/host_status.py +52 -0
- pyg90alarm/local/notifications.py +117 -0
- pyg90alarm/local/paginated_cmd.py +132 -0
- pyg90alarm/local/paginated_result.py +135 -0
- pyg90alarm/local/targeted_discovery.py +162 -0
- pyg90alarm/local/user_data_crc.py +46 -0
- pyg90alarm/notifications/__init__.py +0 -0
- pyg90alarm/notifications/base.py +481 -0
- pyg90alarm/notifications/protocol.py +127 -0
- pyg90alarm/py.typed +0 -0
- pyg90alarm-2.3.0.dist-info/METADATA +277 -0
- pyg90alarm-2.3.0.dist-info/RECORD +42 -0
- pyg90alarm-2.3.0.dist-info/WHEEL +5 -0
- pyg90alarm-2.3.0.dist-info/licenses/LICENSE +21 -0
- 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
|