pyg90alarm 1.19.0__tar.gz → 1.20.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 (61) hide show
  1. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/PKG-INFO +13 -2
  2. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/src/pyg90alarm/alarm.py +29 -80
  3. pyg90alarm-1.20.0/src/pyg90alarm/entities/base_entity.py +83 -0
  4. pyg90alarm-1.20.0/src/pyg90alarm/entities/base_list.py +165 -0
  5. pyg90alarm-1.20.0/src/pyg90alarm/entities/device_list.py +58 -0
  6. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/src/pyg90alarm/entities/sensor.py +63 -3
  7. pyg90alarm-1.20.0/src/pyg90alarm/entities/sensor_list.py +50 -0
  8. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/src/pyg90alarm.egg-info/PKG-INFO +13 -2
  9. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/src/pyg90alarm.egg-info/SOURCES.txt +4 -0
  10. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/tests/test_alarm.py +85 -42
  11. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/tests/test_history.py +1 -1
  12. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/.github/CODEOWNERS +0 -0
  13. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/.github/workflows/main.yml +0 -0
  14. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/.gitignore +0 -0
  15. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/.pylintrc +0 -0
  16. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/.readthedocs.yaml +0 -0
  17. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/LICENSE +0 -0
  18. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/MANIFEST.in +0 -0
  19. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/README.rst +0 -0
  20. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/docs/.DS_Store +0 -0
  21. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/docs/.gitignore +0 -0
  22. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/docs/api-docs.rst +0 -0
  23. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/docs/conf.py +0 -0
  24. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/docs/index.rst +0 -0
  25. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/docs/protocol.rst +0 -0
  26. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/docs/requirements.txt +0 -0
  27. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/pyproject.toml +0 -0
  28. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/setup.cfg +0 -0
  29. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/setup.py +0 -0
  30. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/sonar-project.properties +0 -0
  31. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/src/pyg90alarm/__init__.py +0 -0
  32. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/src/pyg90alarm/base_cmd.py +0 -0
  33. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/src/pyg90alarm/callback.py +0 -0
  34. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/src/pyg90alarm/config.py +0 -0
  35. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/src/pyg90alarm/const.py +0 -0
  36. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/src/pyg90alarm/definitions/__init__.py +0 -0
  37. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/src/pyg90alarm/definitions/sensors.py +0 -0
  38. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/src/pyg90alarm/device_notifications.py +0 -0
  39. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/src/pyg90alarm/discovery.py +0 -0
  40. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/src/pyg90alarm/entities/__init__.py +0 -0
  41. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/src/pyg90alarm/entities/device.py +0 -0
  42. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/src/pyg90alarm/exceptions.py +0 -0
  43. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/src/pyg90alarm/history.py +0 -0
  44. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/src/pyg90alarm/host_info.py +0 -0
  45. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/src/pyg90alarm/host_status.py +0 -0
  46. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/src/pyg90alarm/paginated_cmd.py +0 -0
  47. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/src/pyg90alarm/paginated_result.py +0 -0
  48. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/src/pyg90alarm/py.typed +0 -0
  49. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/src/pyg90alarm/targeted_discovery.py +0 -0
  50. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/src/pyg90alarm/user_data_crc.py +0 -0
  51. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/src/pyg90alarm.egg-info/dependency_links.txt +0 -0
  52. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/src/pyg90alarm.egg-info/requires.txt +0 -0
  53. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/src/pyg90alarm.egg-info/top_level.txt +0 -0
  54. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/tests/__init__.py +0 -0
  55. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/tests/conftest.py +0 -0
  56. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/tests/device_mock.py +0 -0
  57. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/tests/test_base_commands.py +0 -0
  58. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/tests/test_discovery.py +0 -0
  59. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/tests/test_notifications.py +0 -0
  60. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/tests/test_paginated_commands.py +0 -0
  61. {pyg90alarm-1.19.0 → pyg90alarm-1.20.0}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: pyg90alarm
3
- Version: 1.19.0
3
+ Version: 1.20.0
4
4
  Summary: G90 Alarm system protocol
5
5
  Home-page: https://github.com/hostcc/pyg90alarm
6
6
  Author: Ilia Sotnikov
@@ -33,6 +33,17 @@ Requires-Dist: asynctest; extra == "test"
33
33
  Provides-Extra: docs
34
34
  Requires-Dist: sphinx; extra == "docs"
35
35
  Requires-Dist: sphinx-rtd-theme; extra == "docs"
36
+ Dynamic: author
37
+ Dynamic: author-email
38
+ Dynamic: classifier
39
+ Dynamic: description
40
+ Dynamic: description-content-type
41
+ Dynamic: home-page
42
+ Dynamic: keywords
43
+ Dynamic: project-url
44
+ Dynamic: provides-extra
45
+ Dynamic: requires-python
46
+ Dynamic: summary
36
47
 
37
48
  .. image:: https://github.com/hostcc/pyg90alarm/actions/workflows/main.yml/badge.svg?branch=master
38
49
  :target: https://github.com/hostcc/pyg90alarm/tree/master
@@ -69,7 +69,9 @@ from .const import (
69
69
  from .base_cmd import (G90BaseCommand, G90BaseCommandData)
70
70
  from .paginated_result import G90PaginatedResult, G90PaginatedResponse
71
71
  from .entities.sensor import (G90Sensor, G90SensorTypes)
72
+ from .entities.sensor_list import G90SensorList
72
73
  from .entities.device import G90Device
74
+ from .entities.device_list import G90DeviceList
73
75
  from .device_notifications import (
74
76
  G90DeviceNotifications,
75
77
  )
@@ -177,10 +179,8 @@ class G90Alarm(G90DeviceNotifications):
177
179
  )
178
180
  self._host: str = host
179
181
  self._port: int = port
180
- self._sensors: List[G90Sensor] = []
181
- self._sensors_lock = asyncio.Lock()
182
- self._devices: List[G90Device] = []
183
- self._devices_lock = asyncio.Lock()
182
+ self._sensors = G90SensorList(self)
183
+ self._devices = G90DeviceList(self)
184
184
  self._notifications: Optional[G90DeviceNotifications] = None
185
185
  self._sensor_cb: Optional[SensorCallback] = None
186
186
  self._armdisarm_cb: Optional[ArmDisarmCallback] = None
@@ -268,109 +268,58 @@ class G90Alarm(G90DeviceNotifications):
268
268
  @property
269
269
  async def sensors(self) -> List[G90Sensor]:
270
270
  """
271
- Property over new :meth:`.get_sensors` method, retained for
272
- compatibility.
271
+ Returns the list of sensors configured in the device. Please note
272
+ it doesn't update those from the panel except initially when the list
273
+ if empty.
274
+
275
+ :return: List of sensors
273
276
  """
274
- return await self.get_sensors()
277
+ return await self._sensors.entities
275
278
 
276
279
  async def get_sensors(self) -> List[G90Sensor]:
277
280
  """
278
- Provides list of sensors configured in the device. Please note the list
279
- is cached upon first call, so you need to re-instantiate the class to
280
- reflect any updates there.
281
+ Provides list of sensors configured in the device, updating them from
282
+ panel on each call.
281
283
 
282
284
  :return: List of sensors
283
285
  """
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
291
- )
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)
286
+ return await self._sensors.update()
298
287
 
299
- _LOGGER.debug(
300
- 'Total number of sensors: %s', len(self._sensors)
301
- )
302
-
303
- return self._sensors
304
-
305
- async def find_sensor(self, idx: int, name: str) -> Optional[G90Sensor]:
288
+ async def find_sensor(
289
+ self, idx: int, name: str, exclude_unavailable: bool = True
290
+ ) -> Optional[G90Sensor]:
306
291
  """
307
292
  Finds sensor by index and name.
308
293
 
309
294
  :param idx: Sensor index
310
295
  :param name: Sensor name
296
+ :param exclude_unavailable: Flag indicating if unavailable sensors
297
+ should be excluded from the search
311
298
  :return: Sensor instance
312
299
  """
313
- sensors = await self.get_sensors()
314
-
315
- # Fast lookup by direct index
316
- if idx < len(sensors) and sensors[idx].name == name:
317
- sensor = sensors[idx]
318
- _LOGGER.debug('Found sensor via fast lookup: %s', sensor)
319
- return sensor
320
-
321
- # Fast lookup failed, perform slow one over the whole sensors list
322
- for sensor in sensors:
323
- if sensor.index == idx and sensor.name == name:
324
- _LOGGER.debug('Found sensor: %s', sensor)
325
- return sensor
326
-
327
- _LOGGER.error('Sensor not found: idx=%s, name=%s', idx, name)
328
- return None
300
+ return await self._sensors.find(idx, name, exclude_unavailable)
329
301
 
330
302
  @property
331
303
  async def devices(self) -> List[G90Device]:
332
304
  """
333
- Property over new :meth:`.get_devices` method, retained for
334
- compatibility.
305
+ Returns the list of devices (switches) configured in the device. Please
306
+ note it doesn't update those from the panel except initially when
307
+ the list if empty.
308
+
309
+ :return: List of devices
335
310
  """
336
- return await self.get_devices()
311
+ return await self._devices.entities
337
312
 
338
313
  async def get_devices(self) -> List[G90Device]:
339
314
  """
340
- Provides list of devices (switches) configured in the device. Please
341
- note the list is cached upon first call, so you need to re-instantiate
342
- the class to reflect any updates there. Multi-node devices, those
315
+ Provides list of devices (switches) configured in the device, updating
316
+ them from panel on each call. Multi-node devices, those
343
317
  having multiple ports, are expanded into corresponding number of
344
318
  resulting entries.
345
319
 
346
320
  :return: List of devices
347
321
  """
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
353
- )
354
- async for device in devices:
355
- obj = G90Device(
356
- *device.data, parent=self, subindex=0,
357
- proto_idx=device.proto_idx
358
- )
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)
368
-
369
- _LOGGER.debug(
370
- 'Total number of devices: %s', len(self._devices)
371
- )
372
-
373
- return self._devices
322
+ return await self._devices.update()
374
323
 
375
324
  @property
376
325
  async def host_info(self) -> G90HostInfo:
@@ -637,7 +586,7 @@ class G90Alarm(G90DeviceNotifications):
637
586
 
638
587
  # Reset the tampered and door open when arming flags on all sensors
639
588
  # having those set
640
- for sensor in await self.get_sensors():
589
+ for sensor in await self.sensors:
641
590
  if sensor.is_tampered:
642
591
  # pylint: disable=protected-access
643
592
  sensor._set_tampered(False)
@@ -0,0 +1,83 @@
1
+ # Copyright (c) 2025 Ilia Sotnikov
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ # SOFTWARE.
20
+ """
21
+ Base entity.
22
+ """
23
+ from abc import ABC, abstractmethod
24
+ # `Self` has been introduced in Python 3.11, need to use `typing_extensions`
25
+ # for earlier versions
26
+ try:
27
+ from typing import Self # type: ignore[attr-defined,unused-ignore]
28
+ except ImportError:
29
+ from typing_extensions import Self
30
+
31
+
32
+ class G90BaseEntity(ABC):
33
+ """
34
+ Base entity class.
35
+
36
+ Contains minimal set of method for :class:`.G90BaseList` class
37
+ """
38
+ @abstractmethod
39
+ def update(
40
+ self,
41
+ obj: Self # pylint: disable=used-before-assignment
42
+ ) -> None:
43
+ """
44
+ Update the entity from another one.
45
+
46
+ :param obj: Object to update from.
47
+ """
48
+
49
+ @property
50
+ @abstractmethod
51
+ def is_unavailable(self) -> bool:
52
+ """
53
+ Check if the entity is unavailable.
54
+
55
+ :return: True if the entity is unavailable.
56
+ """
57
+
58
+ @is_unavailable.setter
59
+ @abstractmethod
60
+ def is_unavailable(self, value: bool) -> None:
61
+ """
62
+ Set the entity as unavailable.
63
+
64
+ :param value: Value to set.
65
+ """
66
+
67
+ @property
68
+ @abstractmethod
69
+ def name(self) -> str:
70
+ """
71
+ Get the name of the entity.
72
+
73
+ :return: Name of the entity.
74
+ """
75
+
76
+ @property
77
+ @abstractmethod
78
+ def index(self) -> int:
79
+ """
80
+ Get the index of the entity.
81
+
82
+ :return: Index of the entity.
83
+ """
@@ -0,0 +1,165 @@
1
+ # Copyright (c) 2025 Ilia Sotnikov
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ # SOFTWARE.
20
+
21
+ """
22
+ Base entity list.
23
+ """
24
+ from abc import ABC, abstractmethod
25
+ from typing import (
26
+ List, AsyncGenerator, Optional, TypeVar, Generic, cast, TYPE_CHECKING,
27
+ )
28
+ import asyncio
29
+ import logging
30
+
31
+ from .base_entity import G90BaseEntity
32
+ if TYPE_CHECKING:
33
+ from ..alarm import G90Alarm
34
+ else:
35
+ # Alias G90Alarm to object avoid circular imports
36
+ # (`G90Alarm` -> `G90SensorList` -> `G90BaseList` -> `G90Alarm`)
37
+ G90Alarm = object
38
+
39
+ T = TypeVar('T', bound=G90BaseEntity)
40
+ _LOGGER = logging.getLogger(__name__)
41
+
42
+
43
+ class G90BaseList(Generic[T], ABC):
44
+ """
45
+ Base entity list class.
46
+
47
+ :param parent: Parent alarm panel instance.
48
+ """
49
+ def __init__(self, parent: G90Alarm) -> None:
50
+ self._entities: List[T] = []
51
+ self._lock = asyncio.Lock()
52
+ self._parent = parent
53
+
54
+ @abstractmethod
55
+ async def _fetch(self) -> AsyncGenerator[T, None]:
56
+ """
57
+ Fetch the list of entities from the panel.
58
+
59
+ :return: Async generator of entities
60
+ """
61
+ yield cast(T, None)
62
+
63
+ @property
64
+ async def entities(self) -> List[T]:
65
+ """
66
+ Return the list of entities.
67
+
68
+ :meth:`update` is called if the list is empty.
69
+
70
+ :return: List of entities
71
+ """
72
+ # Please see below for the explanation of the lock usage
73
+ async with self._lock:
74
+ entities = self._entities
75
+
76
+ if not entities:
77
+ return await self.update()
78
+
79
+ return entities
80
+
81
+ async def update(self) -> List[T]:
82
+ """
83
+ Update the list of entities from the panel.
84
+
85
+ :return: List of entities
86
+ """
87
+ # Use lock around the operation, to ensure no duplicated entries in the
88
+ # resulting list or redundant exchanges with panel are made when the
89
+ # method is called concurrently
90
+ async with self._lock:
91
+ entities = self._fetch()
92
+
93
+ non_existing_entities = self._entities.copy()
94
+ async for entity in entities:
95
+ try:
96
+ existing_entity_idx = self._entities.index(entity)
97
+ except ValueError:
98
+ existing_entity_idx = None
99
+
100
+ if existing_entity_idx is not None:
101
+ existing_entity = self._entities[existing_entity_idx]
102
+ # Update the existing entity with the new data
103
+ _LOGGER.debug(
104
+ "Updating existing entity '%s' from protocol"
105
+ " data '%s'", existing_entity, entity
106
+ )
107
+
108
+ self._entities[existing_entity_idx].update(entity)
109
+ non_existing_entities.remove(entity)
110
+ else:
111
+ # Add the new entity to the list
112
+ _LOGGER.debug('Adding new entity: %s', entity)
113
+ self._entities.append(entity)
114
+
115
+ # Mark the entities that are no longer in the list
116
+ for unavailable_entity in non_existing_entities:
117
+ _LOGGER.debug(
118
+ 'Marking entity as unavailable: %s', unavailable_entity
119
+ )
120
+ unavailable_entity.is_unavailable = True
121
+
122
+ _LOGGER.debug(
123
+ 'Total number of entities: %s, unavailable: %s',
124
+ len(self._entities), len(non_existing_entities)
125
+ )
126
+
127
+ return self._entities
128
+
129
+ async def find(
130
+ self, idx: int, name: str, exclude_unavailable: bool
131
+ ) -> Optional[T]:
132
+ """
133
+ Finds entity by index and name.
134
+
135
+ :param idx: Entity index
136
+ :param name: Entity name
137
+ :param exclude_unavailable: Exclude unavailable entities
138
+ :return: Entity instance
139
+ """
140
+ entities = await self.entities
141
+
142
+ found = None
143
+ # Fast lookup by direct index
144
+ if idx < len(entities) and entities[idx].name == name:
145
+ entity = entities[idx]
146
+ _LOGGER.debug('Found entity via fast lookup: %s', entity)
147
+ found = entity
148
+
149
+ # Fast lookup failed, perform slow one over the whole entities list
150
+ if not found:
151
+ for entity in entities:
152
+ if entity.index == idx and entity.name == name:
153
+ _LOGGER.debug('Found entity: %s', entity)
154
+ found = entity
155
+
156
+ if found:
157
+ if not exclude_unavailable or not found.is_unavailable:
158
+ return found
159
+
160
+ _LOGGER.debug(
161
+ 'Entity is found but unavailable, will result in none returned'
162
+ )
163
+
164
+ _LOGGER.error('Entity not found: idx=%s, name=%s', idx, name)
165
+ return None
@@ -0,0 +1,58 @@
1
+ # Copyright (c) 2025 Ilia Sotnikov
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ # SOFTWARE.
20
+ """
21
+ Device list.
22
+ """
23
+ from typing import AsyncGenerator
24
+ from .device import G90Device
25
+ from .base_list import G90BaseList
26
+ from ..const import G90Commands
27
+
28
+
29
+ class G90DeviceList(G90BaseList[G90Device]):
30
+ """
31
+ Device list class.
32
+ """
33
+ async def _fetch(self) -> AsyncGenerator[G90Device, None]:
34
+ """
35
+ Fetch the list of devices from the panel.
36
+
37
+ :yields: G90Device: Device entity.
38
+ """
39
+ devices = self._parent.paginated_result(
40
+ G90Commands.GETDEVICELIST
41
+ )
42
+
43
+ async for device in devices:
44
+ obj = G90Device(
45
+ *device.data, parent=self._parent, subindex=0,
46
+ proto_idx=device.proto_idx
47
+ )
48
+
49
+ yield obj
50
+
51
+ # Multi-node devices (first node has already been handled
52
+ # above)
53
+ for node in range(1, obj.node_count):
54
+ obj = G90Device(
55
+ *device.data, parent=self._parent,
56
+ subindex=node, proto_idx=device.proto_idx
57
+ )
58
+ yield obj
@@ -31,6 +31,7 @@ from typing import (
31
31
  from enum import IntEnum, IntFlag
32
32
  from ..definitions.sensors import SENSOR_DEFINITIONS, SensorDefinition
33
33
  from ..const import G90Commands
34
+ from .base_entity import G90BaseEntity
34
35
  if TYPE_CHECKING:
35
36
  from ..alarm import (
36
37
  G90Alarm, SensorStateCallback, SensorLowBatteryCallback,
@@ -171,7 +172,7 @@ _LOGGER = logging.getLogger(__name__)
171
172
 
172
173
 
173
174
  # pylint: disable=too-many-public-methods
174
- class G90Sensor: # pylint:disable=too-many-instance-attributes
175
+ class G90Sensor(G90BaseEntity): # pylint:disable=too-many-instance-attributes
175
176
  """
176
177
  Interacts with sensor on G90 alarm panel.
177
178
 
@@ -208,6 +209,7 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
208
209
  self._door_open_when_arming = False
209
210
  self._proto_idx = proto_idx
210
211
  self._extra_data: Any = None
212
+ self._unavailable = False
211
213
 
212
214
  self._definition: Optional[SensorDefinition] = None
213
215
  # Get sensor definition corresponds to the sensor type/subtype if any
@@ -219,6 +221,15 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
219
221
  self._definition = s_def
220
222
  break
221
223
 
224
+ def update(self, obj: G90Sensor) -> None:
225
+ """
226
+ Updates sensor from another instance.
227
+
228
+ :param obj: Sensor instance to update from
229
+ """
230
+ self._protocol_data = obj.protocol_data
231
+ self._proto_idx = obj.proto_idx
232
+
222
233
  @property
223
234
  def name(self) -> str:
224
235
  """
@@ -396,6 +407,16 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
396
407
  """
397
408
  return self._subindex
398
409
 
410
+ @property
411
+ def proto_idx(self) -> int:
412
+ """
413
+ Index of the sensor within list of sensors as retrieved from the alarm
414
+ panel.
415
+
416
+ :return: Index of sensor in list of sensors.
417
+ """
418
+ return self._proto_idx
419
+
399
420
  @property
400
421
  def supports_enable_disable(self) -> bool:
401
422
  """
@@ -405,6 +426,15 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
405
426
  """
406
427
  return self._definition is not None
407
428
 
429
+ @property
430
+ def protocol_data(self) -> G90SensorIncomingData:
431
+ """
432
+ Protocol data of the sensor.
433
+
434
+ :return: Protocol data
435
+ """
436
+ return self._protocol_data
437
+
408
438
  @property
409
439
  def is_wireless(self) -> bool:
410
440
  """
@@ -532,11 +562,11 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
532
562
  # when instantiated.
533
563
  _LOGGER.debug(
534
564
  'Refreshing sensor at index=%s, position in protocol list=%s',
535
- self.index, self._proto_idx
565
+ self.index, self.proto_idx
536
566
  )
537
567
  sensors_result = self.parent.paginated_result(
538
568
  G90Commands.GETSENSORLIST,
539
- start=self._proto_idx, end=self._proto_idx
569
+ start=self.proto_idx, end=self.proto_idx
540
570
  )
541
571
  sensors = [x.data async for x in sensors_result]
542
572
 
@@ -633,6 +663,17 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
633
663
  def extra_data(self, val: Any) -> None:
634
664
  self._extra_data = val
635
665
 
666
+ @property
667
+ def is_unavailable(self) -> bool:
668
+ """
669
+ Indicates if the sensor is unavailable (e.g. has been removed).
670
+ """
671
+ return self._unavailable
672
+
673
+ @is_unavailable.setter
674
+ def is_unavailable(self, value: bool) -> None:
675
+ self._unavailable = value
676
+
636
677
  def _asdict(self) -> Dict[str, Any]:
637
678
  """
638
679
  Returns dictionary representation of the sensor.
@@ -644,6 +685,7 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
644
685
  'type': self.type,
645
686
  'subtype': self.subtype,
646
687
  'index': self.index,
688
+ 'protocol_index': self.proto_idx,
647
689
  'subindex': self.subindex,
648
690
  'node_count': self.node_count,
649
691
  'protocol': self.protocol,
@@ -657,6 +699,7 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
657
699
  'is_low_battery': self.is_low_battery,
658
700
  'is_tampered': self.is_tampered,
659
701
  'is_door_open_when_arming': self.is_door_open_when_arming,
702
+ 'is_unavailable': self.is_unavailable,
660
703
  }
661
704
 
662
705
  def __repr__(self) -> str:
@@ -666,3 +709,20 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
666
709
  :return: String representation
667
710
  """
668
711
  return super().__repr__() + f'({repr(self._asdict())})'
712
+
713
+ def __eq__(self, value: object) -> bool:
714
+ """
715
+ Compares the sensor with another object.
716
+
717
+ :param value: Object to compare with
718
+ :return: If the sensor is equal to the object
719
+ """
720
+ if not isinstance(value, G90Sensor):
721
+ return False
722
+
723
+ return (
724
+ self.type == value.type
725
+ and self.subtype == value.subtype
726
+ and self.index == value.index
727
+ and self.name == value.name
728
+ )
@@ -0,0 +1,50 @@
1
+ # Copyright (c) 2025 Ilia Sotnikov
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ # SOFTWARE.
20
+ """
21
+ Sensor list.
22
+ """
23
+ from typing import (
24
+ AsyncGenerator
25
+ )
26
+
27
+ from .sensor import G90Sensor
28
+ from .base_list import G90BaseList
29
+ from ..const import G90Commands
30
+
31
+
32
+ class G90SensorList(G90BaseList[G90Sensor]):
33
+ """
34
+ Sensor list class.
35
+ """
36
+ async def _fetch(self) -> AsyncGenerator[G90Sensor, None]:
37
+ """
38
+ Fetch the list of sensors from the panel.
39
+
40
+ :yields: G90Sensor: Sensor entity.
41
+ """
42
+ entities = self._parent.paginated_result(
43
+ G90Commands.GETSENSORLIST
44
+ )
45
+
46
+ async for entity in entities:
47
+ yield G90Sensor(
48
+ *entity.data, parent=self._parent, subindex=0,
49
+ proto_idx=entity.proto_idx
50
+ )
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: pyg90alarm
3
- Version: 1.19.0
3
+ Version: 1.20.0
4
4
  Summary: G90 Alarm system protocol
5
5
  Home-page: https://github.com/hostcc/pyg90alarm
6
6
  Author: Ilia Sotnikov
@@ -33,6 +33,17 @@ Requires-Dist: asynctest; extra == "test"
33
33
  Provides-Extra: docs
34
34
  Requires-Dist: sphinx; extra == "docs"
35
35
  Requires-Dist: sphinx-rtd-theme; extra == "docs"
36
+ Dynamic: author
37
+ Dynamic: author-email
38
+ Dynamic: classifier
39
+ Dynamic: description
40
+ Dynamic: description-content-type
41
+ Dynamic: home-page
42
+ Dynamic: keywords
43
+ Dynamic: project-url
44
+ Dynamic: provides-extra
45
+ Dynamic: requires-python
46
+ Dynamic: summary
36
47
 
37
48
  .. image:: https://github.com/hostcc/pyg90alarm/actions/workflows/main.yml/badge.svg?branch=master
38
49
  :target: https://github.com/hostcc/pyg90alarm/tree/master
@@ -43,8 +43,12 @@ src/pyg90alarm.egg-info/top_level.txt
43
43
  src/pyg90alarm/definitions/__init__.py
44
44
  src/pyg90alarm/definitions/sensors.py
45
45
  src/pyg90alarm/entities/__init__.py
46
+ src/pyg90alarm/entities/base_entity.py
47
+ src/pyg90alarm/entities/base_list.py
46
48
  src/pyg90alarm/entities/device.py
49
+ src/pyg90alarm/entities/device_list.py
47
50
  src/pyg90alarm/entities/sensor.py
51
+ src/pyg90alarm/entities/sensor_list.py
48
52
  tests/__init__.py
49
53
  tests/conftest.py
50
54
  tests/device_mock.py
@@ -152,9 +152,9 @@ async def test_get_devices_concurrent(mock_device: DeviceMock) -> None:
152
152
  # Issue two concurrent requests to retrieve devices
153
153
  res = await asyncio.gather(g90.get_devices(), g90.get_devices())
154
154
  # Ensure only single exchange with the panel
155
- g90.paginated_result.assert_called_once()
155
+ assert g90.paginated_result.call_count == 2
156
156
  # While `pylint` demands use of generator, the comprehension is used
157
- # instead for ease of trroubleshooting test failures as it will show the
157
+ # instead for ease of troubleshooting test failures as it will show the
158
158
  # list elements, not just generator instance
159
159
  # pylint: disable=use-a-generator
160
160
  assert all([len(x) == 1 for x in res])
@@ -224,10 +224,8 @@ async def test_single_sensor(mock_device: DeviceMock) -> None:
224
224
  """
225
225
  g90 = G90Alarm(host=mock_device.host, port=mock_device.port)
226
226
 
227
- sensors = await g90.get_sensors()
228
- prop_sensors = await g90.sensors
227
+ sensors = await g90.sensors
229
228
 
230
- assert sensors == prop_sensors
231
229
  assert mock_device.recv_data == [
232
230
  b'ISTART[102,102,[102,[1,10]]]IEND\0',
233
231
  ]
@@ -255,11 +253,78 @@ async def test_get_sensors_concurrent(mock_device: DeviceMock) -> None:
255
253
  )
256
254
 
257
255
  res = await asyncio.gather(g90.get_sensors(), g90.get_sensors())
258
- g90.paginated_result.assert_called_once()
256
+ assert g90.paginated_result.call_count == 2
259
257
  # pylint: disable=use-a-generator
260
258
  assert all([len(x) == 1 for x in res])
261
259
 
262
260
 
261
+ @pytest.mark.g90device(sent_data=[
262
+ b'ISTART[102,'
263
+ b'[[1,1,1],["Remote",10,0,10,1,0,32,0,0,16,1,0,""]]]IEND\0',
264
+ b'ISTART[102,'
265
+ b'[[1,1,1],["Remote",10,0,10,1,0,33,0,0,16,1,0,""]]]IEND\0',
266
+ b'ISTART[102,'
267
+ b'[[1,1,1],["Remote 2",11,0,10,1,0,33,0,0,16,1,0,""]]]IEND\0',
268
+ ])
269
+ async def test_get_sensors_update(mock_device: DeviceMock) -> None:
270
+ """
271
+ Verifies updating the sensor list from the panel properly updates entitries
272
+ exists in the list already, and marks those are not.
273
+ """
274
+ g90 = G90Alarm(host=mock_device.host, port=mock_device.port)
275
+
276
+ # Ensure initial update results in single list entry
277
+ sensors = await g90.get_sensors()
278
+ assert len(sensors) == 1
279
+ assert sensors[0].name == 'Remote'
280
+ assert sensors[0].enabled is False
281
+ # Check if existing enty is properly updated
282
+ sensors = await g90.get_sensors()
283
+ assert len(sensors) == 1
284
+ assert sensors[0].name == 'Remote'
285
+ assert sensors[0].enabled is True
286
+
287
+ sensor_1 = sensors[0]
288
+
289
+ # Ensure subsequent update results in two list entries, one being added and
290
+ # another one is marked as unavailable (since it isn't present in the list
291
+ # fetched from the device)
292
+ sensors = await g90.get_sensors()
293
+ assert len(sensors) == 2
294
+ assert sensors[0].is_unavailable is True
295
+ assert sensors[0].name == 'Remote'
296
+
297
+ assert sensors[1].is_unavailable is False
298
+ assert sensors[1].name == 'Remote 2'
299
+
300
+ # Ensure the first entry is still in the list
301
+ assert sensors[0] == sensor_1
302
+
303
+
304
+ @pytest.mark.g90device(sent_data=[
305
+ b'ISTART[102,'
306
+ b'[[1,1,1],["Remote",10,0,10,1,0,32,0,0,16,1,0,""]]]IEND\0',
307
+ b'ISTART[102,'
308
+ b'[[1,1,1],["Remote 2",11,0,10,1,0,33,0,0,16,1,0,""]]]IEND\0',
309
+ ])
310
+ async def test_find_sensor(mock_device: DeviceMock) -> None:
311
+ """
312
+ Verifies updating the sensor list from the panel properly updates entitries
313
+ exists in the list already, and marks those are not.
314
+ """
315
+ g90 = G90Alarm(host=mock_device.host, port=mock_device.port)
316
+
317
+ sensor = await g90.find_sensor(10, 'Remote')
318
+ assert sensor is not None
319
+ assert sensor.name == 'Remote'
320
+
321
+ await g90.get_sensors()
322
+ sensor = await g90.find_sensor(10, 'Remote')
323
+ assert sensor is None
324
+ sensor = await g90.find_sensor(10, 'Remote', exclude_unavailable=False)
325
+ assert sensor is not None
326
+
327
+
263
328
  @pytest.mark.g90device(sent_data=[
264
329
  b'ISTART[102,'
265
330
  b'[[3,1,3],["Remote 1",10,0,10,1,0,32,0,0,16,1,0,""],'
@@ -276,10 +341,8 @@ async def test_multiple_sensors_shorter_than_page(
276
341
  """
277
342
  g90 = G90Alarm(host=mock_device.host, port=mock_device.port)
278
343
 
279
- sensors = await g90.get_sensors()
280
- prop_sensors = await g90.sensors
344
+ sensors = await g90.sensors
281
345
 
282
- assert sensors == prop_sensors
283
346
  assert mock_device.recv_data == [
284
347
  b'ISTART[102,102,[102,[1,10]]]IEND\0',
285
348
  ]
@@ -327,10 +390,8 @@ async def test_multiple_sensors_longer_than_page(
327
390
  """
328
391
  g90 = G90Alarm(host=mock_device.host, port=mock_device.port)
329
392
 
330
- sensors = await g90.get_sensors()
331
- prop_sensors = await g90.sensors
393
+ sensors = await g90.sensors
332
394
 
333
- assert sensors == prop_sensors
334
395
  assert mock_device.recv_data == [
335
396
  b'ISTART[102,102,[102,[1,10]]]IEND\0',
336
397
  b'ISTART[102,102,[102,[11,11]]]IEND\0',
@@ -362,10 +423,8 @@ async def test_sensor_callback(mock_device: DeviceMock) -> None:
362
423
  notifications_local_host=mock_device.notification_host,
363
424
  notifications_local_port=mock_device.notification_port)
364
425
 
365
- sensors = await g90.get_sensors()
366
- prop_sensors = await g90.sensors
426
+ sensors = await g90.sensors
367
427
 
368
- assert sensors == prop_sensors
369
428
  future = asyncio.get_running_loop().create_future()
370
429
  sensor = [x for x in sensors if x.index == 10 and x.name == 'Remote']
371
430
  sensor[0].state_callback = lambda *args: future.set_result(True)
@@ -406,10 +465,8 @@ async def test_sensor_low_battery_callback(mock_device: DeviceMock) -> None:
406
465
  notifications_local_host=mock_device.notification_host,
407
466
  notifications_local_port=mock_device.notification_port)
408
467
 
409
- sensors = await g90.get_sensors()
410
- prop_sensors = await g90.sensors
468
+ sensors = await g90.sensors
411
469
 
412
- assert sensors == prop_sensors
413
470
  future = asyncio.get_running_loop().create_future()
414
471
  sensor = [x for x in sensors if x.index == 26 and x.name == 'Remote']
415
472
  low_battery_sensor_cb = MagicMock()
@@ -459,10 +516,8 @@ async def test_sensor_door_open_when_arming_callback(
459
516
  notifications_local_host=mock_device.notification_host,
460
517
  notifications_local_port=mock_device.notification_port)
461
518
 
462
- sensors = await g90.get_sensors()
463
- prop_sensors = await g90.sensors
519
+ sensors = await g90.sensors
464
520
 
465
- assert sensors == prop_sensors
466
521
  future = asyncio.get_running_loop().create_future()
467
522
  sensor = [x for x in sensors if x.index == 21 and x.name == 'Hall']
468
523
  door_open_when_arming_sensor_cb = MagicMock()
@@ -558,9 +613,7 @@ async def test_door_open_close_callback(mock_device: DeviceMock) -> None:
558
613
  await mock_device.send_next_notification()
559
614
  await asyncio.wait([future], timeout=0.1)
560
615
  # Corresponding sensor should turn to occupied (=door opened)
561
- sensors = await g90.get_sensors()
562
- prop_sensors = await g90.sensors
563
- assert sensors == prop_sensors
616
+ sensors = await g90.sensors
564
617
  assert sensors[0].occupancy
565
618
 
566
619
  # Simulate second device alert for door close
@@ -571,9 +624,7 @@ async def test_door_open_close_callback(mock_device: DeviceMock) -> None:
571
624
 
572
625
  await asyncio.wait([future], timeout=0.1)
573
626
  # The sensor should become inactive (=door closed)
574
- sensors = await g90.get_sensors()
575
- prop_sensors = await g90.sensors
576
- assert sensors == prop_sensors
627
+ sensors = await g90.sensors
577
628
  assert not sensors[0].occupancy
578
629
 
579
630
  g90.close_device_notifications()
@@ -608,7 +659,7 @@ async def test_alarm_callback(mock_device: DeviceMock) -> None:
608
659
  g90 = G90Alarm(host=mock_device.host, port=mock_device.port,
609
660
  notifications_local_host=mock_device.notification_host,
610
661
  notifications_local_port=mock_device.notification_port)
611
- sensors = await g90.get_sensors()
662
+ sensors = await g90.sensors
612
663
  # Set extra data for the 1st sensor
613
664
  sensors[0].extra_data = 'Dummy extra data'
614
665
 
@@ -668,10 +719,8 @@ async def test_sensor_tamper_callback(
668
719
  notifications_local_host=mock_device.notification_host,
669
720
  notifications_local_port=mock_device.notification_port)
670
721
 
671
- sensors = await g90.get_sensors()
672
- prop_sensors = await g90.sensors
722
+ sensors = await g90.sensors
673
723
 
674
- assert sensors == prop_sensors
675
724
  future = asyncio.get_running_loop().create_future()
676
725
  sensor = [x for x in sensors if x.index == 100 and x.name == 'Hall']
677
726
  tamper_sensor_cb = MagicMock()
@@ -984,9 +1033,7 @@ async def test_sensor_disable(mock_device: DeviceMock) -> None:
984
1033
  Tests for disabling a sensor.
985
1034
  """
986
1035
  g90 = G90Alarm(host=mock_device.host, port=mock_device.port)
987
- sensors = await g90.get_sensors()
988
- prop_sensors = await g90.sensors
989
- assert sensors == prop_sensors
1036
+ sensors = await g90.sensors
990
1037
  assert sensors[1].enabled
991
1038
  await sensors[1].set_enabled(False)
992
1039
  assert not sensors[1].enabled
@@ -1019,9 +1066,7 @@ async def test_sensor_disable_externally_modified(
1019
1066
  """
1020
1067
  g90 = G90Alarm(host=mock_device.host, port=mock_device.port)
1021
1068
 
1022
- sensors = await g90.get_sensors()
1023
- prop_sensors = await g90.sensors
1024
- assert sensors == prop_sensors
1069
+ sensors = await g90.sensors
1025
1070
  assert sensors[1].enabled
1026
1071
  await sensors[1].set_enabled(False)
1027
1072
  assert sensors[1].enabled
@@ -1043,9 +1088,7 @@ async def test_sensor_unsupported_disable(mock_device: DeviceMock) -> None:
1043
1088
  """
1044
1089
  g90 = G90Alarm(host=mock_device.host, port=mock_device.port)
1045
1090
 
1046
- sensors = await g90.get_sensors()
1047
- prop_sensors = await g90.sensors
1048
- assert sensors == prop_sensors
1091
+ sensors = await g90.sensors
1049
1092
  assert sensors[0].enabled
1050
1093
  await sensors[0].set_enabled(False)
1051
1094
  assert mock_device.recv_data == [
@@ -1069,7 +1112,7 @@ async def test_sensor_disable_sensor_not_found_on_refresh(
1069
1112
  """
1070
1113
  g90 = G90Alarm(host=mock_device.host, port=mock_device.port)
1071
1114
 
1072
- sensors = await g90.get_sensors()
1115
+ sensors = await g90.sensors
1073
1116
  assert sensors[1].enabled
1074
1117
  await sensors[1].set_enabled(False)
1075
1118
  assert sensors[1].enabled
@@ -1117,7 +1160,7 @@ async def test_sensor_set_user_flags(mock_device: DeviceMock) -> None:
1117
1160
  Tests for setting user flags on a sensor.
1118
1161
  """
1119
1162
  g90 = G90Alarm(host=mock_device.host, port=mock_device.port)
1120
- sensors = await g90.get_sensors()
1163
+ sensors = await g90.sensors
1121
1164
  await sensors[0].set_user_flag(
1122
1165
  # Intentionally contains non-user settable flag, which should be
1123
1166
  # ignored and not configured for the sensor that initial doesn't have
@@ -196,7 +196,7 @@ async def test_simulate_alerts_from_history(mock_device: DeviceMock) -> None:
196
196
  # Stop simulating the alert from history
197
197
  await g90.stop_simulating_alerts_from_history()
198
198
 
199
- sensors = await g90.get_sensors()
199
+ sensors = await g90.sensors
200
200
  # Ensure callbacks have been called and with expected arguments
201
201
  alarm_cb.assert_called_once_with(33, 'Sensor 1', None)
202
202
  armdisarm_cb.assert_called_once_with(3)
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