pyg90alarm 1.10.0__py3-none-any.whl → 1.12.1__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,12 +48,13 @@ 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,
55
55
  REMOTE_TARGETED_DISCOVERY_PORT,
56
56
  LOCAL_TARGETED_DISCOVERY_PORT,
57
+ NOTIFICATIONS_PORT,
57
58
  G90ArmDisarmTypes,
58
59
  )
59
60
  from .base_cmd import G90BaseCommand
@@ -71,11 +72,14 @@ from .config import (G90AlertConfig, G90AlertConfigFlags)
71
72
  from .history import G90History
72
73
  from .user_data_crc import G90UserDataCRC
73
74
  from .callback import G90Callback
75
+ from .exceptions import G90Error, G90TimeoutError
74
76
 
75
77
  _LOGGER = logging.getLogger(__name__)
76
78
 
77
79
 
78
- class G90Alarm: # pylint: disable=too-many-public-methods
80
+ # pylint: disable=too-many-public-methods
81
+ class G90Alarm(G90DeviceNotifications):
82
+
79
83
  """
80
84
  Allows to interact with G90 alarm panel.
81
85
 
@@ -87,30 +91,31 @@ class G90Alarm: # pylint: disable=too-many-public-methods
87
91
  protocol commands on WiFi interface, currently the devices don't allow it
88
92
  to be customized
89
93
  :type port: int, optional
90
- :param sock: The existing socket to operate on, instead of
91
- creating one internally. Primarily used by the tests to mock the network
92
- traffic
93
- :type sock: socket.socket or None, optional
94
94
  :param reset_occupancy_interval: The interval upon that the sensors are
95
95
  simulated to go into inactive state.
96
96
  :type reset_occupancy_interval: int, optional
97
97
  """
98
- # pylint: disable=too-many-instance-attributes
99
- def __init__(self, host, port=REMOTE_PORT, sock=None,
100
- reset_occupancy_interval=3):
98
+ # pylint: disable=too-many-instance-attributes,too-many-arguments
99
+ def __init__(self, host, port=REMOTE_PORT,
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)
101
104
  self._host = host
102
105
  self._port = port
103
106
  self._sensors = []
104
107
  self._devices = []
105
108
  self._notifications = None
106
- self._sock = sock
107
109
  self._sensor_cb = None
108
110
  self._armdisarm_cb = None
109
111
  self._door_open_close_cb = None
110
112
  self._alarm_cb = None
113
+ self._low_battery_cb = None
111
114
  self._reset_occupancy_interval = reset_occupancy_interval
112
115
  self._alert_config = None
113
116
  self._sms_alert_when_armed = False
117
+ self._alert_simulation_task = None
118
+ self._alert_simulation_start_listener_back = False
114
119
 
115
120
  async def command(self, code, data=None):
116
121
  """
@@ -124,7 +129,7 @@ class G90Alarm: # pylint: disable=too-many-public-methods
124
129
  command invocation
125
130
  """
126
131
  cmd = await G90BaseCommand(
127
- self._host, self._port, code, data, sock=self._sock).process()
132
+ self._host, self._port, code, data).process()
128
133
  return cmd.result
129
134
 
130
135
  def paginated_result(self, code, start=1, end=None):
@@ -141,7 +146,7 @@ class G90Alarm: # pylint: disable=too-many-public-methods
141
146
  yields :class:`.G90PaginatedResponse` instance
142
147
  """
143
148
  return G90PaginatedResult(
144
- self._host, self._port, code, start, end, sock=self._sock
149
+ self._host, self._port, code, start, end
145
150
  ).process()
146
151
 
147
152
  @classmethod
@@ -404,10 +409,15 @@ class G90Alarm: # pylint: disable=too-many-public-methods
404
409
  """
405
410
  res = self.paginated_result(G90Commands.GETHISTORY,
406
411
  start, count)
407
- history = [G90History(*x.data) async for x in res]
408
- return history
409
412
 
410
- 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):
411
421
  """
412
422
  Callback that invoked both for sensor notifications and door open/close
413
423
  alerts, since the logic for both is same and could be reused. Please
@@ -424,6 +434,7 @@ class G90Alarm: # pylint: disable=too-many-public-methods
424
434
  alerts (only for `door` type sensors, if door open/close alerts are
425
435
  enabled)
426
436
  """
437
+ _LOGGER.debug('on_sensor_acitvity: %s %s %s', idx, name, occupancy)
427
438
  sensor = await self.find_sensor(idx, name)
428
439
  if sensor:
429
440
  _LOGGER.debug('Setting occupancy to %s (previously %s)',
@@ -481,19 +492,21 @@ class G90Alarm: # pylint: disable=too-many-public-methods
481
492
  def sensor_callback(self, value):
482
493
  self._sensor_cb = value
483
494
 
484
- 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):
485
496
  """
486
497
  Callback that invoked when door open/close alert comes from the alarm
487
498
  panel. Please note the callback is for internal use by the class.
488
499
 
489
- .. seealso:: `method`:_internal_sensor_cb for arguments
500
+ .. seealso:: `method`:on_sensor_activity for arguments
490
501
  """
491
502
  # Same internal callback is reused both for door open/close alerts and
492
503
  # sensor notifications. The former adds reporting when a door is
493
504
  # closed, since the notifications aren't sent for such events
494
- await self._internal_sensor_cb(idx, name, is_open)
505
+ await self.on_sensor_activity(event_id, zone_name, is_open)
495
506
  # Invoke user specified callback if any
496
- 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
+ )
497
510
 
498
511
  @property
499
512
  def door_open_close_callback(self):
@@ -513,7 +526,7 @@ class G90Alarm: # pylint: disable=too-many-public-methods
513
526
  """
514
527
  self._door_open_close_cb = value
515
528
 
516
- async def _internal_armdisarm_cb(self, state):
529
+ async def on_armdisarm(self, state):
517
530
  """
518
531
  Callback that invoked when the device is armed or disarmed. Please note
519
532
  the callback is for internal use by the class.
@@ -549,7 +562,7 @@ class G90Alarm: # pylint: disable=too-many-public-methods
549
562
  def armdisarm_callback(self, value):
550
563
  self._armdisarm_cb = value
551
564
 
552
- async def _internal_alarm_cb(self, sensor_idx, sensor_name):
565
+ async def on_alarm(self, event_id, zone_name):
553
566
  """
554
567
  Callback that invoked when alarm is triggered. Fires alarm callback if
555
568
  set by the user with `:property:G90Alarm.alarm_callback`.
@@ -558,14 +571,14 @@ class G90Alarm: # pylint: disable=too-many-public-methods
558
571
  :param int: Index of the sensor triggered alarm
559
572
  :param str: Sensor name
560
573
  """
561
- sensor = await self.find_sensor(sensor_idx, sensor_name)
574
+ sensor = await self.find_sensor(event_id, zone_name)
562
575
  # The callback is still delivered to the caller even if the sensor
563
576
  # isn't found, only `extra_data` is skipped. That is to ensur the
564
577
  # important callback isn't filtered
565
578
  extra_data = sensor.extra_data if sensor else None
566
579
 
567
580
  G90Callback.invoke(
568
- self._alarm_cb, sensor_idx, sensor_name, extra_data
581
+ self._alarm_cb, event_id, zone_name, extra_data
569
582
  )
570
583
 
571
584
  @property
@@ -585,27 +598,49 @@ class G90Alarm: # pylint: disable=too-many-public-methods
585
598
  def alarm_callback(self, value):
586
599
  self._alarm_cb = value
587
600
 
588
- async def listen_device_notifications(self, sock=None):
601
+ async def on_low_battery(self, event_id, zone_name):
602
+ """
603
+ Callback that invoked when the sensor reports on low battery. Fires
604
+ corresponding callback if set by the user with
605
+ `:property:G90Alarm.on_low_battery_callback`.
606
+ Please note the callback is for internal use by the class.
607
+
608
+ :param int: Index of the sensor triggered alarm
609
+ :param str: Sensor name
610
+ """
611
+ sensor = await self.find_sensor(event_id, zone_name)
612
+ if sensor:
613
+ # Invoke per-sensor callback if provided
614
+ G90Callback.invoke(sensor.low_battery_callback)
615
+
616
+ G90Callback.invoke(self._low_battery_cb, event_id, zone_name)
617
+
618
+ @property
619
+ def low_battery_callback(self):
620
+ """
621
+ Get or set low battery callback, the callback is invoked when sensor
622
+ the condition is reported by a sensor.
623
+
624
+ :type: .. py:function:: ()(idx, name)
625
+ """
626
+ return self._low_battery_cb
627
+
628
+ @low_battery_callback.setter
629
+ def low_battery_callback(self, value):
630
+ self._low_battery_cb = value
631
+
632
+ async def listen_device_notifications(self):
589
633
  """
590
634
  Starts internal listener for device notifications/alerts.
591
635
 
592
- :param sock: socket instance to listen on, mostly used by tests
593
- :type: socket.socket
594
636
  """
595
- self._notifications = G90DeviceNotifications(
596
- sensor_cb=self._internal_sensor_cb,
597
- door_open_close_cb=self._internal_door_open_close_cb,
598
- armdisarm_cb=self._internal_armdisarm_cb,
599
- alarm_cb=self._internal_alarm_cb,
600
- sock=sock)
601
- await self._notifications.listen()
637
+ await self.listen()
602
638
 
603
639
  def close_device_notifications(self):
604
640
  """
605
641
  Closes the listener for device notifications/alerts.
606
642
  """
607
- if self._notifications:
608
- self._notifications.close()
643
+ self.close()
609
644
 
610
645
  async def arm_away(self):
611
646
  """
@@ -642,3 +677,125 @@ class G90Alarm: # pylint: disable=too-many-public-methods
642
677
  @sms_alert_when_armed.setter
643
678
  def sms_alert_when_armed(self, value):
644
679
  self._sms_alert_when_armed = value
680
+
681
+ async def start_simulating_alerts_from_history(
682
+ self, interval=5, history_depth=5
683
+ ):
684
+ """
685
+ Starts the separate task to simulate device alerts from history
686
+ entries.
687
+
688
+ The listener for device notifications will be stopped, so device
689
+ notifications will not be processed thus resulting in possible
690
+ duplicated if those could be received from the network.
691
+
692
+ :param int interval: Interval (in seconds) between polling for newer
693
+ history entities
694
+ :param int history_depth: Amount of history entries to fetch during
695
+ each polling cycle
696
+ """
697
+ # Remember if device notifications listener has been started already
698
+ self._alert_simulation_start_listener_back = self.listener_started
699
+ # And then stop it
700
+ self.close()
701
+
702
+ # Start the task
703
+ self._alert_simulation_task = asyncio.create_task(
704
+ self._simulate_alerts_from_history(interval, history_depth)
705
+ )
706
+
707
+ async def stop_simulating_alerts_from_history(self):
708
+ """
709
+ Stops the task simulating device alerts from history entries.
710
+
711
+ The listener for device notifications will be started back, if it was
712
+ running when simulation has been started.
713
+ """
714
+ # Stop the task simulating the device alerts from history if it was
715
+ # running
716
+ if self._alert_simulation_task:
717
+ self._alert_simulation_task.cancel()
718
+ self._alert_simulation_task = None
719
+
720
+ # Start device notifications listener back if it was running when
721
+ # simulated alerts have been enabled
722
+ if self._alert_simulation_start_listener_back:
723
+ await self.listen()
724
+
725
+ async def _simulate_alerts_from_history(self, interval, history_depth):
726
+ """
727
+ Periodically fetches history entries from the device and simulates
728
+ device alerts off of those.
729
+
730
+ Only the history entries occur after the process is started are
731
+ handled, to avoid triggering callbacks retrospectively.
732
+
733
+ See :method:`start_simulating_alerts_from_history` for the parameters.
734
+ """
735
+ last_history_ts = None
736
+
737
+ _LOGGER.debug(
738
+ 'Simulating device alerts from history:'
739
+ ' interval %s, history depth %s',
740
+ interval, history_depth
741
+ )
742
+ while True:
743
+ try:
744
+ # Retrieve the history entries of the specified amount - full
745
+ # history retrieval might be an unnecessary long operation
746
+ history = await self.history(count=history_depth)
747
+
748
+ # Initial iteration where no timestamp of most recent history
749
+ # entry is recorded - do that and skip to next iteration, since
750
+ # it isn't yet known what entries would be considered as new
751
+ # ones.
752
+ # Empty history will skip recording the timestamp and the
753
+ # looping over entries below, effectively skipping to next
754
+ # iteration
755
+ if not last_history_ts and history:
756
+ # First entry in the list is assumed to be the most recent
757
+ # one
758
+ last_history_ts = history[0].datetime
759
+ _LOGGER.debug(
760
+ 'Initial time stamp of last history entry: %s',
761
+ last_history_ts
762
+ )
763
+ continue
764
+
765
+ # Process history entries from older to newer to preserve the
766
+ # order of happenings
767
+ for item in reversed(history):
768
+ # Process only the entries newer than one been recorded as
769
+ # most recent one
770
+ if item.datetime > last_history_ts:
771
+ _LOGGER.debug(
772
+ 'Found newer history entry: %s, simulating alert',
773
+ repr(item)
774
+ )
775
+ # Send the history entry down the device notification
776
+ # code as alert, as if it came from the device and its
777
+ # notifications port
778
+ self._handle_alert(
779
+ (self._host, self._notifications_port),
780
+ item.as_device_alert()
781
+ )
782
+
783
+ # Record the entry as most recent one
784
+ last_history_ts = item.datetime
785
+ _LOGGER.debug(
786
+ 'Time stamp of last history entry: %s',
787
+ last_history_ts
788
+ )
789
+ except (G90Error, G90TimeoutError) as exc:
790
+ _LOGGER.debug(
791
+ 'Error interacting with device, ignoring %s', repr(exc)
792
+ )
793
+ except Exception as exc:
794
+ _LOGGER.error(
795
+ 'Exception simulating device alerts from history: %s',
796
+ repr(exc)
797
+ )
798
+ raise exc
799
+
800
+ # Sleep to next iteration
801
+ 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
@@ -110,7 +54,7 @@ class G90BaseCommand:
110
54
 
111
55
  def __init__(self, host, port, code,
112
56
  data=None, local_port=None,
113
- timeout=3.0, retries=3, sock=None):
57
+ timeout=3.0, retries=3):
114
58
  """
115
59
  tbd
116
60
  """
@@ -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
@@ -130,13 +75,40 @@ class G90BaseCommand:
130
75
  # No whitespace around entities
131
76
  separators=(',', ':'))
132
77
  self._resp = G90Header()
133
- self._sock = sock
134
78
 
135
- def _proto_factory(self): # pylint: disable=no-self-use
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):
136
104
  """
137
105
  tbd
138
106
  """
139
- 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)
140
112
 
141
113
  async def _create_connection(self):
142
114
  """
@@ -147,23 +119,17 @@ class G90BaseCommand:
147
119
  except AttributeError:
148
120
  loop = asyncio.get_event_loop()
149
121
 
150
- if self._sock:
151
- _LOGGER.debug('Using provided socket %s', self._sock)
152
- transport, protocol = await loop.create_datagram_endpoint(
153
- self._proto_factory,
154
- sock=self._sock)
155
- else:
156
- _LOGGER.debug('Creating UDP endpoint for %s:%s',
157
- self.host, self.port)
158
- extra_kwargs = {}
159
- if self._local_port:
160
- extra_kwargs['local_addr'] = ('0.0.0.0', self._local_port)
161
-
162
- transport, protocol = await loop.create_datagram_endpoint(
163
- self._proto_factory,
164
- remote_addr=(self.host, self.port),
165
- **extra_kwargs,
166
- allow_broadcast=True)
122
+ _LOGGER.debug('Creating UDP endpoint for %s:%s',
123
+ self.host, self.port)
124
+ extra_kwargs = {}
125
+ if self._local_port:
126
+ extra_kwargs['local_addr'] = ('0.0.0.0', self._local_port)
127
+
128
+ transport, protocol = await loop.create_datagram_endpoint(
129
+ lambda: self,
130
+ remote_addr=(self.host, self.port),
131
+ **extra_kwargs,
132
+ allow_broadcast=True)
167
133
 
168
134
  return transport, protocol
169
135
 
@@ -248,7 +214,7 @@ class G90BaseCommand:
248
214
  tbd
249
215
  """
250
216
 
251
- transport, protocol = await self._create_connection()
217
+ transport, _ = await self._create_connection()
252
218
  attempts = self._retries
253
219
  while True:
254
220
  attempts = attempts - 1
@@ -256,24 +222,24 @@ class G90BaseCommand:
256
222
  loop = asyncio.get_running_loop()
257
223
  except AttributeError:
258
224
  loop = asyncio.get_event_loop()
259
- protocol.future_data = loop.create_future()
225
+ self._connection_result = loop.create_future()
260
226
  async with self._sk_lock:
261
227
  _LOGGER.debug('(code %s) Sending request to %s:%s',
262
228
  self._code, self.host, self.port)
263
229
  transport.sendto(self.to_wire())
264
- done, _ = await asyncio.wait([protocol.future_data],
230
+ done, _ = await asyncio.wait([self._connection_result],
265
231
  timeout=self._timeout)
266
- if protocol.future_data in done:
232
+ if self._connection_result in done:
267
233
  break
268
234
  # Cancel the future to signal protocol handler it is no longer
269
235
  # valid, the future will be re-created on next retry
270
- protocol.future_data.cancel()
236
+ self._connection_result.cancel()
271
237
  if not attempts:
272
238
  transport.close()
273
239
  raise G90TimeoutError()
274
240
  _LOGGER.debug('Timed out, retrying')
275
241
  transport.close()
276
- (host, port, data) = protocol.future_data.result()
242
+ (host, port, data) = self._connection_result.result()
277
243
  _LOGGER.debug('Received response from %s:%s', host, port)
278
244
  if self.host != '255.255.255.255':
279
245
  if self.host != host or host == '255.255.255.255':
pyg90alarm/const.py CHANGED
@@ -27,6 +27,7 @@ from enum import IntEnum
27
27
  REMOTE_PORT = 12368
28
28
  REMOTE_TARGETED_DISCOVERY_PORT = 12900
29
29
  LOCAL_TARGETED_DISCOVERY_PORT = 12901
30
+ NOTIFICATIONS_PORT = 12901
30
31
 
31
32
  CMD_PAGE_SIZE = 10
32
33
 
@@ -184,10 +185,21 @@ class G90AlertSources(IntEnum):
184
185
  """
185
186
  Defines possible sources of the alert sent by the panel.
186
187
  """
188
+ DEVICE = 0
187
189
  SENSOR = 1
188
190
  DOORBELL = 12
189
191
 
190
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
+
191
203
  class G90AlertStateChangeTypes(IntEnum):
192
204
  """
193
205
  Defines types of alert for device state changes.
@@ -200,3 +212,21 @@ class G90AlertStateChangeTypes(IntEnum):
200
212
  LOW_BATTERY = 6
201
213
  WIFI_CONNECTED = 7
202
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