pyg90alarm 2.6.0__py3-none-any.whl → 2.7.1__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/alarm.py +118 -15
- pyg90alarm/cloud/messages.py +5 -9
- pyg90alarm/cloud/notifications.py +14 -10
- pyg90alarm/cloud/protocol.py +2 -2
- pyg90alarm/const.py +32 -5
- pyg90alarm/local/base_cmd.py +170 -100
- pyg90alarm/local/notifications.py +5 -5
- pyg90alarm/local/paginated_cmd.py +5 -10
- pyg90alarm/local/system_cmd.py +276 -0
- {pyg90alarm-2.6.0.dist-info → pyg90alarm-2.7.1.dist-info}/METADATA +28 -26
- {pyg90alarm-2.6.0.dist-info → pyg90alarm-2.7.1.dist-info}/RECORD +14 -13
- {pyg90alarm-2.6.0.dist-info → pyg90alarm-2.7.1.dist-info}/WHEEL +0 -0
- {pyg90alarm-2.6.0.dist-info → pyg90alarm-2.7.1.dist-info}/licenses/LICENSE +0 -0
- {pyg90alarm-2.6.0.dist-info → pyg90alarm-2.7.1.dist-info}/top_level.txt +0 -0
pyg90alarm/alarm.py
CHANGED
|
@@ -60,14 +60,14 @@ from typing import (
|
|
|
60
60
|
Callable, Coroutine, Union
|
|
61
61
|
)
|
|
62
62
|
from .const import (
|
|
63
|
-
G90Commands,
|
|
63
|
+
G90Commands, G90SystemCommands,
|
|
64
|
+
REMOTE_PORT,
|
|
64
65
|
REMOTE_TARGETED_DISCOVERY_PORT,
|
|
65
66
|
LOCAL_TARGETED_DISCOVERY_PORT,
|
|
66
|
-
|
|
67
|
+
LOCAL_NOTIFICATIONS_IP,
|
|
67
68
|
LOCAL_NOTIFICATIONS_PORT,
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
REMOTE_CLOUD_HOST,
|
|
69
|
+
LOCAL_CLOUD_NOTIFICATIONS_IP,
|
|
70
|
+
LOCAL_CLOUD_NOTIFICATIONS_PORT,
|
|
71
71
|
REMOTE_CLOUD_PORT,
|
|
72
72
|
DEVICE_REGISTRATION_TIMEOUT,
|
|
73
73
|
ROOM_ID,
|
|
@@ -75,7 +75,8 @@ from .const import (
|
|
|
75
75
|
G90RemoteButtonStates,
|
|
76
76
|
G90RFIDKeypadStates,
|
|
77
77
|
)
|
|
78
|
-
from .local.base_cmd import
|
|
78
|
+
from .local.base_cmd import G90BaseCommand, BaseCommandsDataT
|
|
79
|
+
from .local.system_cmd import G90SystemCommand, G90SetServerAddressCommand
|
|
79
80
|
from .local.paginated_result import G90PaginatedResult, G90PaginatedResponse
|
|
80
81
|
from .entities.base_list import ListChangeCallback
|
|
81
82
|
from .entities.sensor import G90Sensor
|
|
@@ -263,8 +264,8 @@ class G90Alarm(G90NotificationProtocol):
|
|
|
263
264
|
return self._port
|
|
264
265
|
|
|
265
266
|
async def command(
|
|
266
|
-
self, code: G90Commands, data: Optional[
|
|
267
|
-
) ->
|
|
267
|
+
self, code: G90Commands, data: Optional[BaseCommandsDataT] = None
|
|
268
|
+
) -> BaseCommandsDataT:
|
|
268
269
|
"""
|
|
269
270
|
Invokes a command against the alarm panel.
|
|
270
271
|
|
|
@@ -272,7 +273,7 @@ class G90Alarm(G90NotificationProtocol):
|
|
|
272
273
|
:param data: Command data
|
|
273
274
|
:return: The result of command invocation
|
|
274
275
|
"""
|
|
275
|
-
cmd
|
|
276
|
+
cmd = await G90BaseCommand(
|
|
276
277
|
self._host, self._port, code, data).process()
|
|
277
278
|
return cmd.result
|
|
278
279
|
|
|
@@ -292,6 +293,81 @@ class G90Alarm(G90NotificationProtocol):
|
|
|
292
293
|
self._host, self._port, code, start, end
|
|
293
294
|
).process()
|
|
294
295
|
|
|
296
|
+
async def mcu_reboot(self) -> None:
|
|
297
|
+
"""
|
|
298
|
+
Reboots the MCU of the alarm panel.
|
|
299
|
+
|
|
300
|
+
Note that underlying command doesn't return any result, so there is no
|
|
301
|
+
feedback from the panel upon execution.
|
|
302
|
+
"""
|
|
303
|
+
await G90SystemCommand(
|
|
304
|
+
host=self._host, port=self._port,
|
|
305
|
+
code=G90SystemCommands.MCU_REBOOT
|
|
306
|
+
).process()
|
|
307
|
+
|
|
308
|
+
async def gsm_reboot(self) -> None:
|
|
309
|
+
"""
|
|
310
|
+
Reboots the GSM module of the alarm panel.
|
|
311
|
+
|
|
312
|
+
Note that underlying command doesn't return any result, so there is no
|
|
313
|
+
feedback from the panel upon execution.
|
|
314
|
+
"""
|
|
315
|
+
await G90SystemCommand(
|
|
316
|
+
host=self._host, port=self._port,
|
|
317
|
+
code=G90SystemCommands.GSM_REBOOT
|
|
318
|
+
).process()
|
|
319
|
+
|
|
320
|
+
async def wifi_reboot(self) -> None:
|
|
321
|
+
"""
|
|
322
|
+
Reboots the WiFi module of the alarm panel.
|
|
323
|
+
|
|
324
|
+
Note that underlying command doesn't return any result, so there is no
|
|
325
|
+
feedback from the panel upon execution.
|
|
326
|
+
"""
|
|
327
|
+
await G90SystemCommand(
|
|
328
|
+
host=self._host, port=self._port,
|
|
329
|
+
code=G90SystemCommands.WIFI_REBOOT
|
|
330
|
+
).process()
|
|
331
|
+
|
|
332
|
+
async def reboot(self) -> None:
|
|
333
|
+
"""
|
|
334
|
+
Reboots the entire alarm panel.
|
|
335
|
+
|
|
336
|
+
The system commands performing reboot of a panel's module don't return
|
|
337
|
+
anything so there is no feedback from the panel upon execution, hence
|
|
338
|
+
the commands are spaced with delays to allow the panel to process
|
|
339
|
+
them.
|
|
340
|
+
|
|
341
|
+
Please be aware that the delays are determined experimentally and might
|
|
342
|
+
be too long or too short.
|
|
343
|
+
"""
|
|
344
|
+
await self.gsm_reboot()
|
|
345
|
+
await asyncio.sleep(1)
|
|
346
|
+
await self.mcu_reboot()
|
|
347
|
+
await asyncio.sleep(1)
|
|
348
|
+
await self.wifi_reboot()
|
|
349
|
+
# The MCU likely needs more than 1 second to reboot, but checking it
|
|
350
|
+
# completed the reboot should be done separately
|
|
351
|
+
await asyncio.sleep(1)
|
|
352
|
+
|
|
353
|
+
async def set_cloud_server_address(
|
|
354
|
+
self, cloud_ip: str, cloud_port: int
|
|
355
|
+
) -> None:
|
|
356
|
+
"""
|
|
357
|
+
Sets the cloud server address the alarm panel connects to.
|
|
358
|
+
|
|
359
|
+
:param cloud_ip: IP address of the server to receive cloud protocol
|
|
360
|
+
notifications, should be reachable from the panel. Typically it is the
|
|
361
|
+
IP address of the host running the package
|
|
362
|
+
:param cloud_port: Port number of the server to receive cloud protocol
|
|
363
|
+
notifications, should be reachable from the panel
|
|
364
|
+
"""
|
|
365
|
+
await G90SetServerAddressCommand(
|
|
366
|
+
host=self._host, port=self._port,
|
|
367
|
+
cloud_ip=cloud_ip,
|
|
368
|
+
cloud_port=cloud_port
|
|
369
|
+
).process()
|
|
370
|
+
|
|
295
371
|
@classmethod
|
|
296
372
|
async def discover(cls) -> List[G90DiscoveredDevice]:
|
|
297
373
|
"""
|
|
@@ -1303,7 +1379,7 @@ class G90Alarm(G90NotificationProtocol):
|
|
|
1303
1379
|
await asyncio.sleep(interval)
|
|
1304
1380
|
|
|
1305
1381
|
async def use_local_notifications(
|
|
1306
|
-
self,
|
|
1382
|
+
self, notifications_local_ip: str = LOCAL_NOTIFICATIONS_IP,
|
|
1307
1383
|
notifications_local_port: int = LOCAL_NOTIFICATIONS_PORT
|
|
1308
1384
|
) -> None:
|
|
1309
1385
|
"""
|
|
@@ -1315,20 +1391,45 @@ class G90Alarm(G90NotificationProtocol):
|
|
|
1315
1391
|
protocol_factory=lambda: self,
|
|
1316
1392
|
host=self._host,
|
|
1317
1393
|
port=self._port,
|
|
1318
|
-
|
|
1394
|
+
local_ip=notifications_local_ip,
|
|
1319
1395
|
local_port=notifications_local_port
|
|
1320
1396
|
)
|
|
1321
1397
|
|
|
1322
1398
|
# pylint: disable=too-many-positional-arguments
|
|
1323
1399
|
async def use_cloud_notifications(
|
|
1324
|
-
self,
|
|
1325
|
-
|
|
1326
|
-
|
|
1400
|
+
self,
|
|
1401
|
+
cloud_ip: str,
|
|
1402
|
+
cloud_port: int,
|
|
1403
|
+
cloud_local_ip: str = LOCAL_CLOUD_NOTIFICATIONS_IP,
|
|
1404
|
+
cloud_local_port: int = LOCAL_CLOUD_NOTIFICATIONS_PORT,
|
|
1405
|
+
upstream_host: Optional[str] = None,
|
|
1327
1406
|
upstream_port: Optional[int] = REMOTE_CLOUD_PORT,
|
|
1328
1407
|
keep_single_connection: bool = True
|
|
1329
1408
|
) -> None:
|
|
1330
1409
|
"""
|
|
1331
1410
|
Switches to use cloud notifications for device alerts.
|
|
1411
|
+
|
|
1412
|
+
Please note the method does not configure the panel for the host to
|
|
1413
|
+
receive the notifications - please invoke
|
|
1414
|
+
:meth:`G90Alarm.set_cloud_server_address` method to do that. The reason
|
|
1415
|
+
of that is configuring cloud server address on the panel is one-time
|
|
1416
|
+
operation, while the method will be called multiple times.
|
|
1417
|
+
|
|
1418
|
+
:param cloud_ip: The IP address of cloud server to connect to, should
|
|
1419
|
+
be reachable from the panel
|
|
1420
|
+
:param cloud_port: The cloud server port to connect to, should be
|
|
1421
|
+
reachable from the panel
|
|
1422
|
+
:param cloud_local_ip: Local IP address to bind cloud notifications
|
|
1423
|
+
listener to
|
|
1424
|
+
:param cloud_local_port: Local port to bind cloud notifications
|
|
1425
|
+
listener to, should match `cloud_port` above unless network setup
|
|
1426
|
+
dictates otherwise
|
|
1427
|
+
:param upstream_host: Optional upstream host to connect to cloud
|
|
1428
|
+
server through
|
|
1429
|
+
:param upstream_port: Optional upstream port to connect to cloud
|
|
1430
|
+
server through
|
|
1431
|
+
:param keep_single_connection: If enabled, keeps a single connection
|
|
1432
|
+
to the upstream cloud server for both sending and receiving data
|
|
1332
1433
|
"""
|
|
1333
1434
|
await self.close_notifications()
|
|
1334
1435
|
|
|
@@ -1336,8 +1437,10 @@ class G90Alarm(G90NotificationProtocol):
|
|
|
1336
1437
|
protocol_factory=lambda: self,
|
|
1337
1438
|
upstream_host=upstream_host,
|
|
1338
1439
|
upstream_port=upstream_port,
|
|
1339
|
-
|
|
1440
|
+
local_ip=cloud_local_ip,
|
|
1340
1441
|
local_port=cloud_local_port,
|
|
1442
|
+
cloud_ip=cloud_ip,
|
|
1443
|
+
cloud_port=cloud_port,
|
|
1341
1444
|
keep_single_connection=keep_single_connection
|
|
1342
1445
|
)
|
|
1343
1446
|
|
pyg90alarm/cloud/messages.py
CHANGED
|
@@ -37,7 +37,7 @@ from .protocol import (
|
|
|
37
37
|
)
|
|
38
38
|
from .const import G90CloudDirection, G90CloudCommand
|
|
39
39
|
from ..const import (
|
|
40
|
-
G90AlertStateChangeTypes,
|
|
40
|
+
G90AlertStateChangeTypes,
|
|
41
41
|
G90AlertTypes, G90AlertSources, G90AlertStates,
|
|
42
42
|
)
|
|
43
43
|
from ..definitions.base import G90PeripheralTypes
|
|
@@ -212,15 +212,11 @@ class G90CloudHelloDiscoveryRespMessage(G90CloudMessage):
|
|
|
212
212
|
_source = G90CloudDirection.CLOUD_DISCOVERY
|
|
213
213
|
_destination = G90CloudDirection.DEVICE
|
|
214
214
|
|
|
215
|
-
#
|
|
216
|
-
|
|
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()
|
|
215
|
+
# The default values are set in `__post_init__()` method below
|
|
216
|
+
ip_addr: bytes = b''
|
|
221
217
|
flag2: int = 0
|
|
222
218
|
flag3: int = 0
|
|
223
|
-
port: int =
|
|
219
|
+
port: int = 0
|
|
224
220
|
_timestamp: int = 0 # unix timestamp
|
|
225
221
|
|
|
226
222
|
def __post_init__(self, context: G90CloudMessageContext) -> None:
|
|
@@ -230,7 +226,7 @@ class G90CloudHelloDiscoveryRespMessage(G90CloudMessage):
|
|
|
230
226
|
_LOGGER.debug(
|
|
231
227
|
"%s: Timestamp added: %s", type(self).__name__, str(self)
|
|
232
228
|
)
|
|
233
|
-
self.ip_addr = context.
|
|
229
|
+
self.ip_addr = context.cloud_ip.encode()
|
|
234
230
|
self.port = context.cloud_port
|
|
235
231
|
|
|
236
232
|
@property
|
|
@@ -45,7 +45,6 @@ from .messages import (
|
|
|
45
45
|
)
|
|
46
46
|
from ..notifications.base import G90NotificationsBase
|
|
47
47
|
from ..notifications.protocol import G90NotificationProtocol
|
|
48
|
-
from ..const import (REMOTE_CLOUD_HOST, REMOTE_CLOUD_PORT)
|
|
49
48
|
|
|
50
49
|
|
|
51
50
|
_LOGGER = logging.getLogger(__name__)
|
|
@@ -62,7 +61,9 @@ class G90CloudNotifications(G90NotificationsBase, asyncio.Protocol):
|
|
|
62
61
|
|
|
63
62
|
:param protocol_factory: Factory function to create notification
|
|
64
63
|
protocol handlers
|
|
65
|
-
:param
|
|
64
|
+
:param cloud_ip: Cloud IP address to announce to the panel
|
|
65
|
+
:param cloud_port: Cloud port to announce to the panel
|
|
66
|
+
:param local_ip: Local IP to bind the server to
|
|
66
67
|
:param local_port: Local port to bind the server to
|
|
67
68
|
:param upstream_host: Optional upstream host to forward messages to
|
|
68
69
|
:param upstream_port: Optional upstream port to forward messages to
|
|
@@ -74,7 +75,8 @@ class G90CloudNotifications(G90NotificationsBase, asyncio.Protocol):
|
|
|
74
75
|
def __init__(
|
|
75
76
|
self,
|
|
76
77
|
protocol_factory: Callable[[], G90NotificationProtocol],
|
|
77
|
-
|
|
78
|
+
local_ip: str, local_port: int,
|
|
79
|
+
cloud_ip: str, cloud_port: int,
|
|
78
80
|
upstream_host: Optional[str] = None,
|
|
79
81
|
upstream_port: Optional[int] = None,
|
|
80
82
|
keep_single_connection: bool = True,
|
|
@@ -82,8 +84,10 @@ class G90CloudNotifications(G90NotificationsBase, asyncio.Protocol):
|
|
|
82
84
|
super().__init__(protocol_factory)
|
|
83
85
|
self._transport: Optional[Transport] = None
|
|
84
86
|
self._server: Optional[asyncio.Server] = None
|
|
85
|
-
self.
|
|
87
|
+
self._local_ip = local_ip
|
|
86
88
|
self._local_port = local_port
|
|
89
|
+
self._cloud_ip = cloud_ip
|
|
90
|
+
self._cloud_port = cloud_port
|
|
87
91
|
self._upstream_host = upstream_host
|
|
88
92
|
self._upstream_port = upstream_port
|
|
89
93
|
self._keep_single_connection = keep_single_connection
|
|
@@ -162,10 +166,10 @@ class G90CloudNotifications(G90NotificationsBase, asyncio.Protocol):
|
|
|
162
166
|
# Instantiate a context for the messages
|
|
163
167
|
ctx = G90CloudMessageContext(
|
|
164
168
|
device_id=self.device_id,
|
|
165
|
-
|
|
169
|
+
local_ip=self._local_ip,
|
|
166
170
|
local_port=self._local_port,
|
|
167
|
-
|
|
168
|
-
cloud_port=
|
|
171
|
+
cloud_ip=self._cloud_ip,
|
|
172
|
+
cloud_port=self._cloud_port,
|
|
169
173
|
upstream_host=self._upstream_host,
|
|
170
174
|
upstream_port=self._upstream_port,
|
|
171
175
|
remote_host=host,
|
|
@@ -371,11 +375,11 @@ class G90CloudNotifications(G90NotificationsBase, asyncio.Protocol):
|
|
|
371
375
|
loop = asyncio.get_running_loop()
|
|
372
376
|
|
|
373
377
|
_LOGGER.debug('Creating cloud endpoint for %s:%s',
|
|
374
|
-
self.
|
|
378
|
+
self._local_ip,
|
|
375
379
|
self._local_port)
|
|
376
380
|
self._server = await loop.create_server(
|
|
377
381
|
lambda: self,
|
|
378
|
-
self.
|
|
382
|
+
self._local_ip, self._local_port
|
|
379
383
|
)
|
|
380
384
|
|
|
381
385
|
async def close(self) -> None:
|
|
@@ -404,7 +408,7 @@ class G90CloudNotifications(G90NotificationsBase, asyncio.Protocol):
|
|
|
404
408
|
if self._server:
|
|
405
409
|
_LOGGER.debug(
|
|
406
410
|
'No longer listening for cloud connections on %s:%s',
|
|
407
|
-
self.
|
|
411
|
+
self._local_ip, self._local_port
|
|
408
412
|
)
|
|
409
413
|
self._server.close()
|
|
410
414
|
self._server = None
|
pyg90alarm/cloud/protocol.py
CHANGED
|
@@ -73,11 +73,11 @@ class G90CloudMessageContext: # pylint:disable=too-many-instance-attributes
|
|
|
73
73
|
This class holds information about the local and remote hosts and ports,
|
|
74
74
|
as well as the cloud server and upstream connection details.
|
|
75
75
|
"""
|
|
76
|
-
|
|
76
|
+
local_ip: str
|
|
77
77
|
local_port: int
|
|
78
78
|
remote_host: str
|
|
79
79
|
remote_port: int
|
|
80
|
-
|
|
80
|
+
cloud_ip: str
|
|
81
81
|
cloud_port: int
|
|
82
82
|
upstream_host: Optional[str]
|
|
83
83
|
upstream_port: Optional[int]
|
pyg90alarm/const.py
CHANGED
|
@@ -28,11 +28,10 @@ from typing import Optional
|
|
|
28
28
|
REMOTE_PORT = 12368
|
|
29
29
|
REMOTE_TARGETED_DISCOVERY_PORT = 12900
|
|
30
30
|
LOCAL_TARGETED_DISCOVERY_PORT = 12901
|
|
31
|
-
|
|
31
|
+
LOCAL_NOTIFICATIONS_IP = '0.0.0.0'
|
|
32
32
|
LOCAL_NOTIFICATIONS_PORT = 12901
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
REMOTE_CLOUD_HOST = '47.88.7.61'
|
|
33
|
+
LOCAL_CLOUD_NOTIFICATIONS_IP = '0.0.0.0'
|
|
34
|
+
LOCAL_CLOUD_NOTIFICATIONS_PORT = 5678
|
|
36
35
|
REMOTE_CLOUD_PORT = 5678
|
|
37
36
|
DEVICE_REGISTRATION_TIMEOUT = 30
|
|
38
37
|
ROOM_ID = 0
|
|
@@ -42,7 +41,14 @@ CMD_PAGE_SIZE = 10
|
|
|
42
41
|
BUG_REPORT_URL = 'https://github.com/hostcc/pyg90alarm/issues'
|
|
43
42
|
|
|
44
43
|
|
|
45
|
-
class
|
|
44
|
+
class G90CommandsBase(IntEnum):
|
|
45
|
+
"""
|
|
46
|
+
Base class for G90Commands and G90SystemCommands to allow proper typing
|
|
47
|
+
with subclasses of `G90BaseCommand`.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class G90Commands(G90CommandsBase):
|
|
46
52
|
"""
|
|
47
53
|
Defines the alarm panel commands and their codes.
|
|
48
54
|
|
|
@@ -163,6 +169,27 @@ class G90Commands(IntEnum):
|
|
|
163
169
|
PING = 219
|
|
164
170
|
|
|
165
171
|
|
|
172
|
+
class G90SystemCommands(G90CommandsBase):
|
|
173
|
+
"""
|
|
174
|
+
Defines system commands for the G90 alarm panel.
|
|
175
|
+
"""
|
|
176
|
+
GET_CONFIGURATION = 0
|
|
177
|
+
SET_CONFIGURATION = 1
|
|
178
|
+
GSM_REBOOT = 1129
|
|
179
|
+
MCU_REBOOT = 1123
|
|
180
|
+
WIFI_REBOOT = 1006
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class G90SystemConfigurationCommands(IntEnum):
|
|
184
|
+
"""
|
|
185
|
+
Defines sub-commands for getting/setting system configuration.
|
|
186
|
+
|
|
187
|
+
Applicable only to `G90SystemCommands.GET_CONFIGURATION` and
|
|
188
|
+
`G90SystemCommands.SET_CONFIGURATION` commands above.
|
|
189
|
+
"""
|
|
190
|
+
SERVER_ADDRESS = 78
|
|
191
|
+
|
|
192
|
+
|
|
166
193
|
class G90MessageTypes(IntEnum):
|
|
167
194
|
"""
|
|
168
195
|
Defines message types (codes) from messages coming from the alarm panel.
|
pyg90alarm/local/base_cmd.py
CHANGED
|
@@ -28,40 +28,34 @@ import asyncio
|
|
|
28
28
|
from asyncio import Future
|
|
29
29
|
from asyncio.protocols import DatagramProtocol
|
|
30
30
|
from asyncio.transports import DatagramTransport, BaseTransport
|
|
31
|
-
from typing import Optional, Tuple, List, Any
|
|
31
|
+
from typing import Optional, Tuple, List, Any, TypeVar, Generic
|
|
32
32
|
from dataclasses import dataclass
|
|
33
|
+
# `Self` is available in `typing` module only starting from Python 3.11, for
|
|
34
|
+
# older versions need to use typing_extensions`
|
|
35
|
+
from typing_extensions import Self
|
|
33
36
|
from ..exceptions import (
|
|
34
37
|
G90Error, G90TimeoutError, G90CommandFailure, G90CommandError
|
|
35
38
|
)
|
|
36
|
-
from ..const import G90Commands
|
|
39
|
+
from ..const import G90Commands, G90CommandsBase
|
|
37
40
|
|
|
38
41
|
|
|
39
42
|
_LOGGER = logging.getLogger(__name__)
|
|
40
|
-
G90BaseCommandData = List[Any]
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
class G90Header:
|
|
45
|
-
"""
|
|
46
|
-
Represents JSON structure of the header used in alarm panel commands.
|
|
47
|
-
|
|
48
|
-
:meta private:
|
|
49
|
-
"""
|
|
50
|
-
code: Optional[int] = None
|
|
51
|
-
data: Optional[G90BaseCommandData] = None
|
|
44
|
+
CommandT = TypeVar('CommandT', bound=G90CommandsBase)
|
|
45
|
+
CommandDataT = TypeVar('CommandDataT')
|
|
52
46
|
|
|
53
47
|
|
|
54
|
-
class
|
|
48
|
+
class G90Command(DatagramProtocol, Generic[CommandT, CommandDataT]):
|
|
55
49
|
"""
|
|
56
|
-
|
|
50
|
+
Base class for command handling for alarm panel protocol.
|
|
57
51
|
"""
|
|
58
52
|
# pylint: disable=too-many-instance-attributes
|
|
59
53
|
# Lock need to be shared across all of the class instances
|
|
60
54
|
_sk_lock = asyncio.Lock()
|
|
61
55
|
|
|
62
56
|
# pylint: disable=too-many-positional-arguments,too-many-arguments
|
|
63
|
-
def __init__(self, host: str, port: int, code:
|
|
64
|
-
data: Optional[
|
|
57
|
+
def __init__(self, host: str, port: int, code: CommandT,
|
|
58
|
+
data: Optional[CommandDataT] = None,
|
|
65
59
|
local_port: Optional[int] = None,
|
|
66
60
|
timeout: float = 3.0, retries: int = 3) -> None:
|
|
67
61
|
self._remote_host = host
|
|
@@ -70,18 +64,11 @@ class G90BaseCommand(DatagramProtocol):
|
|
|
70
64
|
self._code = code
|
|
71
65
|
self._timeout = timeout
|
|
72
66
|
self._retries = retries
|
|
73
|
-
self.
|
|
74
|
-
self._result: G90BaseCommandData = []
|
|
67
|
+
self._result: Optional[CommandDataT] = None
|
|
75
68
|
self._connection_result: Optional[
|
|
76
69
|
Future[Tuple[str, int, bytes]]
|
|
77
70
|
] = None
|
|
78
|
-
|
|
79
|
-
self._data = json.dumps([code, data],
|
|
80
|
-
# No newlines to be inserted
|
|
81
|
-
indent=None,
|
|
82
|
-
# No whitespace around entities
|
|
83
|
-
separators=(',', ':'))
|
|
84
|
-
self._resp = G90Header()
|
|
71
|
+
self._data = self.encode_data(data)
|
|
85
72
|
|
|
86
73
|
# Implementation of datagram protocol,
|
|
87
74
|
# https://docs.python.org/3/library/asyncio-protocol.html#datagram-protocols
|
|
@@ -139,92 +126,36 @@ class G90BaseCommand(DatagramProtocol):
|
|
|
139
126
|
|
|
140
127
|
return (transport, protocol)
|
|
141
128
|
|
|
142
|
-
def
|
|
129
|
+
def encode_data(self, data: Optional[CommandDataT]) -> str:
|
|
143
130
|
"""
|
|
144
|
-
|
|
131
|
+
Encodes the command data to JSON string.
|
|
145
132
|
"""
|
|
146
|
-
|
|
147
|
-
'utf-8')
|
|
148
|
-
_LOGGER.debug('Encoded to wire format %s', wire)
|
|
149
|
-
return wire
|
|
133
|
+
raise NotImplementedError()
|
|
150
134
|
|
|
151
|
-
def
|
|
135
|
+
def decode_data(self, payload: Optional[str]) -> CommandDataT:
|
|
152
136
|
"""
|
|
153
|
-
|
|
137
|
+
Decodes the command data from JSON string.
|
|
154
138
|
"""
|
|
155
|
-
|
|
156
|
-
try:
|
|
157
|
-
self._parse(data.decode('utf-8'))
|
|
158
|
-
except UnicodeDecodeError as exc:
|
|
159
|
-
raise G90Error(
|
|
160
|
-
'Unable to decode response from UTF-8'
|
|
161
|
-
) from exc
|
|
162
|
-
return self._resp.data or []
|
|
139
|
+
raise NotImplementedError()
|
|
163
140
|
|
|
164
|
-
def
|
|
141
|
+
def to_wire(self) -> bytes:
|
|
165
142
|
"""
|
|
166
|
-
|
|
143
|
+
Serializes the command to wire format.
|
|
167
144
|
"""
|
|
168
|
-
|
|
169
|
-
raise G90Error('Missing start marker in data')
|
|
170
|
-
if not data.endswith('IEND\0'):
|
|
171
|
-
raise G90Error('Missing end marker in data')
|
|
172
|
-
payload = data[6:-5]
|
|
173
|
-
_LOGGER.debug("Decoded from wire: string '%s'", payload)
|
|
174
|
-
|
|
175
|
-
if not payload:
|
|
176
|
-
return
|
|
177
|
-
|
|
178
|
-
# Panel may report the last command has failed
|
|
179
|
-
if payload == 'fail':
|
|
180
|
-
raise G90CommandFailure(
|
|
181
|
-
f"Command {self._code.name}"
|
|
182
|
-
f" (code={self._code.value}) failed"
|
|
183
|
-
)
|
|
145
|
+
raise NotImplementedError()
|
|
184
146
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
f"Command {self._code.name}"
|
|
191
|
-
f" (code={self._code.value}) failed"
|
|
192
|
-
f" with error: '{error}'")
|
|
193
|
-
|
|
194
|
-
resp = None
|
|
195
|
-
try:
|
|
196
|
-
resp = json.loads(payload, strict=False)
|
|
197
|
-
except json.JSONDecodeError as exc:
|
|
198
|
-
raise G90Error(
|
|
199
|
-
f"Unable to parse response as JSON: '{payload}'"
|
|
200
|
-
) from exc
|
|
201
|
-
|
|
202
|
-
if not isinstance(resp, list):
|
|
203
|
-
raise G90Error(
|
|
204
|
-
f"Malformed response, 'list' expected: '{payload}'"
|
|
205
|
-
)
|
|
206
|
-
|
|
207
|
-
if resp is not None:
|
|
208
|
-
self._resp = G90Header(*resp)
|
|
209
|
-
_LOGGER.debug('Parsed from wire: %s', self._resp)
|
|
210
|
-
|
|
211
|
-
if not self._resp.code:
|
|
212
|
-
raise G90Error(f"Missing code in response: '{payload}'")
|
|
213
|
-
# Check there is data if the response is non-empty
|
|
214
|
-
if not self._resp.data:
|
|
215
|
-
raise G90Error(f"Missing data in response: '{payload}'")
|
|
216
|
-
|
|
217
|
-
if self._resp.code != self._code:
|
|
218
|
-
raise G90Error(
|
|
219
|
-
'Wrong response - received code '
|
|
220
|
-
f"{self._resp.code}, expected code {self._code}")
|
|
147
|
+
def from_wire(self, data: bytes) -> Optional[CommandDataT]:
|
|
148
|
+
"""
|
|
149
|
+
Deserializes the command from wire format.
|
|
150
|
+
"""
|
|
151
|
+
raise NotImplementedError()
|
|
221
152
|
|
|
222
153
|
@property
|
|
223
|
-
def result(self) ->
|
|
154
|
+
def result(self) -> CommandDataT:
|
|
224
155
|
"""
|
|
225
156
|
The result of the command.
|
|
226
157
|
"""
|
|
227
|
-
|
|
158
|
+
raise NotImplementedError()
|
|
228
159
|
|
|
229
160
|
@property
|
|
230
161
|
def host(self) -> str:
|
|
@@ -240,7 +171,14 @@ class G90BaseCommand(DatagramProtocol):
|
|
|
240
171
|
"""
|
|
241
172
|
return self._remote_port
|
|
242
173
|
|
|
243
|
-
|
|
174
|
+
@property
|
|
175
|
+
def expects_response(self) -> bool:
|
|
176
|
+
"""
|
|
177
|
+
Indicates whether the command expects a response.
|
|
178
|
+
"""
|
|
179
|
+
return True
|
|
180
|
+
|
|
181
|
+
async def process(self) -> Self: # G90Command[CommandT, CommandDataT]:
|
|
244
182
|
"""
|
|
245
183
|
Processes the command.
|
|
246
184
|
"""
|
|
@@ -259,6 +197,9 @@ class G90BaseCommand(DatagramProtocol):
|
|
|
259
197
|
_LOGGER.debug('(code %s) Sending request to %s:%s',
|
|
260
198
|
self._code, self.host, self.port)
|
|
261
199
|
transport.sendto(self.to_wire())
|
|
200
|
+
if not self.expects_response:
|
|
201
|
+
self._result = None
|
|
202
|
+
return self
|
|
262
203
|
done, _ = await asyncio.wait([self._connection_result],
|
|
263
204
|
timeout=self._timeout)
|
|
264
205
|
if self._connection_result in done:
|
|
@@ -290,4 +231,133 @@ class G90BaseCommand(DatagramProtocol):
|
|
|
290
231
|
Returns string representation of the command.
|
|
291
232
|
"""
|
|
292
233
|
return f'Command: {self._code}, request: {self._data},' \
|
|
293
|
-
f' response: {self.
|
|
234
|
+
f' response: {self.result}'
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
BaseCommandsT = G90Commands
|
|
238
|
+
BaseCommandsDataT = List[Any]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@dataclass
|
|
242
|
+
class G90Header:
|
|
243
|
+
"""
|
|
244
|
+
Represents JSON structure of the header used in base panel commands.
|
|
245
|
+
|
|
246
|
+
:meta private:
|
|
247
|
+
"""
|
|
248
|
+
code: Optional[int] = None
|
|
249
|
+
data: Optional[BaseCommandsDataT] = None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class G90BaseCommand(G90Command[BaseCommandsT, BaseCommandsDataT]):
|
|
253
|
+
"""
|
|
254
|
+
Class for handling base G90 panel commands.
|
|
255
|
+
"""
|
|
256
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
257
|
+
super().__init__(*args, **kwargs)
|
|
258
|
+
self._resp = G90Header()
|
|
259
|
+
|
|
260
|
+
def encode_data(self, data: Optional[BaseCommandsDataT]) -> str:
|
|
261
|
+
"""
|
|
262
|
+
Encodes the command data to JSON string.
|
|
263
|
+
"""
|
|
264
|
+
if data is None:
|
|
265
|
+
return '""'
|
|
266
|
+
return json.dumps([self._code, data],
|
|
267
|
+
# No newlines to be inserted
|
|
268
|
+
indent=None,
|
|
269
|
+
# No whitespace around entities
|
|
270
|
+
separators=(',', ':'))
|
|
271
|
+
|
|
272
|
+
def decode_data(self, payload: Optional[str]) -> BaseCommandsDataT:
|
|
273
|
+
"""
|
|
274
|
+
Decodes the command data from JSON string.
|
|
275
|
+
"""
|
|
276
|
+
# Also, panel may report an error supplying specific reason, e.g.
|
|
277
|
+
# command and its arguments that have failed
|
|
278
|
+
if not payload:
|
|
279
|
+
return []
|
|
280
|
+
if payload.startswith('error'):
|
|
281
|
+
error = payload[5:]
|
|
282
|
+
raise G90CommandError(
|
|
283
|
+
f"Command {self._code.name}"
|
|
284
|
+
f" (code={self._code.value}) failed"
|
|
285
|
+
f" with error: '{error}'")
|
|
286
|
+
|
|
287
|
+
resp = None
|
|
288
|
+
try:
|
|
289
|
+
resp = json.loads(payload, strict=False)
|
|
290
|
+
except json.JSONDecodeError as exc:
|
|
291
|
+
raise G90Error(
|
|
292
|
+
f"Unable to parse response as JSON: '{payload}'"
|
|
293
|
+
) from exc
|
|
294
|
+
|
|
295
|
+
if not isinstance(resp, list):
|
|
296
|
+
raise G90Error(
|
|
297
|
+
f"Malformed response, 'list' expected: '{payload}'"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
if resp is not None:
|
|
301
|
+
self._resp = G90Header(*resp)
|
|
302
|
+
_LOGGER.debug('Parsed from wire: %s', self._resp)
|
|
303
|
+
|
|
304
|
+
if not self._resp.code:
|
|
305
|
+
raise G90Error(f"Missing code in response: '{payload}'")
|
|
306
|
+
# Check there is data if the response is non-empty
|
|
307
|
+
if not self._resp.data:
|
|
308
|
+
raise G90Error(f"Missing data in response: '{payload}'")
|
|
309
|
+
|
|
310
|
+
if self._resp.code != self._code:
|
|
311
|
+
raise G90Error(
|
|
312
|
+
'Wrong response - received code '
|
|
313
|
+
f"{self._resp.code}, expected code {self._code}")
|
|
314
|
+
|
|
315
|
+
return self._resp.data or []
|
|
316
|
+
|
|
317
|
+
def to_wire(self) -> bytes:
|
|
318
|
+
"""
|
|
319
|
+
Returns the command in wire format.
|
|
320
|
+
"""
|
|
321
|
+
wire = bytes(f'ISTART[{self._code},{self._code},{self._data}]IEND\0',
|
|
322
|
+
'utf-8')
|
|
323
|
+
_LOGGER.debug('Encoded to wire format %s', wire)
|
|
324
|
+
return wire
|
|
325
|
+
|
|
326
|
+
def from_wire(self, data: bytes) -> BaseCommandsDataT:
|
|
327
|
+
"""
|
|
328
|
+
Parses the response from the alarm panel.
|
|
329
|
+
"""
|
|
330
|
+
_LOGGER.debug('To be decoded from wire format %s', data)
|
|
331
|
+
try:
|
|
332
|
+
decoded_data = data.decode('utf-8')
|
|
333
|
+
if not decoded_data.startswith('ISTART'):
|
|
334
|
+
raise G90Error('Missing start marker in data')
|
|
335
|
+
if not decoded_data.endswith('IEND\0'):
|
|
336
|
+
raise G90Error('Missing end marker in data')
|
|
337
|
+
payload = decoded_data[6:-5]
|
|
338
|
+
_LOGGER.debug("Decoded from wire: string '%s'", payload)
|
|
339
|
+
|
|
340
|
+
if not payload:
|
|
341
|
+
return []
|
|
342
|
+
|
|
343
|
+
# Panel may report the last command has failed
|
|
344
|
+
if payload == 'fail':
|
|
345
|
+
raise G90CommandFailure(
|
|
346
|
+
f"Command {self._code.name}"
|
|
347
|
+
f" (code={self._code.value}) failed"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
return self.decode_data(payload)
|
|
351
|
+
except UnicodeDecodeError as exc:
|
|
352
|
+
raise G90Error(
|
|
353
|
+
'Unable to decode response from UTF-8'
|
|
354
|
+
) from exc
|
|
355
|
+
|
|
356
|
+
@property
|
|
357
|
+
def result(self) -> BaseCommandsDataT:
|
|
358
|
+
"""
|
|
359
|
+
The result of the command.
|
|
360
|
+
"""
|
|
361
|
+
if self._result is None:
|
|
362
|
+
return []
|
|
363
|
+
return self._result
|
|
@@ -52,19 +52,19 @@ class G90LocalNotifications(G90NotificationsBase, DatagramProtocol):
|
|
|
52
52
|
:param host: The host on which the device is listening for notifications.
|
|
53
53
|
:param local_port: The port on which the local host is listening for
|
|
54
54
|
notifications.
|
|
55
|
-
:param
|
|
55
|
+
:param local_ip: The IP address on which the local host is listening for
|
|
56
56
|
notifications.
|
|
57
57
|
"""
|
|
58
58
|
# pylint:disable=too-many-positional-arguments,too-many-arguments
|
|
59
59
|
def __init__(
|
|
60
60
|
self, protocol_factory: Callable[[], G90NotificationProtocol],
|
|
61
|
-
port: int, host: str, local_port: int,
|
|
61
|
+
port: int, host: str, local_port: int, local_ip: str,
|
|
62
62
|
):
|
|
63
63
|
super().__init__(protocol_factory)
|
|
64
64
|
|
|
65
65
|
self._host = host
|
|
66
66
|
self._port = port
|
|
67
|
-
self.
|
|
67
|
+
self._notifications_local_ip = local_ip
|
|
68
68
|
self._notifications_local_port = local_port
|
|
69
69
|
|
|
70
70
|
# Implementation of datagram protocol,
|
|
@@ -107,11 +107,11 @@ class G90LocalNotifications(G90NotificationsBase, DatagramProtocol):
|
|
|
107
107
|
loop = asyncio.get_running_loop()
|
|
108
108
|
|
|
109
109
|
_LOGGER.debug('Creating UDP endpoint for %s:%s',
|
|
110
|
-
self.
|
|
110
|
+
self._notifications_local_ip,
|
|
111
111
|
self._notifications_local_port)
|
|
112
112
|
(self._transport,
|
|
113
113
|
_protocol) = await loop.create_datagram_endpoint(
|
|
114
114
|
lambda: self,
|
|
115
115
|
local_addr=(
|
|
116
|
-
self.
|
|
116
|
+
self._notifications_local_ip, self._notifications_local_port
|
|
117
117
|
))
|
|
@@ -23,9 +23,9 @@ Implements paginated command for G90 alarm panel protocol.
|
|
|
23
23
|
"""
|
|
24
24
|
from __future__ import annotations
|
|
25
25
|
import logging
|
|
26
|
-
from typing import Any,
|
|
26
|
+
from typing import Any, Optional
|
|
27
27
|
from dataclasses import dataclass
|
|
28
|
-
from .base_cmd import G90BaseCommand,
|
|
28
|
+
from .base_cmd import G90BaseCommand, BaseCommandsDataT
|
|
29
29
|
from ..exceptions import G90Error
|
|
30
30
|
from ..const import G90Commands
|
|
31
31
|
|
|
@@ -82,12 +82,11 @@ class G90PaginatedCommand(G90BaseCommand):
|
|
|
82
82
|
"""
|
|
83
83
|
return self._nelems
|
|
84
84
|
|
|
85
|
-
def
|
|
85
|
+
def decode_data(self, payload: Optional[str]) -> BaseCommandsDataT:
|
|
86
86
|
"""
|
|
87
87
|
Parses the response from the alarm panel.
|
|
88
88
|
"""
|
|
89
|
-
super().
|
|
90
|
-
resp_data: G90BaseCommandData = self._resp.data or []
|
|
89
|
+
resp_data = super().decode_data(payload)
|
|
91
90
|
try:
|
|
92
91
|
page_data = resp_data.pop(0)
|
|
93
92
|
page_info = G90PaginationFields(*page_data)
|
|
@@ -125,8 +124,4 @@ class G90PaginatedCommand(G90BaseCommand):
|
|
|
125
124
|
'total records %s, start record %s, record count %s',
|
|
126
125
|
page_info.total, page_info.start, page_info.nelems)
|
|
127
126
|
|
|
128
|
-
|
|
129
|
-
"""
|
|
130
|
-
Initiates the command processing.
|
|
131
|
-
"""
|
|
132
|
-
return cast(G90PaginatedCommand, await super().process())
|
|
127
|
+
return resp_data
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# Copyright (c) 2026 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
|
+
Provides support for system commands of the G90 alarm panel.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
from typing import Any, Optional, TypeVar, List
|
|
25
|
+
import logging
|
|
26
|
+
|
|
27
|
+
from ..const import G90SystemCommands, G90SystemConfigurationCommands
|
|
28
|
+
from .base_cmd import G90Command
|
|
29
|
+
|
|
30
|
+
_LOGGER = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
SystemCommandsT = TypeVar('SystemCommandsT', bound=G90SystemCommands)
|
|
34
|
+
SystemCommandsDataT = TypeVar('SystemCommandsDataT')
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class G90SystemCommandBase(G90Command[SystemCommandsT, SystemCommandsDataT]):
|
|
38
|
+
"""
|
|
39
|
+
Base class for system commands of the G90 alarm panel.
|
|
40
|
+
|
|
41
|
+
:param code: System command code
|
|
42
|
+
:param kwargs: Additional arguments passed to the base class
|
|
43
|
+
"""
|
|
44
|
+
def __init__(
|
|
45
|
+
self, *, code: SystemCommandsT, **kwargs: Any
|
|
46
|
+
) -> None:
|
|
47
|
+
super().__init__(code=code, **kwargs)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def expects_response(self) -> bool:
|
|
51
|
+
"""
|
|
52
|
+
Indicates whether the command expects a response.
|
|
53
|
+
|
|
54
|
+
System commands do not return any response.
|
|
55
|
+
|
|
56
|
+
:return: False
|
|
57
|
+
"""
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
def encode_data(self, data: Optional[SystemCommandsDataT]) -> str:
|
|
61
|
+
"""
|
|
62
|
+
Encodes the command data to string.
|
|
63
|
+
|
|
64
|
+
:param data: Data for the command
|
|
65
|
+
:return: Encoded data string
|
|
66
|
+
"""
|
|
67
|
+
# Placeholder as required by the base class
|
|
68
|
+
raise NotImplementedError() # pragma: no cover
|
|
69
|
+
|
|
70
|
+
def decode_data(self, payload: Optional[str]) -> SystemCommandsDataT:
|
|
71
|
+
"""
|
|
72
|
+
Decodes the command data from string.
|
|
73
|
+
|
|
74
|
+
:param payload: Payload string
|
|
75
|
+
:return: Decoded data
|
|
76
|
+
"""
|
|
77
|
+
# Placeholder as required by the base class
|
|
78
|
+
raise NotImplementedError() # pragma: no cover
|
|
79
|
+
|
|
80
|
+
def to_wire(self) -> bytes:
|
|
81
|
+
"""
|
|
82
|
+
Serializes the command to wire format as expected by the panel.
|
|
83
|
+
|
|
84
|
+
:return: Wire data
|
|
85
|
+
"""
|
|
86
|
+
wire = bytes(
|
|
87
|
+
f'ISTART[0,100,"AT^IWT={self._code}{self._data},IWT"]IEND\0',
|
|
88
|
+
'utf-8'
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
_LOGGER.debug('Encoded to wire format %s', wire)
|
|
92
|
+
return wire
|
|
93
|
+
|
|
94
|
+
def from_wire(self, data: bytes) -> SystemCommandsDataT:
|
|
95
|
+
"""
|
|
96
|
+
Deserializes the command from wire format.
|
|
97
|
+
|
|
98
|
+
:param data: Wire data
|
|
99
|
+
:return: Decoded data
|
|
100
|
+
"""
|
|
101
|
+
# Placeholder as required by the base class
|
|
102
|
+
raise NotImplementedError() # pragma: no cover
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def result(self) -> SystemCommandsDataT:
|
|
106
|
+
"""
|
|
107
|
+
The result of the command.
|
|
108
|
+
|
|
109
|
+
:return: Decoded data
|
|
110
|
+
"""
|
|
111
|
+
# Placeholder as required by the base class
|
|
112
|
+
raise NotImplementedError() # pragma: no cover
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class G90SystemCommand(
|
|
116
|
+
G90SystemCommandBase[SystemCommandsT, str]
|
|
117
|
+
):
|
|
118
|
+
"""
|
|
119
|
+
Represents a system command of the G90 alarm panel.
|
|
120
|
+
|
|
121
|
+
:param code: System command code
|
|
122
|
+
:param kwargs: Additional arguments passed to the base class
|
|
123
|
+
"""
|
|
124
|
+
def __init__(
|
|
125
|
+
self, *, code: SystemCommandsT, **kwargs: Any
|
|
126
|
+
) -> None:
|
|
127
|
+
if code in [G90SystemCommands.SET_CONFIGURATION,
|
|
128
|
+
G90SystemCommands.GET_CONFIGURATION]:
|
|
129
|
+
raise ValueError(
|
|
130
|
+
'Use G90SystemConfigurationCommand class for '
|
|
131
|
+
'SET_CONFIGURATION and GET_CONFIGURATION commands'
|
|
132
|
+
)
|
|
133
|
+
super().__init__(code=code, **kwargs)
|
|
134
|
+
|
|
135
|
+
def encode_data(self, data: Optional[str]) -> str:
|
|
136
|
+
"""
|
|
137
|
+
Encodes the command data to string.
|
|
138
|
+
|
|
139
|
+
:param data: Data for the command
|
|
140
|
+
:return: Encoded data string
|
|
141
|
+
"""
|
|
142
|
+
return data or ''
|
|
143
|
+
|
|
144
|
+
def decode_data(self, payload: Optional[str]) -> str:
|
|
145
|
+
"""
|
|
146
|
+
Decodes the command data from string.
|
|
147
|
+
|
|
148
|
+
No response is expected for system commands.
|
|
149
|
+
|
|
150
|
+
:param payload: Payload string (ignored)
|
|
151
|
+
:return: Empty string
|
|
152
|
+
"""
|
|
153
|
+
# No response is expected for system commands
|
|
154
|
+
return '' # pragma: no cover
|
|
155
|
+
|
|
156
|
+
def from_wire(self, data: bytes) -> str:
|
|
157
|
+
"""
|
|
158
|
+
Deserializes the command from wire format.
|
|
159
|
+
|
|
160
|
+
No response is expected for system commands.
|
|
161
|
+
|
|
162
|
+
:param data: Wire data (ignored)
|
|
163
|
+
:return: Empty string
|
|
164
|
+
"""
|
|
165
|
+
# No response is expected for system commands
|
|
166
|
+
return '' # pragma: no cover
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def result(self) -> str:
|
|
170
|
+
"""
|
|
171
|
+
The result of the command.
|
|
172
|
+
|
|
173
|
+
No response is expected for system commands.
|
|
174
|
+
|
|
175
|
+
:return: Empty string
|
|
176
|
+
"""
|
|
177
|
+
# No response is expected for system commands
|
|
178
|
+
return ''
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class G90SystemConfigurationCommand(
|
|
182
|
+
G90SystemCommandBase[G90SystemCommands, List[str]]
|
|
183
|
+
):
|
|
184
|
+
"""
|
|
185
|
+
Represents a system configuration command of the G90 alarm panel.
|
|
186
|
+
|
|
187
|
+
:param cmd: Sub-command code for configuration command
|
|
188
|
+
:param data: Data for the command
|
|
189
|
+
:param kwargs: Additional arguments passed to the base class
|
|
190
|
+
"""
|
|
191
|
+
def __init__(
|
|
192
|
+
self, *, cmd: G90SystemConfigurationCommands,
|
|
193
|
+
data: Optional[List[str]] = None,
|
|
194
|
+
**kwargs: Any
|
|
195
|
+
) -> None:
|
|
196
|
+
self._cmd = cmd
|
|
197
|
+
super().__init__(
|
|
198
|
+
code=G90SystemCommands.SET_CONFIGURATION, data=data, **kwargs
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def encode_data(self, data: Optional[List[str]]) -> str:
|
|
202
|
+
"""
|
|
203
|
+
Encodes the command data to string.
|
|
204
|
+
|
|
205
|
+
:param data: Data for the command
|
|
206
|
+
:return: Encoded data string
|
|
207
|
+
"""
|
|
208
|
+
if data is None:
|
|
209
|
+
raise ValueError('Data must be provided for configuration command')
|
|
210
|
+
|
|
211
|
+
res = f',{self._cmd.value}={"&".join(data)}'
|
|
212
|
+
_LOGGER.debug('Encoded data "%s" to string format: %s', data, res)
|
|
213
|
+
return res
|
|
214
|
+
|
|
215
|
+
def decode_data(self, payload: Optional[str]) -> List[str]:
|
|
216
|
+
"""
|
|
217
|
+
Decodes the command data from string.
|
|
218
|
+
|
|
219
|
+
System configuration commands do not return any response.
|
|
220
|
+
|
|
221
|
+
:param payload: Payload string
|
|
222
|
+
:return: Empty list
|
|
223
|
+
"""
|
|
224
|
+
# System configuration commands do not return any response.
|
|
225
|
+
return [] # pragma: no cover
|
|
226
|
+
|
|
227
|
+
def from_wire(self, data: bytes) -> List[str]:
|
|
228
|
+
"""
|
|
229
|
+
Deserializes the command from wire format.
|
|
230
|
+
|
|
231
|
+
System configuration commands do not return any response.
|
|
232
|
+
|
|
233
|
+
:param data: Wire data
|
|
234
|
+
:return: Empty list
|
|
235
|
+
"""
|
|
236
|
+
# System configuration commands do not return any response.
|
|
237
|
+
return [] # pragma: no cover
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def result(self) -> List[str]:
|
|
241
|
+
"""
|
|
242
|
+
The result of the command.
|
|
243
|
+
|
|
244
|
+
System configuration commands do not return any response.
|
|
245
|
+
|
|
246
|
+
:return: Empty list
|
|
247
|
+
"""
|
|
248
|
+
# System configuration commands do not return any response.
|
|
249
|
+
return [] # pragma: no cover
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class G90SetServerAddressCommand(
|
|
253
|
+
G90SystemConfigurationCommand
|
|
254
|
+
):
|
|
255
|
+
"""
|
|
256
|
+
Sets the server address for the panel to communicate with using the cloud
|
|
257
|
+
notifications protocol.
|
|
258
|
+
|
|
259
|
+
:param cloud_ip: IP address of the cloud server
|
|
260
|
+
:param cloud_port: Port number of the cloud server
|
|
261
|
+
:param kwargs: Additional arguments passed to the base class
|
|
262
|
+
"""
|
|
263
|
+
def __init__(
|
|
264
|
+
self, *, cloud_ip: str, cloud_port: int,
|
|
265
|
+
**kwargs: Any
|
|
266
|
+
) -> None:
|
|
267
|
+
super().__init__(
|
|
268
|
+
cmd=G90SystemConfigurationCommands.SERVER_ADDRESS,
|
|
269
|
+
# The command requires two fields for the cloud server address, 1st
|
|
270
|
+
# is used by the panel to communicate with the server, while 2nd
|
|
271
|
+
# might potentially be a fallback address or alike. However,
|
|
272
|
+
# experiments did not show any attempts by the panel to use the 2nd
|
|
273
|
+
# address, so we set both to the same value in an avoidance of
|
|
274
|
+
# doubt.
|
|
275
|
+
data=[cloud_ip, cloud_ip, str(cloud_port)], **kwargs
|
|
276
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyg90alarm
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.7.1
|
|
4
4
|
Summary: G90 Alarm system protocol
|
|
5
5
|
Home-page: https://github.com/hostcc/pyg90alarm
|
|
6
6
|
Author: Ilia Sotnikov
|
|
@@ -189,7 +189,7 @@ Cloud notifications
|
|
|
189
189
|
The cloud protocol is native to the panel and is used to interact with mobile application. The package can mimic the cloud server and interpret the messages the panel sends to the cloud, allowing to receive the notifications and alerts.
|
|
190
190
|
While the protocol also allows to send commands to the panel, it is not implemented and local protocol is used for that - i.e. when cloud notifications are in use the local protocol still utilized for sending commands to the panel.
|
|
191
191
|
|
|
192
|
-
The cloud protocol is TCP based and typically interacts with cloud service at known IP address and port
|
|
192
|
+
The cloud protocol is TCP based and typically interacts with cloud service at known IP address and port, which could be customized. To process the cloud notifications all the traffic from panel towards the configured IP address service needs to be received by the node where the package is running.
|
|
193
193
|
|
|
194
194
|
Please see
|
|
195
195
|
`the section <docs/cloud-protocol.rst>`_ for further details on the protocol.
|
|
@@ -203,25 +203,6 @@ The package could act as:
|
|
|
203
203
|
- Chained cloud server, where in addition to interpreting the notifications it
|
|
204
204
|
will also forward all packets received from the panel to the cloud server, and pass its responses back to the panel. This allows to have notifications processed by the package and the mobile application working as well.
|
|
205
205
|
|
|
206
|
-
.. note:: Sending packets upstream to the known IP address and port of the cloud server might result in those looped back (since traffic from panel to cloud service has to be redirected to the host where package runs), if your network equipment can't account for source address in redirection rules (i.e. limiting the port redirection to the panel's IP address). In that case you'll need another redirection, from the host where the package runs to the cloud service using an IP from your network. That way those two redirection rules will coexist correctly. To illustrate:
|
|
207
|
-
|
|
208
|
-
Port forwarding rule 1:
|
|
209
|
-
|
|
210
|
-
- Source: panel IP address
|
|
211
|
-
- Destination: 47.88.7.61
|
|
212
|
-
- Port: 5678
|
|
213
|
-
- Redirect to host: host where package runs
|
|
214
|
-
- Redirect to port: 5678 (or other port if you want to use it)
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
Port forwarding rule 2 (optional):
|
|
218
|
-
|
|
219
|
-
- Source: host where package runs
|
|
220
|
-
- Destination: an IP address from your network
|
|
221
|
-
- Port: 5678 (or other port if you want to use it)
|
|
222
|
-
- Redirect to : 47.88.7.61
|
|
223
|
-
- Redirect to port: 5678
|
|
224
|
-
|
|
225
206
|
The code fragments below demonstrate how to utilize both modes - please note those are incomplete, since no callbacks are set to process the notifications.
|
|
226
207
|
|
|
227
208
|
**Standalone mode**
|
|
@@ -232,10 +213,20 @@ The code fragments below demonstrate how to utilize both modes - please note tho
|
|
|
232
213
|
|
|
233
214
|
# Create an instance of the alarm panel
|
|
234
215
|
alarm = G90Alarm(host='<panel IP address>')
|
|
216
|
+
|
|
217
|
+
# Configure cloud server address the panel should use - the host running the
|
|
218
|
+
# package.
|
|
219
|
+
await alarm.set_cloud_server_address(
|
|
220
|
+
cloud_ip='<host IP address running the package>', cloud_port=5678
|
|
221
|
+
)
|
|
222
|
+
|
|
235
223
|
# Enable cloud notifications
|
|
236
224
|
await alarm.use_cloud_notifications(
|
|
237
|
-
#
|
|
238
|
-
|
|
225
|
+
# The host/port the package will listen on for the cloud notifications,
|
|
226
|
+
# should match ones above.
|
|
227
|
+
cloud_ip='<host IP address running the package>',
|
|
228
|
+
cloud_port=5678,
|
|
229
|
+
cloud_local_port=5678,
|
|
239
230
|
upstream_host=None
|
|
240
231
|
)
|
|
241
232
|
# Start listening for notifications
|
|
@@ -250,11 +241,22 @@ The code fragments below demonstrate how to utilize both modes - please note tho
|
|
|
250
241
|
|
|
251
242
|
# Create an instance of the alarm panel
|
|
252
243
|
alarm = G90Alarm(host='<panel IP address>')
|
|
244
|
+
|
|
245
|
+
# Configure cloud server address the panel should use - the host running the
|
|
246
|
+
# package.
|
|
247
|
+
await alarm.set_cloud_server_address(
|
|
248
|
+
cloud_ip='<host IP address running the package>', cloud_port=5678
|
|
249
|
+
)
|
|
250
|
+
|
|
253
251
|
# Enable cloud notifications
|
|
254
252
|
await alarm.use_cloud_notifications(
|
|
255
|
-
#
|
|
256
|
-
|
|
257
|
-
|
|
253
|
+
# The host/port the package will listen on for the cloud notifications,
|
|
254
|
+
# should match ones above.
|
|
255
|
+
cloud_ip='<host IP address running the package>',
|
|
256
|
+
cloud_port=5678,
|
|
257
|
+
cloud_local_port=5678,
|
|
258
|
+
# Upstream cloud server address the package should forward the
|
|
259
|
+
# notifications to.
|
|
258
260
|
upstream_host='47.88.7.61',
|
|
259
261
|
upstream_port=5678
|
|
260
262
|
)
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
pyg90alarm/__init__.py,sha256=Z1Y5bwYmtY5iycIv6IDQ464o4Q-sU7fo9S3f94NGOpg,3786
|
|
2
|
-
pyg90alarm/alarm.py,sha256=
|
|
2
|
+
pyg90alarm/alarm.py,sha256=3ct1UbLKSnazVKRxQvAi1N1cazT-EoEcp_ttHJu8L-g,54147
|
|
3
3
|
pyg90alarm/callback.py,sha256=9PVtjRs2MLn80AgiM-UJNL8ZJF4_PxcopJIpxMmB3vc,4707
|
|
4
|
-
pyg90alarm/const.py,sha256=
|
|
4
|
+
pyg90alarm/const.py,sha256=DKM1c3meX0kPnCm8g9b65o5UYm-89mgBv1we8ypLVFI,8674
|
|
5
5
|
pyg90alarm/event_mapping.py,sha256=hSmRWkkuA5hlauGvYakdOrw8QFt0TMNfUuDQ4J3vHpQ,3438
|
|
6
6
|
pyg90alarm/exceptions.py,sha256=9rSd5zIKALHtRHBBLD-R5zxW-5OBkHK04uVupCnAK8s,1937
|
|
7
7
|
pyg90alarm/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
8
|
pyg90alarm/cloud/__init__.py,sha256=u7ZzrIwCsdgmBmgtpCe6c_Kipdo8nG8PEOEcaAHXCxI,1407
|
|
9
9
|
pyg90alarm/cloud/const.py,sha256=FYUxkj0DSI7nnXZHsJMfv8RNQiXKV3TYKGHSdfHdMjg,2113
|
|
10
|
-
pyg90alarm/cloud/messages.py,sha256=
|
|
11
|
-
pyg90alarm/cloud/notifications.py,sha256=
|
|
12
|
-
pyg90alarm/cloud/protocol.py,sha256=
|
|
10
|
+
pyg90alarm/cloud/messages.py,sha256=kpaEPagzsK_fQZmNNIliz3F3KXi-xLXzR0AYze4CMKI,17674
|
|
11
|
+
pyg90alarm/cloud/notifications.py,sha256=yW9ye2qgoE3djKFN-y6UKScBkmzaCm5wKFbovjoLFPU,15818
|
|
12
|
+
pyg90alarm/cloud/protocol.py,sha256=pr7jwukZr8jKUApqAsTfX9U8mZS8DXlVNRPcdio3lqI,16155
|
|
13
13
|
pyg90alarm/dataclass/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
14
|
pyg90alarm/dataclass/load_save.py,sha256=I2WDeW4GingN8AEiwZm9Epf1gVL_Ynkise2h781Dn3I,8604
|
|
15
15
|
pyg90alarm/dataclass/validation.py,sha256=yd3Q86S0L5NekzoiVFW5SwpefbIhWpwvsS-S15X4Sf8,19107
|
|
@@ -27,7 +27,7 @@ pyg90alarm/entities/sensor_list.py,sha256=0S88bhTn91O45WgbIIMQ0iXaNjlUWmMBOOFwj2
|
|
|
27
27
|
pyg90alarm/local/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
28
28
|
pyg90alarm/local/alarm_phones.py,sha256=bGg4aEJwtl7oMlLdOGpW2Vv4q4zS9wzkpW4q3Qy2JCU,4375
|
|
29
29
|
pyg90alarm/local/alert_config.py,sha256=n2dycEf6TH0MKefQXMcRsCFJyYvUMV55LRsy4FARtDI,6027
|
|
30
|
-
pyg90alarm/local/base_cmd.py,sha256=
|
|
30
|
+
pyg90alarm/local/base_cmd.py,sha256=kmjIFwt63sNxz2CdGw1wHv2LOsD8BJhRFxaPszhywSM,12591
|
|
31
31
|
pyg90alarm/local/config.py,sha256=QYetAc6QLrAN8T-37D4Esifvao52w_uJ01nHciLbGME,1390
|
|
32
32
|
pyg90alarm/local/discovery.py,sha256=8YVIXuNe57lhas0VSRf7QXZH83pEDGNj9oehNY4Kh2U,3576
|
|
33
33
|
pyg90alarm/local/history.py,sha256=sL6_Z1BNYkuUwAZUi78d4p5hhcCfsXKw29i4Qu1O60M,10811
|
|
@@ -35,16 +35,17 @@ pyg90alarm/local/host_config.py,sha256=RSvg2UXPTCzzwDW6Iz3CyFtjxPUzSSdlxRMqGpWA6
|
|
|
35
35
|
pyg90alarm/local/host_info.py,sha256=gdneg2XxxMNSFfNTjXCqsBsLZgZr_wg38sudKJ24RD8,3550
|
|
36
36
|
pyg90alarm/local/host_status.py,sha256=WHGtw-A0wHILqVWP4DnZhXj8DPRGyS26AV0bL1isTz4,1863
|
|
37
37
|
pyg90alarm/local/net_config.py,sha256=Y4-SUVSukk-h5x3LK-HH3IlM72dP_46nj1H8lN9rTfo,5998
|
|
38
|
-
pyg90alarm/local/notifications.py,sha256=
|
|
39
|
-
pyg90alarm/local/paginated_cmd.py,sha256=
|
|
38
|
+
pyg90alarm/local/notifications.py,sha256=T9Tfi_e_q0yIJEU_6A1XpBUhLVORWfNnlilQRwLx5n4,4585
|
|
39
|
+
pyg90alarm/local/paginated_cmd.py,sha256=mgk46Xz7vJP9qv2YZlUIzGhxRKReuydKVgcj-f8DkvE,4300
|
|
40
40
|
pyg90alarm/local/paginated_result.py,sha256=p_e8QAVznp1Q5Xi9ifjb9Bx-S3ZiAkVlPKrY6r0bYLs,5483
|
|
41
|
+
pyg90alarm/local/system_cmd.py,sha256=LtkrGlBEjZKaNsycWDLS2lt8NLasWgRAMpMtpAD7AOk,8770
|
|
41
42
|
pyg90alarm/local/targeted_discovery.py,sha256=Ik2C2VBtVLurf3-RKko4O2R3B6MrmFdOskd457uyASU,5516
|
|
42
43
|
pyg90alarm/local/user_data_crc.py,sha256=JQBOPY3RlOgVtvR55R-rM8OuKjYW-BPXQ0W4pi6CEH0,1689
|
|
43
44
|
pyg90alarm/notifications/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
44
45
|
pyg90alarm/notifications/base.py,sha256=d3N_zNPa_jcTX4QpA78jdgMHDhmrgwqyM3HdvuO14Jk,16682
|
|
45
46
|
pyg90alarm/notifications/protocol.py,sha256=TlZQ3P8-N-E2X5bzkGefz432x4lBYyIBF9VriwYn9ds,4790
|
|
46
|
-
pyg90alarm-2.
|
|
47
|
-
pyg90alarm-2.
|
|
48
|
-
pyg90alarm-2.
|
|
49
|
-
pyg90alarm-2.
|
|
50
|
-
pyg90alarm-2.
|
|
47
|
+
pyg90alarm-2.7.1.dist-info/licenses/LICENSE,sha256=f884inRbeNv-O-hbwz62Ro_1J8xiHRTnJ2cCx6A0WvU,1070
|
|
48
|
+
pyg90alarm-2.7.1.dist-info/METADATA,sha256=vQ43pgpWnoP3zPamoD0IgtDrDPZ8lY5C-B8l2dNyUsg,11955
|
|
49
|
+
pyg90alarm-2.7.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
50
|
+
pyg90alarm-2.7.1.dist-info/top_level.txt,sha256=czHiGxYMyTk5QEDTDb0EpPiKqUMRa8zI4zx58Ii409M,11
|
|
51
|
+
pyg90alarm-2.7.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|