pyg90alarm 1.17.2__py3-none-any.whl → 1.20.0__py3-none-any.whl

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.
pyg90alarm/alarm.py CHANGED
@@ -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.
@@ -68,7 +69,9 @@ from .const import (
68
69
  from .base_cmd import (G90BaseCommand, G90BaseCommandData)
69
70
  from .paginated_result import G90PaginatedResult, G90PaginatedResponse
70
71
  from .entities.sensor import (G90Sensor, G90SensorTypes)
72
+ from .entities.sensor_list import G90SensorList
71
73
  from .entities.device import G90Device
74
+ from .entities.device_list import G90DeviceList
72
75
  from .device_notifications import (
73
76
  G90DeviceNotifications,
74
77
  )
@@ -120,6 +123,14 @@ if TYPE_CHECKING:
120
123
  [int, str, G90RemoteButtonStates], Coroutine[None, None, None]
121
124
  ]
122
125
  ]
126
+ DoorOpenWhenArmingCallback = Union[
127
+ Callable[[int, str], None],
128
+ Callable[[int, str], Coroutine[None, None, None]]
129
+ ]
130
+ TamperCallback = Union[
131
+ Callable[[int, str], None],
132
+ Callable[[int, str], Coroutine[None, None, None]]
133
+ ]
123
134
  # Sensor-related callbacks for `G90Sensor` class - despite that class
124
135
  # stores them, the invocation is done by the `G90Alarm` class hence these
125
136
  # are defined here
@@ -131,6 +142,14 @@ if TYPE_CHECKING:
131
142
  Callable[[], None],
132
143
  Callable[[], Coroutine[None, None, None]]
133
144
  ]
145
+ SensorDoorOpenWhenArmingCallback = Union[
146
+ Callable[[], None],
147
+ Callable[[], Coroutine[None, None, None]]
148
+ ]
149
+ SensorTamperCallback = Union[
150
+ Callable[[], None],
151
+ Callable[[], Coroutine[None, None, None]]
152
+ ]
134
153
 
135
154
 
136
155
  # pylint: disable=too-many-public-methods
@@ -160,10 +179,8 @@ class G90Alarm(G90DeviceNotifications):
160
179
  )
161
180
  self._host: str = host
162
181
  self._port: int = port
163
- self._sensors: List[G90Sensor] = []
164
- self._sensors_lock = asyncio.Lock()
165
- self._devices: List[G90Device] = []
166
- self._devices_lock = asyncio.Lock()
182
+ self._sensors = G90SensorList(self)
183
+ self._devices = G90DeviceList(self)
167
184
  self._notifications: Optional[G90DeviceNotifications] = None
168
185
  self._sensor_cb: Optional[SensorCallback] = None
169
186
  self._armdisarm_cb: Optional[ArmDisarmCallback] = None
@@ -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
@@ -247,109 +268,58 @@ class G90Alarm(G90DeviceNotifications):
247
268
  @property
248
269
  async def sensors(self) -> List[G90Sensor]:
249
270
  """
250
- Property over new :meth:`.get_sensors` method, retained for
251
- 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
252
276
  """
253
- return await self.get_sensors()
277
+ return await self._sensors.entities
254
278
 
255
279
  async def get_sensors(self) -> List[G90Sensor]:
256
280
  """
257
- Provides list of sensors configured in the device. Please note the list
258
- is cached upon first call, so you need to re-instantiate the class to
259
- reflect any updates there.
281
+ Provides list of sensors configured in the device, updating them from
282
+ panel on each call.
260
283
 
261
284
  :return: List of sensors
262
285
  """
263
- # Use lock around the operation, to ensure no duplicated entries in the
264
- # resulting list or redundant exchanges with panel are made when the
265
- # method is called concurrently
266
- async with self._sensors_lock:
267
- if not self._sensors:
268
- sensors = self.paginated_result(
269
- G90Commands.GETSENSORLIST
270
- )
271
- async for sensor in sensors:
272
- obj = G90Sensor(
273
- *sensor.data, parent=self, subindex=0,
274
- proto_idx=sensor.proto_idx
275
- )
276
- self._sensors.append(obj)
277
-
278
- _LOGGER.debug(
279
- 'Total number of sensors: %s', len(self._sensors)
280
- )
286
+ return await self._sensors.update()
281
287
 
282
- return self._sensors
283
-
284
- 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]:
285
291
  """
286
292
  Finds sensor by index and name.
287
293
 
288
294
  :param idx: Sensor index
289
295
  :param name: Sensor name
296
+ :param exclude_unavailable: Flag indicating if unavailable sensors
297
+ should be excluded from the search
290
298
  :return: Sensor instance
291
299
  """
292
- sensors = await self.get_sensors()
293
-
294
- # Fast lookup by direct index
295
- if idx < len(sensors) and sensors[idx].name == name:
296
- sensor = sensors[idx]
297
- _LOGGER.debug('Found sensor via fast lookup: %s', sensor)
298
- return sensor
299
-
300
- # Fast lookup failed, perform slow one over the whole sensors list
301
- for sensor in sensors:
302
- if sensor.index == idx and sensor.name == name:
303
- _LOGGER.debug('Found sensor: %s', sensor)
304
- return sensor
305
-
306
- _LOGGER.error('Sensor not found: idx=%s, name=%s', idx, name)
307
- return None
300
+ return await self._sensors.find(idx, name, exclude_unavailable)
308
301
 
309
302
  @property
310
303
  async def devices(self) -> List[G90Device]:
311
304
  """
312
- Property over new :meth:`.get_devices` method, retained for
313
- 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
314
310
  """
315
- return await self.get_devices()
311
+ return await self._devices.entities
316
312
 
317
313
  async def get_devices(self) -> List[G90Device]:
318
314
  """
319
- Provides list of devices (switches) configured in the device. Please
320
- note the list is cached upon first call, so you need to re-instantiate
321
- 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
322
317
  having multiple ports, are expanded into corresponding number of
323
318
  resulting entries.
324
319
 
325
320
  :return: List of devices
326
321
  """
327
- # See `get_sensors` method for the rationale behind the lock usage
328
- async with self._devices_lock:
329
- if not self._devices:
330
- devices = self.paginated_result(
331
- G90Commands.GETDEVICELIST
332
- )
333
- async for device in devices:
334
- obj = G90Device(
335
- *device.data, parent=self, subindex=0,
336
- proto_idx=device.proto_idx
337
- )
338
- self._devices.append(obj)
339
- # Multi-node devices (first node has already been added
340
- # above
341
- for node in range(1, obj.node_count):
342
- obj = G90Device(
343
- *device.data, parent=self,
344
- subindex=node, proto_idx=device.proto_idx
345
- )
346
- self._devices.append(obj)
347
-
348
- _LOGGER.debug(
349
- 'Total number of devices: %s', len(self._devices)
350
- )
351
-
352
- return self._devices
322
+ return await self._devices.update()
353
323
 
354
324
  @property
355
325
  async def host_info(self) -> G90HostInfo:
@@ -613,6 +583,17 @@ class G90Alarm(G90DeviceNotifications):
613
583
  await self.set_alert_config(
614
584
  await self.alert_config | G90AlertConfigFlags.SMS_PUSH
615
585
  )
586
+
587
+ # Reset the tampered and door open when arming flags on all sensors
588
+ # having those set
589
+ for sensor in await self.sensors:
590
+ if sensor.is_tampered:
591
+ # pylint: disable=protected-access
592
+ sensor._set_tampered(False)
593
+ if sensor.is_door_open_when_arming:
594
+ # pylint: disable=protected-access
595
+ sensor._set_door_open_when_arming(False)
596
+
616
597
  G90Callback.invoke(self._armdisarm_cb, state)
617
598
 
618
599
  @property
@@ -627,7 +608,9 @@ class G90Alarm(G90DeviceNotifications):
627
608
  def armdisarm_callback(self, value: ArmDisarmCallback) -> None:
628
609
  self._armdisarm_cb = value
629
610
 
630
- async def on_alarm(self, event_id: int, zone_name: str) -> None:
611
+ async def on_alarm(
612
+ self, event_id: int, zone_name: str, is_tampered: bool
613
+ ) -> None:
631
614
  """
632
615
  Invoked when alarm is triggered. Fires corresponding callback if set by
633
616
  the user with :attr:`.alarm_callback`.
@@ -638,16 +621,31 @@ class G90Alarm(G90DeviceNotifications):
638
621
  :param zone_name: Sensor name
639
622
  """
640
623
  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)
624
+ extra_data = None
625
+ if sensor:
626
+ # The callback is still delivered to the caller even if the sensor
627
+ # isn't found, only `extra_data` is skipped. That is to ensure the
628
+ # important callback isn't filtered
629
+ extra_data = sensor.extra_data
630
+
631
+ # Invoke the sensor activity callback to set the sensor occupancy
632
+ # if sensor is known, but only if that isn't already set - it helps
633
+ # when device notifications on triggerring sensor's activity aren't
634
+ # receveid by a reason
635
+ if not sensor.occupancy:
636
+ await self.on_sensor_activity(event_id, zone_name, True)
637
+
638
+ if is_tampered:
639
+ # Set the tampered flag on the sensor
640
+ # pylint: disable=protected-access
641
+ sensor._set_tampered(True)
642
+
643
+ # Invoke per-sensor callback if provided
644
+ G90Callback.invoke(sensor.tamper_callback)
645
+
646
+ # Invoke global tamper callback if provided and the sensor is tampered
647
+ if is_tampered:
648
+ G90Callback.invoke(self._tamper_cb, event_id, zone_name)
651
649
 
652
650
  G90Callback.invoke(
653
651
  self._alarm_cb, event_id, zone_name, extra_data
@@ -718,7 +716,10 @@ class G90Alarm(G90DeviceNotifications):
718
716
 
719
717
  # Also report the event as alarm for unification, hard-coding the
720
718
  # sensor name in case of host SOS
721
- await self.on_alarm(event_id, 'Host SOS' if is_host_sos else zone_name)
719
+ await self.on_alarm(
720
+ event_id, zone_name='Host SOS' if is_host_sos else zone_name,
721
+ is_tampered=False
722
+ )
722
723
 
723
724
  if not is_host_sos:
724
725
  # Also report the remote button press for SOS - the panel will not
@@ -778,6 +779,58 @@ class G90Alarm(G90DeviceNotifications):
778
779
  ) -> None:
779
780
  self._remote_button_press_cb = value
780
781
 
782
+ async def on_door_open_when_arming(
783
+ self, event_id: int, zone_name: str
784
+ ) -> None:
785
+ """
786
+ Invoked when door is open when arming the device. Fires corresponding
787
+ callback if set by the user with
788
+ :attr:`.door_open_when_arming_callback`.
789
+
790
+ Please note the method is for internal use by the class.
791
+
792
+ :param event_id: The index of the sensor being active when the panel
793
+ is being armed.
794
+ :param zone_name: The name of the sensor
795
+ """
796
+ _LOGGER.debug('on_door_open_when_arming: %s %s', event_id, zone_name)
797
+ sensor = await self.find_sensor(event_id, zone_name)
798
+ if sensor:
799
+ # Set the low battery flag on the sensor
800
+ # pylint: disable=protected-access
801
+ sensor._set_door_open_when_arming(True)
802
+ # Invoke per-sensor callback if provided
803
+ G90Callback.invoke(sensor.door_open_when_arming_callback)
804
+
805
+ G90Callback.invoke(self._door_open_when_arming_cb, event_id, zone_name)
806
+
807
+ @property
808
+ def door_open_when_arming_callback(
809
+ self
810
+ ) -> Optional[DoorOpenWhenArmingCallback]:
811
+ """
812
+ Door open when arming callback, which is invoked when sensor reports
813
+ the condition.
814
+ """
815
+ return self._door_open_when_arming_cb
816
+
817
+ @door_open_when_arming_callback.setter
818
+ def door_open_when_arming_callback(
819
+ self, value: DoorOpenWhenArmingCallback
820
+ ) -> None:
821
+ self._door_open_when_arming_cb = value
822
+
823
+ @property
824
+ async def tamper_callback(self) -> Optional[TamperCallback]:
825
+ """
826
+ Tamper callback, which is invoked when sensor reports the condition.
827
+ """
828
+ return self._tamper_cb
829
+
830
+ @tamper_callback.setter
831
+ def tamper_callback(self, value: TamperCallback) -> None:
832
+ self._tamper_cb = value
833
+
781
834
  async def listen_device_notifications(self) -> None:
782
835
  """
783
836
  Starts internal listener for device notifications/alerts.
pyg90alarm/const.py CHANGED
@@ -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
 
@@ -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,8 +31,12 @@ 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
- from ..alarm import G90Alarm, SensorStateCallback, SensorLowBatteryCallback
36
+ from ..alarm import (
37
+ G90Alarm, SensorStateCallback, SensorLowBatteryCallback,
38
+ SensorDoorOpenWhenArmingCallback, SensorTamperCallback,
39
+ )
36
40
 
37
41
 
38
42
  @dataclass
@@ -101,6 +105,16 @@ class G90SensorUserFlags(IntFlag):
101
105
  ALERT_WHEN_AWAY_AND_HOME = 32
102
106
  ALERT_WHEN_AWAY = 64
103
107
  SUPPORTS_UPDATING_SUBTYPE = 512 # Only relevant for cord sensors
108
+ # Flags that can be set by the user
109
+ USER_SETTABLE = (
110
+ ENABLED
111
+ | ARM_DELAY
112
+ | DETECT_DOOR
113
+ | DOOR_CHIME
114
+ | INDEPENDENT_ZONE
115
+ | ALERT_WHEN_AWAY_AND_HOME
116
+ | ALERT_WHEN_AWAY
117
+ )
104
118
 
105
119
 
106
120
  class G90SensorProtocols(IntEnum):
@@ -157,7 +171,8 @@ class G90SensorTypes(IntEnum):
157
171
  _LOGGER = logging.getLogger(__name__)
158
172
 
159
173
 
160
- class G90Sensor: # pylint:disable=too-many-instance-attributes
174
+ # pylint: disable=too-many-public-methods
175
+ class G90Sensor(G90BaseEntity): # pylint:disable=too-many-instance-attributes
161
176
  """
162
177
  Interacts with sensor on G90 alarm panel.
163
178
 
@@ -186,8 +201,15 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
186
201
  self._state_callback: Optional[SensorStateCallback] = None
187
202
  self._low_battery_callback: Optional[SensorLowBatteryCallback] = None
188
203
  self._low_battery = False
204
+ self._tampered = False
205
+ self._door_open_when_arming_callback: Optional[
206
+ SensorDoorOpenWhenArmingCallback
207
+ ] = None
208
+ self._tamper_callback: Optional[SensorTamperCallback] = None
209
+ self._door_open_when_arming = False
189
210
  self._proto_idx = proto_idx
190
211
  self._extra_data: Any = None
212
+ self._unavailable = False
191
213
 
192
214
  self._definition: Optional[SensorDefinition] = None
193
215
  # Get sensor definition corresponds to the sensor type/subtype if any
@@ -199,6 +221,15 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
199
221
  self._definition = s_def
200
222
  break
201
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
+
202
233
  @property
203
234
  def name(self) -> str:
204
235
  """
@@ -238,6 +269,37 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
238
269
  def low_battery_callback(self, value: SensorLowBatteryCallback) -> None:
239
270
  self._low_battery_callback = value
240
271
 
272
+ @property
273
+ def door_open_when_arming_callback(
274
+ self
275
+ ) -> Optional[SensorDoorOpenWhenArmingCallback]:
276
+ """
277
+ Callback that is invoked when the sensor reports on open door
278
+ condition when arming.
279
+
280
+ :return: Sensor's door open when arming callback
281
+ """
282
+ return self._door_open_when_arming_callback
283
+
284
+ @door_open_when_arming_callback.setter
285
+ def door_open_when_arming_callback(
286
+ self, value: SensorDoorOpenWhenArmingCallback
287
+ ) -> None:
288
+ self._door_open_when_arming_callback = value
289
+
290
+ @property
291
+ def tamper_callback(self) -> Optional[SensorTamperCallback]:
292
+ """
293
+ Callback that is invoked when the sensor reports being tampered.
294
+
295
+ :return: Sensor's tamper callback
296
+ """
297
+ return self._tamper_callback
298
+
299
+ @tamper_callback.setter
300
+ def tamper_callback(self, value: SensorTamperCallback) -> None:
301
+ self._tamper_callback = value
302
+
241
303
  @property
242
304
  def occupancy(self) -> bool:
243
305
  """
@@ -345,6 +407,16 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
345
407
  """
346
408
  return self._subindex
347
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
+
348
420
  @property
349
421
  def supports_enable_disable(self) -> bool:
350
422
  """
@@ -354,6 +426,15 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
354
426
  """
355
427
  return self._definition is not None
356
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
+
357
438
  @property
358
439
  def is_wireless(self) -> bool:
359
440
  """
@@ -365,12 +446,16 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
365
446
  def is_low_battery(self) -> bool:
366
447
  """
367
448
  Indicates if the sensor is reporting low battery.
449
+
450
+ The condition is cleared when the sensor reports activity (i.e. is no
451
+ longer low on battery as it is able to report the activity).
368
452
  """
369
453
  return self._low_battery
370
454
 
371
455
  def _set_low_battery(self, value: bool) -> None:
372
456
  """
373
457
  Sets low battery state of the sensor.
458
+
374
459
  Intentionally private, as low battery state is derived from
375
460
  notifications/alerts.
376
461
 
@@ -383,6 +468,56 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
383
468
  )
384
469
  self._low_battery = value
385
470
 
471
+ @property
472
+ def is_tampered(self) -> bool:
473
+ """
474
+ Indicates if the sensor has been tampered.
475
+
476
+ The condition is cleared when panel is armed/disarmed next time.
477
+ """
478
+ return self._tampered
479
+
480
+ def _set_tampered(self, value: bool) -> None:
481
+ """
482
+ Sets tamper state of the sensor.
483
+
484
+ Intentionally private, as tamper state is derived from
485
+ notifications/alerts.
486
+
487
+ :param value: Tamper state
488
+ """
489
+ _LOGGER.debug(
490
+ "Setting tamper for sensor index=%s '%s': %s"
491
+ " (previous value: %s)",
492
+ self.index, self.name, value, self._tampered
493
+ )
494
+ self._tampered = value
495
+
496
+ @property
497
+ def is_door_open_when_arming(self) -> bool:
498
+ """
499
+ Indicates if the sensor reports on open door when arming.
500
+
501
+ The condition is cleared when panel is armed/disarmed next time.
502
+ """
503
+ return self._door_open_when_arming
504
+
505
+ def _set_door_open_when_arming(self, value: bool) -> None:
506
+ """
507
+ Sets door open state of the sensor when arming.
508
+
509
+ Intentionally private, as door open state is derived from
510
+ notifications/alerts.
511
+
512
+ :param value: Door open state
513
+ """
514
+ _LOGGER.debug(
515
+ "Setting door open when arming for sensor index=%s '%s': %s"
516
+ " (previous value: %s)",
517
+ self.index, self.name, value, self._door_open_when_arming
518
+ )
519
+ self._door_open_when_arming = value
520
+
386
521
  @property
387
522
  def enabled(self) -> bool:
388
523
  """
@@ -392,18 +527,26 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
392
527
  """
393
528
  return self.user_flag & G90SensorUserFlags.ENABLED != 0
394
529
 
395
- async def set_enabled(self, value: bool) -> None:
530
+ async def set_user_flag(self, value: G90SensorUserFlags) -> None:
396
531
  """
397
- Sets disabled/enabled state of the sensor.
532
+ Sets user flags of the sensor.
398
533
 
399
- :param value: Whether to enable or disable the sensor
534
+ :param value: User flags to set, values other than
535
+ :attr:`.G90SensorUserFlags.USER_SETTABLE` will be ignored and
536
+ preserved from existing sensor flags.
400
537
  """
538
+ if value & ~G90SensorUserFlags.USER_SETTABLE:
539
+ _LOGGER.warning(
540
+ 'User flags for sensor index=%s contain non-user settable'
541
+ ' flags, those will be ignored: %s',
542
+ self.index, repr(value & ~G90SensorUserFlags.USER_SETTABLE)
543
+ )
401
544
  # Checking private attribute directly, since `mypy` doesn't recognize
402
545
  # the check for sensor definition to be defined if done over
403
546
  # `self.supports_enable_disable` property
404
547
  if not self._definition:
405
548
  _LOGGER.warning(
406
- 'Manipulating with enable/disable for sensor index=%s'
549
+ 'Manipulating with user flags for sensor index=%s'
407
550
  ' is unsupported - no sensor definition for'
408
551
  ' type=%s, subtype=%s',
409
552
  self.index,
@@ -419,11 +562,11 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
419
562
  # when instantiated.
420
563
  _LOGGER.debug(
421
564
  'Refreshing sensor at index=%s, position in protocol list=%s',
422
- self.index, self._proto_idx
565
+ self.index, self.proto_idx
423
566
  )
424
567
  sensors_result = self.parent.paginated_result(
425
568
  G90Commands.GETSENSORLIST,
426
- start=self._proto_idx, end=self._proto_idx
569
+ start=self.proto_idx, end=self.proto_idx
427
570
  )
428
571
  sensors = [x.data async for x in sensors_result]
429
572
 
@@ -431,7 +574,7 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
431
574
  if not sensors:
432
575
  _LOGGER.error(
433
576
  'Sensor index=%s not found when attempting to set its'
434
- ' enable/disable state',
577
+ ' user flag',
435
578
  self.index,
436
579
  )
437
580
  return
@@ -444,30 +587,30 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
444
587
  ) != self._protocol_data:
445
588
  _LOGGER.error(
446
589
  "Sensor index=%s '%s' has been changed externally,"
447
- " refusing to alter its enable/disable state",
590
+ " refusing to alter its user flag",
448
591
  self.index,
449
592
  self.name
450
593
  )
451
594
  return
452
595
 
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
596
+ prev_user_flag = self.user_flag
460
597
 
461
598
  # Re-instantiate the protocol data with modified user flags
462
599
  _data = asdict(self._protocol_data)
463
- _data['user_flag_data'] = user_flag
600
+ _data['user_flag_data'] = (
601
+ # Preserve flags that are not user-settable
602
+ self.user_flag & ~G90SensorUserFlags.USER_SETTABLE
603
+ ) | (
604
+ # Combine them with the new user-settable flags
605
+ value & G90SensorUserFlags.USER_SETTABLE
606
+ )
464
607
  self._protocol_data = self._protocol_incoming_data_kls(**_data)
465
608
 
466
609
  _LOGGER.debug(
467
- 'Sensor index=%s: %s enabled flag, resulting user_flag %s',
610
+ 'Sensor index=%s: previous user_flag %s, resulting user_flag %s',
468
611
  self._protocol_data.index,
469
- 'Setting' if value else 'Clearing',
470
- self.user_flag
612
+ repr(prev_user_flag),
613
+ repr(self.user_flag)
471
614
  )
472
615
 
473
616
  # Generate protocol data from write operation, deriving values either
@@ -494,6 +637,20 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
494
637
  G90Commands.SETSINGLESENSOR, list(astuple(outgoing_data))
495
638
  )
496
639
 
640
+ async def set_enabled(self, value: bool) -> None:
641
+ """
642
+ Sets the sensor enabled/disabled.
643
+ """
644
+
645
+ # Modify the value of the user flag setting enabled/disabled one
646
+ # appropriately.
647
+ user_flag = self.user_flag
648
+ if value:
649
+ user_flag |= G90SensorUserFlags.ENABLED
650
+ else:
651
+ user_flag &= ~G90SensorUserFlags.ENABLED
652
+ await self.set_user_flag(user_flag)
653
+
497
654
  @property
498
655
  def extra_data(self) -> Any:
499
656
  """
@@ -506,6 +663,17 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
506
663
  def extra_data(self, val: Any) -> None:
507
664
  self._extra_data = val
508
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
+
509
677
  def _asdict(self) -> Dict[str, Any]:
510
678
  """
511
679
  Returns dictionary representation of the sensor.
@@ -517,6 +685,7 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
517
685
  'type': self.type,
518
686
  'subtype': self.subtype,
519
687
  'index': self.index,
688
+ 'protocol_index': self.proto_idx,
520
689
  'subindex': self.subindex,
521
690
  'node_count': self.node_count,
522
691
  'protocol': self.protocol,
@@ -528,6 +697,9 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
528
697
  'supports_enable_disable': self.supports_enable_disable,
529
698
  'is_wireless': self.is_wireless,
530
699
  'is_low_battery': self.is_low_battery,
700
+ 'is_tampered': self.is_tampered,
701
+ 'is_door_open_when_arming': self.is_door_open_when_arming,
702
+ 'is_unavailable': self.is_unavailable,
531
703
  }
532
704
 
533
705
  def __repr__(self) -> str:
@@ -537,3 +709,20 @@ class G90Sensor: # pylint:disable=too-many-instance-attributes
537
709
  :return: String representation
538
710
  """
539
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.17.2
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
@@ -1,10 +1,10 @@
1
1
  pyg90alarm/__init__.py,sha256=5AITRm5jZSzuQaL7PS8fZZMZb4-IuGRhSqyAdfTt0Cs,2236
2
- pyg90alarm/alarm.py,sha256=nTP0JrXQCqLz_lhA9F_AHK3CPNbYMt3gSeQVOL9QSpI,36589
2
+ pyg90alarm/alarm.py,sha256=YMqf5nyoqQVZPJJB2vRXzWcHNcRjfVGojN7KDBM-KS8,38093
3
3
  pyg90alarm/base_cmd.py,sha256=Bz7yoZ0RpkcjWARya664DKAPo3goD6BeaKtuW-hA804,9902
4
4
  pyg90alarm/callback.py,sha256=3JsD_JChmZD24OyjaCP-PxxuBDBX7myGYhkM4RN7bk4,3742
5
5
  pyg90alarm/config.py,sha256=2YtIgdT7clQXmYvkdn_fhIdS05CY8E1Yc90R8_tAmRI,1961
6
- pyg90alarm/const.py,sha256=0EkfCtySEPi6W0TO-j-F1y7_MVFaMOuKDY1Bx6QGDDQ,6617
7
- pyg90alarm/device_notifications.py,sha256=61oruR70snBRXNzOfj313TO7p2TmbEk-dH9AA1nkDkY,16687
6
+ pyg90alarm/const.py,sha256=uBzDUSlVO3FZxLI11bzfCm6JTI2_ciageyf43JLSehg,6632
7
+ pyg90alarm/device_notifications.py,sha256=pqkeKYORUUZUEovUKajuu8JHCtXD0ulAZIKelNfn2Eg,17602
8
8
  pyg90alarm/discovery.py,sha256=fwyBHDCKGej06OwhpbVCHYTRU9WWkeYysAFgv3FiwqI,3575
9
9
  pyg90alarm/exceptions.py,sha256=eiOcRe7D18EIPyPFDNU9DdFgbnkwPmkiLl8lGPOhBNw,1475
10
10
  pyg90alarm/history.py,sha256=5NfgB0V-7TZlMNHEjtRbOA0ZtJfQTgh2ysZIg5RM7ck,9080
@@ -18,10 +18,14 @@ pyg90alarm/user_data_crc.py,sha256=JQBOPY3RlOgVtvR55R-rM8OuKjYW-BPXQ0W4pi6CEH0,1
18
18
  pyg90alarm/definitions/__init__.py,sha256=s0NZnkW_gMH718DJbgez28z9WA231CyszUf1O_ojUiI,68
19
19
  pyg90alarm/definitions/sensors.py,sha256=rKOu21ZpI44xk6aMh_vBjniFqnsNTc1CKwAvnv4PYLQ,19904
20
20
  pyg90alarm/entities/__init__.py,sha256=hHb6AOiC4Tz--rOWiiICMdLaZDs1Tf_xpWk_HeS_gO4,66
21
+ pyg90alarm/entities/base_entity.py,sha256=Ea1r1aP7FMnOiEPhBhkrndmcr5Iy8jcfQuuXCNHajrU,2473
22
+ pyg90alarm/entities/base_list.py,sha256=76DUtdXTwd26vv6HIw_qdtBNd1FqMfoRf0VI9KxV5aA,5703
21
23
  pyg90alarm/entities/device.py,sha256=f_LHvKCAqTEebZ4mrRh3CpPUI7o-OvpvOfyTRCbftJs,2818
22
- pyg90alarm/entities/sensor.py,sha256=4r8ouAYTZB8ih8I4ncWdQOaifYsRxaC-ukY9jvnrRvk,16139
23
- pyg90alarm-1.17.2.dist-info/LICENSE,sha256=f884inRbeNv-O-hbwz62Ro_1J8xiHRTnJ2cCx6A0WvU,1070
24
- pyg90alarm-1.17.2.dist-info/METADATA,sha256=8PoCUW_lwXEsjV5MZUGOb362h7C2JL0PHIOlEWr8twk,7714
25
- pyg90alarm-1.17.2.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
26
- pyg90alarm-1.17.2.dist-info/top_level.txt,sha256=czHiGxYMyTk5QEDTDb0EpPiKqUMRa8zI4zx58Ii409M,11
27
- pyg90alarm-1.17.2.dist-info/RECORD,,
24
+ pyg90alarm/entities/device_list.py,sha256=FrsfzP1BgwCKOn6bzVUpQHdatB6MUcWhPeuaJ3c5mqg,2154
25
+ pyg90alarm/entities/sensor.py,sha256=ax-6ttps7sCWfuP6CcnS00Zy5WZVKleO8vAsdhU3KQc,21986
26
+ pyg90alarm/entities/sensor_list.py,sha256=qLNbLZXrPlcdZLoyZw3Mzf1PwX4BiW3z6SKi_NqhVnM,1806
27
+ pyg90alarm-1.20.0.dist-info/LICENSE,sha256=f884inRbeNv-O-hbwz62Ro_1J8xiHRTnJ2cCx6A0WvU,1070
28
+ pyg90alarm-1.20.0.dist-info/METADATA,sha256=UDwmoHFOiwFFQiq0yVLsV0Pl1YV2D09H8AwNtw8zESQ,7951
29
+ pyg90alarm-1.20.0.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
30
+ pyg90alarm-1.20.0.dist-info/top_level.txt,sha256=czHiGxYMyTk5QEDTDb0EpPiKqUMRa8zI4zx58Ii409M,11
31
+ pyg90alarm-1.20.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.6.0)
2
+ Generator: setuptools (75.8.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5