Skip to content

Commit 6f0757d

Browse files
authored
Merge pull request #34 from rlippmann/master
1.1.3
2 parents 8ccdf42 + c06ee2e commit 6f0757d

File tree

7 files changed

+178
-71
lines changed

7 files changed

+178
-71
lines changed

Diff for: CHANGELOG.md

+8
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
## 1.1.3 (2023-10-11)
2+
3+
* revert sync check logic to check against last check value. this should hopefully fix the problem of HA alarm status not updating
4+
* use exponential backoff for gateway updates if offline instead of constant 90 seconds
5+
* add jitter to relogin interval
6+
* add quick_relogin/async_quick_relogin to do a quick relogin without requerying devices, exiting tasks
7+
* add more alarm testing in example client
8+
19
## 1.1.2 (2023-10-06)
210

311
* change default poll interval to 2 seconds

Diff for: example-client.py

+75-23
Original file line numberDiff line numberDiff line change
@@ -321,9 +321,9 @@ def test_alarm(site: ADTPulseSite, adt: PyADTPulse) -> None:
321321
check_updates(site, adt, True)
322322
print("Testing disarming twice")
323323
if site.disarm():
324-
print("Test disarm twice failed")
324+
print("Test disarm twice suceeded")
325325
else:
326-
print("Test succeeded")
326+
print("Test disarm twice failed")
327327
else:
328328
print("Disarming failed")
329329

@@ -445,16 +445,33 @@ async def async_test_alarm(adt: PyADTPulse) -> None:
445445
"""
446446
print("Arming alarm stay")
447447
if await adt.site.async_arm_home():
448-
print("Alarm arming home succeeded")
449-
# check_updates(site, adt, False)
448+
print("Arming stay call succeeded")
449+
if adt.site.alarm_control_panel.is_arming:
450+
print("Arming stay pending check succeeded")
451+
else:
452+
print(
453+
f"FAIL: Arming home pending check failed {adt.site.alarm_control_panel} "
454+
)
455+
await adt.wait_for_update()
456+
if adt.site.alarm_control_panel.is_home:
457+
print("Arm stay no longer pending")
458+
else:
459+
while not adt.site.alarm_control_panel.is_home:
460+
pprint(f"FAIL: Arm stay value incorrect {adt.site.alarm_control_panel}")
461+
await adt.wait_for_update()
462+
450463
print("Testing invalid alarm state change from armed home to armed away")
451464
if await adt.site.async_arm_away():
452-
print("Error, armed away while already armed")
465+
print(
466+
f"FAIL: armed away while already armed {adt.site.alarm_control_panel}"
467+
)
453468
else:
454469
print("Test succeeded")
455470
print("Testing changing alarm status to same value")
456471
if await adt.site.async_arm_home():
457-
print("Error, allowed arming to same state")
472+
print(
473+
f"FAIL: allowed arming to same state {adt.site.alarm_control_panel}"
474+
)
458475
else:
459476
print("Test succeeded")
460477

@@ -463,35 +480,70 @@ async def async_test_alarm(adt: PyADTPulse) -> None:
463480
if await adt.site.async_arm_home(True):
464481
print("Force arm succeeded")
465482
else:
466-
print("Force arm failed")
467-
468-
print()
469-
print_site(adt.site)
470-
483+
print(f"FAIL: Force arm failed {adt.site.alarm_control_panel}")
471484
print("Disarming alarm")
472485
if await adt.site.async_disarm():
473486
print("Disarming succeeded")
474-
# check_updates(site, adt, False)
487+
if adt.site.alarm_control_panel.is_disarming:
488+
print("Disarm pending success")
489+
else:
490+
pprint(f"FAIL: Disarm pending fail {adt.site.alarm_control_panel}")
491+
await adt.wait_for_update()
492+
if adt.site.alarm_control_panel.is_disarmed:
493+
print("Success update to disarm")
494+
else:
495+
while not adt.site.alarm_control_panel.is_disarmed:
496+
pprint(
497+
"FAIL: did not set to disarm after update "
498+
f"{adt.site.alarm_control_panel}"
499+
)
500+
await adt.wait_for_update()
501+
print("Test finally succeeded")
475502
print("Testing disarming twice")
476503
if await adt.site.async_disarm():
477-
print("Test failed")
504+
print("Double disarm call succeeded")
478505
else:
479-
print("Test succeeded")
506+
pprint(f"FAIL: Double disarm call failed {adt.site.alarm_control_panel}")
507+
if adt.site.alarm_control_panel.is_disarming:
508+
print("Double disarm state is disarming")
509+
else:
510+
pprint(
511+
"FAIL: Double disarm state is not disarming "
512+
f"{adt.site.alarm_control_panel}"
513+
)
514+
await adt.wait_for_update()
515+
if adt.site.alarm_control_panel.is_disarmed:
516+
print("Double disarm success")
517+
else:
518+
while not adt.site.alarm_control_panel.is_disarmed:
519+
pprint(
520+
"FAIL: Double disarm state is not disarmed "
521+
f"{adt.site.alarm_control_panel}"
522+
)
523+
await adt.wait_for_update()
524+
print("Test finally succeeded")
480525
else:
481526
print("Disarming failed")
482-
483-
print()
484-
print_site(adt.site)
485527
print("Arming alarm away")
486-
487528
if await adt.site.async_arm_away():
488-
print("Arm away succeeded")
489-
# check_updates(site, adt, False)
529+
print("Arm away call succeeded")
530+
if adt.site.alarm_control_panel.is_arming:
531+
print("Arm away arm pending")
532+
else:
533+
pprint(f"FAIL: arm away call not pending {adt.site.alarm_control_panel}")
534+
await adt.wait_for_update()
535+
if adt.site.alarm_control_panel.is_away:
536+
print("Arm away call after update succeed")
537+
else:
538+
while not adt.site.alarm_control_panel.is_away:
539+
pprint(
540+
"FAIL: arm away call after update failed "
541+
"f{adt.site.alarm_control_panel}"
542+
)
543+
await adt.wait_for_update()
544+
print("Test finally succeeded")
490545
else:
491546
print("Arm away failed")
492-
493-
print()
494-
print_site(adt.site)
495547
await adt.site.async_disarm()
496548
print("Disarmed")
497549

Diff for: pyadtpulse/__init__.py

+56-37
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import re
77
import time
88
from contextlib import suppress
9+
from random import randint
910
from threading import RLock, Thread
1011
from typing import List, Optional, Union
1112
from warnings import warn
@@ -371,22 +372,21 @@ def _check_retry_after(
371372

372373
async def _keepalive_task(self) -> None:
373374
retry_after = 0
375+
response: ClientResponse | None = None
374376
if self._timeout_task is not None:
375377
task_name = self._timeout_task.get_name()
376378
else:
377379
task_name = f"{KEEPALIVE_TASK_NAME} - possible internal error"
378380
LOG.debug("creating %s", task_name)
379-
response = None
380381
with self._attribute_lock:
381382
if self._authenticated is None:
382383
raise RuntimeError(
383384
"Keepalive task is running without an authenticated event"
384385
)
385386
while self._authenticated.is_set():
386387
relogin_interval = self.relogin_interval * 60
387-
if (
388-
relogin_interval != 0
389-
and time.time() - self._last_login_time > relogin_interval
388+
if relogin_interval != 0 and time.time() - self._last_login_time > randint(
389+
int(0.75 * relogin_interval), relogin_interval
390390
):
391391
LOG.info("Login timeout reached, re-logging in")
392392
# FIXME?: should we just pause the task?
@@ -396,13 +396,9 @@ async def _keepalive_task(self) -> None:
396396
with suppress(Exception):
397397
await self._sync_task
398398
await self._do_logout_query()
399-
response = await self._do_login_query()
400-
if response is None:
401-
LOG.error(
402-
"%s could not re-login to ADT Pulse, exiting...", task_name
403-
)
399+
if not await self.async_quick_relogin():
400+
LOG.error("%s could not re-login, exiting", task_name)
404401
return
405-
close_response(response)
406402
if self._sync_task is not None:
407403
coro = self._sync_check_task()
408404
self._sync_task = asyncio.create_task(
@@ -509,6 +505,27 @@ def loop(self) -> Optional[asyncio.AbstractEventLoop]:
509505
"""
510506
return self._pulse_connection.loop
511507

508+
async def async_quick_relogin(self) -> bool:
509+
"""Quickly re-login to Pulse.
510+
511+
Doesn't do device queries or set connected event unless a failure occurs.
512+
FIXME: Should probably just re-work login logic."""
513+
response = await self._do_login_query()
514+
if not handle_response(response, logging.ERROR, "Could not re-login to Pulse"):
515+
await self.async_logout()
516+
return False
517+
return True
518+
519+
def quick_relogin(self) -> bool:
520+
"""Perform quick_relogin synchronously."""
521+
coro = self.async_quick_relogin()
522+
return asyncio.run_coroutine_threadsafe(
523+
coro,
524+
self._pulse_connection.check_sync(
525+
"Attempting to do call sync quick re-login from async"
526+
),
527+
).result()
528+
512529
async def _do_login_query(self, timeout: int = 30) -> ClientResponse | None:
513530
try:
514531
retval = await self._pulse_connection.async_query(
@@ -635,9 +652,9 @@ async def async_logout(self) -> None:
635652

636653
def logout(self) -> None:
637654
"""Log out of ADT Pulse."""
638-
loop = self._pulse_connection.loop
639-
if loop is None:
640-
raise RuntimeError("Attempting to call sync logout without sync login")
655+
loop = self._pulse_connection.check_sync(
656+
"Attempting to call sync logout without sync login"
657+
)
641658
sync_thread = self._session_thread
642659

643660
coro = self.async_logout()
@@ -655,14 +672,17 @@ async def _sync_check_task(self) -> None:
655672
LOG.debug("creating %s", task_name)
656673
response = None
657674
retry_after = 0
675+
last_sync_text = "0-0-0"
658676
if self._updates_exist is None:
659677
raise RuntimeError(f"{task_name} started without update event initialized")
660-
have_update = False
678+
have_updates = False
661679
while True:
662680
try:
663-
pi = self.site.gateway.poll_interval
664-
if have_update:
665-
pi = pi / 2.0
681+
self.site.gateway.adjust_backoff_poll_interval()
682+
if not have_updates:
683+
pi = self.site.gateway.poll_interval
684+
else:
685+
pi = 0.0
666686
if retry_after == 0:
667687
await asyncio.sleep(pi)
668688
else:
@@ -684,35 +704,32 @@ async def _sync_check_task(self) -> None:
684704
):
685705
close_response(response)
686706
continue
687-
707+
close_response(response)
688708
pattern = r"\d+[-]\d+[-]\d+"
689709
if not re.match(pattern, text):
690710
LOG.warning(
691711
"Unexpected sync check format (%s), forcing re-auth", pattern
692712
)
693713
LOG.debug("Received %s from ADT Pulse site", text)
694-
close_response(response)
695714
await self._do_logout_query()
696-
await self.async_login()
715+
if not await self.async_quick_relogin():
716+
LOG.error("%s couldn't re-login, exiting.", task_name)
697717
continue
698-
699-
# we can have 0-0-0 followed by 1-0-0 followed by 2-0-0, etc
700-
# wait until these settle
701-
if text.endswith("-0-0"):
702-
LOG.debug(
703-
"Sync token %s indicates updates may exist, requerying", text
704-
)
705-
close_response(response)
706-
have_update = True
718+
if text != last_sync_text:
719+
LOG.debug("Updates exist: %s, requerying", text)
720+
last_sync_text = text
721+
have_updates = True
707722
continue
708-
if have_update:
709-
have_update = False
723+
if have_updates:
724+
have_updates = False
710725
if await self.async_update() is False:
711726
LOG.debug("Pulse data update from %s failed", task_name)
712727
continue
713728
self._updates_exist.set()
714-
LOG.debug("Sync token %s indicates no remote updates to process", text)
715-
close_response(response)
729+
else:
730+
LOG.debug(
731+
"Sync token %s indicates no remote updates to process", text
732+
)
716733

717734
except asyncio.CancelledError:
718735
LOG.debug("%s cancelled", task_name)
@@ -802,11 +819,13 @@ def update(self) -> bool:
802819
Returns:
803820
bool: True on success
804821
"""
805-
loop = self._pulse_connection.loop
806-
if loop is None:
807-
raise RuntimeError("Attempting to run sync update from async login")
808822
coro = self.async_update()
809-
return asyncio.run_coroutine_threadsafe(coro, loop).result()
823+
return asyncio.run_coroutine_threadsafe(
824+
coro,
825+
self._pulse_connection.check_sync(
826+
"Attempting to run sync update from async login"
827+
),
828+
).result()
810829

811830
@property
812831
def sites(self) -> List[ADTPulseSite]:

Diff for: pyadtpulse/alarm_panel.py

+6-7
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,6 @@ async def _arm(
137137
mode,
138138
self._status,
139139
)
140-
return False
141140
if self._status != ADT_ALARM_OFF and mode != ADT_ALARM_OFF:
142141
LOG.warning("Cannot set alarm status from %s to %s", self._status, mode)
143142
return False
@@ -195,13 +194,13 @@ def _sync_set_alarm_mode(
195194
mode: str,
196195
force_arm: bool = False,
197196
) -> bool:
198-
loop = connection.loop
199-
if loop is None:
200-
raise RuntimeError(
201-
"Attempting to sync change alarm mode from async session"
202-
)
203197
coro = self._arm(connection, mode, force_arm)
204-
return run_coroutine_threadsafe(coro, loop).result()
198+
return run_coroutine_threadsafe(
199+
coro,
200+
connection.check_sync(
201+
"Attempting to sync change alarm mode from async session"
202+
),
203+
).result()
205204

206205
def arm_away(self, connection: ADTPulseConnection, force_arm: bool = False) -> bool:
207206
"""Arm the alarm in Away mode.

Diff for: pyadtpulse/const.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""Constants for pyadtpulse."""
2-
__version__ = "1.1.2"
2+
__version__ = "1.1.3"
33
DEFAULT_API_HOST = "https://portal.adtpulse.com"
44
API_HOST_CA = "https://portal-ca.adtpulse.com" # Canada
55

Diff for: pyadtpulse/gateway.py

+19
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,25 @@ def poll_interval(self, new_interval: Optional[float]) -> None:
128128
self._current_poll_interval = new_interval
129129
LOG.debug("Set poll interval to %f", self._initial_poll_interval)
130130

131+
def adjust_backoff_poll_interval(self) -> None:
132+
"""Calculates the backoff poll interval.
133+
134+
Each call will adjust current_poll interval with exponential backoff,
135+
unless gateway is online, in which case, poll interval will be reset to
136+
initial_poll interval."""
137+
138+
with self._attribute_lock:
139+
if self.is_online:
140+
self._current_poll_interval = self._initial_poll_interval
141+
return
142+
# use an exponential backoff
143+
self._current_poll_interval = self._current_poll_interval * 2
144+
if self._current_poll_interval > ADT_GATEWAY_OFFLINE_POLL_INTERVAL:
145+
self._current_poll_interval = ADT_DEFAULT_POLL_INTERVAL
146+
LOG.debug(
147+
"Setting current poll interval to %f", self._current_poll_interval
148+
)
149+
131150
def set_gateway_attributes(self, gateway_attributes: dict[str, str]) -> None:
132151
"""Set gateway attributes from dictionary.
133152

0 commit comments

Comments
 (0)