pyg90alarm 1.10.1__py3-none-any.whl → 1.13.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
@@ -48,7 +48,7 @@ G90HostInfo(host_guid='<...>',
48
48
  wifi_signal_level=100)
49
49
 
50
50
  """
51
-
51
+ import asyncio
52
52
  import logging
53
53
  from .const import (
54
54
  G90Commands, REMOTE_PORT,
@@ -72,11 +72,14 @@ from .config import (G90AlertConfig, G90AlertConfigFlags)
72
72
  from .history import G90History
73
73
  from .user_data_crc import G90UserDataCRC
74
74
  from .callback import G90Callback
75
+ from .exceptions import G90Error, G90TimeoutError
75
76
 
76
77
  _LOGGER = logging.getLogger(__name__)
77
78
 
78
79
 
79
- class G90Alarm: # pylint: disable=too-many-public-methods
80
+ # pylint: disable=too-many-public-methods
81
+ class G90Alarm(G90DeviceNotifications):
82
+
80
83
  """
81
84
  Allows to interact with G90 alarm panel.
82
85
 
@@ -92,9 +95,12 @@ class G90Alarm: # pylint: disable=too-many-public-methods
92
95
  simulated to go into inactive state.
93
96
  :type reset_occupancy_interval: int, optional
94
97
  """
95
- # pylint: disable=too-many-instance-attributes
98
+ # pylint: disable=too-many-instance-attributes,too-many-arguments
96
99
  def __init__(self, host, port=REMOTE_PORT,
97
- reset_occupancy_interval=3):
100
+ reset_occupancy_interval=3,
101
+ notifications_host='0.0.0.0',
102
+ notifications_port=NOTIFICATIONS_PORT):
103
+ super().__init__(host=notifications_host, port=notifications_port)
98
104
  self._host = host
99
105
  self._port = port
100
106
  self._sensors = []
@@ -104,9 +110,12 @@ class G90Alarm: # pylint: disable=too-many-public-methods
104
110
  self._armdisarm_cb = None
105
111
  self._door_open_close_cb = None
106
112
  self._alarm_cb = None
113
+ self._low_battery_cb = None
107
114
  self._reset_occupancy_interval = reset_occupancy_interval
108
115
  self._alert_config = None
109
116
  self._sms_alert_when_armed = False
117
+ self._alert_simulation_task = None
118
+ self._alert_simulation_start_listener_back = False
110
119
 
111
120
  async def command(self, code, data=None):
112
121
  """
@@ -400,10 +409,15 @@ class G90Alarm: # pylint: disable=too-many-public-methods
400
409
  """
401
410
  res = self.paginated_result(G90Commands.GETHISTORY,
402
411
  start, count)
403
- history = [G90History(*x.data) async for x in res]
404
- return history
405
412
 
406
- async def _internal_sensor_cb(self, idx, name, occupancy=True):
413
+ # Sort the history entries from older to newer - device typically does
414
+ # that, but apparently that is not guaranteed
415
+ return sorted(
416
+ [G90History(*x.data) async for x in res],
417
+ key=lambda x: x.datetime, reverse=True
418
+ )
419
+
420
+ async def on_sensor_activity(self, idx, name, occupancy=True):
407
421
  """
408
422
  Callback that invoked both for sensor notifications and door open/close
409
423
  alerts, since the logic for both is same and could be reused. Please
@@ -420,6 +434,7 @@ class G90Alarm: # pylint: disable=too-many-public-methods
420
434
  alerts (only for `door` type sensors, if door open/close alerts are
421
435
  enabled)
422
436
  """
437
+ _LOGGER.debug('on_sensor_activity: %s %s %s', idx, name, occupancy)
423
438
  sensor = await self.find_sensor(idx, name)
424
439
  if sensor:
425
440
  _LOGGER.debug('Setting occupancy to %s (previously %s)',
@@ -477,19 +492,21 @@ class G90Alarm: # pylint: disable=too-many-public-methods
477
492
  def sensor_callback(self, value):
478
493
  self._sensor_cb = value
479
494
 
480
- async def _internal_door_open_close_cb(self, idx, name, is_open):
495
+ async def on_door_open_close(self, event_id, zone_name, is_open):
481
496
  """
482
497
  Callback that invoked when door open/close alert comes from the alarm
483
498
  panel. Please note the callback is for internal use by the class.
484
499
 
485
- .. seealso:: `method`:_internal_sensor_cb for arguments
500
+ .. seealso:: `method`:on_sensor_activity for arguments
486
501
  """
487
502
  # Same internal callback is reused both for door open/close alerts and
488
503
  # sensor notifications. The former adds reporting when a door is
489
504
  # closed, since the notifications aren't sent for such events
490
- await self._internal_sensor_cb(idx, name, is_open)
505
+ await self.on_sensor_activity(event_id, zone_name, is_open)
491
506
  # Invoke user specified callback if any
492
- G90Callback.invoke(self._door_open_close_cb, idx, name, is_open)
507
+ G90Callback.invoke(
508
+ self._door_open_close_cb, event_id, zone_name, is_open
509
+ )
493
510
 
494
511
  @property
495
512
  def door_open_close_callback(self):
@@ -509,7 +526,7 @@ class G90Alarm: # pylint: disable=too-many-public-methods
509
526
  """
510
527
  self._door_open_close_cb = value
511
528
 
512
- async def _internal_armdisarm_cb(self, state):
529
+ async def on_armdisarm(self, state):
513
530
  """
514
531
  Callback that invoked when the device is armed or disarmed. Please note
515
532
  the callback is for internal use by the class.
@@ -545,7 +562,7 @@ class G90Alarm: # pylint: disable=too-many-public-methods
545
562
  def armdisarm_callback(self, value):
546
563
  self._armdisarm_cb = value
547
564
 
548
- async def _internal_alarm_cb(self, sensor_idx, sensor_name):
565
+ async def on_alarm(self, event_id, zone_name):
549
566
  """
550
567
  Callback that invoked when alarm is triggered. Fires alarm callback if
551
568
  set by the user with `:property:G90Alarm.alarm_callback`.
@@ -554,14 +571,20 @@ class G90Alarm: # pylint: disable=too-many-public-methods
554
571
  :param int: Index of the sensor triggered alarm
555
572
  :param str: Sensor name
556
573
  """
557
- sensor = await self.find_sensor(sensor_idx, sensor_name)
574
+ sensor = await self.find_sensor(event_id, zone_name)
558
575
  # The callback is still delivered to the caller even if the sensor
559
- # isn't found, only `extra_data` is skipped. That is to ensur the
576
+ # isn't found, only `extra_data` is skipped. That is to ensure the
560
577
  # important callback isn't filtered
561
578
  extra_data = sensor.extra_data if sensor else None
579
+ # Invoke the sensor activity callback to set the sensor occupancy if
580
+ # sensor is known, but only if that isn't already set - it helps when
581
+ # device notifications on triggerring sensor's activity aren't receveid
582
+ # by a reason
583
+ if sensor and not sensor.occupancy:
584
+ await self.on_sensor_activity(event_id, zone_name, True)
562
585
 
563
586
  G90Callback.invoke(
564
- self._alarm_cb, sensor_idx, sensor_name, extra_data
587
+ self._alarm_cb, event_id, zone_name, extra_data
565
588
  )
566
589
 
567
590
  @property
@@ -581,27 +604,49 @@ class G90Alarm: # pylint: disable=too-many-public-methods
581
604
  def alarm_callback(self, value):
582
605
  self._alarm_cb = value
583
606
 
584
- async def listen_device_notifications(
585
- self, host='0.0.0.0', port=NOTIFICATIONS_PORT
586
- ):
607
+ async def on_low_battery(self, event_id, zone_name):
608
+ """
609
+ Callback that invoked when the sensor reports on low battery. Fires
610
+ corresponding callback if set by the user with
611
+ `:property:G90Alarm.on_low_battery_callback`.
612
+ Please note the callback is for internal use by the class.
613
+
614
+ :param int: Index of the sensor triggered alarm
615
+ :param str: Sensor name
616
+ """
617
+ sensor = await self.find_sensor(event_id, zone_name)
618
+ if sensor:
619
+ # Invoke per-sensor callback if provided
620
+ G90Callback.invoke(sensor.low_battery_callback)
621
+
622
+ G90Callback.invoke(self._low_battery_cb, event_id, zone_name)
623
+
624
+ @property
625
+ def low_battery_callback(self):
626
+ """
627
+ Get or set low battery callback, the callback is invoked when sensor
628
+ the condition is reported by a sensor.
629
+
630
+ :type: .. py:function:: ()(idx, name)
631
+ """
632
+ return self._low_battery_cb
633
+
634
+ @low_battery_callback.setter
635
+ def low_battery_callback(self, value):
636
+ self._low_battery_cb = value
637
+
638
+ async def listen_device_notifications(self):
587
639
  """
588
640
  Starts internal listener for device notifications/alerts.
589
641
 
590
642
  """
591
- self._notifications = G90DeviceNotifications(
592
- host=host, port=port,
593
- sensor_cb=self._internal_sensor_cb,
594
- door_open_close_cb=self._internal_door_open_close_cb,
595
- armdisarm_cb=self._internal_armdisarm_cb,
596
- alarm_cb=self._internal_alarm_cb)
597
- await self._notifications.listen()
643
+ await self.listen()
598
644
 
599
645
  def close_device_notifications(self):
600
646
  """
601
647
  Closes the listener for device notifications/alerts.
602
648
  """
603
- if self._notifications:
604
- self._notifications.close()
649
+ self.close()
605
650
 
606
651
  async def arm_away(self):
607
652
  """
@@ -638,3 +683,125 @@ class G90Alarm: # pylint: disable=too-many-public-methods
638
683
  @sms_alert_when_armed.setter
639
684
  def sms_alert_when_armed(self, value):
640
685
  self._sms_alert_when_armed = value
686
+
687
+ async def start_simulating_alerts_from_history(
688
+ self, interval=5, history_depth=5
689
+ ):
690
+ """
691
+ Starts the separate task to simulate device alerts from history
692
+ entries.
693
+
694
+ The listener for device notifications will be stopped, so device
695
+ notifications will not be processed thus resulting in possible
696
+ duplicated if those could be received from the network.
697
+
698
+ :param int interval: Interval (in seconds) between polling for newer
699
+ history entities
700
+ :param int history_depth: Amount of history entries to fetch during
701
+ each polling cycle
702
+ """
703
+ # Remember if device notifications listener has been started already
704
+ self._alert_simulation_start_listener_back = self.listener_started
705
+ # And then stop it
706
+ self.close()
707
+
708
+ # Start the task
709
+ self._alert_simulation_task = asyncio.create_task(
710
+ self._simulate_alerts_from_history(interval, history_depth)
711
+ )
712
+
713
+ async def stop_simulating_alerts_from_history(self):
714
+ """
715
+ Stops the task simulating device alerts from history entries.
716
+
717
+ The listener for device notifications will be started back, if it was
718
+ running when simulation has been started.
719
+ """
720
+ # Stop the task simulating the device alerts from history if it was
721
+ # running
722
+ if self._alert_simulation_task:
723
+ self._alert_simulation_task.cancel()
724
+ self._alert_simulation_task = None
725
+
726
+ # Start device notifications listener back if it was running when
727
+ # simulated alerts have been enabled
728
+ if self._alert_simulation_start_listener_back:
729
+ await self.listen()
730
+
731
+ async def _simulate_alerts_from_history(self, interval, history_depth):
732
+ """
733
+ Periodically fetches history entries from the device and simulates
734
+ device alerts off of those.
735
+
736
+ Only the history entries occur after the process is started are
737
+ handled, to avoid triggering callbacks retrospectively.
738
+
739
+ See :method:`start_simulating_alerts_from_history` for the parameters.
740
+ """
741
+ last_history_ts = None
742
+
743
+ _LOGGER.debug(
744
+ 'Simulating device alerts from history:'
745
+ ' interval %s, history depth %s',
746
+ interval, history_depth
747
+ )
748
+ while True:
749
+ try:
750
+ # Retrieve the history entries of the specified amount - full
751
+ # history retrieval might be an unnecessary long operation
752
+ history = await self.history(count=history_depth)
753
+
754
+ # Initial iteration where no timestamp of most recent history
755
+ # entry is recorded - do that and skip to next iteration, since
756
+ # it isn't yet known what entries would be considered as new
757
+ # ones.
758
+ # Empty history will skip recording the timestamp and the
759
+ # looping over entries below, effectively skipping to next
760
+ # iteration
761
+ if not last_history_ts and history:
762
+ # First entry in the list is assumed to be the most recent
763
+ # one
764
+ last_history_ts = history[0].datetime
765
+ _LOGGER.debug(
766
+ 'Initial time stamp of last history entry: %s',
767
+ last_history_ts
768
+ )
769
+ continue
770
+
771
+ # Process history entries from older to newer to preserve the
772
+ # order of happenings
773
+ for item in reversed(history):
774
+ # Process only the entries newer than one been recorded as
775
+ # most recent one
776
+ if item.datetime > last_history_ts:
777
+ _LOGGER.debug(
778
+ 'Found newer history entry: %s, simulating alert',
779
+ repr(item)
780
+ )
781
+ # Send the history entry down the device notification
782
+ # code as alert, as if it came from the device and its
783
+ # notifications port
784
+ self._handle_alert(
785
+ (self._host, self._notifications_port),
786
+ item.as_device_alert()
787
+ )
788
+
789
+ # Record the entry as most recent one
790
+ last_history_ts = item.datetime
791
+ _LOGGER.debug(
792
+ 'Time stamp of last history entry: %s',
793
+ last_history_ts
794
+ )
795
+ except (G90Error, G90TimeoutError) as exc:
796
+ _LOGGER.debug(
797
+ 'Error interacting with device, ignoring %s', repr(exc)
798
+ )
799
+ except Exception as exc:
800
+ _LOGGER.error(
801
+ 'Exception simulating device alerts from history: %s',
802
+ repr(exc)
803
+ )
804
+ raise exc
805
+
806
+ # Sleep to next iteration
807
+ await asyncio.sleep(interval)
pyg90alarm/base_cmd.py CHANGED
@@ -44,62 +44,6 @@ class G90Header(NamedTuple):
44
44
  data: Optional[str] = None
45
45
 
46
46
 
47
- class G90DeviceProtocol:
48
- """
49
- tbd
50
-
51
- :meta private:
52
- """
53
- def __init__(self):
54
- """
55
- tbd
56
- """
57
- self._data = None
58
-
59
- @property
60
- def future_data(self):
61
- """
62
- tbd
63
- """
64
- return self._data
65
-
66
- @future_data.setter
67
- def future_data(self, value):
68
- """
69
- tbd
70
- """
71
- self._data = value
72
-
73
- def connection_made(self, transport):
74
- """
75
- tbd
76
- """
77
-
78
- def connection_lost(self, exc):
79
- """
80
- tbd
81
- """
82
-
83
- def datagram_received(self, data, addr):
84
- """
85
- tbd
86
- """
87
- if asyncio.isfuture(self._data):
88
- if self._data.done():
89
- _LOGGER.warning('Excessive packet received'
90
- ' from %s:%s: %s',
91
- addr[0], addr[1], data)
92
- return
93
- self._data.set_result((*addr, data))
94
-
95
- def error_received(self, exc):
96
- """
97
- tbd
98
- """
99
- if asyncio.isfuture(self._data) and not self._data.done():
100
- self._data.set_exception(exc)
101
-
102
-
103
47
  class G90BaseCommand:
104
48
  """
105
49
  tbd
@@ -123,6 +67,7 @@ class G90BaseCommand:
123
67
  self._retries = retries
124
68
  self._data = '""'
125
69
  self._result = None
70
+ self._connection_result = None
126
71
  if data:
127
72
  self._data = json.dumps([code, data],
128
73
  # No newlines to be inserted
@@ -131,11 +76,39 @@ class G90BaseCommand:
131
76
  separators=(',', ':'))
132
77
  self._resp = G90Header()
133
78
 
134
- def _proto_factory(self):
79
+ # Implementation of datagram protocol,
80
+ # https://docs.python.org/3/library/asyncio-protocol.html#datagram-protocols
81
+ def connection_made(self, transport):
82
+ """
83
+ tbd
84
+ """
85
+
86
+ def connection_lost(self, exc):
87
+ """
88
+ tbd
89
+ """
90
+
91
+ def datagram_received(self, data, addr):
92
+ """
93
+ tbd
94
+ """
95
+ if asyncio.isfuture(self._connection_result):
96
+ if self._connection_result.done():
97
+ _LOGGER.warning('Excessive packet received'
98
+ ' from %s:%s: %s',
99
+ addr[0], addr[1], data)
100
+ return
101
+ self._connection_result.set_result((*addr, data))
102
+
103
+ def error_received(self, exc):
135
104
  """
136
105
  tbd
137
106
  """
138
- return G90DeviceProtocol()
107
+ if (
108
+ asyncio.isfuture(self._connection_result) and not
109
+ self._connection_result.done()
110
+ ):
111
+ self._connection_result.set_exception(exc)
139
112
 
140
113
  async def _create_connection(self):
141
114
  """
@@ -153,7 +126,7 @@ class G90BaseCommand:
153
126
  extra_kwargs['local_addr'] = ('0.0.0.0', self._local_port)
154
127
 
155
128
  transport, protocol = await loop.create_datagram_endpoint(
156
- self._proto_factory,
129
+ lambda: self,
157
130
  remote_addr=(self.host, self.port),
158
131
  **extra_kwargs,
159
132
  allow_broadcast=True)
@@ -241,7 +214,7 @@ class G90BaseCommand:
241
214
  tbd
242
215
  """
243
216
 
244
- transport, protocol = await self._create_connection()
217
+ transport, _ = await self._create_connection()
245
218
  attempts = self._retries
246
219
  while True:
247
220
  attempts = attempts - 1
@@ -249,24 +222,24 @@ class G90BaseCommand:
249
222
  loop = asyncio.get_running_loop()
250
223
  except AttributeError:
251
224
  loop = asyncio.get_event_loop()
252
- protocol.future_data = loop.create_future()
225
+ self._connection_result = loop.create_future()
253
226
  async with self._sk_lock:
254
227
  _LOGGER.debug('(code %s) Sending request to %s:%s',
255
228
  self._code, self.host, self.port)
256
229
  transport.sendto(self.to_wire())
257
- done, _ = await asyncio.wait([protocol.future_data],
230
+ done, _ = await asyncio.wait([self._connection_result],
258
231
  timeout=self._timeout)
259
- if protocol.future_data in done:
232
+ if self._connection_result in done:
260
233
  break
261
234
  # Cancel the future to signal protocol handler it is no longer
262
235
  # valid, the future will be re-created on next retry
263
- protocol.future_data.cancel()
236
+ self._connection_result.cancel()
264
237
  if not attempts:
265
238
  transport.close()
266
239
  raise G90TimeoutError()
267
240
  _LOGGER.debug('Timed out, retrying')
268
241
  transport.close()
269
- (host, port, data) = protocol.future_data.result()
242
+ (host, port, data) = self._connection_result.result()
270
243
  _LOGGER.debug('Received response from %s:%s', host, port)
271
244
  if self.host != '255.255.255.255':
272
245
  if self.host != host or host == '255.255.255.255':
pyg90alarm/const.py CHANGED
@@ -185,10 +185,21 @@ class G90AlertSources(IntEnum):
185
185
  """
186
186
  Defines possible sources of the alert sent by the panel.
187
187
  """
188
+ DEVICE = 0
188
189
  SENSOR = 1
189
190
  DOORBELL = 12
190
191
 
191
192
 
193
+ class G90AlertStates(IntEnum):
194
+ """
195
+ Defines possible states of the alert sent by the panel.
196
+ """
197
+ DOOR_CLOSE = 0
198
+ DOOR_OPEN = 1
199
+ TAMPER = 3
200
+ LOW_BATTERY = 4
201
+
202
+
192
203
  class G90AlertStateChangeTypes(IntEnum):
193
204
  """
194
205
  Defines types of alert for device state changes.
@@ -201,3 +212,21 @@ class G90AlertStateChangeTypes(IntEnum):
201
212
  LOW_BATTERY = 6
202
213
  WIFI_CONNECTED = 7
203
214
  WIFI_DISCONNECTED = 8
215
+
216
+
217
+ class G90HistoryStates(IntEnum):
218
+ """
219
+ Defines possible states for history entities.
220
+ """
221
+ DOOR_CLOSE = 1
222
+ DOOR_OPEN = 2
223
+ TAMPER = 3
224
+ ALARM = 4
225
+ AC_POWER_FAILURE = 5
226
+ AC_POWER_RECOVER = 6
227
+ DISARM = 7
228
+ ARM_AWAY = 8
229
+ ARM_HOME = 9
230
+ LOW_BATTERY = 10
231
+ WIFI_CONNECTED = 11
232
+ WIFI_DISCONNECTED = 12