diff --git a/setup.py b/setup.py index 6578c6d..5ba5ced 100755 --- a/setup.py +++ b/setup.py @@ -36,17 +36,17 @@ 'Topic :: System :: Hardware', 'License :: OSI Approved :: MIT License', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3 :: Only', ], keywords='g90, alarm, protocol', package_dir={'': 'src'}, packages=find_packages(where='src'), - python_requires='>=3.6, <4', + python_requires='>=3.7, <4', install_requires=[], extras_require={ diff --git a/sonar-project.properties b/sonar-project.properties index 46026bc..c57507b 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,4 +1,4 @@ -sonar.python.version=3.6, 3.7, 3.8, 3.9 +sonar.python.version=3.7, 3.8, 3.9, 3.10 sonar.python.coverage.reportPaths=coverage.xml sonar.python.pylint.reportPaths=pylint.txt sonar.python.flake8.reportPaths=flake8.txt diff --git a/src/pyg90alarm/alarm.py b/src/pyg90alarm/alarm.py index 9ec733e..51e6b3b 100644 --- a/src/pyg90alarm/alarm.py +++ b/src/pyg90alarm/alarm.py @@ -107,6 +107,7 @@ def __init__(self, host, port=REMOTE_PORT, sock=None, self._sensor_cb = None self._armdisarm_cb = None self._door_open_close_cb = None + self._alarm_cb = None self._reset_occupancy_interval = reset_occupancy_interval self._alert_config = None self._sms_alert_when_armed = False @@ -535,6 +536,20 @@ def armdisarm_callback(self): def armdisarm_callback(self, value): self._armdisarm_cb = value + @property + def alarm_callback(self): + """ + Get or set device alarm callback, the callback is invoked when + device alarm triggers. + + :type: .. py:function:: ()(sensor_idx: int, sensor_name: str) + """ + return self._alarm_cb + + @alarm_callback.setter + def alarm_callback(self, value): + self._alarm_cb = value + async def listen_device_notifications(self, sock=None): """ Starts internal listener for device notifications/alerts. @@ -546,6 +561,7 @@ async def listen_device_notifications(self, sock=None): sensor_cb=self._internal_sensor_cb, door_open_close_cb=self._internal_door_open_close_cb, armdisarm_cb=self._internal_armdisarm_cb, + alarm_cb=self._alarm_cb, sock=sock) await self._notifications.listen() diff --git a/src/pyg90alarm/device_notifications.py b/src/pyg90alarm/device_notifications.py index 98c7c77..0138f05 100644 --- a/src/pyg90alarm/device_notifications.py +++ b/src/pyg90alarm/device_notifications.py @@ -93,13 +93,14 @@ class G90DeviceNotificationProtocol: :meta private: """ def __init__(self, armdisarm_cb=None, sensor_cb=None, - door_open_close_cb=None): + door_open_close_cb=None, alarm_cb=None): """ tbd """ self._armdisarm_cb = armdisarm_cb self._sensor_cb = sensor_cb self._door_open_close_cb = door_open_close_cb + self._alarm_cb = alarm_cb def connection_made(self, transport): """ @@ -169,6 +170,11 @@ def _handle_alert(self, addr, alert): G90Callback.invoke(self._armdisarm_cb, state) return + if alert.type == G90AlertTypes.ALARM: + _LOGGER.debug('Alarm: %s', alert.zone_name) + G90Callback.invoke(self._alarm_cb, alert.event_id, alert.zone_name) + return + _LOGGER.warning('Unknown alert received from %s:%s:' ' type %s, data %s', addr[0], addr[1], alert.type, alert) @@ -227,13 +233,14 @@ class G90DeviceNotifications: tbd """ def __init__(self, port=12901, armdisarm_cb=None, sensor_cb=None, - door_open_close_cb=None, sock=None): + door_open_close_cb=None, alarm_cb=None, sock=None): # pylint: disable=too-many-arguments self._notification_transport = None self._port = port self._armdisarm_cb = armdisarm_cb self._sensor_cb = sensor_cb self._door_open_close_cb = door_open_close_cb + self._alarm_cb = alarm_cb self._sock = sock def proto_factory(self): @@ -241,7 +248,11 @@ def proto_factory(self): tbd """ return G90DeviceNotificationProtocol( - self._armdisarm_cb, self._sensor_cb, self._door_open_close_cb) + armdisarm_cb=self._armdisarm_cb, + sensor_cb=self._sensor_cb, + door_open_close_cb=self._door_open_close_cb, + alarm_cb=self._alarm_cb + ) async def listen(self): """ diff --git a/src/pyg90alarm/entities/sensor.py b/src/pyg90alarm/entities/sensor.py index 0455ab7..fa70a59 100644 --- a/src/pyg90alarm/entities/sensor.py +++ b/src/pyg90alarm/entities/sensor.py @@ -363,14 +363,24 @@ async def set_enabled(self, value): 'Refreshing sensor at index=%s, position in protocol list=%s', self.index, self._proto_idx ) - sensors = self.parent.paginated_result( + sensors_result = self.parent.paginated_result( G90Commands.GETSENSORLIST, start=self._proto_idx, end=self._proto_idx ) + sensors = [x async for x in sensors_result] + + # Abort if sensor is not found + if not sensors: + _LOGGER.error( + 'Sensor index=%s not found when attempting to set its' + ' enable/disable state', + self.index, + ) + return # Compare actual sensor data from what the sensor has been instantiated # from, and abort the operation if out-of-band changes are detected. - _sensor_pos, sensor_data = [x async for x in sensors][0] + _sensor_pos, sensor_data = sensors[0] if self._protocol_incoming_data_kls( *sensor_data ) != self._protocol_data: diff --git a/src/pyg90alarm/paginated_result.py b/src/pyg90alarm/paginated_result.py index c4e93ec..df82286 100644 --- a/src/pyg90alarm/paginated_result.py +++ b/src/pyg90alarm/paginated_result.py @@ -94,6 +94,11 @@ async def process(self): ' latter', self._end, cmd.total) self._end = cmd.total + _LOGGER.debug('Retrieved %i records in the iteration,' + ' %i available in total, target end' + ' record number is %i', + cmd.count, cmd.total, self._end) + # Produce the resulting records for the consumer for idx, data in enumerate(cmd.result): # Protocol uses one-based indexes, `start` implies that so no @@ -107,11 +112,9 @@ async def process(self): # End the loop if we processed same number of sensors as in the # pagination header (or attempted to process more than that by - # an error) - _LOGGER.debug('Retrieved %i records in the iteration,' - ' %i available in total, target end' - ' record number is %i', - cmd.count, cmd.total, self._end) + # an error), or no records have been received + if not cmd.count: + break if cmd.start + cmd.count - 1 >= self._end: break # Move to the next page for another iteration diff --git a/tests/test_alarm.py b/tests/test_alarm.py index aef1d9b..cec91b6 100644 --- a/tests/test_alarm.py +++ b/tests/test_alarm.py @@ -563,6 +563,32 @@ async def test_sensor_unsupported_disable(self): b'ISTART[102,102,[102,[1,10]]]IEND\0', ]) + async def test_sensor_disable_sensor_not_found_on_refresh(self): + g90 = G90Alarm(host='mocked', port=12345, sock=self.socket_mock) + self.socket_mock.recvfrom.side_effect = [ + ( + b'ISTART[102,' + b'[[2,1,2],' + b'["Night Light1",11,0,138,0,0,33,0,0,17,1,0,""],' + b'["Night Light2",10,0,138,0,0,33,0,0,17,1,0,""]' + b']]IEND\0', + ('mocked', 12345) + ), + ( + b'ISTART[102,[[2,2,0]]]IEND\0', + ('mocked', 12345) + ), + ] + + sensors = await g90.get_sensors() + self.assertEqual(sensors[1].enabled, True) + await sensors[1].set_enabled(False) + self.assertEqual(sensors[1].enabled, True) + self.assert_callargs_on_sent_data([ + b'ISTART[102,102,[102,[1,10]]]IEND\0', + b'ISTART[102,102,[102,[2,2]]]IEND\0', + ]) + async def test_device_unsupported_disable(self): g90 = G90Alarm(host='mocked', port=12345, sock=self.socket_mock) self.socket_mock.recvfrom.side_effect = [ diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 4a3d44c..1a13394 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -221,3 +221,18 @@ async def test_doorbell_callback(self): await asyncio.wait([future], timeout=0.1) notifications.close() door_open_close_cb.assert_called_once_with(111, 'Doorbell', True) + + async def test_alarm_callback(self): + future = self.loop.create_future() + alarm_cb = MagicMock() + alarm_cb.side_effect = lambda *args: future.set_result(True) + notifications = G90DeviceNotifications( + alarm_cb=alarm_cb, sock=self.socket_mock) + await notifications.listen() + asynctest.set_read_ready(self.socket_mock, self.loop) + self.socket_mock.recvfrom.return_value = ( + b'[208,[3,11,1,1,"Hall","DUMMYGUID",1630876128,0,[""]]]\0', + ('mocked', 12345)) + await asyncio.wait([future], timeout=0.1) + notifications.close() + alarm_cb.assert_called_once_with(11, 'Hall') diff --git a/tox.ini b/tox.ini index aee5f04..411d7e9 100644 --- a/tox.ini +++ b/tox.ini @@ -36,7 +36,7 @@ commands = pylint --output-format=parseable --output=pylint.txt src/pyg90alarm # Ensure only traces for in-repository module is processed, not for one # installed by `tox` (see above for more details) - coverage run --source=src/pyg90alarm --parallel-mode -m unittest -v tests + coverage run --source=src/pyg90alarm --parallel-mode -m unittest -v tests [] commands_post = # Show the `pylint` report to the standard output, to ease fixing the issues reported cat pylint.txt