Skip to content

Commit

Permalink
Merge pull request #34 from hostcc/feat-simulate-device-alerts-from-h…
Browse files Browse the repository at this point in the history
…istory

Device alerts simulation from recorded history
  • Loading branch information
hostcc committed Aug 17, 2024
2 parents 38792be + a1f0499 commit 120969d
Show file tree
Hide file tree
Showing 7 changed files with 373 additions and 15 deletions.
7 changes: 7 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,13 @@ set the IP address allocation up.
status will not be reflected and those will always be reported as inactive,
since there is no way to read their state in a polled manner.

To work that limitation around the package now supports simulating device
notifications from periodically polling the history it records - the
simulation works only for the alerts, not notifications (e.g. notifications
include low battery events and alike). This also requires the particular
alert to be enabled in the mobile application, otherwise it won't be
recorded in the history.

Quick start
===========

Expand Down
118 changes: 115 additions & 3 deletions src/pyg90alarm/alarm.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
wifi_signal_level=100)
"""

import asyncio
import logging
from .const import (
G90Commands, REMOTE_PORT,
Expand Down Expand Up @@ -113,6 +113,8 @@ def __init__(self, host, port=REMOTE_PORT,
self._reset_occupancy_interval = reset_occupancy_interval
self._alert_config = None
self._sms_alert_when_armed = False
self._alert_simulation_task = None
self._alert_simulation_start_listener_back = False

async def command(self, code, data=None):
"""
Expand Down Expand Up @@ -406,8 +408,13 @@ async def history(self, start=1, count=1):
"""
res = self.paginated_result(G90Commands.GETHISTORY,
start, count)
history = [G90History(*x.data) async for x in res]
return history

# Sort the history entries from older to newer - device typically does
# that, but apparently that is not guaranteed
return sorted(
[G90History(*x.data) async for x in res],
key=lambda x: x.datetime, reverse=True
)

async def on_sensor_activity(self, idx, name, occupancy=True):
"""
Expand Down Expand Up @@ -669,3 +676,108 @@ def sms_alert_when_armed(self):
@sms_alert_when_armed.setter
def sms_alert_when_armed(self, value):
self._sms_alert_when_armed = value

async def start_simulating_alerts_from_history(
self, interval=5, history_depth=5
):
"""
Starts the separate task to simulate device alerts from history
entries.
The listener for device notifications will be stopped, so device
notifications will not be processed thus resulting in possible
duplicated if those could be received from the network.
:param int interval: Interval (in seconds) between polling for newer
history entities
:param int history_depth: Amount of history entries to fetch during
each polling cycle
"""
# Remember if device notifications listener has been started already
self._alert_simulation_start_listener_back = self.listener_started
# And then stop it
self.close()

# Start the task
self._alert_simulation_task = asyncio.create_task(
self._simulate_alerts_from_history(interval, history_depth)
)

async def stop_simulating_alerts_from_history(self):
"""
Stops the task simulating device alerts from history entries.
The listener for device notifications will be started back, if it was
running when simulation has been started.
"""
# Stop the task simulating the device alerts from history if it was
# running
if self._alert_simulation_task:
self._alert_simulation_task.cancel()
self._alert_simulation_task = None

# Start device notifications listener back if it was running when
# simulated alerts have been enabled
if self._alert_simulation_start_listener_back:
await self.listen()

async def _simulate_alerts_from_history(self, interval, history_depth):
"""
Periodically fetches history entries from the device and simulates
device alerts off of those.
Only the history entries occur after the process is started are
handled, to avoid triggering callbacks retrospectively.
See :method:`start_simulating_alerts_from_history` for the parameters.
"""
last_history_ts = None

_LOGGER.debug(
'Simulating device alerts from history:'
' interval %s, history depth %s',
interval, history_depth
)
while True:
# Retrieve the history entries of the specified amount - full
# history retrieval might be an unnecessary long operation
history = await self.history(count=history_depth)

# Initial iteration where no timestamp of most recent history entry
# is recorded - do that and skip to next iteration, since it isn't
# yet known what entries would be considered as new ones
if not last_history_ts:
# First entry in the list is assumed to be the most recent one
last_history_ts = history[0].datetime
_LOGGER.debug(
'Initial time stamp of last history entry: %s',
last_history_ts
)
continue

# Process history entries from older to newer to preserve the order
# of happenings
for item in reversed(history):
# Process only the entries newer than one been recorded as most
# recent one
if item.datetime > last_history_ts:
_LOGGER.debug(
'Found newer history entry: %s, simulating alert',
repr(item)
)
# Send the history entry down the device notification code
# as alert, as if it came from the device and its
# notifications port
self._handle_alert(
(self._host, self._notifications_port),
item.as_device_alert()
)

# Record the entry as most recent one
last_history_ts = item.datetime
_LOGGER.debug(
'Time stamp of last history entry: %s', last_history_ts
)

# Sleep to next iteration
await asyncio.sleep(interval)
19 changes: 19 additions & 0 deletions src/pyg90alarm/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ class G90AlertSources(IntEnum):
"""
Defines possible sources of the alert sent by the panel.
"""
DEVICE = 0
SENSOR = 1
DOORBELL = 12

Expand All @@ -211,3 +212,21 @@ class G90AlertStateChangeTypes(IntEnum):
LOW_BATTERY = 6
WIFI_CONNECTED = 7
WIFI_DISCONNECTED = 8


class G90HistoryStates(IntEnum):
"""
Defines possible states for history entities.
"""
DOOR_CLOSE = 1
DOOR_OPEN = 2
TAMPER = 3
ALARM = 4
AC_POWER_FAILURE = 5
AC_POWER_RECOVER = 6
DISARM = 7
ARM_AWAY = 8
ARM_HOME = 9
LOW_BATTERY = 10
WIFI_CONNECTED = 11
WIFI_DISCONNECTED = 12
11 changes: 11 additions & 0 deletions src/pyg90alarm/device_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,9 +287,20 @@ async def listen(self):
self._notifications_host, self._notifications_port
))

@property
def listener_started(self):
"""
Indicates if the listener of the device notifications has been started.
:rtype: bool
"""
return self._notification_transport is not None

def close(self):
"""
Closes the listener.
"""
if self._notification_transport:
_LOGGER.debug('No longer listening for device notifications')
self._notification_transport.close()
self._notification_transport = None
Loading

0 comments on commit 120969d

Please sign in to comment.