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 CHANGED
@@ -60,14 +60,14 @@ from typing import (
60
60
  Callable, Coroutine, Union
61
61
  )
62
62
  from .const import (
63
- G90Commands, REMOTE_PORT,
63
+ G90Commands, G90SystemCommands,
64
+ REMOTE_PORT,
64
65
  REMOTE_TARGETED_DISCOVERY_PORT,
65
66
  LOCAL_TARGETED_DISCOVERY_PORT,
66
- LOCAL_NOTIFICATIONS_HOST,
67
+ LOCAL_NOTIFICATIONS_IP,
67
68
  LOCAL_NOTIFICATIONS_PORT,
68
- CLOUD_NOTIFICATIONS_HOST,
69
- CLOUD_NOTIFICATIONS_PORT,
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 (G90BaseCommand, G90BaseCommandData)
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[G90BaseCommandData] = None
267
- ) -> G90BaseCommandData:
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: G90BaseCommand = await G90BaseCommand(
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, notifications_local_host: str = LOCAL_NOTIFICATIONS_HOST,
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
- local_host=notifications_local_host,
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, cloud_local_host: str = CLOUD_NOTIFICATIONS_HOST,
1325
- cloud_local_port: int = CLOUD_NOTIFICATIONS_PORT,
1326
- upstream_host: Optional[str] = REMOTE_CLOUD_HOST,
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
- local_host=cloud_local_host,
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
 
@@ -37,7 +37,7 @@ from .protocol import (
37
37
  )
38
38
  from .const import G90CloudDirection, G90CloudCommand
39
39
  from ..const import (
40
- G90AlertStateChangeTypes, REMOTE_CLOUD_HOST, REMOTE_CLOUD_PORT,
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
- # Simulated cloud response always contains known IP address of the vendor's
216
- # cloud service - that is, all interactions between alarm panel and
217
- # simulated cloud service will use same IP address for unification (i.e.
218
- # traffic redicrection will always be used to divert panel's cloud traffic
219
- # to the simulated cloud service)
220
- ip_addr: bytes = REMOTE_CLOUD_HOST.encode()
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 = REMOTE_CLOUD_PORT
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.cloud_host.encode()
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 local_host: Local host to bind the server to
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
- local_host: str, local_port: int,
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._local_host = local_host
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
- local_host=self._local_host,
169
+ local_ip=self._local_ip,
166
170
  local_port=self._local_port,
167
- cloud_host=REMOTE_CLOUD_HOST,
168
- cloud_port=REMOTE_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._local_host,
378
+ self._local_ip,
375
379
  self._local_port)
376
380
  self._server = await loop.create_server(
377
381
  lambda: self,
378
- self._local_host, self._local_port
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._local_host, self._local_port
411
+ self._local_ip, self._local_port
408
412
  )
409
413
  self._server.close()
410
414
  self._server = None
@@ -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
- local_host: str
76
+ local_ip: str
77
77
  local_port: int
78
78
  remote_host: str
79
79
  remote_port: int
80
- cloud_host: str
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
- LOCAL_NOTIFICATIONS_HOST = '0.0.0.0'
31
+ LOCAL_NOTIFICATIONS_IP = '0.0.0.0'
32
32
  LOCAL_NOTIFICATIONS_PORT = 12901
33
- CLOUD_NOTIFICATIONS_HOST = '0.0.0.0'
34
- CLOUD_NOTIFICATIONS_PORT = 5678
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 G90Commands(IntEnum):
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.
@@ -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
- @dataclass
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 G90BaseCommand(DatagramProtocol):
48
+ class G90Command(DatagramProtocol, Generic[CommandT, CommandDataT]):
55
49
  """
56
- Implements basic command handling for alarm panel protocol.
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: G90Commands,
64
- data: Optional[G90BaseCommandData] = None,
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._data = '""'
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
- if data:
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 to_wire(self) -> bytes:
129
+ def encode_data(self, data: Optional[CommandDataT]) -> str:
143
130
  """
144
- Returns the command in wire format.
131
+ Encodes the command data to JSON string.
145
132
  """
146
- wire = bytes(f'ISTART[{self._code},{self._code},{self._data}]IEND\0',
147
- 'utf-8')
148
- _LOGGER.debug('Encoded to wire format %s', wire)
149
- return wire
133
+ raise NotImplementedError()
150
134
 
151
- def from_wire(self, data: bytes) -> G90BaseCommandData:
135
+ def decode_data(self, payload: Optional[str]) -> CommandDataT:
152
136
  """
153
- Parses the response from the alarm panel.
137
+ Decodes the command data from JSON string.
154
138
  """
155
- _LOGGER.debug('To be decoded from wire format %s', data)
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 _parse(self, data: str) -> None:
141
+ def to_wire(self) -> bytes:
165
142
  """
166
- Processes the response from the alarm panel.
143
+ Serializes the command to wire format.
167
144
  """
168
- if not data.startswith('ISTART'):
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
- # Also, panel may report an error supplying specific reason, e.g.
186
- # command and its arguments that have failed
187
- if payload.startswith('error'):
188
- error = payload[5:]
189
- raise G90CommandError(
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) -> G90BaseCommandData:
154
+ def result(self) -> CommandDataT:
224
155
  """
225
156
  The result of the command.
226
157
  """
227
- return self._result
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
- async def process(self) -> G90BaseCommand:
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._resp.data}'
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 local_host: The host on which the local host is listening for
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, local_host: str,
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._notifications_local_host = local_host
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._notifications_local_host,
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._notifications_local_host, self._notifications_local_port
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, cast
26
+ from typing import Any, Optional
27
27
  from dataclasses import dataclass
28
- from .base_cmd import G90BaseCommand, G90BaseCommandData
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 _parse(self, data: str) -> None:
85
+ def decode_data(self, payload: Optional[str]) -> BaseCommandsDataT:
86
86
  """
87
87
  Parses the response from the alarm panel.
88
88
  """
89
- super()._parse(data)
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
- async def process(self) -> G90PaginatedCommand:
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.6.0
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 (not customizable at panel side). To process the cloud notifications all the traffic from panel towards the cloud (IP address ``47.88.7.61`` and TCP port ``5678`` as of writing) needs to be diverted to the node where the package is running - depending on your network equipment it could be port forwarding, DNAT or other means. It is unclear whether the panel utilizes DNS to resolve the cloud service IP address, hence the documentation only mentions IP-based traffic redirection.
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
- # Optional, see note above redirecting cloud traffic from panel
238
- local_port=5678,
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
- # Optional, see note above redirecting cloud traffic from panel
256
- local_port=5678,
257
- # See note above re: cloud service and sending packets to it
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=BDBVQ8qRa2Bx2-786uytShTw8znlFEWakxweCGvgxME,50130
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=cpaMlb8rqE1EfvmUEh3Yd_29dNO0usIo_Y-esPBh8B0,8012
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=L3cpP3IbDRdw3W6FeQxTPXoDlpUv7Fm4t-RkH9Uj4dg,18032
11
- pyg90alarm/cloud/notifications.py,sha256=0RxCBVcvDuwE0I1m3SLDXDQqCJimDcN4f45gr-Hvt1A,15669
12
- pyg90alarm/cloud/protocol.py,sha256=82l2IXSM12tv_iWkTrAQZ-aw5UR4tmWFQJKVcgBfIww,16159
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=f0PozHJjErIg08vn0sAacHbigJSFor8q5jdxfXiM27c,10491
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=Vs6NQJciYqDALV-WwzH6wIcTGdX_UD4XBuHWjSOpCDY,4591
39
- pyg90alarm/local/paginated_cmd.py,sha256=5pPVP8f4ydjgu8Yq6MwqINJAUt52fFlD17wO4AI88Pc,4467
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.6.0.dist-info/licenses/LICENSE,sha256=f884inRbeNv-O-hbwz62Ro_1J8xiHRTnJ2cCx6A0WvU,1070
47
- pyg90alarm-2.6.0.dist-info/METADATA,sha256=VDotWAaE433s73_xrvXL1dpHDBG2T9o10-UuASDBvRs,12568
48
- pyg90alarm-2.6.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
49
- pyg90alarm-2.6.0.dist-info/top_level.txt,sha256=czHiGxYMyTk5QEDTDb0EpPiKqUMRa8zI4zx58Ii409M,11
50
- pyg90alarm-2.6.0.dist-info/RECORD,,
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,,