pyg90alarm 1.17.1__tar.gz → 1.18.0__tar.gz

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 (57) hide show
  1. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/.github/workflows/main.yml +4 -2
  2. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/PKG-INFO +1 -1
  3. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/src/pyg90alarm/alarm.py +155 -39
  4. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/src/pyg90alarm/const.py +1 -0
  5. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/src/pyg90alarm/device_notifications.py +29 -3
  6. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/src/pyg90alarm/entities/sensor.py +96 -1
  7. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/src/pyg90alarm.egg-info/PKG-INFO +1 -1
  8. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/tests/test_alarm.py +191 -4
  9. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/tests/test_notifications.py +51 -1
  10. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/tox.ini +1 -6
  11. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/.github/CODEOWNERS +0 -0
  12. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/.gitignore +0 -0
  13. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/.pylintrc +0 -0
  14. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/.readthedocs.yaml +0 -0
  15. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/LICENSE +0 -0
  16. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/MANIFEST.in +0 -0
  17. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/README.rst +0 -0
  18. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/docs/.DS_Store +0 -0
  19. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/docs/.gitignore +0 -0
  20. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/docs/api-docs.rst +0 -0
  21. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/docs/conf.py +0 -0
  22. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/docs/index.rst +0 -0
  23. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/docs/protocol.rst +0 -0
  24. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/docs/requirements.txt +0 -0
  25. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/pyproject.toml +0 -0
  26. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/setup.cfg +0 -0
  27. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/setup.py +0 -0
  28. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/sonar-project.properties +0 -0
  29. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/src/pyg90alarm/__init__.py +0 -0
  30. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/src/pyg90alarm/base_cmd.py +0 -0
  31. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/src/pyg90alarm/callback.py +0 -0
  32. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/src/pyg90alarm/config.py +0 -0
  33. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/src/pyg90alarm/definitions/__init__.py +0 -0
  34. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/src/pyg90alarm/definitions/sensors.py +0 -0
  35. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/src/pyg90alarm/discovery.py +0 -0
  36. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/src/pyg90alarm/entities/__init__.py +0 -0
  37. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/src/pyg90alarm/entities/device.py +0 -0
  38. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/src/pyg90alarm/exceptions.py +0 -0
  39. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/src/pyg90alarm/history.py +0 -0
  40. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/src/pyg90alarm/host_info.py +0 -0
  41. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/src/pyg90alarm/host_status.py +0 -0
  42. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/src/pyg90alarm/paginated_cmd.py +0 -0
  43. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/src/pyg90alarm/paginated_result.py +0 -0
  44. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/src/pyg90alarm/py.typed +0 -0
  45. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/src/pyg90alarm/targeted_discovery.py +0 -0
  46. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/src/pyg90alarm/user_data_crc.py +0 -0
  47. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/src/pyg90alarm.egg-info/SOURCES.txt +0 -0
  48. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/src/pyg90alarm.egg-info/dependency_links.txt +0 -0
  49. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/src/pyg90alarm.egg-info/requires.txt +0 -0
  50. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/src/pyg90alarm.egg-info/top_level.txt +0 -0
  51. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/tests/__init__.py +0 -0
  52. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/tests/conftest.py +0 -0
  53. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/tests/device_mock.py +0 -0
  54. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/tests/test_base_commands.py +0 -0
  55. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/tests/test_discovery.py +0 -0
  56. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/tests/test_history.py +0 -0
  57. {pyg90alarm-1.17.1 → pyg90alarm-1.18.0}/tests/test_paginated_commands.py +0 -0
@@ -75,6 +75,8 @@ jobs:
75
75
  name: Publish to PyPi
76
76
  runs-on: ubuntu-latest
77
77
  needs: [tests]
78
+ permissions:
79
+ id-token: write # Required for trusted publishing
78
80
  steps:
79
81
  - name: Checkout the code
80
82
  uses: actions/checkout@v3
@@ -94,8 +96,8 @@ jobs:
94
96
  if: github.event_name != 'release'
95
97
  uses: pypa/gh-action-pypi-publish@release/v1
96
98
  with:
97
- password: ${{ secrets.TEST_PYPI_TOKEN }}
98
99
  repository_url: https://test.pypi.org/legacy/
100
+ attestations: true
99
101
  - name: Publish the release to PyPi
100
102
  # Publish to production PyPi only happens when a release published out
101
103
  # of the main branch
@@ -106,4 +108,4 @@ jobs:
106
108
  || github.event.release.target_commitish == 'master')
107
109
  uses: pypa/gh-action-pypi-publish@release/v1
108
110
  with:
109
- password: ${{ secrets.PYPI_TOKEN }}
111
+ attestations: true
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pyg90alarm
3
- Version: 1.17.1
3
+ Version: 1.18.0
4
4
  Summary: G90 Alarm system protocol
5
5
  Home-page: https://github.com/hostcc/pyg90alarm
6
6
  Author: Ilia Sotnikov
@@ -17,6 +17,7 @@
17
17
  # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
18
  # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
19
  # SOFTWARE.
20
+ # pylint: disable=too-many-lines
20
21
 
21
22
  """
22
23
  Provides interface to G90 alarm panel.
@@ -120,6 +121,14 @@ if TYPE_CHECKING:
120
121
  [int, str, G90RemoteButtonStates], Coroutine[None, None, None]
121
122
  ]
122
123
  ]
124
+ DoorOpenWhenArmingCallback = Union[
125
+ Callable[[int, str], None],
126
+ Callable[[int, str], Coroutine[None, None, None]]
127
+ ]
128
+ TamperCallback = Union[
129
+ Callable[[int, str], None],
130
+ Callable[[int, str], Coroutine[None, None, None]]
131
+ ]
123
132
  # Sensor-related callbacks for `G90Sensor` class - despite that class
124
133
  # stores them, the invocation is done by the `G90Alarm` class hence these
125
134
  # are defined here
@@ -131,6 +140,14 @@ if TYPE_CHECKING:
131
140
  Callable[[], None],
132
141
  Callable[[], Coroutine[None, None, None]]
133
142
  ]
143
+ SensorDoorOpenWhenArmingCallback = Union[
144
+ Callable[[], None],
145
+ Callable[[], Coroutine[None, None, None]]
146
+ ]
147
+ SensorTamperCallback = Union[
148
+ Callable[[], None],
149
+ Callable[[], Coroutine[None, None, None]]
150
+ ]
134
151
 
135
152
 
136
153
  # pylint: disable=too-many-public-methods
@@ -161,7 +178,9 @@ class G90Alarm(G90DeviceNotifications):
161
178
  self._host: str = host
162
179
  self._port: int = port
163
180
  self._sensors: List[G90Sensor] = []
181
+ self._sensors_lock = asyncio.Lock()
164
182
  self._devices: List[G90Device] = []
183
+ self._devices_lock = asyncio.Lock()
165
184
  self._notifications: Optional[G90DeviceNotifications] = None
166
185
  self._sensor_cb: Optional[SensorCallback] = None
167
186
  self._armdisarm_cb: Optional[ArmDisarmCallback] = None
@@ -172,6 +191,10 @@ class G90Alarm(G90DeviceNotifications):
172
191
  self._remote_button_press_cb: Optional[
173
192
  RemoteButtonPressCallback
174
193
  ] = None
194
+ self._door_open_when_arming_cb: Optional[
195
+ DoorOpenWhenArmingCallback
196
+ ] = None
197
+ self._tamper_cb: Optional[TamperCallback] = None
175
198
  self._reset_occupancy_interval = reset_occupancy_interval
176
199
  self._alert_config: Optional[G90AlertConfigFlags] = None
177
200
  self._sms_alert_when_armed = False
@@ -258,20 +281,26 @@ class G90Alarm(G90DeviceNotifications):
258
281
 
259
282
  :return: List of sensors
260
283
  """
261
- if not self._sensors:
262
- sensors = self.paginated_result(
263
- G90Commands.GETSENSORLIST
264
- )
265
- async for sensor in sensors:
266
- obj = G90Sensor(
267
- *sensor.data, parent=self, subindex=0,
268
- proto_idx=sensor.proto_idx
284
+ # Use lock around the operation, to ensure no duplicated entries in the
285
+ # resulting list or redundant exchanges with panel are made when the
286
+ # method is called concurrently
287
+ async with self._sensors_lock:
288
+ if not self._sensors:
289
+ sensors = self.paginated_result(
290
+ G90Commands.GETSENSORLIST
269
291
  )
270
- self._sensors.append(obj)
292
+ async for sensor in sensors:
293
+ obj = G90Sensor(
294
+ *sensor.data, parent=self, subindex=0,
295
+ proto_idx=sensor.proto_idx
296
+ )
297
+ self._sensors.append(obj)
271
298
 
272
- _LOGGER.debug('Total number of sensors: %s', len(self._sensors))
299
+ _LOGGER.debug(
300
+ 'Total number of sensors: %s', len(self._sensors)
301
+ )
273
302
 
274
- return self._sensors
303
+ return self._sensors
275
304
 
276
305
  async def find_sensor(self, idx: int, name: str) -> Optional[G90Sensor]:
277
306
  """
@@ -316,28 +345,32 @@ class G90Alarm(G90DeviceNotifications):
316
345
 
317
346
  :return: List of devices
318
347
  """
319
- if not self._devices:
320
- devices = self.paginated_result(
321
- G90Commands.GETDEVICELIST
322
- )
323
- async for device in devices:
324
- obj = G90Device(
325
- *device.data, parent=self, subindex=0,
326
- proto_idx=device.proto_idx
348
+ # See `get_sensors` method for the rationale behind the lock usage
349
+ async with self._devices_lock:
350
+ if not self._devices:
351
+ devices = self.paginated_result(
352
+ G90Commands.GETDEVICELIST
327
353
  )
328
- self._devices.append(obj)
329
- # Multi-node devices (first node has already been added
330
- # above
331
- for node in range(1, obj.node_count):
354
+ async for device in devices:
332
355
  obj = G90Device(
333
- *device.data, parent=self,
334
- subindex=node, proto_idx=device.proto_idx
356
+ *device.data, parent=self, subindex=0,
357
+ proto_idx=device.proto_idx
335
358
  )
336
359
  self._devices.append(obj)
360
+ # Multi-node devices (first node has already been added
361
+ # above
362
+ for node in range(1, obj.node_count):
363
+ obj = G90Device(
364
+ *device.data, parent=self,
365
+ subindex=node, proto_idx=device.proto_idx
366
+ )
367
+ self._devices.append(obj)
337
368
 
338
- _LOGGER.debug('Total number of devices: %s', len(self._devices))
369
+ _LOGGER.debug(
370
+ 'Total number of devices: %s', len(self._devices)
371
+ )
339
372
 
340
- return self._devices
373
+ return self._devices
341
374
 
342
375
  @property
343
376
  async def host_info(self) -> G90HostInfo:
@@ -601,6 +634,17 @@ class G90Alarm(G90DeviceNotifications):
601
634
  await self.set_alert_config(
602
635
  await self.alert_config | G90AlertConfigFlags.SMS_PUSH
603
636
  )
637
+
638
+ # Reset the tampered and door open when arming flags on all sensors
639
+ # having those set
640
+ for sensor in await self.get_sensors():
641
+ if sensor.is_tampered:
642
+ # pylint: disable=protected-access
643
+ sensor._set_tampered(False)
644
+ if sensor.is_door_open_when_arming:
645
+ # pylint: disable=protected-access
646
+ sensor._set_door_open_when_arming(False)
647
+
604
648
  G90Callback.invoke(self._armdisarm_cb, state)
605
649
 
606
650
  @property
@@ -615,7 +659,9 @@ class G90Alarm(G90DeviceNotifications):
615
659
  def armdisarm_callback(self, value: ArmDisarmCallback) -> None:
616
660
  self._armdisarm_cb = value
617
661
 
618
- async def on_alarm(self, event_id: int, zone_name: str) -> None:
662
+ async def on_alarm(
663
+ self, event_id: int, zone_name: str, is_tampered: bool
664
+ ) -> None:
619
665
  """
620
666
  Invoked when alarm is triggered. Fires corresponding callback if set by
621
667
  the user with :attr:`.alarm_callback`.
@@ -626,16 +672,31 @@ class G90Alarm(G90DeviceNotifications):
626
672
  :param zone_name: Sensor name
627
673
  """
628
674
  sensor = await self.find_sensor(event_id, zone_name)
629
- # The callback is still delivered to the caller even if the sensor
630
- # isn't found, only `extra_data` is skipped. That is to ensure the
631
- # important callback isn't filtered
632
- extra_data = sensor.extra_data if sensor else None
633
- # Invoke the sensor activity callback to set the sensor occupancy if
634
- # sensor is known, but only if that isn't already set - it helps when
635
- # device notifications on triggerring sensor's activity aren't receveid
636
- # by a reason
637
- if sensor and not sensor.occupancy:
638
- await self.on_sensor_activity(event_id, zone_name, True)
675
+ extra_data = None
676
+ if sensor:
677
+ # The callback is still delivered to the caller even if the sensor
678
+ # isn't found, only `extra_data` is skipped. That is to ensure the
679
+ # important callback isn't filtered
680
+ extra_data = sensor.extra_data
681
+
682
+ # Invoke the sensor activity callback to set the sensor occupancy
683
+ # if sensor is known, but only if that isn't already set - it helps
684
+ # when device notifications on triggerring sensor's activity aren't
685
+ # receveid by a reason
686
+ if not sensor.occupancy:
687
+ await self.on_sensor_activity(event_id, zone_name, True)
688
+
689
+ if is_tampered:
690
+ # Set the tampered flag on the sensor
691
+ # pylint: disable=protected-access
692
+ sensor._set_tampered(True)
693
+
694
+ # Invoke per-sensor callback if provided
695
+ G90Callback.invoke(sensor.tamper_callback)
696
+
697
+ # Invoke global tamper callback if provided and the sensor is tampered
698
+ if is_tampered:
699
+ G90Callback.invoke(self._tamper_cb, event_id, zone_name)
639
700
 
640
701
  G90Callback.invoke(
641
702
  self._alarm_cb, event_id, zone_name, extra_data
@@ -706,7 +767,10 @@ class G90Alarm(G90DeviceNotifications):
706
767
 
707
768
  # Also report the event as alarm for unification, hard-coding the
708
769
  # sensor name in case of host SOS
709
- await self.on_alarm(event_id, 'Host SOS' if is_host_sos else zone_name)
770
+ await self.on_alarm(
771
+ event_id, zone_name='Host SOS' if is_host_sos else zone_name,
772
+ is_tampered=False
773
+ )
710
774
 
711
775
  if not is_host_sos:
712
776
  # Also report the remote button press for SOS - the panel will not
@@ -766,6 +830,58 @@ class G90Alarm(G90DeviceNotifications):
766
830
  ) -> None:
767
831
  self._remote_button_press_cb = value
768
832
 
833
+ async def on_door_open_when_arming(
834
+ self, event_id: int, zone_name: str
835
+ ) -> None:
836
+ """
837
+ Invoked when door is open when arming the device. Fires corresponding
838
+ callback if set by the user with
839
+ :attr:`.door_open_when_arming_callback`.
840
+
841
+ Please note the method is for internal use by the class.
842
+
843
+ :param event_id: The index of the sensor being active when the panel
844
+ is being armed.
845
+ :param zone_name: The name of the sensor
846
+ """
847
+ _LOGGER.debug('on_door_open_when_arming: %s %s', event_id, zone_name)
848
+ sensor = await self.find_sensor(event_id, zone_name)
849
+ if sensor:
850
+ # Set the low battery flag on the sensor
851
+ # pylint: disable=protected-access
852
+ sensor._set_door_open_when_arming(True)
853
+ # Invoke per-sensor callback if provided
854
+ G90Callback.invoke(sensor.door_open_when_arming_callback)
855
+
856
+ G90Callback.invoke(self._door_open_when_arming_cb, event_id, zone_name)
857
+
858
+ @property
859
+ def door_open_when_arming_callback(
860
+ self
861
+ ) -> Optional[DoorOpenWhenArmingCallback]:
862
+ """
863
+ Door open when arming callback, which is invoked when sensor reports
864
+ the condition.
865
+ """
866
+ return self._door_open_when_arming_cb
867
+
868
+ @door_open_when_arming_callback.setter
869
+ def door_open_when_arming_callback(
870
+ self, value: DoorOpenWhenArmingCallback
871
+ ) -> None:
872
+ self._door_open_when_arming_cb = value
873
+
874
+ @property
875
+ async def tamper_callback(self) -> Optional[TamperCallback]:
876
+ """
877
+ Tamper callback, which is invoked when sensor reports the condition.
878
+ """
879
+ return self._tamper_cb
880
+
881
+ @tamper_callback.setter
882
+ def tamper_callback(self, value: TamperCallback) -> None:
883
+ self._tamper_cb = value
884
+
769
885
  async def listen_device_notifications(self) -> None:
770
886
  """
771
887
  Starts internal listener for device notifications/alerts.
@@ -201,6 +201,7 @@ class G90AlertSources(IntEnum):
201
201
  """
202
202
  DEVICE = 0
203
203
  SENSOR = 1
204
+ TAMPER = 3
204
205
  REMOTE = 10
205
206
  RFID = 11
206
207
  DOORBELL = 12
@@ -159,6 +159,16 @@ class G90DeviceNotifications(DatagramProtocol):
159
159
 
160
160
  return
161
161
 
162
+ # An open door is detected when arming
163
+ if notification.kind == G90NotificationTypes.DOOR_OPEN_WHEN_ARMING:
164
+ g90_zone_info = G90ZoneInfo(*notification.data)
165
+ _LOGGER.debug('Door open detected when arming: %s', g90_zone_info)
166
+ G90Callback.invoke(
167
+ self.on_door_open_when_arming,
168
+ g90_zone_info.idx, g90_zone_info.name
169
+ )
170
+ return
171
+
162
172
  _LOGGER.warning('Unknown notification received from %s:%s:'
163
173
  ' kind %s, data %s',
164
174
  addr[0], addr[1], notification.kind, notification.data)
@@ -259,11 +269,15 @@ class G90DeviceNotifications(DatagramProtocol):
259
269
  )
260
270
  # Regular alarm
261
271
  else:
262
- _LOGGER.debug('Alarm: %s', alert.zone_name)
272
+ is_tampered = alert.state == G90AlertStates.TAMPER
273
+ _LOGGER.debug(
274
+ 'Alarm: %s, is tampered: %s', alert.zone_name, is_tampered
275
+ )
263
276
  G90Callback.invoke(
264
277
  self.on_alarm,
265
- alert.event_id, alert.zone_name
278
+ alert.event_id, alert.zone_name, is_tampered
266
279
  )
280
+
267
281
  handled = True
268
282
 
269
283
  # Host SOS
@@ -372,6 +386,16 @@ class G90DeviceNotifications(DatagramProtocol):
372
386
  :param name: Name of the sensor.
373
387
  """
374
388
 
389
+ async def on_door_open_when_arming(
390
+ self, event_id: int, zone_name: str
391
+ ) -> None:
392
+ """
393
+ Invoked when door open is detected when panel is armed.
394
+
395
+ :param event_id: Index of the sensor.
396
+ :param zone_name: Name of the sensor that reports door open.
397
+ """
398
+
375
399
  async def on_door_open_close(
376
400
  self, event_id: int, zone_name: str, is_open: bool
377
401
  ) -> None:
@@ -391,7 +415,9 @@ class G90DeviceNotifications(DatagramProtocol):
391
415
  :param zone_name: Name of the sensor that reports low battery.
392
416
  """
393
417
 
394
- async def on_alarm(self, event_id: int, zone_name: str) -> None:
418
+ async def on_alarm(
419
+ self, event_id: int, zone_name: str, is_tampered: bool
420
+ ) -> None:
395
421
  """
396
422
  Invoked when device triggers the alarm.
397
423
 
@@ -32,7 +32,10 @@ from enum import IntEnum, IntFlag
32
32
  from ..definitions.sensors import SENSOR_DEFINITIONS, SensorDefinition
33
33
  from ..const import G90Commands
34
34
  if TYPE_CHECKING:
35
- from ..alarm import G90Alarm, SensorStateCallback, SensorLowBatteryCallback
35
+ from ..alarm import (
36
+ G90Alarm, SensorStateCallback, SensorLowBatteryCallback,
37
+ SensorDoorOpenWhenArmingCallback, SensorTamperCallback,
38
+ )
36
39
 
37
40
 
38
41
  @dataclass
@@ -157,6 +160,7 @@ class G90SensorTypes(IntEnum):
157
160
  _LOGGER = logging.getLogger(__name__)
158
161
 
159
162
 
163
+ # pylint: disable=too-many-public-methods
160
164
  class G90Sensor: # pylint:disable=too-many-instance-attributes
161
165
  """
162
166
  Interacts with sensor on G90 alarm panel.
@@ -186,6 +190,12 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
186
190
  self._state_callback: Optional[SensorStateCallback] = None
187
191
  self._low_battery_callback: Optional[SensorLowBatteryCallback] = None
188
192
  self._low_battery = False
193
+ self._tampered = False
194
+ self._door_open_when_arming_callback: Optional[
195
+ SensorDoorOpenWhenArmingCallback
196
+ ] = None
197
+ self._tamper_callback: Optional[SensorTamperCallback] = None
198
+ self._door_open_when_arming = False
189
199
  self._proto_idx = proto_idx
190
200
  self._extra_data: Any = None
191
201
 
@@ -238,6 +248,37 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
238
248
  def low_battery_callback(self, value: SensorLowBatteryCallback) -> None:
239
249
  self._low_battery_callback = value
240
250
 
251
+ @property
252
+ def door_open_when_arming_callback(
253
+ self
254
+ ) -> Optional[SensorDoorOpenWhenArmingCallback]:
255
+ """
256
+ Callback that is invoked when the sensor reports on open door
257
+ condition when arming.
258
+
259
+ :return: Sensor's door open when arming callback
260
+ """
261
+ return self._door_open_when_arming_callback
262
+
263
+ @door_open_when_arming_callback.setter
264
+ def door_open_when_arming_callback(
265
+ self, value: SensorDoorOpenWhenArmingCallback
266
+ ) -> None:
267
+ self._door_open_when_arming_callback = value
268
+
269
+ @property
270
+ def tamper_callback(self) -> Optional[SensorTamperCallback]:
271
+ """
272
+ Callback that is invoked when the sensor reports being tampered.
273
+
274
+ :return: Sensor's tamper callback
275
+ """
276
+ return self._tamper_callback
277
+
278
+ @tamper_callback.setter
279
+ def tamper_callback(self, value: SensorTamperCallback) -> None:
280
+ self._tamper_callback = value
281
+
241
282
  @property
242
283
  def occupancy(self) -> bool:
243
284
  """
@@ -365,12 +406,16 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
365
406
  def is_low_battery(self) -> bool:
366
407
  """
367
408
  Indicates if the sensor is reporting low battery.
409
+
410
+ The condition is cleared when the sensor reports activity (i.e. is no
411
+ longer low on battery as it is able to report the activity).
368
412
  """
369
413
  return self._low_battery
370
414
 
371
415
  def _set_low_battery(self, value: bool) -> None:
372
416
  """
373
417
  Sets low battery state of the sensor.
418
+
374
419
  Intentionally private, as low battery state is derived from
375
420
  notifications/alerts.
376
421
 
@@ -383,6 +428,56 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
383
428
  )
384
429
  self._low_battery = value
385
430
 
431
+ @property
432
+ def is_tampered(self) -> bool:
433
+ """
434
+ Indicates if the sensor has been tampered.
435
+
436
+ The condition is cleared when panel is armed/disarmed next time.
437
+ """
438
+ return self._tampered
439
+
440
+ def _set_tampered(self, value: bool) -> None:
441
+ """
442
+ Sets tamper state of the sensor.
443
+
444
+ Intentionally private, as tamper state is derived from
445
+ notifications/alerts.
446
+
447
+ :param value: Tamper state
448
+ """
449
+ _LOGGER.debug(
450
+ "Setting tamper for sensor index=%s '%s': %s"
451
+ " (previous value: %s)",
452
+ self.index, self.name, value, self._tampered
453
+ )
454
+ self._tampered = value
455
+
456
+ @property
457
+ def is_door_open_when_arming(self) -> bool:
458
+ """
459
+ Indicates if the sensor reports on open door when arming.
460
+
461
+ The condition is cleared when panel is armed/disarmed next time.
462
+ """
463
+ return self._door_open_when_arming
464
+
465
+ def _set_door_open_when_arming(self, value: bool) -> None:
466
+ """
467
+ Sets door open state of the sensor when arming.
468
+
469
+ Intentionally private, as door open state is derived from
470
+ notifications/alerts.
471
+
472
+ :param value: Door open state
473
+ """
474
+ _LOGGER.debug(
475
+ "Setting door open when arming for sensor index=%s '%s': %s"
476
+ " (previous value: %s)",
477
+ self.index, self.name, value, self._door_open_when_arming
478
+ )
479
+ self._door_open_when_arming = value
480
+
386
481
  @property
387
482
  def enabled(self) -> bool:
388
483
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pyg90alarm
3
- Version: 1.17.1
3
+ Version: 1.18.0
4
4
  Summary: G90 Alarm system protocol
5
5
  Home-page: https://github.com/hostcc/pyg90alarm
6
6
  Author: Ilia Sotnikov
@@ -1,7 +1,10 @@
1
1
  """
2
2
  Tests for G90Alarm class
3
3
  """
4
+ # pylint: disable=too-many-lines
5
+
4
6
  import asyncio
7
+ from itertools import cycle
5
8
  from unittest.mock import MagicMock
6
9
  import pytest
7
10
 
@@ -128,6 +131,35 @@ async def test_devices(mock_device: DeviceMock) -> None:
128
131
  assert isinstance(devices[0]._asdict(), dict)
129
132
 
130
133
 
134
+ # Provide an endless sequence of simulated panel responses for the devices
135
+ # list. Each attempt will simulate a single device. This sequence will prevent
136
+ # `G90TimeoutError` if the code under test initiates more exchanges with the
137
+ # panel than the simulated data contains.
138
+ @pytest.mark.g90device(sent_data=cycle([
139
+ b'ISTART[138,'
140
+ b'[[1,1,1],["Switch",10,0,10,1,0,32,0,0,16,1,0,""]]]IEND\0',
141
+ ]))
142
+ async def test_get_devices_concurrent(mock_device: DeviceMock) -> None:
143
+ """
144
+ Tests for concurrently retrieving list of devices produces consistent
145
+ results.
146
+ """
147
+ g90 = G90Alarm(host=mock_device.host, port=mock_device.port)
148
+ g90.paginated_result = MagicMock( # type: ignore[method-assign]
149
+ spec=g90.paginated_result, wraps=g90.paginated_result
150
+ )
151
+
152
+ # Issue two concurrent requests to retrieve devices
153
+ res = await asyncio.gather(g90.get_devices(), g90.get_devices())
154
+ # Ensure only single exchange with the panel
155
+ g90.paginated_result.assert_called_once()
156
+ # While `pylint` demands use of generator, the comprehension is used
157
+ # instead for ease of trroubleshooting test failures as it will show the
158
+ # list elements, not just generator instance
159
+ # pylint: disable=use-a-generator
160
+ assert all([len(x) == 1 for x in res])
161
+
162
+
131
163
  @pytest.mark.g90device(sent_data=[
132
164
  b'ISTART[138,'
133
165
  b'[[1,1,1],["Switch",10,0,10,1,0,32,0,0,16,2,0,""]]]IEND\0'
@@ -207,6 +239,27 @@ async def test_single_sensor(mock_device: DeviceMock) -> None:
207
239
  assert isinstance(sensors[0]._asdict(), dict)
208
240
 
209
241
 
242
+ # See `test_get_devices_concurrent` for the explanation of the test
243
+ @pytest.mark.g90device(sent_data=cycle([
244
+ b'ISTART[102,'
245
+ b'[[1,1,1],["Remote",10,0,10,1,0,32,0,0,16,1,0,""]]]IEND\0',
246
+ ]))
247
+ async def test_get_sensors_concurrent(mock_device: DeviceMock) -> None:
248
+ """
249
+ Tests for concurrently retrieving list of sensors produces consistent
250
+ results.
251
+ """
252
+ g90 = G90Alarm(host=mock_device.host, port=mock_device.port)
253
+ g90.paginated_result = MagicMock( # type: ignore[method-assign]
254
+ spec=g90.paginated_result, wraps=g90.paginated_result
255
+ )
256
+
257
+ res = await asyncio.gather(g90.get_sensors(), g90.get_sensors())
258
+ g90.paginated_result.assert_called_once()
259
+ # pylint: disable=use-a-generator
260
+ assert all([len(x) == 1 for x in res])
261
+
262
+
210
263
  @pytest.mark.g90device(sent_data=[
211
264
  b'ISTART[102,'
212
265
  b'[[3,1,3],["Remote 1",10,0,10,1,0,32,0,0,16,1,0,""],'
@@ -387,6 +440,69 @@ async def test_sensor_low_battery_callback(mock_device: DeviceMock) -> None:
387
440
 
388
441
 
389
442
  @pytest.mark.g90device(
443
+ sent_data=[
444
+ b'ISTART[102,'
445
+ b'[[1,1,1],["Hall",21,0,10,1,0,32,0,0,16,1,0,""]]]IEND\0',
446
+ ],
447
+ notification_data=[
448
+ b'[170,[6,[21,"Hall"]]]\0',
449
+ b'[170,[1,[3]]]\0',
450
+ ]
451
+ )
452
+ async def test_sensor_door_open_when_arming_callback(
453
+ mock_device: DeviceMock
454
+ ) -> None:
455
+ """
456
+ Tests for sensor door open when arming callback.
457
+ """
458
+ g90 = G90Alarm(host=mock_device.host, port=mock_device.port,
459
+ notifications_local_host=mock_device.notification_host,
460
+ notifications_local_port=mock_device.notification_port)
461
+
462
+ sensors = await g90.get_sensors()
463
+ prop_sensors = await g90.sensors
464
+
465
+ assert sensors == prop_sensors
466
+ future = asyncio.get_running_loop().create_future()
467
+ sensor = [x for x in sensors if x.index == 21 and x.name == 'Hall']
468
+ door_open_when_arming_sensor_cb = MagicMock()
469
+ door_open_when_arming_sensor_cb.side_effect = (
470
+ lambda *args: future.set_result(True)
471
+ )
472
+ sensor[0].door_open_when_arming_callback = door_open_when_arming_sensor_cb
473
+ door_open_when_arming_cb = MagicMock()
474
+ g90.door_open_when_arming_callback = door_open_when_arming_cb
475
+
476
+ await g90.listen_device_notifications()
477
+ await mock_device.send_next_notification()
478
+ await asyncio.wait([future], timeout=0.1)
479
+
480
+ door_open_when_arming_sensor_cb.assert_called_once_with()
481
+ door_open_when_arming_cb.assert_called_once_with(21, 'Hall')
482
+ # Verify the door open when arming state is set upon receiving the
483
+ # notification
484
+ assert sensor[0].is_door_open_when_arming is True
485
+
486
+ # Signal the second notification is ready, the future has to be re-created
487
+ # as the corresponding callback will be fired again
488
+ future = asyncio.get_running_loop().create_future()
489
+ await mock_device.send_next_notification()
490
+ await asyncio.wait([future], timeout=0.1)
491
+
492
+ # Verify the door open when arming state is reset upon disarming
493
+ assert sensor[0].is_door_open_when_arming is False
494
+
495
+ g90.close_device_notifications()
496
+
497
+
498
+ @pytest.mark.g90device(
499
+ sent_data=[
500
+ b'ISTART[102,'
501
+ b'[[3,1,3],["Remote 1",10,0,10,1,0,32,0,0,16,1,0,""],'
502
+ b'["Remote 2",11,0,10,1,0,32,0,0,16,1,0,""],'
503
+ b'["Cord 1",12,0,126,1,0,32,0,5,16,1,0,""]'
504
+ b']]IEND\0',
505
+ ],
390
506
  notification_data=[
391
507
  b'[170,[1,[1]]]\0'
392
508
  ]
@@ -529,6 +645,65 @@ async def test_alarm_callback(mock_device: DeviceMock) -> None:
529
645
  g90.close_device_notifications()
530
646
 
531
647
 
648
+ @pytest.mark.g90device(
649
+ sent_data=[
650
+ b'ISTART[102,'
651
+ b'[[1,1,1],["Hall",100,0,1,1,0,32,0,0,16,1,0,""]]]IEND\0',
652
+ # Alert configuration, used by sensor activity callback invoked when
653
+ # handling alarm
654
+ b'ISTART[117,[256]]IEND\0',
655
+ ],
656
+ notification_data=[
657
+ b'[208,[3,100,1,3,"Hall","DUMMYGUID",1630876128,0,[""]]]\0',
658
+ b'[170,[1,[3]]]\0',
659
+ ]
660
+ )
661
+ async def test_sensor_tamper_callback(
662
+ mock_device: DeviceMock
663
+ ) -> None:
664
+ """
665
+ Tests for sensor tamper callback.
666
+ """
667
+ g90 = G90Alarm(host=mock_device.host, port=mock_device.port,
668
+ notifications_local_host=mock_device.notification_host,
669
+ notifications_local_port=mock_device.notification_port)
670
+
671
+ sensors = await g90.get_sensors()
672
+ prop_sensors = await g90.sensors
673
+
674
+ assert sensors == prop_sensors
675
+ future = asyncio.get_running_loop().create_future()
676
+ sensor = [x for x in sensors if x.index == 100 and x.name == 'Hall']
677
+ tamper_sensor_cb = MagicMock()
678
+ tamper_sensor_cb.side_effect = (
679
+ lambda *args: future.set_result(True)
680
+ )
681
+ sensor[0].tamper_callback = tamper_sensor_cb
682
+ tamper_cb = MagicMock()
683
+ g90.tamper_callback = tamper_cb
684
+
685
+ await g90.listen_device_notifications()
686
+ await mock_device.send_next_notification()
687
+ await asyncio.wait([future], timeout=0.1)
688
+
689
+ tamper_sensor_cb.assert_called_once_with()
690
+ tamper_cb.assert_called_once_with(100, 'Hall')
691
+ # Verify the sensor tampered state is set upon receiving the
692
+ # notification
693
+ assert sensor[0].is_tampered is True
694
+
695
+ # Signal the second notification is ready, the future has to be re-created
696
+ # as the corresponding callback will be fired again
697
+ future = asyncio.get_running_loop().create_future()
698
+ await mock_device.send_next_notification()
699
+ await asyncio.wait([future], timeout=0.1)
700
+
701
+ # Verify the sensor tampered state is reset upon disarming
702
+ assert sensor[0].is_tampered is False
703
+
704
+ g90.close_device_notifications()
705
+
706
+
532
707
  @pytest.mark.g90device(
533
708
  sent_data=[
534
709
  b'ISTART[102,'
@@ -718,6 +893,13 @@ async def test_set_alert_config(mock_device: DeviceMock) -> None:
718
893
  # that checks if alert config has been modified externally
719
894
  b"ISTART[117,[1]]IEND\0",
720
895
  b"ISTARTIEND\0",
896
+ # Simulated list of sensors, which is used to reset door open when
897
+ # arming/tamper flags on those had the flags set when arming
898
+ b'ISTART[102,'
899
+ b'[[3,1,3],["Remote 1",10,0,10,1,0,32,0,0,16,1,0,""],'
900
+ b'["Remote 2",11,0,10,1,0,32,0,0,16,1,0,""],'
901
+ b'["Cord 1",12,0,126,1,0,32,0,5,16,1,0,""]'
902
+ b']]IEND\0',
721
903
  ],
722
904
  notification_data=[
723
905
  b'[170,[1,[1]]]\0',
@@ -739,11 +921,11 @@ async def test_sms_alert_when_armed(mock_device: DeviceMock) -> None:
739
921
  await mock_device.send_next_notification()
740
922
  await asyncio.wait([future], timeout=0.1)
741
923
  g90.close_device_notifications()
742
- assert mock_device.recv_data == [
924
+ assert set([
743
925
  b'ISTART[117,117,""]IEND\0',
744
926
  b'ISTART[117,117,""]IEND\0',
745
927
  b"ISTART[116,116,[116,[513]]]IEND\0",
746
- ]
928
+ ]).issubset(set(mock_device.recv_data))
747
929
 
748
930
 
749
931
  @pytest.mark.g90device(
@@ -752,6 +934,11 @@ async def test_sms_alert_when_armed(mock_device: DeviceMock) -> None:
752
934
  b"ISTART[117,[513]]IEND\0",
753
935
  b"ISTART[117,[513]]IEND\0",
754
936
  b"ISTARTIEND\0",
937
+ b'ISTART[102,'
938
+ b'[[3,1,3],["Remote 1",10,0,10,1,0,32,0,0,16,1,0,""],'
939
+ b'["Remote 2",11,0,10,1,0,32,0,0,16,1,0,""],'
940
+ b'["Cord 1",12,0,126,1,0,32,0,5,16,1,0,""]'
941
+ b']]IEND\0',
755
942
  ],
756
943
  notification_data=[
757
944
  b'[170,[1,[3]]]\0',
@@ -773,11 +960,11 @@ async def test_sms_alert_when_disarmed(mock_device: DeviceMock) -> None:
773
960
  await mock_device.send_next_notification()
774
961
  await asyncio.wait([future], timeout=0.1)
775
962
  g90.close_device_notifications()
776
- assert mock_device.recv_data == [
963
+ assert set([
777
964
  b'ISTART[117,117,""]IEND\0',
778
965
  b'ISTART[117,117,""]IEND\0',
779
966
  b"ISTART[116,116,[116,[1]]]IEND\0",
780
- ]
967
+ ]).issubset(set(mock_device.recv_data))
781
968
 
782
969
 
783
970
  @pytest.mark.g90device(sent_data=[
@@ -481,7 +481,31 @@ async def test_alarm_callback(mock_device: DeviceMock) -> None:
481
481
  await mock_device.send_next_notification()
482
482
  await asyncio.wait([future], timeout=0.1)
483
483
  notifications.close()
484
- notifications.on_alarm.assert_called_once_with(11, 'Hall')
484
+ notifications.on_alarm.assert_called_once_with(11, 'Hall', False)
485
+
486
+
487
+ @pytest.mark.g90device(notification_data=[
488
+ b'[208,[3,11,1,3,"Hall","DUMMYGUID",1630876128,0,[""]]]\0',
489
+ ])
490
+ async def test_tamper_callback(mock_device: DeviceMock) -> None:
491
+ """
492
+ Verifies that alarm callback is handled correctly when a sensor is
493
+ tampered.
494
+ """
495
+ future = asyncio.get_running_loop().create_future()
496
+ notifications = G90DeviceNotifications(
497
+ local_host=mock_device.notification_host,
498
+ local_port=mock_device.notification_port
499
+ )
500
+ notifications.on_alarm = MagicMock() # type: ignore[method-assign]
501
+ notifications.on_alarm.side_effect = (
502
+ lambda *args: future.set_result(True)
503
+ )
504
+ await notifications.listen()
505
+ await mock_device.send_next_notification()
506
+ await asyncio.wait([future], timeout=0.1)
507
+ notifications.close()
508
+ notifications.on_alarm.assert_called_once_with(11, 'Hall', True)
485
509
 
486
510
 
487
511
  @pytest.mark.g90device(notification_data=[
@@ -541,3 +565,29 @@ async def test_low_battery_callback(mock_device: DeviceMock) -> None:
541
565
  await asyncio.wait([future], timeout=0.1)
542
566
  notifications.close()
543
567
  notifications.on_low_battery.assert_called_once_with(26, 'Hall')
568
+
569
+
570
+ @pytest.mark.g90device(notification_data=[
571
+ b'[170,[6,[21,"Hall"]]]\0'
572
+ ])
573
+ async def test_door_open_when_arming_callback(mock_device: DeviceMock) -> None:
574
+ """
575
+ Verifies that door open when arming callback is handled correctly.
576
+ """
577
+ future = asyncio.get_running_loop().create_future()
578
+ notifications = G90DeviceNotifications(
579
+ local_host=mock_device.notification_host,
580
+ local_port=mock_device.notification_port
581
+ )
582
+
583
+ notifications.on_door_open_when_arming = ( # type: ignore[method-assign]
584
+ MagicMock()
585
+ )
586
+ notifications.on_door_open_when_arming.side_effect = (
587
+ lambda *args: future.set_result(True)
588
+ )
589
+ await notifications.listen()
590
+ await mock_device.send_next_notification()
591
+ await asyncio.wait([future], timeout=0.1)
592
+ notifications.close()
593
+ notifications.on_door_open_when_arming.assert_called_once_with(21, 'Hall')
@@ -16,19 +16,14 @@ isolated_build = true
16
16
  deps =
17
17
  -r requirements_dev.txt
18
18
 
19
- allowlist_externals =
20
- cat
21
19
  commands =
22
20
  check-manifest --ignore 'tox.ini,tests/**,docs/**,.pylintrc,.readthedocs.yaml,sonar-project.properties'
23
21
  flake8 --tee --output-file=flake8.txt src/pyg90alarm/ tests/
24
- pylint --output-format=parseable --output=pylint.txt src/pyg90alarm/ tests/
22
+ pylint --output-format=text,parseable:pylint.txt src/pyg90alarm/ tests/
25
23
  mypy --strict --cobertura-xml-report=mypy/ src/pyg90alarm/ tests/
26
24
  # Ensure only traces for in-repository module is processed, not for one
27
25
  # installed by `tox` (see above for more details)
28
26
  pytest --cov=src/pyg90alarm --cov-append --cov-report=term-missing -v tests []
29
- commands_post =
30
- # Show the `pylint` report to the standard output, to ease fixing the issues reported
31
- cat pylint.txt
32
27
 
33
28
  [flake8]
34
29
  exclude = .tox,*.egg,build,data,scripts,docs
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes