pyg90alarm 1.17.2__tar.gz → 1.19.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.2 → pyg90alarm-1.19.0}/PKG-INFO +1 -1
  2. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/src/pyg90alarm/alarm.py +116 -12
  3. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/src/pyg90alarm/const.py +1 -0
  4. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/src/pyg90alarm/device_notifications.py +29 -3
  5. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/src/pyg90alarm/entities/sensor.py +147 -18
  6. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/src/pyg90alarm.egg-info/PKG-INFO +1 -1
  7. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/tests/test_alarm.py +174 -5
  8. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/tests/test_notifications.py +51 -1
  9. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/.github/CODEOWNERS +0 -0
  10. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/.github/workflows/main.yml +0 -0
  11. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/.gitignore +0 -0
  12. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/.pylintrc +0 -0
  13. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/.readthedocs.yaml +0 -0
  14. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/LICENSE +0 -0
  15. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/MANIFEST.in +0 -0
  16. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/README.rst +0 -0
  17. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/docs/.DS_Store +0 -0
  18. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/docs/.gitignore +0 -0
  19. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/docs/api-docs.rst +0 -0
  20. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/docs/conf.py +0 -0
  21. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/docs/index.rst +0 -0
  22. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/docs/protocol.rst +0 -0
  23. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/docs/requirements.txt +0 -0
  24. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/pyproject.toml +0 -0
  25. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/setup.cfg +0 -0
  26. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/setup.py +0 -0
  27. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/sonar-project.properties +0 -0
  28. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/src/pyg90alarm/__init__.py +0 -0
  29. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/src/pyg90alarm/base_cmd.py +0 -0
  30. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/src/pyg90alarm/callback.py +0 -0
  31. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/src/pyg90alarm/config.py +0 -0
  32. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/src/pyg90alarm/definitions/__init__.py +0 -0
  33. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/src/pyg90alarm/definitions/sensors.py +0 -0
  34. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/src/pyg90alarm/discovery.py +0 -0
  35. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/src/pyg90alarm/entities/__init__.py +0 -0
  36. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/src/pyg90alarm/entities/device.py +0 -0
  37. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/src/pyg90alarm/exceptions.py +0 -0
  38. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/src/pyg90alarm/history.py +0 -0
  39. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/src/pyg90alarm/host_info.py +0 -0
  40. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/src/pyg90alarm/host_status.py +0 -0
  41. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/src/pyg90alarm/paginated_cmd.py +0 -0
  42. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/src/pyg90alarm/paginated_result.py +0 -0
  43. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/src/pyg90alarm/py.typed +0 -0
  44. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/src/pyg90alarm/targeted_discovery.py +0 -0
  45. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/src/pyg90alarm/user_data_crc.py +0 -0
  46. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/src/pyg90alarm.egg-info/SOURCES.txt +0 -0
  47. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/src/pyg90alarm.egg-info/dependency_links.txt +0 -0
  48. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/src/pyg90alarm.egg-info/requires.txt +0 -0
  49. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/src/pyg90alarm.egg-info/top_level.txt +0 -0
  50. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/tests/__init__.py +0 -0
  51. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/tests/conftest.py +0 -0
  52. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/tests/device_mock.py +0 -0
  53. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/tests/test_base_commands.py +0 -0
  54. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/tests/test_discovery.py +0 -0
  55. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/tests/test_history.py +0 -0
  56. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/tests/test_paginated_commands.py +0 -0
  57. {pyg90alarm-1.17.2 → pyg90alarm-1.19.0}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pyg90alarm
3
- Version: 1.17.2
3
+ Version: 1.19.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
@@ -174,6 +191,10 @@ class G90Alarm(G90DeviceNotifications):
174
191
  self._remote_button_press_cb: Optional[
175
192
  RemoteButtonPressCallback
176
193
  ] = None
194
+ self._door_open_when_arming_cb: Optional[
195
+ DoorOpenWhenArmingCallback
196
+ ] = None
197
+ self._tamper_cb: Optional[TamperCallback] = None
177
198
  self._reset_occupancy_interval = reset_occupancy_interval
178
199
  self._alert_config: Optional[G90AlertConfigFlags] = None
179
200
  self._sms_alert_when_armed = False
@@ -613,6 +634,17 @@ class G90Alarm(G90DeviceNotifications):
613
634
  await self.set_alert_config(
614
635
  await self.alert_config | G90AlertConfigFlags.SMS_PUSH
615
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
+
616
648
  G90Callback.invoke(self._armdisarm_cb, state)
617
649
 
618
650
  @property
@@ -627,7 +659,9 @@ class G90Alarm(G90DeviceNotifications):
627
659
  def armdisarm_callback(self, value: ArmDisarmCallback) -> None:
628
660
  self._armdisarm_cb = value
629
661
 
630
- 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:
631
665
  """
632
666
  Invoked when alarm is triggered. Fires corresponding callback if set by
633
667
  the user with :attr:`.alarm_callback`.
@@ -638,16 +672,31 @@ class G90Alarm(G90DeviceNotifications):
638
672
  :param zone_name: Sensor name
639
673
  """
640
674
  sensor = await self.find_sensor(event_id, zone_name)
641
- # The callback is still delivered to the caller even if the sensor
642
- # isn't found, only `extra_data` is skipped. That is to ensure the
643
- # important callback isn't filtered
644
- extra_data = sensor.extra_data if sensor else None
645
- # Invoke the sensor activity callback to set the sensor occupancy if
646
- # sensor is known, but only if that isn't already set - it helps when
647
- # device notifications on triggerring sensor's activity aren't receveid
648
- # by a reason
649
- if sensor and not sensor.occupancy:
650
- 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)
651
700
 
652
701
  G90Callback.invoke(
653
702
  self._alarm_cb, event_id, zone_name, extra_data
@@ -718,7 +767,10 @@ class G90Alarm(G90DeviceNotifications):
718
767
 
719
768
  # Also report the event as alarm for unification, hard-coding the
720
769
  # sensor name in case of host SOS
721
- 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
+ )
722
774
 
723
775
  if not is_host_sos:
724
776
  # Also report the remote button press for SOS - the panel will not
@@ -778,6 +830,58 @@ class G90Alarm(G90DeviceNotifications):
778
830
  ) -> None:
779
831
  self._remote_button_press_cb = value
780
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
+
781
885
  async def listen_device_notifications(self) -> None:
782
886
  """
783
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
@@ -101,6 +104,16 @@ class G90SensorUserFlags(IntFlag):
101
104
  ALERT_WHEN_AWAY_AND_HOME = 32
102
105
  ALERT_WHEN_AWAY = 64
103
106
  SUPPORTS_UPDATING_SUBTYPE = 512 # Only relevant for cord sensors
107
+ # Flags that can be set by the user
108
+ USER_SETTABLE = (
109
+ ENABLED
110
+ | ARM_DELAY
111
+ | DETECT_DOOR
112
+ | DOOR_CHIME
113
+ | INDEPENDENT_ZONE
114
+ | ALERT_WHEN_AWAY_AND_HOME
115
+ | ALERT_WHEN_AWAY
116
+ )
104
117
 
105
118
 
106
119
  class G90SensorProtocols(IntEnum):
@@ -157,6 +170,7 @@ class G90SensorTypes(IntEnum):
157
170
  _LOGGER = logging.getLogger(__name__)
158
171
 
159
172
 
173
+ # pylint: disable=too-many-public-methods
160
174
  class G90Sensor: # pylint:disable=too-many-instance-attributes
161
175
  """
162
176
  Interacts with sensor on G90 alarm panel.
@@ -186,6 +200,12 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
186
200
  self._state_callback: Optional[SensorStateCallback] = None
187
201
  self._low_battery_callback: Optional[SensorLowBatteryCallback] = None
188
202
  self._low_battery = False
203
+ self._tampered = False
204
+ self._door_open_when_arming_callback: Optional[
205
+ SensorDoorOpenWhenArmingCallback
206
+ ] = None
207
+ self._tamper_callback: Optional[SensorTamperCallback] = None
208
+ self._door_open_when_arming = False
189
209
  self._proto_idx = proto_idx
190
210
  self._extra_data: Any = None
191
211
 
@@ -238,6 +258,37 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
238
258
  def low_battery_callback(self, value: SensorLowBatteryCallback) -> None:
239
259
  self._low_battery_callback = value
240
260
 
261
+ @property
262
+ def door_open_when_arming_callback(
263
+ self
264
+ ) -> Optional[SensorDoorOpenWhenArmingCallback]:
265
+ """
266
+ Callback that is invoked when the sensor reports on open door
267
+ condition when arming.
268
+
269
+ :return: Sensor's door open when arming callback
270
+ """
271
+ return self._door_open_when_arming_callback
272
+
273
+ @door_open_when_arming_callback.setter
274
+ def door_open_when_arming_callback(
275
+ self, value: SensorDoorOpenWhenArmingCallback
276
+ ) -> None:
277
+ self._door_open_when_arming_callback = value
278
+
279
+ @property
280
+ def tamper_callback(self) -> Optional[SensorTamperCallback]:
281
+ """
282
+ Callback that is invoked when the sensor reports being tampered.
283
+
284
+ :return: Sensor's tamper callback
285
+ """
286
+ return self._tamper_callback
287
+
288
+ @tamper_callback.setter
289
+ def tamper_callback(self, value: SensorTamperCallback) -> None:
290
+ self._tamper_callback = value
291
+
241
292
  @property
242
293
  def occupancy(self) -> bool:
243
294
  """
@@ -365,12 +416,16 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
365
416
  def is_low_battery(self) -> bool:
366
417
  """
367
418
  Indicates if the sensor is reporting low battery.
419
+
420
+ The condition is cleared when the sensor reports activity (i.e. is no
421
+ longer low on battery as it is able to report the activity).
368
422
  """
369
423
  return self._low_battery
370
424
 
371
425
  def _set_low_battery(self, value: bool) -> None:
372
426
  """
373
427
  Sets low battery state of the sensor.
428
+
374
429
  Intentionally private, as low battery state is derived from
375
430
  notifications/alerts.
376
431
 
@@ -383,6 +438,56 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
383
438
  )
384
439
  self._low_battery = value
385
440
 
441
+ @property
442
+ def is_tampered(self) -> bool:
443
+ """
444
+ Indicates if the sensor has been tampered.
445
+
446
+ The condition is cleared when panel is armed/disarmed next time.
447
+ """
448
+ return self._tampered
449
+
450
+ def _set_tampered(self, value: bool) -> None:
451
+ """
452
+ Sets tamper state of the sensor.
453
+
454
+ Intentionally private, as tamper state is derived from
455
+ notifications/alerts.
456
+
457
+ :param value: Tamper state
458
+ """
459
+ _LOGGER.debug(
460
+ "Setting tamper for sensor index=%s '%s': %s"
461
+ " (previous value: %s)",
462
+ self.index, self.name, value, self._tampered
463
+ )
464
+ self._tampered = value
465
+
466
+ @property
467
+ def is_door_open_when_arming(self) -> bool:
468
+ """
469
+ Indicates if the sensor reports on open door when arming.
470
+
471
+ The condition is cleared when panel is armed/disarmed next time.
472
+ """
473
+ return self._door_open_when_arming
474
+
475
+ def _set_door_open_when_arming(self, value: bool) -> None:
476
+ """
477
+ Sets door open state of the sensor when arming.
478
+
479
+ Intentionally private, as door open state is derived from
480
+ notifications/alerts.
481
+
482
+ :param value: Door open state
483
+ """
484
+ _LOGGER.debug(
485
+ "Setting door open when arming for sensor index=%s '%s': %s"
486
+ " (previous value: %s)",
487
+ self.index, self.name, value, self._door_open_when_arming
488
+ )
489
+ self._door_open_when_arming = value
490
+
386
491
  @property
387
492
  def enabled(self) -> bool:
388
493
  """
@@ -392,18 +497,26 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
392
497
  """
393
498
  return self.user_flag & G90SensorUserFlags.ENABLED != 0
394
499
 
395
- async def set_enabled(self, value: bool) -> None:
500
+ async def set_user_flag(self, value: G90SensorUserFlags) -> None:
396
501
  """
397
- Sets disabled/enabled state of the sensor.
502
+ Sets user flags of the sensor.
398
503
 
399
- :param value: Whether to enable or disable the sensor
504
+ :param value: User flags to set, values other than
505
+ :attr:`.G90SensorUserFlags.USER_SETTABLE` will be ignored and
506
+ preserved from existing sensor flags.
400
507
  """
508
+ if value & ~G90SensorUserFlags.USER_SETTABLE:
509
+ _LOGGER.warning(
510
+ 'User flags for sensor index=%s contain non-user settable'
511
+ ' flags, those will be ignored: %s',
512
+ self.index, repr(value & ~G90SensorUserFlags.USER_SETTABLE)
513
+ )
401
514
  # Checking private attribute directly, since `mypy` doesn't recognize
402
515
  # the check for sensor definition to be defined if done over
403
516
  # `self.supports_enable_disable` property
404
517
  if not self._definition:
405
518
  _LOGGER.warning(
406
- 'Manipulating with enable/disable for sensor index=%s'
519
+ 'Manipulating with user flags for sensor index=%s'
407
520
  ' is unsupported - no sensor definition for'
408
521
  ' type=%s, subtype=%s',
409
522
  self.index,
@@ -431,7 +544,7 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
431
544
  if not sensors:
432
545
  _LOGGER.error(
433
546
  'Sensor index=%s not found when attempting to set its'
434
- ' enable/disable state',
547
+ ' user flag',
435
548
  self.index,
436
549
  )
437
550
  return
@@ -444,30 +557,30 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
444
557
  ) != self._protocol_data:
445
558
  _LOGGER.error(
446
559
  "Sensor index=%s '%s' has been changed externally,"
447
- " refusing to alter its enable/disable state",
560
+ " refusing to alter its user flag",
448
561
  self.index,
449
562
  self.name
450
563
  )
451
564
  return
452
565
 
453
- # Modify the value of the user flag setting enabled/disabled one
454
- # appropriately.
455
- user_flag = self.user_flag
456
- if value:
457
- user_flag |= G90SensorUserFlags.ENABLED
458
- else:
459
- user_flag &= ~G90SensorUserFlags.ENABLED
566
+ prev_user_flag = self.user_flag
460
567
 
461
568
  # Re-instantiate the protocol data with modified user flags
462
569
  _data = asdict(self._protocol_data)
463
- _data['user_flag_data'] = user_flag
570
+ _data['user_flag_data'] = (
571
+ # Preserve flags that are not user-settable
572
+ self.user_flag & ~G90SensorUserFlags.USER_SETTABLE
573
+ ) | (
574
+ # Combine them with the new user-settable flags
575
+ value & G90SensorUserFlags.USER_SETTABLE
576
+ )
464
577
  self._protocol_data = self._protocol_incoming_data_kls(**_data)
465
578
 
466
579
  _LOGGER.debug(
467
- 'Sensor index=%s: %s enabled flag, resulting user_flag %s',
580
+ 'Sensor index=%s: previous user_flag %s, resulting user_flag %s',
468
581
  self._protocol_data.index,
469
- 'Setting' if value else 'Clearing',
470
- self.user_flag
582
+ repr(prev_user_flag),
583
+ repr(self.user_flag)
471
584
  )
472
585
 
473
586
  # Generate protocol data from write operation, deriving values either
@@ -494,6 +607,20 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
494
607
  G90Commands.SETSINGLESENSOR, list(astuple(outgoing_data))
495
608
  )
496
609
 
610
+ async def set_enabled(self, value: bool) -> None:
611
+ """
612
+ Sets the sensor enabled/disabled.
613
+ """
614
+
615
+ # Modify the value of the user flag setting enabled/disabled one
616
+ # appropriately.
617
+ user_flag = self.user_flag
618
+ if value:
619
+ user_flag |= G90SensorUserFlags.ENABLED
620
+ else:
621
+ user_flag &= ~G90SensorUserFlags.ENABLED
622
+ await self.set_user_flag(user_flag)
623
+
497
624
  @property
498
625
  def extra_data(self) -> Any:
499
626
  """
@@ -528,6 +655,8 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
528
655
  'supports_enable_disable': self.supports_enable_disable,
529
656
  'is_wireless': self.is_wireless,
530
657
  'is_low_battery': self.is_low_battery,
658
+ 'is_tampered': self.is_tampered,
659
+ 'is_door_open_when_arming': self.is_door_open_when_arming,
531
660
  }
532
661
 
533
662
  def __repr__(self) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pyg90alarm
3
- Version: 1.17.2
3
+ Version: 1.19.0
4
4
  Summary: G90 Alarm system protocol
5
5
  Home-page: https://github.com/hostcc/pyg90alarm
6
6
  Author: Ilia Sotnikov
@@ -1,6 +1,8 @@
1
1
  """
2
2
  Tests for G90Alarm class
3
3
  """
4
+ # pylint: disable=too-many-lines
5
+
4
6
  import asyncio
5
7
  from itertools import cycle
6
8
  from unittest.mock import MagicMock
@@ -13,7 +15,7 @@ from pyg90alarm.host_info import (
13
15
  G90HostInfo, G90HostInfoGsmStatus, G90HostInfoWifiStatus,
14
16
  )
15
17
  from pyg90alarm.entities.sensor import (
16
- G90Sensor,
18
+ G90Sensor, G90SensorUserFlags,
17
19
  )
18
20
  from pyg90alarm.entities.device import (
19
21
  G90Device,
@@ -438,6 +440,69 @@ async def test_sensor_low_battery_callback(mock_device: DeviceMock) -> None:
438
440
 
439
441
 
440
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
+ ],
441
506
  notification_data=[
442
507
  b'[170,[1,[1]]]\0'
443
508
  ]
@@ -580,6 +645,65 @@ async def test_alarm_callback(mock_device: DeviceMock) -> None:
580
645
  g90.close_device_notifications()
581
646
 
582
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
+
583
707
  @pytest.mark.g90device(
584
708
  sent_data=[
585
709
  b'ISTART[102,'
@@ -769,6 +893,13 @@ async def test_set_alert_config(mock_device: DeviceMock) -> None:
769
893
  # that checks if alert config has been modified externally
770
894
  b"ISTART[117,[1]]IEND\0",
771
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',
772
903
  ],
773
904
  notification_data=[
774
905
  b'[170,[1,[1]]]\0',
@@ -790,11 +921,11 @@ async def test_sms_alert_when_armed(mock_device: DeviceMock) -> None:
790
921
  await mock_device.send_next_notification()
791
922
  await asyncio.wait([future], timeout=0.1)
792
923
  g90.close_device_notifications()
793
- assert mock_device.recv_data == [
924
+ assert set([
794
925
  b'ISTART[117,117,""]IEND\0',
795
926
  b'ISTART[117,117,""]IEND\0',
796
927
  b"ISTART[116,116,[116,[513]]]IEND\0",
797
- ]
928
+ ]).issubset(set(mock_device.recv_data))
798
929
 
799
930
 
800
931
  @pytest.mark.g90device(
@@ -803,6 +934,11 @@ async def test_sms_alert_when_armed(mock_device: DeviceMock) -> None:
803
934
  b"ISTART[117,[513]]IEND\0",
804
935
  b"ISTART[117,[513]]IEND\0",
805
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',
806
942
  ],
807
943
  notification_data=[
808
944
  b'[170,[1,[3]]]\0',
@@ -824,11 +960,11 @@ async def test_sms_alert_when_disarmed(mock_device: DeviceMock) -> None:
824
960
  await mock_device.send_next_notification()
825
961
  await asyncio.wait([future], timeout=0.1)
826
962
  g90.close_device_notifications()
827
- assert mock_device.recv_data == [
963
+ assert set([
828
964
  b'ISTART[117,117,""]IEND\0',
829
965
  b'ISTART[117,117,""]IEND\0',
830
966
  b"ISTART[116,116,[116,[1]]]IEND\0",
831
- ]
967
+ ]).issubset(set(mock_device.recv_data))
832
968
 
833
969
 
834
970
  @pytest.mark.g90device(sent_data=[
@@ -963,3 +1099,36 @@ async def test_device_unsupported_disable(mock_device: DeviceMock) -> None:
963
1099
  assert mock_device.recv_data == [
964
1100
  b'ISTART[138,138,[138,[1,10]]]IEND\0',
965
1101
  ]
1102
+
1103
+
1104
+ @pytest.mark.g90device(sent_data=[
1105
+ b'ISTART[102,'
1106
+ b'[[1,1,1],'
1107
+ b'["Night Light2",10,0,138,0,0,33,0,0,17,1,0,""]'
1108
+ b']]IEND\0',
1109
+ b'ISTART[102,'
1110
+ b'[[1,1,1],'
1111
+ b'["Night Light2",10,0,138,0,0,33,0,0,17,1,0,""]'
1112
+ b']]IEND\0',
1113
+ b"ISTARTIEND\0",
1114
+ ])
1115
+ async def test_sensor_set_user_flags(mock_device: DeviceMock) -> None:
1116
+ """
1117
+ Tests for setting user flags on a sensor.
1118
+ """
1119
+ g90 = G90Alarm(host=mock_device.host, port=mock_device.port)
1120
+ sensors = await g90.get_sensors()
1121
+ await sensors[0].set_user_flag(
1122
+ # Intentionally contains non-user settable flag, which should be
1123
+ # ignored and not configured for the sensor that initial doesn't have
1124
+ # it set
1125
+ G90SensorUserFlags.INDEPENDENT_ZONE | G90SensorUserFlags.ARM_DELAY
1126
+ | G90SensorUserFlags.SUPPORTS_UPDATING_SUBTYPE
1127
+ )
1128
+ assert mock_device.recv_data == [
1129
+ b'ISTART[102,102,[102,[1,10]]]IEND\0',
1130
+ b'ISTART[102,102,[102,[1,1]]]IEND\0',
1131
+ b'ISTART[103,103,[103,'
1132
+ b'["Night Light2",10,0,138,0,0,18,0,0,17,1,0,2,"060A0600"]'
1133
+ b']]IEND\0',
1134
+ ]
@@ -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')
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
File without changes