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 +144 -91
- pyg90alarm/const.py +1 -0
- pyg90alarm/device_notifications.py +29 -3
- pyg90alarm/entities/base_entity.py +83 -0
- pyg90alarm/entities/base_list.py +165 -0
- pyg90alarm/entities/device_list.py +58 -0
- pyg90alarm/entities/sensor.py +210 -21
- pyg90alarm/entities/sensor_list.py +50 -0
- {pyg90alarm-1.17.2.dist-info → pyg90alarm-1.20.0.dist-info}/METADATA +13 -2
- {pyg90alarm-1.17.2.dist-info → pyg90alarm-1.20.0.dist-info}/RECORD +13 -9
- {pyg90alarm-1.17.2.dist-info → pyg90alarm-1.20.0.dist-info}/WHEEL +1 -1
- {pyg90alarm-1.17.2.dist-info → pyg90alarm-1.20.0.dist-info}/LICENSE +0 -0
- {pyg90alarm-1.17.2.dist-info → pyg90alarm-1.20.0.dist-info}/top_level.txt +0 -0
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
|
|
164
|
-
self.
|
|
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
|
-
|
|
251
|
-
|
|
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.
|
|
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
|
|
258
|
-
|
|
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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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.
|
|
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
|
|
320
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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(
|
|
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
|
@@ -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
|
-
|
|
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(
|
|
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
|
pyg90alarm/entities/sensor.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
530
|
+
async def set_user_flag(self, value: G90SensorUserFlags) -> None:
|
|
396
531
|
"""
|
|
397
|
-
Sets
|
|
532
|
+
Sets user flags of the sensor.
|
|
398
533
|
|
|
399
|
-
:param value:
|
|
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
|
|
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.
|
|
565
|
+
self.index, self.proto_idx
|
|
423
566
|
)
|
|
424
567
|
sensors_result = self.parent.paginated_result(
|
|
425
568
|
G90Commands.GETSENSORLIST,
|
|
426
|
-
start=self.
|
|
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
|
-
'
|
|
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
|
|
590
|
+
" refusing to alter its user flag",
|
|
448
591
|
self.index,
|
|
449
592
|
self.name
|
|
450
593
|
)
|
|
451
594
|
return
|
|
452
595
|
|
|
453
|
-
|
|
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'] =
|
|
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
|
|
610
|
+
'Sensor index=%s: previous user_flag %s, resulting user_flag %s',
|
|
468
611
|
self._protocol_data.index,
|
|
469
|
-
|
|
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
|
+
Metadata-Version: 2.2
|
|
2
2
|
Name: pyg90alarm
|
|
3
|
-
Version: 1.
|
|
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=
|
|
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=
|
|
7
|
-
pyg90alarm/device_notifications.py,sha256=
|
|
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/
|
|
23
|
-
pyg90alarm
|
|
24
|
-
pyg90alarm
|
|
25
|
-
pyg90alarm-1.
|
|
26
|
-
pyg90alarm-1.
|
|
27
|
-
pyg90alarm-1.
|
|
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,,
|
|
File without changes
|
|
File without changes
|