pyg90alarm 1.15.0__tar.gz → 1.16.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 (56) hide show
  1. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/.github/workflows/main.yml +3 -0
  2. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/PKG-INFO +2 -1
  3. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/setup.py +1 -0
  4. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/src/pyg90alarm/alarm.py +25 -16
  5. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/src/pyg90alarm/base_cmd.py +6 -1
  6. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/src/pyg90alarm/const.py +2 -1
  7. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/src/pyg90alarm/device_notifications.py +61 -10
  8. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/src/pyg90alarm/targeted_discovery.py +6 -1
  9. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/src/pyg90alarm.egg-info/PKG-INFO +2 -1
  10. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/tests/test_alarm.py +14 -14
  11. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/tests/test_base_commands.py +20 -0
  12. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/tests/test_discovery.py +25 -0
  13. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/tests/test_notifications.py +127 -16
  14. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/tox.ini +1 -1
  15. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/.github/CODEOWNERS +0 -0
  16. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/.gitignore +0 -0
  17. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/.pylintrc +0 -0
  18. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/.readthedocs.yaml +0 -0
  19. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/LICENSE +0 -0
  20. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/MANIFEST.in +0 -0
  21. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/README.rst +0 -0
  22. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/docs/.DS_Store +0 -0
  23. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/docs/.gitignore +0 -0
  24. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/docs/api-docs.rst +0 -0
  25. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/docs/conf.py +0 -0
  26. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/docs/index.rst +0 -0
  27. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/docs/protocol.rst +0 -0
  28. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/docs/requirements.txt +0 -0
  29. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/pyproject.toml +0 -0
  30. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/setup.cfg +0 -0
  31. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/sonar-project.properties +0 -0
  32. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/src/pyg90alarm/__init__.py +0 -0
  33. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/src/pyg90alarm/callback.py +0 -0
  34. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/src/pyg90alarm/config.py +0 -0
  35. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/src/pyg90alarm/definitions/__init__.py +0 -0
  36. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/src/pyg90alarm/definitions/sensors.py +0 -0
  37. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/src/pyg90alarm/discovery.py +0 -0
  38. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/src/pyg90alarm/entities/__init__.py +0 -0
  39. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/src/pyg90alarm/entities/device.py +0 -0
  40. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/src/pyg90alarm/entities/sensor.py +0 -0
  41. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/src/pyg90alarm/exceptions.py +0 -0
  42. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/src/pyg90alarm/history.py +0 -0
  43. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/src/pyg90alarm/host_info.py +0 -0
  44. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/src/pyg90alarm/host_status.py +0 -0
  45. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/src/pyg90alarm/paginated_cmd.py +0 -0
  46. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/src/pyg90alarm/paginated_result.py +0 -0
  47. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/src/pyg90alarm/py.typed +0 -0
  48. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/src/pyg90alarm/user_data_crc.py +0 -0
  49. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/src/pyg90alarm.egg-info/SOURCES.txt +0 -0
  50. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/src/pyg90alarm.egg-info/dependency_links.txt +0 -0
  51. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/src/pyg90alarm.egg-info/requires.txt +0 -0
  52. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/src/pyg90alarm.egg-info/top_level.txt +0 -0
  53. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/tests/__init__.py +0 -0
  54. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/tests/conftest.py +0 -0
  55. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/tests/device_mock.py +0 -0
  56. {pyg90alarm-1.15.0 → pyg90alarm-1.16.0}/tests/test_paginated_commands.py +0 -0
@@ -31,6 +31,9 @@ jobs:
31
31
  - os: ubuntu-latest
32
32
  python: '3.12'
33
33
  toxenv: py
34
+ - os: ubuntu-latest
35
+ python: '3.13'
36
+ toxenv: py
34
37
  runs-on: ${{ matrix.os }}
35
38
  steps:
36
39
  - name: Checkout the code
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pyg90alarm
3
- Version: 1.15.0
3
+ Version: 1.16.0
4
4
  Summary: G90 Alarm system protocol
5
5
  Home-page: https://github.com/hostcc/pyg90alarm
6
6
  Author: Ilia Sotnikov
@@ -20,6 +20,7 @@ Classifier: Programming Language :: Python :: 3.9
20
20
  Classifier: Programming Language :: Python :: 3.10
21
21
  Classifier: Programming Language :: Python :: 3.11
22
22
  Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
23
24
  Classifier: Programming Language :: Python :: 3 :: Only
24
25
  Requires-Python: >=3.8, <4
25
26
  Description-Content-Type: text/x-rst
@@ -41,6 +41,7 @@ setup(
41
41
  'Programming Language :: Python :: 3.10',
42
42
  'Programming Language :: Python :: 3.11',
43
43
  'Programming Language :: Python :: 3.12',
44
+ 'Programming Language :: Python :: 3.13',
44
45
  'Programming Language :: Python :: 3 :: Only',
45
46
  ],
46
47
 
@@ -60,7 +60,8 @@ from .const import (
60
60
  G90Commands, REMOTE_PORT,
61
61
  REMOTE_TARGETED_DISCOVERY_PORT,
62
62
  LOCAL_TARGETED_DISCOVERY_PORT,
63
- NOTIFICATIONS_PORT,
63
+ LOCAL_NOTIFICATIONS_HOST,
64
+ LOCAL_NOTIFICATIONS_PORT,
64
65
  G90ArmDisarmTypes,
65
66
  )
66
67
  from .base_cmd import (G90BaseCommand, G90BaseCommandData)
@@ -100,22 +101,25 @@ if TYPE_CHECKING:
100
101
  Callable[[int, str, bool], None],
101
102
  Callable[[int, str, bool], Coroutine[None, None, None]]
102
103
  ]
103
- SensorStateCallback = Union[
104
- Callable[[bool], None],
105
- Callable[[bool], Coroutine[None, None, None]]
106
- ]
107
104
  LowBatteryCallback = Union[
108
105
  Callable[[int, str], None],
109
106
  Callable[[int, str], Coroutine[None, None, None]]
110
107
  ]
111
- SensorLowBatteryCallback = Union[
112
- Callable[[bool], None],
113
- Callable[[bool], Coroutine[None, None, None]]
114
- ]
115
108
  ArmDisarmCallback = Union[
116
109
  Callable[[G90ArmDisarmTypes], None],
117
110
  Callable[[G90ArmDisarmTypes], Coroutine[None, None, None]]
118
111
  ]
112
+ # Sensor-related callbacks for `G90Sensor` class - despite that class
113
+ # stores them, the invication is done by the `G90Alarm` class hence these
114
+ # are defined here
115
+ SensorStateCallback = Union[
116
+ Callable[[bool], None],
117
+ Callable[[bool], Coroutine[None, None, None]]
118
+ ]
119
+ SensorLowBatteryCallback = Union[
120
+ Callable[[], None],
121
+ Callable[[], Coroutine[None, None, None]]
122
+ ]
119
123
 
120
124
 
121
125
  # pylint: disable=too-many-public-methods
@@ -137,11 +141,14 @@ class G90Alarm(G90DeviceNotifications):
137
141
  # pylint: disable=too-many-instance-attributes,too-many-arguments
138
142
  def __init__(self, host: str, port: int = REMOTE_PORT,
139
143
  reset_occupancy_interval: float = 3.0,
140
- notifications_host: str = '0.0.0.0',
141
- notifications_port: int = NOTIFICATIONS_PORT):
142
- super().__init__(host=notifications_host, port=notifications_port)
143
- self._host = host
144
- self._port = port
144
+ notifications_local_host: str = LOCAL_NOTIFICATIONS_HOST,
145
+ notifications_local_port: int = LOCAL_NOTIFICATIONS_PORT):
146
+ super().__init__(
147
+ local_host=notifications_local_host,
148
+ local_port=notifications_local_port
149
+ )
150
+ self._host: str = host
151
+ self._port: int = port
145
152
  self._sensors: List[G90Sensor] = []
146
153
  self._devices: List[G90Device] = []
147
154
  self._notifications: Optional[G90DeviceNotifications] = None
@@ -333,7 +340,9 @@ class G90Alarm(G90DeviceNotifications):
333
340
  :return: Device information
334
341
  """
335
342
  res = await self.command(G90Commands.GETHOSTINFO)
336
- return G90HostInfo(*res)
343
+ info = G90HostInfo(*res)
344
+ self.device_id = info.host_guid
345
+ return info
337
346
 
338
347
  @property
339
348
  async def host_status(self) -> G90HostStatus:
@@ -810,7 +819,7 @@ class G90Alarm(G90DeviceNotifications):
810
819
  # code as alert, as if it came from the device and its
811
820
  # notifications port
812
821
  self._handle_alert(
813
- (self._host, self._notifications_port),
822
+ (self._host, self._notifications_local_port),
814
823
  item.as_device_alert()
815
824
  )
816
825
 
@@ -154,7 +154,12 @@ class G90BaseCommand(DatagramProtocol):
154
154
  Parses the response from the alarm panel.
155
155
  """
156
156
  _LOGGER.debug('To be decoded from wire format %s', data)
157
- self._parse(data.decode('utf-8', errors='ignore'))
157
+ try:
158
+ self._parse(data.decode('utf-8'))
159
+ except UnicodeDecodeError as exc:
160
+ raise G90Error(
161
+ 'Unable to decode response from UTF-8'
162
+ ) from exc
158
163
  return self._resp.data or []
159
164
 
160
165
  def _parse(self, data: str) -> None:
@@ -28,7 +28,8 @@ from typing import Optional
28
28
  REMOTE_PORT = 12368
29
29
  REMOTE_TARGETED_DISCOVERY_PORT = 12900
30
30
  LOCAL_TARGETED_DISCOVERY_PORT = 12901
31
- NOTIFICATIONS_PORT = 12901
31
+ LOCAL_NOTIFICATIONS_HOST = '0.0.0.0'
32
+ LOCAL_NOTIFICATIONS_PORT = 12901
32
33
 
33
34
  CMD_PAGE_SIZE = 10
34
35
 
@@ -41,7 +41,6 @@ from .const import (
41
41
  G90AlertStates,
42
42
  )
43
43
 
44
-
45
44
  _LOGGER = logging.getLogger(__name__)
46
45
 
47
46
 
@@ -107,12 +106,29 @@ class G90DeviceAlert: # pylint: disable=too-many-instance-attributes
107
106
  class G90DeviceNotifications(DatagramProtocol):
108
107
  """
109
108
  Implements support for notifications/alerts sent by alarm panel.
109
+
110
+ There is a basic check to ensure only notifications/alerts from the correct
111
+ device are processed - the check uses the host and port of the device, and
112
+ the device ID (GUID) that is set by the ancestor class that implements the
113
+ commands (e.g. :class:`G90Alarm`). The latter to work correctly needs a
114
+ command to be performed first, one that fetches device GUID and then stores
115
+ it using :attr:`.device_id` (e.g. :meth:`G90Alarm.get_host_info`).
110
116
  """
111
- def __init__(self, port: int, host: str):
117
+ def __init__(self, local_port: int, local_host: str):
112
118
  # pylint: disable=too-many-arguments
113
119
  self._notification_transport: Optional[BaseTransport] = None
114
- self._notifications_host = host
115
- self._notifications_port = port
120
+ self._notifications_local_host = local_host
121
+ self._notifications_local_port = local_port
122
+ # Host/port of the device is configured to communicating via commands.
123
+ # Inteded to validate if notifications/alert are received from the
124
+ # correct device.
125
+ self._host: Optional[str] = None
126
+ self._port: Optional[int] = None
127
+ # Same but for device ID (GUID) - the notifications logic uses it to
128
+ # perform validation, but doesn't set it from messages received (it
129
+ # will diminish the purpose of the validation, should be done by an
130
+ # ancestor class).
131
+ self._device_id: Optional[str] = None
116
132
 
117
133
  def _handle_notification(
118
134
  self, addr: Tuple[str, int], notification: G90Notification
@@ -145,6 +161,15 @@ class G90DeviceNotifications(DatagramProtocol):
145
161
  def _handle_alert(
146
162
  self, addr: Tuple[str, int], alert: G90DeviceAlert
147
163
  ) -> None:
164
+ # Stop processing when alert is received from the device with different
165
+ # GUID
166
+ if self.device_id and alert.device_id != self.device_id:
167
+ _LOGGER.error(
168
+ "Received alert from wrong device: expected '%s', got '%s'",
169
+ self.device_id, alert.device_id
170
+ )
171
+ return
172
+
148
173
  if alert.type == G90AlertTypes.DOOR_OPEN_CLOSE:
149
174
  if alert.state in (
150
175
  G90AlertStates.DOOR_OPEN, G90AlertStates.DOOR_CLOSE
@@ -219,11 +244,23 @@ class G90DeviceNotifications(DatagramProtocol):
219
244
  def datagram_received( # pylint:disable=R0911
220
245
  self, data: bytes, addr: Tuple[str, int]
221
246
  ) -> None:
222
-
223
247
  """
224
- Invoked from datagram is received from the device.
248
+ Invoked when datagram is received from the device.
225
249
  """
226
- s_data = data.decode('utf-8')
250
+ if self._host and self._host != addr[0]:
251
+ _LOGGER.error(
252
+ "Received notification/alert from wrong host '%s',"
253
+ " expected from '%s'",
254
+ addr[0], self._host
255
+ )
256
+ return
257
+
258
+ try:
259
+ s_data = data.decode('utf-8')
260
+ except UnicodeDecodeError:
261
+ _LOGGER.error('Unable to decode device message from UTF-8')
262
+ return
263
+
227
264
  if not s_data.endswith('\0'):
228
265
  _LOGGER.error('Missing end marker in data')
229
266
  return
@@ -304,13 +341,13 @@ class G90DeviceNotifications(DatagramProtocol):
304
341
  loop = asyncio.get_event_loop()
305
342
 
306
343
  _LOGGER.debug('Creating UDP endpoint for %s:%s',
307
- self._notifications_host,
308
- self._notifications_port)
344
+ self._notifications_local_host,
345
+ self._notifications_local_port)
309
346
  (self._notification_transport,
310
347
  _protocol) = await loop.create_datagram_endpoint(
311
348
  lambda: self,
312
349
  local_addr=(
313
- self._notifications_host, self._notifications_port
350
+ self._notifications_local_host, self._notifications_local_port
314
351
  ))
315
352
 
316
353
  @property
@@ -328,3 +365,17 @@ class G90DeviceNotifications(DatagramProtocol):
328
365
  _LOGGER.debug('No longer listening for device notifications')
329
366
  self._notification_transport.close()
330
367
  self._notification_transport = None
368
+
369
+ @property
370
+ def device_id(self) -> Optional[str]:
371
+ """
372
+ The ID (GUID) of the panel being communicated with thru commands.
373
+
374
+ Available when any panel command receives it from the device
375
+ (:meth:`G90Alarm.get_host_info` currently).
376
+ """
377
+ return self._device_id
378
+
379
+ @device_id.setter
380
+ def device_id(self, device_id: str) -> None:
381
+ self._device_id = device_id
@@ -103,7 +103,12 @@ class G90TargetedDiscovery(G90BaseCommand):
103
103
  """
104
104
  try:
105
105
  _LOGGER.debug('Received from %s:%s: %s', addr[0], addr[1], data)
106
- decoded = data.decode('utf-8', errors='ignore')
106
+ try:
107
+ decoded = data.decode('utf-8')
108
+ except UnicodeDecodeError as exc:
109
+ raise G90Error(
110
+ 'Unable to decode discovery response from UTF-8'
111
+ ) from exc
107
112
  if not decoded.endswith('\0'):
108
113
  raise G90Error('Invalid discovery response')
109
114
  host_info = G90TargetedDiscoveryInfo(*decoded[:-1].split(','))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pyg90alarm
3
- Version: 1.15.0
3
+ Version: 1.16.0
4
4
  Summary: G90 Alarm system protocol
5
5
  Home-page: https://github.com/hostcc/pyg90alarm
6
6
  Author: Ilia Sotnikov
@@ -20,6 +20,7 @@ Classifier: Programming Language :: Python :: 3.9
20
20
  Classifier: Programming Language :: Python :: 3.10
21
21
  Classifier: Programming Language :: Python :: 3.11
22
22
  Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
23
24
  Classifier: Programming Language :: Python :: 3 :: Only
24
25
  Requires-Python: >=3.8, <4
25
26
  Description-Content-Type: text/x-rst
@@ -308,8 +308,8 @@ async def test_sensor_event(mock_device: DeviceMock) -> None:
308
308
  reset_interval = 0.5
309
309
  g90 = G90Alarm(host=mock_device.host, port=mock_device.port,
310
310
  reset_occupancy_interval=reset_interval,
311
- notifications_host=mock_device.notification_host,
312
- notifications_port=mock_device.notification_port)
311
+ notifications_local_host=mock_device.notification_host,
312
+ notifications_local_port=mock_device.notification_port)
313
313
 
314
314
  sensors = await g90.get_sensors()
315
315
  prop_sensors = await g90.sensors
@@ -352,8 +352,8 @@ async def test_sensor_low_battery_event(mock_device: DeviceMock) -> None:
352
352
  Tests for sensor low battery callback.
353
353
  """
354
354
  g90 = G90Alarm(host=mock_device.host, port=mock_device.port,
355
- notifications_host=mock_device.notification_host,
356
- notifications_port=mock_device.notification_port)
355
+ notifications_local_host=mock_device.notification_host,
356
+ notifications_local_port=mock_device.notification_port)
357
357
 
358
358
  sensors = await g90.get_sensors()
359
359
  prop_sensors = await g90.sensors
@@ -401,8 +401,8 @@ async def test_armdisarm_callback(mock_device: DeviceMock) -> None:
401
401
  armdisarm_cb = MagicMock()
402
402
  armdisarm_cb.side_effect = lambda *args: future.set_result(True)
403
403
  g90 = G90Alarm(host=mock_device.host, port=mock_device.port,
404
- notifications_host=mock_device.notification_host,
405
- notifications_port=mock_device.notification_port)
404
+ notifications_local_host=mock_device.notification_host,
405
+ notifications_local_port=mock_device.notification_port)
406
406
  g90.armdisarm_callback = armdisarm_cb
407
407
  await g90.listen_device_notifications()
408
408
  await mock_device.send_next_notification()
@@ -434,8 +434,8 @@ async def test_door_open_close_callback(mock_device: DeviceMock) -> None:
434
434
  door_open_close_cb.side_effect = lambda *args: future.set_result(True)
435
435
 
436
436
  g90 = G90Alarm(host=mock_device.host, port=mock_device.port,
437
- notifications_host=mock_device.notification_host,
438
- notifications_port=mock_device.notification_port)
437
+ notifications_local_host=mock_device.notification_host,
438
+ notifications_local_port=mock_device.notification_port)
439
439
  g90.door_open_close_callback = door_open_close_cb
440
440
 
441
441
  # Simulate two device alerts - for opening (this one) and then closing the
@@ -492,8 +492,8 @@ async def test_alarm_callback(mock_device: DeviceMock) -> None:
492
492
  alarm_cb.side_effect = lambda *args: future.set_result(True)
493
493
 
494
494
  g90 = G90Alarm(host=mock_device.host, port=mock_device.port,
495
- notifications_host=mock_device.notification_host,
496
- notifications_port=mock_device.notification_port)
495
+ notifications_local_host=mock_device.notification_host,
496
+ notifications_local_port=mock_device.notification_port)
497
497
  sensors = await g90.get_sensors()
498
498
  # Set extra data for the 1st sensor
499
499
  sensors[0].extra_data = 'Dummy extra data'
@@ -753,8 +753,8 @@ async def test_sms_alert_when_armed(mock_device: DeviceMock) -> None:
753
753
  armdisarm_cb = MagicMock()
754
754
  armdisarm_cb.side_effect = lambda *args: future.set_result(True)
755
755
  g90 = G90Alarm(host=mock_device.host, port=mock_device.port,
756
- notifications_host=mock_device.notification_host,
757
- notifications_port=mock_device.notification_port)
756
+ notifications_local_host=mock_device.notification_host,
757
+ notifications_local_port=mock_device.notification_port)
758
758
  g90.armdisarm_callback = armdisarm_cb
759
759
  g90.sms_alert_when_armed = True
760
760
  await g90.listen_device_notifications()
@@ -787,8 +787,8 @@ async def test_sms_alert_when_disarmed(mock_device: DeviceMock) -> None:
787
787
  armdisarm_cb = MagicMock()
788
788
  armdisarm_cb.side_effect = lambda *args: future.set_result(True)
789
789
  g90 = G90Alarm(host=mock_device.host, port=mock_device.port,
790
- notifications_host=mock_device.notification_host,
791
- notifications_port=mock_device.notification_port)
790
+ notifications_local_host=mock_device.notification_host,
791
+ notifications_local_port=mock_device.notification_port)
792
792
  g90.armdisarm_callback = armdisarm_cb
793
793
  g90.sms_alert_when_armed = True
794
794
  await g90.listen_device_notifications()
@@ -117,6 +117,26 @@ async def test_timeout(mock_device: DeviceMock) -> None:
117
117
  ]
118
118
 
119
119
 
120
+ @pytest.mark.g90device(sent_data=[
121
+ b'\xdeadbeef\0',
122
+ ])
123
+ async def test_invalid_utf8_encoding(mock_device: DeviceMock) -> None:
124
+ """
125
+ Verifies that invalid UTF-8 encoding of response is handled properly.
126
+ """
127
+ g90 = G90BaseCommand(
128
+ host=mock_device.host, port=mock_device.port,
129
+ code=G90Commands.GETHOSTINFO
130
+ )
131
+
132
+ with pytest.raises(
133
+ G90Error,
134
+ match=re.escape("Unable to decode response from UTF-8")
135
+ ):
136
+ await g90.process()
137
+ assert mock_device.recv_data == [b'ISTART[206,206,""]IEND\0']
138
+
139
+
120
140
  @pytest.mark.g90device(sent_data=[
121
141
  b'ISTART[IEND\0',
122
142
  ])
@@ -57,6 +57,31 @@ async def test_targeted_discovery(mock_device: DeviceMock) -> None:
57
57
  assert mock_device.recv_data == [b'IWTAC_PROBE_DEVICE,DUMMYGUID\0']
58
58
 
59
59
 
60
+ @pytest.mark.g90device(sent_data=[
61
+ b'\xdeadbeef'
62
+ ])
63
+ async def test_targeted_discovery_invalid_utf_response(
64
+ mock_device: DeviceMock, caplog: LogCaptureFixture
65
+ ) -> None:
66
+ """
67
+ Verifies that invalid UTF-8 data in response to targeted discovery is
68
+ logged but ignored.
69
+ """
70
+ g90 = G90TargetedDiscovery(
71
+ device_id='DUMMYGUID',
72
+ host=mock_device.host,
73
+ port=mock_device.port,
74
+ local_port=LOCAL_TARGETED_DISCOVERY_PORT,
75
+ timeout=0.1)
76
+
77
+ caplog.set_level('WARNING')
78
+ await g90.process()
79
+ assert ''.join(caplog.messages) == (
80
+ 'Got exception, ignoring:'
81
+ ' Unable to decode discovery response from UTF-8'
82
+ )
83
+
84
+
60
85
  @pytest.mark.g90device(sent_data=[
61
86
  b'IWTAC_PROBE_DEVICE_ACK_BAD,TSV018-3SIA'
62
87
  b',1.2,1.1,206,1.8,3,3,1,0,2,50,100\0',
@@ -10,10 +10,34 @@ from pytest import LogCaptureFixture
10
10
  from pyg90alarm.device_notifications import (
11
11
  G90DeviceNotifications,
12
12
  )
13
+ from pyg90alarm.alarm import G90Alarm
13
14
 
14
15
  from .device_mock import DeviceMock
15
16
 
16
17
 
18
+ @pytest.mark.g90device(notification_data=[
19
+ b'\xdeadbeef\0',
20
+ ])
21
+ async def test_device_notification_invalid_utf_data(
22
+ mock_device: DeviceMock, caplog: LogCaptureFixture
23
+ ) -> None:
24
+ """
25
+ Verifies that wrong UTF encoded data in device notification is handled
26
+ correctly.
27
+ """
28
+ notifications = G90DeviceNotifications(
29
+ local_host=mock_device.notification_host,
30
+ local_port=mock_device.notification_port
31
+ )
32
+ caplog.set_level('ERROR')
33
+ await notifications.listen()
34
+ await mock_device.send_next_notification()
35
+ assert ''.join(caplog.messages) == (
36
+ "Unable to decode device message from UTF-8"
37
+ )
38
+ notifications.close()
39
+
40
+
17
41
  @pytest.mark.g90device(notification_data=[
18
42
  b'[170]\0',
19
43
  ])
@@ -24,7 +48,8 @@ async def test_device_notification_missing_header(
24
48
  Verifies that missing header in device notification is handled correctly.
25
49
  """
26
50
  notifications = G90DeviceNotifications(
27
- host=mock_device.notification_host, port=mock_device.notification_port
51
+ local_host=mock_device.notification_host,
52
+ local_port=mock_device.notification_port
28
53
  )
29
54
  caplog.set_level('ERROR')
30
55
  await notifications.listen()
@@ -49,7 +74,8 @@ async def test_device_notification_malformed_message(
49
74
  correctly.
50
75
  """
51
76
  notifications = G90DeviceNotifications(
52
- host=mock_device.notification_host, port=mock_device.notification_port
77
+ local_host=mock_device.notification_host,
78
+ local_port=mock_device.notification_port
53
79
  )
54
80
  caplog.set_level('ERROR')
55
81
  await notifications.listen()
@@ -72,7 +98,8 @@ async def test_device_notification_missing_end_marker(
72
98
  correctly.
73
99
  """
74
100
  notifications = G90DeviceNotifications(
75
- host=mock_device.notification_host, port=mock_device.notification_port
101
+ local_host=mock_device.notification_host,
102
+ local_port=mock_device.notification_port
76
103
  )
77
104
  caplog.set_level('ERROR')
78
105
  await notifications.listen()
@@ -91,7 +118,8 @@ async def test_wrong_device_notification_format(
91
118
  Verifies that wrong device notification format is handled correctly.
92
119
  """
93
120
  notifications = G90DeviceNotifications(
94
- host=mock_device.notification_host, port=mock_device.notification_port
121
+ local_host=mock_device.notification_host,
122
+ local_port=mock_device.notification_port
95
123
  )
96
124
  caplog.set_level('ERROR')
97
125
  await notifications.listen()
@@ -114,7 +142,8 @@ async def test_wrong_device_alert_format(
114
142
  Verifies that wrong device alert format is handled correctly.
115
143
  """
116
144
  notifications = G90DeviceNotifications(
117
- host=mock_device.notification_host, port=mock_device.notification_port
145
+ local_host=mock_device.notification_host,
146
+ local_port=mock_device.notification_port
118
147
  )
119
148
 
120
149
  caplog.set_level('ERROR')
@@ -140,7 +169,8 @@ async def test_unknown_device_notification(
140
169
  Verifies that unknown device notification is handled correctly.
141
170
  """
142
171
  notifications = G90DeviceNotifications(
143
- host=mock_device.notification_host, port=mock_device.notification_port
172
+ local_host=mock_device.notification_host,
173
+ local_port=mock_device.notification_port
144
174
  )
145
175
  caplog.set_level('WARNING')
146
176
  await notifications.listen()
@@ -164,7 +194,8 @@ async def test_unknown_device_alert(
164
194
  Verifies that unknown device alert is handled correctly.
165
195
  """
166
196
  notifications = G90DeviceNotifications(
167
- host=mock_device.notification_host, port=mock_device.notification_port
197
+ local_host=mock_device.notification_host,
198
+ local_port=mock_device.notification_port
168
199
  )
169
200
  caplog.set_level('WARNING')
170
201
  await notifications.listen()
@@ -179,6 +210,79 @@ async def test_unknown_device_alert(
179
210
  notifications.close()
180
211
 
181
212
 
213
+ @pytest.mark.g90device(notification_data=[
214
+ b'[208,[999,100,1,1,"Hall","DUMMYGUID",'
215
+ b'1631545189,0,[""]]]\0',
216
+ ])
217
+ async def test_wrong_host(
218
+ mock_device: DeviceMock, caplog: LogCaptureFixture
219
+ ) -> None:
220
+ """
221
+ Verifies that unknown device alert is handled correctly.
222
+ """
223
+ g90 = G90Alarm(
224
+ host='1.2.3.4',
225
+ notifications_local_host=mock_device.notification_host,
226
+ notifications_local_port=mock_device.notification_port
227
+ )
228
+ # pylint: disable=protected-access
229
+ g90._handle_alert = ( # type: ignore[method-assign]
230
+ MagicMock()
231
+ )
232
+ # pylint: disable=protected-access
233
+ g90._handle_notification = ( # type: ignore[method-assign]
234
+ MagicMock()
235
+ )
236
+ caplog.set_level('WARNING')
237
+ await g90.listen()
238
+ await mock_device.send_next_notification()
239
+ assert ''.join(caplog.messages) == (
240
+ "Received notification/alert from wrong host '127.0.0.1'"
241
+ ", expected from '1.2.3.4'"
242
+ )
243
+ g90.close()
244
+ # pylint: disable=protected-access
245
+ g90._handle_alert.assert_not_called()
246
+ # pylint: disable=protected-access
247
+ g90._handle_notification.assert_not_called()
248
+
249
+
250
+ @pytest.mark.g90device(
251
+ sent_data=[
252
+ b'ISTART[206,'
253
+ b'["DUMMYGUID","DUMMYPRODUCT",'
254
+ b'"1.2","1.1","206","206",3,3,0,2,"4242",50,100]]IEND\0',
255
+ ],
256
+ notification_data=[
257
+ b'[208,[2,4,0,0,"","DIFFERENTGUID",1630876128,0,[""]]]\0'
258
+ ],
259
+ )
260
+ async def test_wrong_device_guid(
261
+ mock_device: DeviceMock, caplog: LogCaptureFixture
262
+ ) -> None:
263
+ """
264
+ Verifies that alert from device with different GUID is ignored.
265
+ """
266
+ g90 = G90Alarm(
267
+ host=mock_device.host, port=mock_device.port,
268
+ notifications_local_host=mock_device.notification_host,
269
+ notifications_local_port=mock_device.notification_port
270
+ )
271
+ caplog.set_level('WARNING')
272
+ # The command will fetch the host info and store the GIUD
273
+ await g90.get_host_info()
274
+ g90.on_armdisarm = MagicMock() # type: ignore[method-assign]
275
+ await g90.listen()
276
+ await mock_device.send_next_notification()
277
+ assert ''.join(caplog.messages) == (
278
+ "Received alert from wrong device: expected 'DUMMYGUID'"
279
+ ", got 'DIFFERENTGUID'"
280
+ )
281
+ g90.close()
282
+ # Verify the associated callback was not called
283
+ g90.on_armdisarm.assert_not_called()
284
+
285
+
182
286
  @pytest.mark.g90device(notification_data=[
183
287
  b'[170,[5,[100,"Hall"]]]\0',
184
288
  ])
@@ -188,8 +292,8 @@ async def test_sensor_callback(mock_device: DeviceMock) -> None:
188
292
  """
189
293
  future = asyncio.get_running_loop().create_future()
190
294
  notifications = G90DeviceNotifications(
191
- host=mock_device.notification_host,
192
- port=mock_device.notification_port,
295
+ local_host=mock_device.notification_host,
296
+ local_port=mock_device.notification_port,
193
297
  )
194
298
 
195
299
  notifications.on_sensor_activity = ( # type: ignore[method-assign]
@@ -216,7 +320,8 @@ async def test_armdisarm_notification_callback(
216
320
  """
217
321
  future = asyncio.get_running_loop().create_future()
218
322
  notifications = G90DeviceNotifications(
219
- host=mock_device.notification_host, port=mock_device.notification_port,
323
+ local_host=mock_device.notification_host,
324
+ local_port=mock_device.notification_port
220
325
  )
221
326
  notifications.on_armdisarm = MagicMock() # type: ignore[method-assign]
222
327
  notifications.on_armdisarm.side_effect = (
@@ -238,7 +343,8 @@ async def test_armdisarm_alert_callback(mock_device: DeviceMock) -> None:
238
343
  """
239
344
  future = asyncio.get_running_loop().create_future()
240
345
  notifications = G90DeviceNotifications(
241
- host=mock_device.notification_host, port=mock_device.notification_port,
346
+ local_host=mock_device.notification_host,
347
+ local_port=mock_device.notification_port
242
348
  )
243
349
  notifications.on_armdisarm = MagicMock() # type: ignore[method-assign]
244
350
  notifications.on_armdisarm.side_effect = (
@@ -260,7 +366,8 @@ async def test_door_open_callback(mock_device: DeviceMock) -> None:
260
366
  """
261
367
  future = asyncio.get_running_loop().create_future()
262
368
  notifications = G90DeviceNotifications(
263
- host=mock_device.notification_host, port=mock_device.notification_port,
369
+ local_host=mock_device.notification_host,
370
+ local_port=mock_device.notification_port
264
371
  )
265
372
 
266
373
  notifications.on_door_open_close = ( # type: ignore[method-assign]
@@ -285,7 +392,8 @@ async def test_door_close_callback(mock_device: DeviceMock) -> None:
285
392
  """
286
393
  future = asyncio.get_running_loop().create_future()
287
394
  notifications = G90DeviceNotifications(
288
- host=mock_device.notification_host, port=mock_device.notification_port,
395
+ local_host=mock_device.notification_host,
396
+ local_port=mock_device.notification_port
289
397
  )
290
398
 
291
399
  notifications.on_door_open_close = ( # type: ignore[method-assign]
@@ -312,7 +420,8 @@ async def test_doorbell_callback(mock_device: DeviceMock) -> None:
312
420
  """
313
421
  future = asyncio.get_running_loop().create_future()
314
422
  notifications = G90DeviceNotifications(
315
- host=mock_device.notification_host, port=mock_device.notification_port,
423
+ local_host=mock_device.notification_host,
424
+ local_port=mock_device.notification_port
316
425
  )
317
426
 
318
427
  notifications.on_door_open_close = ( # type: ignore[method-assign]
@@ -339,7 +448,8 @@ async def test_alarm_callback(mock_device: DeviceMock) -> None:
339
448
  """
340
449
  future = asyncio.get_running_loop().create_future()
341
450
  notifications = G90DeviceNotifications(
342
- host=mock_device.notification_host, port=mock_device.notification_port,
451
+ local_host=mock_device.notification_host,
452
+ local_port=mock_device.notification_port
343
453
  )
344
454
  notifications.on_alarm = MagicMock() # type: ignore[method-assign]
345
455
  notifications.on_alarm.side_effect = (
@@ -361,7 +471,8 @@ async def test_low_battery_callback(mock_device: DeviceMock) -> None:
361
471
  """
362
472
  future = asyncio.get_running_loop().create_future()
363
473
  notifications = G90DeviceNotifications(
364
- host=mock_device.notification_host, port=mock_device.notification_port,
474
+ local_host=mock_device.notification_host,
475
+ local_port=mock_device.notification_port
365
476
  )
366
477
  notifications.on_low_battery = MagicMock() # type: ignore[method-assign]
367
478
  notifications.on_low_battery.side_effect = (
@@ -1,5 +1,5 @@
1
1
  [tox]
2
- envlist = py{38,39,310,311,312}
2
+ envlist = py{38,39,310,311,312,313}
3
3
 
4
4
  # Define the minimal tox version required to run;
5
5
  # if the host tox is less than this the tool with create an environment and
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