pyg90alarm 2.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. pyg90alarm/__init__.py +84 -0
  2. pyg90alarm/alarm.py +1274 -0
  3. pyg90alarm/callback.py +146 -0
  4. pyg90alarm/cloud/__init__.py +31 -0
  5. pyg90alarm/cloud/const.py +56 -0
  6. pyg90alarm/cloud/messages.py +593 -0
  7. pyg90alarm/cloud/notifications.py +410 -0
  8. pyg90alarm/cloud/protocol.py +518 -0
  9. pyg90alarm/const.py +273 -0
  10. pyg90alarm/definitions/__init__.py +3 -0
  11. pyg90alarm/definitions/base.py +247 -0
  12. pyg90alarm/definitions/devices.py +366 -0
  13. pyg90alarm/definitions/sensors.py +843 -0
  14. pyg90alarm/entities/__init__.py +3 -0
  15. pyg90alarm/entities/base_entity.py +93 -0
  16. pyg90alarm/entities/base_list.py +268 -0
  17. pyg90alarm/entities/device.py +97 -0
  18. pyg90alarm/entities/device_list.py +156 -0
  19. pyg90alarm/entities/sensor.py +891 -0
  20. pyg90alarm/entities/sensor_list.py +183 -0
  21. pyg90alarm/exceptions.py +63 -0
  22. pyg90alarm/local/__init__.py +0 -0
  23. pyg90alarm/local/base_cmd.py +293 -0
  24. pyg90alarm/local/config.py +157 -0
  25. pyg90alarm/local/discovery.py +103 -0
  26. pyg90alarm/local/history.py +272 -0
  27. pyg90alarm/local/host_info.py +89 -0
  28. pyg90alarm/local/host_status.py +52 -0
  29. pyg90alarm/local/notifications.py +117 -0
  30. pyg90alarm/local/paginated_cmd.py +132 -0
  31. pyg90alarm/local/paginated_result.py +135 -0
  32. pyg90alarm/local/targeted_discovery.py +162 -0
  33. pyg90alarm/local/user_data_crc.py +46 -0
  34. pyg90alarm/notifications/__init__.py +0 -0
  35. pyg90alarm/notifications/base.py +481 -0
  36. pyg90alarm/notifications/protocol.py +127 -0
  37. pyg90alarm/py.typed +0 -0
  38. pyg90alarm-2.3.0.dist-info/METADATA +277 -0
  39. pyg90alarm-2.3.0.dist-info/RECORD +42 -0
  40. pyg90alarm-2.3.0.dist-info/WHEEL +5 -0
  41. pyg90alarm-2.3.0.dist-info/licenses/LICENSE +21 -0
  42. pyg90alarm-2.3.0.dist-info/top_level.txt +1 -0
pyg90alarm/alarm.py ADDED
@@ -0,0 +1,1274 @@
1
+ # Copyright (c) 2021 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
+ # pylint: disable=too-many-lines
21
+
22
+ """
23
+ Provides interface to G90 alarm panel.
24
+
25
+ .. note:: Both local protocol (referred to as 1.2) and cloud one
26
+ (mentioned as 1.1) are supported.
27
+
28
+ The next example queries the device with IP address `10.10.10.250` for the
29
+ information - the product name, protocol version, HW versions and such.
30
+
31
+ >>> import asyncio
32
+ >>> from pyg90alarm import G90Alarm
33
+ >>> async def main():
34
+ g90 = G90Alarm(host='10.10.10.250')
35
+ s = await g90.host_info
36
+ print(s)
37
+ >>> asyncio.run(main())
38
+ G90HostInfo(host_guid='<...>',
39
+ product_name='TSV018-C3SIA',
40
+ wifi_protocol_version='1.2',
41
+ cloud_protocol_version='1.1',
42
+ mcu_hw_version='206',
43
+ wifi_hw_version='206',
44
+ gsm_status=3,
45
+ wifi_status=3,
46
+ reserved1=0,
47
+ reserved2=0,
48
+ band_frequency='4264',
49
+ gsm_signal_level=50,
50
+ wifi_signal_level=100)
51
+
52
+ """
53
+ from __future__ import annotations
54
+ import asyncio
55
+ from asyncio import Task
56
+ from datetime import datetime
57
+ import logging
58
+ from typing import (
59
+ TYPE_CHECKING, Any, List, Optional, AsyncGenerator,
60
+ Callable, Coroutine, Union
61
+ )
62
+ from .const import (
63
+ G90Commands, REMOTE_PORT,
64
+ REMOTE_TARGETED_DISCOVERY_PORT,
65
+ LOCAL_TARGETED_DISCOVERY_PORT,
66
+ LOCAL_NOTIFICATIONS_HOST,
67
+ LOCAL_NOTIFICATIONS_PORT,
68
+ CLOUD_NOTIFICATIONS_HOST,
69
+ CLOUD_NOTIFICATIONS_PORT,
70
+ REMOTE_CLOUD_HOST,
71
+ REMOTE_CLOUD_PORT,
72
+ DEVICE_REGISTRATION_TIMEOUT,
73
+ ROOM_ID,
74
+ G90ArmDisarmTypes,
75
+ G90RemoteButtonStates,
76
+ )
77
+ from .local.base_cmd import (G90BaseCommand, G90BaseCommandData)
78
+ from .local.paginated_result import G90PaginatedResult, G90PaginatedResponse
79
+ from .entities.base_list import ListChangeCallback
80
+ from .entities.sensor import G90Sensor
81
+ from .entities.sensor_list import G90SensorList
82
+ from .entities.device import G90Device
83
+ from .entities.device_list import G90DeviceList
84
+ from .definitions.base import (
85
+ G90PeripheralTypes
86
+ )
87
+ from .notifications.protocol import (
88
+ G90NotificationProtocol
89
+ )
90
+ from .notifications.base import G90NotificationsBase
91
+ from .local.notifications import G90LocalNotifications
92
+ from .local.discovery import G90Discovery, G90DiscoveredDevice
93
+ from .local.targeted_discovery import (
94
+ G90TargetedDiscovery, G90DiscoveredDeviceTargeted,
95
+ )
96
+ from .local.host_info import G90HostInfo
97
+ from .local.host_status import G90HostStatus
98
+ from .local.config import (G90AlertConfig, G90AlertConfigFlags)
99
+ from .local.history import G90History
100
+ from .local.user_data_crc import G90UserDataCRC
101
+ from .callback import G90Callback, G90CallbackList
102
+ from .exceptions import G90Error, G90TimeoutError
103
+ from .cloud.notifications import G90CloudNotifications
104
+
105
+ _LOGGER = logging.getLogger(__name__)
106
+
107
+ if TYPE_CHECKING:
108
+ # Type alias for the callback functions available to the user, should be
109
+ # compatible with `G90Callback.Callback` type, since `G90Callback.invoke`
110
+ # is used to invoke them
111
+ AlarmCallback = Union[
112
+ Callable[[int, str, Any], None],
113
+ Callable[[int, str, Any], Coroutine[None, None, None]]
114
+ ]
115
+ DoorOpenCloseCallback = Union[
116
+ Callable[[int, str, bool], None],
117
+ Callable[[int, str, bool], Coroutine[None, None, None]]
118
+ ]
119
+ SensorCallback = Union[
120
+ Callable[[int, str, bool], None],
121
+ Callable[[int, str, bool], Coroutine[None, None, None]]
122
+ ]
123
+ LowBatteryCallback = Union[
124
+ Callable[[int, str], None],
125
+ Callable[[int, str], Coroutine[None, None, None]]
126
+ ]
127
+ ArmDisarmCallback = Union[
128
+ Callable[[G90ArmDisarmTypes], None],
129
+ Callable[[G90ArmDisarmTypes], Coroutine[None, None, None]]
130
+ ]
131
+ SosCallback = Union[
132
+ Callable[[int, str, bool], None],
133
+ Callable[[int, str, bool], Coroutine[None, None, None]]
134
+ ]
135
+ RemoteButtonPressCallback = Union[
136
+ Callable[[int, str, G90RemoteButtonStates], None],
137
+ Callable[
138
+ [int, str, G90RemoteButtonStates], Coroutine[None, None, None]
139
+ ]
140
+ ]
141
+ DoorOpenWhenArmingCallback = Union[
142
+ Callable[[int, str], None],
143
+ Callable[[int, str], Coroutine[None, None, None]]
144
+ ]
145
+ TamperCallback = Union[
146
+ Callable[[int, str], None],
147
+ Callable[[int, str], Coroutine[None, None, None]]
148
+ ]
149
+ # Sensor-related callbacks for `G90Sensor` class - despite that class
150
+ # stores them, the invocation is done by the `G90Alarm` class hence these
151
+ # are defined here
152
+ SensorStateCallback = Union[
153
+ Callable[[bool], None],
154
+ Callable[[bool], Coroutine[None, None, None]]
155
+ ]
156
+ SensorLowBatteryCallback = Union[
157
+ Callable[[], None],
158
+ Callable[[], Coroutine[None, None, None]]
159
+ ]
160
+ SensorDoorOpenWhenArmingCallback = Union[
161
+ Callable[[], None],
162
+ Callable[[], Coroutine[None, None, None]]
163
+ ]
164
+ SensorTamperCallback = Union[
165
+ Callable[[], None],
166
+ Callable[[], Coroutine[None, None, None]]
167
+ ]
168
+ SensorChangeCallback = Union[
169
+ Callable[[int, str, bool], None],
170
+ Callable[[int, str, bool], Coroutine[None, None, None]]
171
+ ]
172
+
173
+
174
+ # pylint: disable=too-many-public-methods
175
+ class G90Alarm(G90NotificationProtocol):
176
+
177
+ """
178
+ Allows to interact with G90 alarm panel.
179
+
180
+ :param host: Hostname or IP address of the alarm panel. Since the
181
+ protocol is UDP-based it is ok to use broadcast or directed broadcast
182
+ addresses, such as `255.255.255.255` or `10.10.10.255` (the latter assumes
183
+ the device is on `10.10.10.0/24` network)
184
+ :param port: The UDP port of the device where it listens for the
185
+ protocol commands on WiFi interface, currently the devices don't allow it
186
+ to be customized
187
+ :param reset_occupancy_interval: The interval upon that the sensors are
188
+ simulated to go into inactive state.
189
+ """
190
+ # pylint: disable=too-many-instance-attributes,too-many-arguments
191
+ def __init__(
192
+ self, host: str, port: int = REMOTE_PORT,
193
+ reset_occupancy_interval: float = 3.0
194
+ ) -> None:
195
+ self._host: str = host
196
+ self._port: int = port
197
+ self._notifications: Optional[G90NotificationsBase] = None
198
+ self._sensors = G90SensorList(self)
199
+ # The callback will be invoked when sensor list changes, e.g. sensor is
200
+ # added or updated
201
+ self._sensors.list_change_callback = self.on_sensor_list_change
202
+ self._devices = G90DeviceList(self)
203
+ # Similarly for the device list
204
+ self._devices.list_change_callback = self.on_device_list_change
205
+ self._sensor_cb: G90CallbackList[SensorCallback] = G90CallbackList()
206
+ self._armdisarm_cb: G90CallbackList[ArmDisarmCallback] = (
207
+ G90CallbackList()
208
+ )
209
+ self._door_open_close_cb: G90CallbackList[DoorOpenCloseCallback] = (
210
+ G90CallbackList()
211
+ )
212
+ self._alarm_cb: G90CallbackList[AlarmCallback] = G90CallbackList()
213
+ self._low_battery_cb: G90CallbackList[LowBatteryCallback] = (
214
+ G90CallbackList()
215
+ )
216
+ self._sos_cb: G90CallbackList[SosCallback] = G90CallbackList()
217
+ self._remote_button_press_cb: G90CallbackList[
218
+ RemoteButtonPressCallback
219
+ ] = G90CallbackList()
220
+ self._door_open_when_arming_cb: G90CallbackList[
221
+ DoorOpenWhenArmingCallback
222
+ ] = G90CallbackList()
223
+ self._tamper_cb: G90CallbackList[TamperCallback] = G90CallbackList()
224
+ self._sensor_list_change_cb: G90CallbackList[
225
+ ListChangeCallback[G90Sensor]
226
+ ] = G90CallbackList()
227
+ self._device_list_change_cb: G90CallbackList[
228
+ ListChangeCallback[G90Device]
229
+ ] = G90CallbackList()
230
+ self._reset_occupancy_interval = reset_occupancy_interval
231
+ self._alert_config = G90AlertConfig(self)
232
+ self._sms_alert_when_armed = False
233
+ self._alert_simulation_task: Optional[Task[Any]] = None
234
+ self._alert_simulation_start_listener_back = False
235
+
236
+ @property
237
+ def host(self) -> str:
238
+ """
239
+ Returns the hostname or IP address of the alarm panel.
240
+
241
+ This is the address used for communication with the device.
242
+ """
243
+ return self._host
244
+
245
+ @property
246
+ def port(self) -> int:
247
+ """
248
+ Returns the UDP port number used to communicate with the alarm panel.
249
+
250
+ By default, this is set to the standard G90 protocol port.
251
+ """
252
+ return self._port
253
+
254
+ async def command(
255
+ self, code: G90Commands, data: Optional[G90BaseCommandData] = None
256
+ ) -> G90BaseCommandData:
257
+ """
258
+ Invokes a command against the alarm panel.
259
+
260
+ :param code: Command code
261
+ :param data: Command data
262
+ :return: The result of command invocation
263
+ """
264
+ cmd: G90BaseCommand = await G90BaseCommand(
265
+ self._host, self._port, code, data).process()
266
+ return cmd.result
267
+
268
+ def paginated_result(
269
+ self, code: G90Commands, start: int = 1, end: Optional[int] = None
270
+ ) -> AsyncGenerator[G90PaginatedResponse, None]:
271
+ """
272
+ Returns asynchronous generator for a paginated command, that is -
273
+ command operating on a range of records.
274
+
275
+ :param code: Command code
276
+ :param start: Starting record position (one-based)
277
+ :param end: Ending record position (one-based)
278
+ :return: Asynchronous generator over the result of command invocation.
279
+ """
280
+ return G90PaginatedResult(
281
+ self._host, self._port, code, start, end
282
+ ).process()
283
+
284
+ @classmethod
285
+ async def discover(cls) -> List[G90DiscoveredDevice]:
286
+ """
287
+ Initiates discovering devices available in the same network segment, by
288
+ using global broadcast address as the destination.
289
+
290
+ :return: List of discovered devices
291
+ """
292
+ cmd: G90Discovery = await G90Discovery(
293
+ port=REMOTE_PORT,
294
+ host='255.255.255.255'
295
+ ).process()
296
+ return cmd.devices
297
+
298
+ @classmethod
299
+ async def targeted_discover(
300
+ cls, device_id: str
301
+ ) -> List[G90DiscoveredDeviceTargeted]:
302
+ """
303
+ Initiates discovering devices available in the same network segment
304
+ using targeted protocol, that is - specifying target device GUID in the
305
+ request, so only the specific device should respond to the query.
306
+
307
+ :param device_id: GUID of the target device to discover
308
+ :return: List of discovered devices
309
+ """
310
+ cmd = await G90TargetedDiscovery(
311
+ device_id=device_id,
312
+ port=REMOTE_TARGETED_DISCOVERY_PORT,
313
+ local_port=LOCAL_TARGETED_DISCOVERY_PORT,
314
+ host='255.255.255.255'
315
+ ).process()
316
+ return cmd.devices
317
+
318
+ @property
319
+ async def sensors(self) -> List[G90Sensor]:
320
+ """
321
+ Returns the list of sensors configured in the device. Please note
322
+ it doesn't update those from the panel except initially when the list
323
+ if empty.
324
+
325
+ :return: List of sensors
326
+ """
327
+ return await self._sensors.entities
328
+
329
+ async def get_sensors(self) -> List[G90Sensor]:
330
+ """
331
+ Provides list of sensors configured in the device, updating them from
332
+ panel on each call.
333
+
334
+ :return: List of sensors
335
+ """
336
+ return await self._sensors.update()
337
+
338
+ async def find_sensor(
339
+ self, idx: int, name: str, exclude_unavailable: bool = True
340
+ ) -> Optional[G90Sensor]:
341
+ """
342
+ Finds sensor by index and name.
343
+
344
+ :param idx: Sensor index
345
+ :param name: Sensor name
346
+ :param exclude_unavailable: Flag indicating if unavailable sensors
347
+ should be excluded from the search
348
+ :return: Sensor instance
349
+ """
350
+ return await self._sensors.find(idx, name, exclude_unavailable)
351
+
352
+ async def register_sensor(
353
+ self, definition_name: str, name: Optional[str] = None,
354
+ timeout: float = DEVICE_REGISTRATION_TIMEOUT
355
+ ) -> G90Sensor:
356
+ """
357
+ Registers the sensor with the panel.
358
+
359
+ :param definition_name: Name of the sensor definition to register
360
+ :param name: Optional name of the sensor to register, if not provided
361
+ the name will be taken from the definition
362
+ :param timeout: Timeout for the registration process, in seconds
363
+ :return: Sensor instance
364
+ """
365
+ return await self._sensors.register(
366
+ definition_name, ROOM_ID, timeout, name
367
+ )
368
+
369
+ @property
370
+ async def devices(self) -> List[G90Device]:
371
+ """
372
+ Returns the list of devices (switches) configured in the device. Please
373
+ note it doesn't update those from the panel except initially when
374
+ the list if empty.
375
+
376
+ :return: List of devices
377
+ """
378
+ return await self._devices.entities
379
+
380
+ async def get_devices(self) -> List[G90Device]:
381
+ """
382
+ Provides list of devices (switches) configured in the device, updating
383
+ them from panel on each call. Multi-node devices, those
384
+ having multiple ports, are expanded into corresponding number of
385
+ resulting entries.
386
+
387
+ :return: List of devices
388
+ """
389
+ return await self._devices.update()
390
+
391
+ async def find_device(
392
+ self, idx: int, name: str, exclude_unavailable: bool = True
393
+ ) -> Optional[G90Device]:
394
+ """
395
+ Finds device by index and name.
396
+
397
+ :param idx: Device index
398
+ :param name: Device name
399
+ :param exclude_unavailable: Flag indicating if unavailable devices
400
+ should be excluded from the search
401
+ :return: Device instance
402
+ """
403
+ return await self._devices.find(idx, name, exclude_unavailable)
404
+
405
+ async def register_device(
406
+ self, definition_name: str, name: Optional[str] = None,
407
+ timeout: float = DEVICE_REGISTRATION_TIMEOUT
408
+ ) -> G90Device:
409
+ """
410
+ Registers device (relay, switch) with the panel.
411
+
412
+ :param definition_name: Name of the device definition to register
413
+ :param name: Optional name of the device to register, if not provided
414
+ the name will be taken from the definition
415
+ :param timeout: Timeout for the registration process, in seconds
416
+ :return: Device instance
417
+ """
418
+ return await self._devices.register(
419
+ definition_name, ROOM_ID, timeout, name
420
+ )
421
+
422
+ @property
423
+ async def host_info(self) -> G90HostInfo:
424
+ """
425
+ Property over new :meth:`.get_host_info` method, retained for
426
+ compatibility.
427
+ """
428
+ return await self.get_host_info()
429
+
430
+ async def get_host_info(self) -> G90HostInfo:
431
+ """
432
+ Provides the device information (for example hardware versions, signal
433
+ levels etc.).
434
+
435
+ :return: Device information
436
+ """
437
+ res = await self.command(G90Commands.GETHOSTINFO)
438
+ info = G90HostInfo(*res)
439
+ if self._notifications:
440
+ self._notifications.device_id = info.host_guid
441
+ return info
442
+
443
+ @property
444
+ async def host_status(self) -> G90HostStatus:
445
+ """
446
+ Property over new :meth:`.get_host_status` method, retained for
447
+ compatibility.
448
+ """
449
+ return await self.get_host_status()
450
+
451
+ async def get_host_status(self) -> G90HostStatus:
452
+ """
453
+ Provides the device status (for example, armed or disarmed, configured
454
+ phone number, product name etc.).
455
+
456
+ :return: Device information
457
+
458
+ """
459
+ res = await self.command(G90Commands.GETHOSTSTATUS)
460
+ return G90HostStatus(*res)
461
+
462
+ @property
463
+ def alert_config(self) -> G90AlertConfig:
464
+ """
465
+ Provides alert configuration object.
466
+
467
+ :return: Alert configuration object
468
+ """
469
+ return self._alert_config
470
+
471
+ async def get_alert_config(self) -> G90AlertConfigFlags:
472
+ """
473
+ Provides alert configuration flags, retained for compatibility - using
474
+ :attr:`alert_config` and :class:`.G90AlertConfig` is preferred.
475
+
476
+ :return: The alerts configured
477
+ """
478
+ return await self.alert_config.flags
479
+
480
+ async def set_alert_config(self, flags: G90AlertConfigFlags) -> None:
481
+ """
482
+ Sets the alert configuration flags, retained for compatibility - using
483
+ :attr:`alert_config` and :class:`.G90AlertConfig` is preferred.
484
+ """
485
+ await self.alert_config.set(flags)
486
+
487
+ @property
488
+ async def user_data_crc(self) -> G90UserDataCRC:
489
+ """
490
+ Property over new :meth:`.get_user_data_crc` method, retained for
491
+ compatibility.
492
+ """
493
+ return await self.get_user_data_crc()
494
+
495
+ async def get_user_data_crc(self) -> G90UserDataCRC:
496
+ """
497
+ Retieves checksums (CRC) for different on-device databases (history,
498
+ sensors etc.). Might be used to detect if there is a change in a
499
+ particular database.
500
+
501
+ .. note:: Note that due to a bug in the firmware CRC for sensors and
502
+ device databases change on each call even if there were no changes
503
+
504
+ :return: Checksums for different databases
505
+ """
506
+ res = await self.command(G90Commands.GETUSERDATACRC)
507
+ return G90UserDataCRC(*res)
508
+
509
+ async def history(
510
+ self, start: int = 1, count: int = 1
511
+ ) -> List[G90History]:
512
+ """
513
+ Retrieves event history from the device.
514
+
515
+ :param start: Starting record number (one-based)
516
+ :param count: Number of records to retrieve
517
+ :return: List of history entries
518
+ """
519
+ res = self.paginated_result(G90Commands.GETHISTORY,
520
+ start, count)
521
+
522
+ # Sort the history entries from older to newer - device typically does
523
+ # that, but apparently that is not guaranteed
524
+ return sorted(
525
+ [G90History(*x.data) async for x in res],
526
+ key=lambda x: x.datetime, reverse=True
527
+ )
528
+
529
+ async def on_sensor_activity(
530
+ self, idx: int, name: str, occupancy: bool = True
531
+ ) -> None:
532
+ """
533
+ Invoked both for sensor notifications and door open/close
534
+ alerts, since the logic for both is same and could be reused.
535
+ Fires corresponding callback if set by the user with
536
+ :attr:`.sensor_callback`.
537
+
538
+ Please note the method is for internal use by the class.
539
+
540
+ :param idx: The index of the sensor the callback is invoked for.
541
+ Please note the index is a property of sensor, not the direct index of
542
+ :attr:`sensors` array
543
+ :param name: The name of the sensor, along with the `idx` parameter
544
+ it is used to look the sensor up from the :attr:`sensors` list
545
+ :param occupancy: The flag indicating the target sensor state
546
+ (=occupancy), will always be `True` for callbacks invoked from alarm
547
+ panel notifications, and reflects actual sensor state for device
548
+ alerts (only for `door` type sensors, if door open/close alerts are
549
+ enabled)
550
+ """
551
+ _LOGGER.debug('on_sensor_activity: %s %s %s', idx, name, occupancy)
552
+ sensor = await self.find_sensor(idx, name)
553
+ if sensor:
554
+ # Reset the low battery flag since the sensor reports activity,
555
+ # implying it has sufficient battery power
556
+ # pylint: disable=protected-access
557
+ sensor._set_low_battery(False)
558
+ # Set the sensor occupancy
559
+ # pylint: disable=protected-access
560
+ sensor._set_occupancy(occupancy)
561
+
562
+ # Emulate turning off the occupancy - most of sensors will not
563
+ # notify the device of that, nor the device would emit such
564
+ # notification itself
565
+ def reset_sensor_occupancy(sensor: G90Sensor) -> None:
566
+ sensor._set_occupancy(False)
567
+ sensor.state_callback.invoke(sensor.occupancy)
568
+
569
+ alert_config_flags = await self.alert_config.flags
570
+ # Determine if door close notifications are available for the given
571
+ # sensor
572
+ door_close_alert_enabled = (
573
+ G90AlertConfigFlags.DOOR_CLOSE in alert_config_flags
574
+ )
575
+ # The condition intentionally doesn't account for cord sensors of
576
+ # subtype door, since those won't send door open/close alerts, only
577
+ # notifications
578
+ sensor_is_door = sensor.type == G90PeripheralTypes.DOOR
579
+
580
+ # Alarm panel could emit door close alerts (if enabled) for sensors
581
+ # of type `door`, and such event will be used to reset the
582
+ # occupancy for the given sensor. Otherwise, the sensor closing
583
+ # event will be emulated
584
+ if not door_close_alert_enabled or not sensor_is_door:
585
+ _LOGGER.debug("Sensor '%s' is not a door (type %s),"
586
+ ' or door close alert is disabled'
587
+ ' (alert config flags %s) or is a cord sensor,'
588
+ ' closing event will be emulated upon'
589
+ ' %s seconds',
590
+ name, sensor.type,
591
+ alert_config_flags,
592
+ self._reset_occupancy_interval)
593
+ G90Callback.invoke_delayed(
594
+ self._reset_occupancy_interval,
595
+ reset_sensor_occupancy, sensor)
596
+
597
+ # Invoke per-sensor callback if provided
598
+ sensor.state_callback.invoke(occupancy)
599
+
600
+ # Invoke global callback if provided
601
+ self._sensor_cb.invoke(idx, name, occupancy)
602
+
603
+ @property
604
+ def sensor_callback(self) -> G90CallbackList[SensorCallback]:
605
+ """
606
+ Sensor activity callback, which is invoked when sensor activates.
607
+
608
+ Setting the property will add the callback to the list of (retained for
609
+ compatilibity with earlier package versions), or
610
+ :class:`.G90CallbackList` instance could be accessed over the
611
+ property - `G90Alarm(...).sensor_callback.add(callback)` or
612
+ `G90Alarm(...).sensor_callback.remove(callback)` methods could be used
613
+ to add or remove the callback, respectively.
614
+ """
615
+ return self._sensor_cb
616
+
617
+ @sensor_callback.setter
618
+ def sensor_callback(self, value: SensorCallback) -> None:
619
+ self._sensor_cb.add(value)
620
+
621
+ async def on_door_open_close(
622
+ self, event_id: int, zone_name: str, is_open: bool
623
+ ) -> None:
624
+ """
625
+ Invoked when door open/close alert comes from the alarm
626
+ panel. Fires corresponding callback if set by the user with
627
+ :attr:`.door_open_close_callback`.
628
+
629
+ Please note the method is for internal use by the class.
630
+
631
+ .. seealso:: :meth:`.on_sensor_activity` method for arguments
632
+ """
633
+ # Same internal callback is reused both for door open/close alerts and
634
+ # sensor notifications. The former adds reporting when a door is
635
+ # closed, since the notifications aren't sent for such events
636
+ await self.on_sensor_activity(event_id, zone_name, is_open)
637
+ # Invoke user specified callback if any
638
+ self._door_open_close_cb.invoke(event_id, zone_name, is_open)
639
+
640
+ @property
641
+ def door_open_close_callback(
642
+ self
643
+ ) -> G90CallbackList[DoorOpenCloseCallback]:
644
+ """
645
+ The door open/close callback, which is invoked when door
646
+ is opened or closed (if corresponding alert is configured on the
647
+ device).
648
+
649
+ .. seealso:: :attr:`.sensor_callback` for compatiblity notes
650
+ """
651
+ return self._door_open_close_cb
652
+
653
+ @door_open_close_callback.setter
654
+ def door_open_close_callback(self, value: DoorOpenCloseCallback) -> None:
655
+ self._door_open_close_cb.add(value)
656
+
657
+ async def on_armdisarm(self, state: G90ArmDisarmTypes) -> None:
658
+ """
659
+ Invoked when the device is armed or disarmed. Fires corresponding
660
+ callback if set by the user with :attr:`.armdisarm_callback`.
661
+
662
+ Please note the method is for internal use by the class.
663
+
664
+ :param state: Device state (armed, disarmed, armed home)
665
+ """
666
+ if self._sms_alert_when_armed:
667
+ await self.alert_config.set_flag(
668
+ G90AlertConfigFlags.SMS_PUSH,
669
+ state in (
670
+ G90ArmDisarmTypes.ARM_AWAY, G90ArmDisarmTypes.ARM_HOME
671
+ )
672
+ )
673
+
674
+ # Reset the tampered and door open when arming flags on all sensors
675
+ # having those set
676
+ for sensor in await self.sensors:
677
+ if sensor.is_tampered:
678
+ # pylint: disable=protected-access
679
+ sensor._set_tampered(False)
680
+ if sensor.is_door_open_when_arming:
681
+ # pylint: disable=protected-access
682
+ sensor._set_door_open_when_arming(False)
683
+
684
+ self._armdisarm_cb.invoke(state)
685
+
686
+ @property
687
+ def armdisarm_callback(self) -> G90CallbackList[ArmDisarmCallback]:
688
+ """
689
+ The device arm/disarm callback, which is invoked when device state
690
+ changes.
691
+
692
+ .. seealso:: :attr:`.sensor_callback` for compatiblity notes
693
+ """
694
+ return self._armdisarm_cb
695
+
696
+ @armdisarm_callback.setter
697
+ def armdisarm_callback(self, value: ArmDisarmCallback) -> None:
698
+ self._armdisarm_cb.add(value)
699
+
700
+ async def on_alarm(
701
+ self, event_id: int, zone_name: str, is_tampered: bool
702
+ ) -> None:
703
+ """
704
+ Invoked when alarm is triggered. Fires corresponding callback if set by
705
+ the user with :attr:`.alarm_callback`.
706
+
707
+ Please note the method is for internal use by the class.
708
+
709
+ :param event_id: Index of the sensor triggered alarm
710
+ :param zone_name: Sensor name
711
+ """
712
+ sensor = await self.find_sensor(event_id, zone_name)
713
+ extra_data = None
714
+ if sensor:
715
+ # The callback is still delivered to the caller even if the sensor
716
+ # isn't found, only `extra_data` is skipped. That is to ensure the
717
+ # important callback isn't filtered
718
+ extra_data = sensor.extra_data
719
+
720
+ # Invoke the sensor activity callback to set the sensor occupancy
721
+ # if sensor is known, but only if that isn't already set - it helps
722
+ # when device notifications on triggerring sensor's activity aren't
723
+ # receveid by a reason
724
+ if not sensor.occupancy:
725
+ await self.on_sensor_activity(event_id, zone_name, True)
726
+
727
+ if is_tampered:
728
+ # Set the tampered flag on the sensor
729
+ # pylint: disable=protected-access
730
+ sensor._set_tampered(True)
731
+
732
+ # Invoke per-sensor callback if provided
733
+ sensor.tamper_callback.invoke()
734
+
735
+ # Invoke global tamper callback if provided and the sensor is tampered
736
+ if is_tampered:
737
+ self._tamper_cb.invoke(event_id, zone_name)
738
+
739
+ self._alarm_cb.invoke(event_id, zone_name, extra_data)
740
+
741
+ @property
742
+ def alarm_callback(self) -> G90CallbackList[AlarmCallback]:
743
+ """
744
+ The device alarm callback, which is invoked when device alarm triggers.
745
+
746
+ .. seealso:: :attr:`.sensor_callback` for compatiblity notes
747
+ """
748
+ return self._alarm_cb
749
+
750
+ @alarm_callback.setter
751
+ def alarm_callback(self, value: AlarmCallback) -> None:
752
+ self._alarm_cb.add(value)
753
+
754
+ async def on_low_battery(self, event_id: int, zone_name: str) -> None:
755
+ """
756
+ Invoked when the sensor reports on low battery. Fires
757
+ corresponding callback if set by the user with
758
+ :attr:`.on_low_battery_callback`.
759
+
760
+ Please note the method is for internal use by the class.
761
+
762
+ :param event_id: Index of the sensor triggered alarm
763
+ :param zone_name: Sensor name
764
+ """
765
+ _LOGGER.debug('on_low_battery: %s %s', event_id, zone_name)
766
+ sensor = await self.find_sensor(event_id, zone_name)
767
+ if sensor:
768
+ # Set the low battery flag on the sensor
769
+ # pylint: disable=protected-access
770
+ sensor._set_low_battery(True)
771
+ # Invoke per-sensor callback if provided
772
+ sensor.low_battery_callback.invoke()
773
+
774
+ self._low_battery_cb.invoke(event_id, zone_name)
775
+
776
+ @property
777
+ def low_battery_callback(self) -> G90CallbackList[LowBatteryCallback]:
778
+ """
779
+ Low battery callback, which is invoked when sensor reports the
780
+ condition.
781
+
782
+ .. seealso:: :attr:`.sensor_callback` for compatiblity notes
783
+ """
784
+ return self._low_battery_cb
785
+
786
+ @low_battery_callback.setter
787
+ def low_battery_callback(self, value: LowBatteryCallback) -> None:
788
+ self._low_battery_cb.add(value)
789
+
790
+ async def on_sos(
791
+ self, event_id: int, zone_name: str, is_host_sos: bool
792
+ ) -> None:
793
+ """
794
+ Invoked when SOS alert is triggered. Fires corresponding callback if
795
+ set by the user with :attr:`.sos_callback`.
796
+
797
+ Please note the method is for internal use by the class.
798
+
799
+ :param event_id: Index of the sensor triggered alarm
800
+ :param zone_name: Sensor name
801
+ :param is_host_sos:
802
+ Flag indicating if the SOS alert is triggered by the panel itself
803
+ (host)
804
+ """
805
+ _LOGGER.debug('on_sos: %s %s %s', event_id, zone_name, is_host_sos)
806
+ self._sos_cb.invoke(event_id, zone_name, is_host_sos)
807
+
808
+ # Also report the event as alarm for unification, hard-coding the
809
+ # sensor name in case of host SOS
810
+ await self.on_alarm(
811
+ event_id, zone_name='Host SOS' if is_host_sos else zone_name,
812
+ is_tampered=False
813
+ )
814
+
815
+ if not is_host_sos:
816
+ # Also report the remote button press for SOS - the panel will not
817
+ # send corresponding alert
818
+ await self.on_remote_button_press(
819
+ event_id, zone_name, G90RemoteButtonStates.SOS
820
+ )
821
+
822
+ @property
823
+ def sos_callback(self) -> G90CallbackList[SosCallback]:
824
+ """
825
+ SOS callback, which is invoked when SOS alert is triggered.
826
+
827
+ .. seealso:: :attr:`.sensor_callback` for compatiblity notes
828
+ """
829
+ return self._sos_cb
830
+
831
+ @sos_callback.setter
832
+ def sos_callback(self, value: SosCallback) -> None:
833
+ self._sos_cb.add(value)
834
+
835
+ async def on_remote_button_press(
836
+ self, event_id: int, zone_name: str, button: G90RemoteButtonStates
837
+ ) -> None:
838
+ """
839
+ Invoked when remote button is pressed. Fires corresponding callback if
840
+ set by the user with :attr:`.remote_button_press_callback`.
841
+
842
+ Please note the method is for internal use by the class.
843
+
844
+ :param event_id: Index of the sensor triggered alarm
845
+ :param zone_name: Sensor name
846
+ :param button: The button pressed
847
+ """
848
+ _LOGGER.debug(
849
+ 'on_remote_button_press: %s %s %s', event_id, zone_name, button
850
+ )
851
+ self._remote_button_press_cb.invoke(event_id, zone_name, button)
852
+
853
+ # Also report the event as sensor activity for unification (remote is
854
+ # just a special type of the sensor)
855
+ await self.on_sensor_activity(event_id, zone_name, True)
856
+
857
+ @property
858
+ def remote_button_press_callback(
859
+ self
860
+ ) -> G90CallbackList[RemoteButtonPressCallback]:
861
+ """
862
+ Remote button press callback, which is invoked when remote button is
863
+ pressed.
864
+
865
+ .. seealso:: :attr:`.sensor_callback` for compatiblity notes
866
+ """
867
+ return self._remote_button_press_cb
868
+
869
+ @remote_button_press_callback.setter
870
+ def remote_button_press_callback(
871
+ self, value: RemoteButtonPressCallback
872
+ ) -> None:
873
+ self._remote_button_press_cb.add(value)
874
+
875
+ async def on_door_open_when_arming(
876
+ self, event_id: int, zone_name: str
877
+ ) -> None:
878
+ """
879
+ Invoked when door is open when arming the device. Fires corresponding
880
+ callback if set by the user with
881
+ :attr:`.door_open_when_arming_callback`.
882
+
883
+ Please note the method is for internal use by the class.
884
+
885
+ :param event_id: The index of the sensor being active when the panel
886
+ is being armed.
887
+ :param zone_name: The name of the sensor
888
+ """
889
+ _LOGGER.debug('on_door_open_when_arming: %s %s', event_id, zone_name)
890
+ sensor = await self.find_sensor(event_id, zone_name)
891
+ if sensor:
892
+ # Set the low battery flag on the sensor
893
+ # pylint: disable=protected-access
894
+ sensor._set_door_open_when_arming(True)
895
+ # Invoke per-sensor callback if provided
896
+ sensor.door_open_when_arming_callback.invoke()
897
+
898
+ self._door_open_when_arming_cb.invoke(event_id, zone_name)
899
+
900
+ @property
901
+ def door_open_when_arming_callback(
902
+ self
903
+ ) -> G90CallbackList[DoorOpenWhenArmingCallback]:
904
+ """
905
+ Door open when arming callback, which is invoked when sensor reports
906
+ the condition.
907
+
908
+ .. seealso:: :attr:`.sensor_callback` for compatiblity notes
909
+ """
910
+ return self._door_open_when_arming_cb
911
+
912
+ @door_open_when_arming_callback.setter
913
+ def door_open_when_arming_callback(
914
+ self, value: DoorOpenWhenArmingCallback
915
+ ) -> None:
916
+ self._door_open_when_arming_cb.add(value)
917
+
918
+ @property
919
+ def tamper_callback(self) -> G90CallbackList[TamperCallback]:
920
+ """
921
+ Tamper callback, which is invoked when sensor reports the condition.
922
+ """
923
+ return self._tamper_cb
924
+
925
+ @tamper_callback.setter
926
+ def tamper_callback(self, value: TamperCallback) -> None:
927
+ self._tamper_cb.add(value)
928
+
929
+ async def on_sensor_change(
930
+ self, sensor_idx: int, sensor_name: str, added: bool
931
+ ) -> None:
932
+ """
933
+ Invoked when sensor is added or removed from the device.
934
+
935
+ There is no user-visible callback assoiciated with this method, those
936
+ will be handled by `on_sensor_list_change()` method.
937
+
938
+ Please note the method is for internal use by the class.
939
+
940
+ :param sensor_idx: The index of the sensor being added/removed.
941
+ :param sensor_name: The name of the sensor.
942
+ :param added: Flag indicating if the sensor is added or removed
943
+ """
944
+ _LOGGER.debug(
945
+ 'on_sensor_change: idx=%s name=%s added=%s',
946
+ sensor_idx, sensor_name, added
947
+ )
948
+
949
+ # Invoke internal callback for sensor list to finish the registration
950
+ # process
951
+ G90Callback.invoke(
952
+ self._sensors.sensor_change_callback,
953
+ sensor_idx, sensor_name, added
954
+ )
955
+
956
+ @property
957
+ def sensor_list_change_callback(
958
+ self
959
+ ) -> G90CallbackList[ListChangeCallback[G90Sensor]]:
960
+ """
961
+ Sensor list change callback, which is invoked when sensor list
962
+ changes.
963
+
964
+ .. seealso:: :attr:`.sensor_callback` for compatiblity notes
965
+ """
966
+ return self._sensor_list_change_cb
967
+
968
+ @sensor_list_change_callback.setter
969
+ def sensor_list_change_callback(
970
+ self, value: ListChangeCallback[G90Sensor]
971
+ ) -> None:
972
+ self._sensor_list_change_cb.add(value)
973
+
974
+ async def on_sensor_list_change(
975
+ self, sensor: G90Sensor, added: bool
976
+ ) -> None:
977
+ """
978
+ Invoked when sensor list is changed.
979
+
980
+ Fires corresponding callback if set by the user with
981
+ :attr:`.sensor_list_change_callback`.
982
+ Please note the method is for internal use by the class.
983
+
984
+ :param sensor: The sensor being added or removed
985
+ :param added: Flag indicating if the sensor is added or removed
986
+ """
987
+ _LOGGER.debug(
988
+ 'on_sensor_list_change: %s added=%s', repr(sensor), added
989
+ )
990
+
991
+ self._sensor_list_change_cb.invoke(sensor, added)
992
+
993
+ @property
994
+ def device_list_change_callback(
995
+ self
996
+ ) -> G90CallbackList[ListChangeCallback[G90Device]]:
997
+ """
998
+ Device list change callback, which is invoked when device list
999
+ changes.
1000
+
1001
+ .. seealso:: :attr:`.sensor_callback` for compatiblity notes
1002
+ """
1003
+ return self._device_list_change_cb
1004
+
1005
+ @device_list_change_callback.setter
1006
+ def device_list_change_callback(
1007
+ self, value: ListChangeCallback[G90Device]
1008
+ ) -> None:
1009
+ self._device_list_change_cb.add(value)
1010
+
1011
+ async def on_device_list_change(
1012
+ self, device: G90Device, added: bool
1013
+ ) -> None:
1014
+ """
1015
+ Invoked when device list is changed.
1016
+
1017
+ Fires corresponding callback if set by the user with
1018
+ :attr:`.device_list_change_callback`.
1019
+
1020
+ Please note the method is for internal use by the class.
1021
+
1022
+ :param device: The device being added or removed
1023
+ :param added: Flag indicating if the device is added or removed
1024
+ """
1025
+ _LOGGER.debug(
1026
+ 'on_device_list_change: %s added=%s', repr(device), added
1027
+ )
1028
+
1029
+ self._device_list_change_cb.invoke(device, added)
1030
+
1031
+ async def listen_notifications(self) -> None:
1032
+ """
1033
+ Starts internal listener for device notifications/alerts.
1034
+ """
1035
+ if self._notifications:
1036
+ await self._notifications.listen()
1037
+
1038
+ async def close_notifications(self) -> None:
1039
+ """
1040
+ Closes the listener for device notifications/alerts.
1041
+ """
1042
+ if self._notifications:
1043
+ await self._notifications.close()
1044
+
1045
+ async def arm_away(self) -> None:
1046
+ """
1047
+ Arms the device in away mode.
1048
+ """
1049
+ state = G90ArmDisarmTypes.ARM_AWAY
1050
+ await self.command(G90Commands.SETHOSTSTATUS,
1051
+ [state])
1052
+
1053
+ async def arm_home(self) -> None:
1054
+ """
1055
+ Arms the device in home mode.
1056
+ """
1057
+ state = G90ArmDisarmTypes.ARM_HOME
1058
+ await self.command(G90Commands.SETHOSTSTATUS,
1059
+ [state])
1060
+
1061
+ async def disarm(self) -> None:
1062
+ """
1063
+ Disarms the device.
1064
+ """
1065
+ state = G90ArmDisarmTypes.DISARM
1066
+ await self.command(G90Commands.SETHOSTSTATUS,
1067
+ [state])
1068
+
1069
+ @property
1070
+ def sms_alert_when_armed(self) -> bool:
1071
+ """
1072
+ When enabled, allows to save costs on SMS by having corresponding alert
1073
+ enabled only when device is armed.
1074
+ """
1075
+ return self._sms_alert_when_armed
1076
+
1077
+ @sms_alert_when_armed.setter
1078
+ def sms_alert_when_armed(self, value: bool) -> None:
1079
+ self._sms_alert_when_armed = value
1080
+
1081
+ async def start_simulating_alerts_from_history(
1082
+ self, interval: float = 5, history_depth: int = 5
1083
+ ) -> None:
1084
+ """
1085
+ Starts the separate task to simulate device alerts from history
1086
+ entries.
1087
+
1088
+ The listener for device notifications will be stopped, so device
1089
+ notifications will not be processed thus resulting in possible
1090
+ duplicated if those could be received from the network.
1091
+
1092
+ :param interval: Interval (in seconds) between polling for newer
1093
+ history entities
1094
+ :param history_depth: Amount of history entries to fetch during
1095
+ each polling cycle
1096
+ """
1097
+ # Remember if device notifications listener has been started already
1098
+ self._alert_simulation_start_listener_back = (
1099
+ self._notifications is not None
1100
+ and self._notifications.listener_started
1101
+ )
1102
+ # And then stop it
1103
+ await self.close_notifications()
1104
+
1105
+ # Start the task
1106
+ self._alert_simulation_task = asyncio.create_task(
1107
+ self._simulate_alerts_from_history(interval, history_depth)
1108
+ )
1109
+
1110
+ async def stop_simulating_alerts_from_history(self) -> None:
1111
+ """
1112
+ Stops the task simulating device alerts from history entries.
1113
+
1114
+ The listener for device notifications will be started back, if it was
1115
+ running when simulation has been started.
1116
+ """
1117
+ # Stop the task simulating the device alerts from history if it was
1118
+ # running
1119
+ if self._alert_simulation_task:
1120
+ self._alert_simulation_task.cancel()
1121
+ self._alert_simulation_task = None
1122
+
1123
+ # Start device notifications listener back if it was running when
1124
+ # simulated alerts have been enabled
1125
+ if (
1126
+ self._notifications
1127
+ and self._alert_simulation_start_listener_back
1128
+ ):
1129
+ await self._notifications.listen()
1130
+
1131
+ async def _simulate_alerts_from_history(
1132
+ self, interval: float, history_depth: int
1133
+ ) -> None:
1134
+ """
1135
+ Periodically fetches history entries from the device and simulates
1136
+ device alerts off of those.
1137
+
1138
+ Only the history entries occur after the process is started are
1139
+ handled, to avoid triggering callbacks retrospectively.
1140
+
1141
+ See :meth:`.start_simulating_alerts_from_history` for the parameters.
1142
+ """
1143
+ dummy_notifications = G90NotificationsBase(
1144
+ protocol_factory=lambda: self
1145
+ )
1146
+
1147
+ last_history_ts = None
1148
+
1149
+ _LOGGER.debug(
1150
+ 'Simulating device alerts from history:'
1151
+ ' interval %s, history depth %s',
1152
+ interval, history_depth
1153
+ )
1154
+ while True:
1155
+ try:
1156
+ # Retrieve the history entries of the specified amount - full
1157
+ # history retrieval might be an unnecessary long operation
1158
+ history = await self.history(count=history_depth)
1159
+
1160
+ # Initial iteration where no timestamp of most recent history
1161
+ # entry is recorded - do that and skip to next iteration, since
1162
+ # it isn't yet known what entries would be considered as new
1163
+ # ones.
1164
+ # Empty history will skip recording the timestamp and the
1165
+ # looping over entries below, effectively skipping to next
1166
+ # iteration
1167
+ if not last_history_ts and history:
1168
+ # First entry in the list is assumed to be the most recent
1169
+ # one
1170
+ last_history_ts = history[0].datetime
1171
+ _LOGGER.debug(
1172
+ 'Initial time stamp of last history entry: %s',
1173
+ last_history_ts
1174
+ )
1175
+ continue
1176
+
1177
+ # Process history entries from older to newer to preserve the
1178
+ # order of happenings
1179
+ for item in reversed(history):
1180
+ # Process only the entries newer than one been recorded as
1181
+ # most recent one
1182
+ if last_history_ts and item.datetime > last_history_ts:
1183
+ _LOGGER.debug(
1184
+ 'Found newer history entry: %s, simulating alert',
1185
+ repr(item)
1186
+ )
1187
+ # Send the history entry down the device notification
1188
+ # code as alert, as if it came from the device and its
1189
+ # notifications port
1190
+ dummy_notifications.handle_alert(
1191
+ item.as_device_alert(),
1192
+ # Skip verifying device GUID, since history entry
1193
+ # don't have it
1194
+ verify_device_id=False
1195
+ )
1196
+
1197
+ # Record the entry as most recent one
1198
+ last_history_ts = item.datetime
1199
+ _LOGGER.debug(
1200
+ 'Time stamp of last history entry: %s',
1201
+ last_history_ts
1202
+ )
1203
+ except (G90Error, G90TimeoutError) as exc:
1204
+ _LOGGER.debug(
1205
+ 'Error interacting with device, ignoring %s', repr(exc)
1206
+ )
1207
+ except Exception as exc:
1208
+ _LOGGER.error(
1209
+ 'Exception simulating device alerts from history: %s',
1210
+ repr(exc)
1211
+ )
1212
+ raise exc
1213
+
1214
+ # Sleep to next iteration
1215
+ await asyncio.sleep(interval)
1216
+
1217
+ async def use_local_notifications(
1218
+ self, notifications_local_host: str = LOCAL_NOTIFICATIONS_HOST,
1219
+ notifications_local_port: int = LOCAL_NOTIFICATIONS_PORT
1220
+ ) -> None:
1221
+ """
1222
+ Switches to use local notifications for device alerts.
1223
+ """
1224
+ await self.close_notifications()
1225
+
1226
+ self._notifications = G90LocalNotifications(
1227
+ protocol_factory=lambda: self,
1228
+ host=self._host,
1229
+ port=self._port,
1230
+ local_host=notifications_local_host,
1231
+ local_port=notifications_local_port
1232
+ )
1233
+
1234
+ # pylint: disable=too-many-positional-arguments
1235
+ async def use_cloud_notifications(
1236
+ self, cloud_local_host: str = CLOUD_NOTIFICATIONS_HOST,
1237
+ cloud_local_port: int = CLOUD_NOTIFICATIONS_PORT,
1238
+ upstream_host: Optional[str] = REMOTE_CLOUD_HOST,
1239
+ upstream_port: Optional[int] = REMOTE_CLOUD_PORT,
1240
+ keep_single_connection: bool = True
1241
+ ) -> None:
1242
+ """
1243
+ Switches to use cloud notifications for device alerts.
1244
+ """
1245
+ await self.close_notifications()
1246
+
1247
+ self._notifications = G90CloudNotifications(
1248
+ protocol_factory=lambda: self,
1249
+ upstream_host=upstream_host,
1250
+ upstream_port=upstream_port,
1251
+ local_host=cloud_local_host,
1252
+ local_port=cloud_local_port,
1253
+ keep_single_connection=keep_single_connection
1254
+ )
1255
+
1256
+ @property
1257
+ def last_device_packet_time(self) -> Optional[datetime]:
1258
+ """
1259
+ Returns the time of the last packet received from the device.
1260
+ """
1261
+ if not self._notifications:
1262
+ return None
1263
+
1264
+ return self._notifications.last_device_packet_time
1265
+
1266
+ @property
1267
+ def last_upstream_packet_time(self) -> Optional[datetime]:
1268
+ """
1269
+ Returns the time of the last packet received from the upstream server.
1270
+ """
1271
+ if not self._notifications:
1272
+ return None
1273
+
1274
+ return self._notifications.last_upstream_packet_time