From ce9940bbfb3993c57b22ff6dadf58caefc5c1bd0 Mon Sep 17 00:00:00 2001 From: f34rdotcom Date: Thu, 5 Apr 2018 03:51:45 +0000 Subject: [PATCH 01/10] added on_ready_change event handler and tests --- alarmdecoder/decoder.py | 29 +++++++++++++++++++++++++++++ test/test_ad2.py | 16 ++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/alarmdecoder/decoder.py b/alarmdecoder/decoder.py index 123fbe4..d06fd56 100644 --- a/alarmdecoder/decoder.py +++ b/alarmdecoder/decoder.py @@ -33,6 +33,7 @@ class AlarmDecoder(object): on_arm = event.Event("This event is called when the panel is armed.\n\n**Callback definition:** *def callback(device, stay)*") on_disarm = event.Event("This event is called when the panel is disarmed.\n\n**Callback definition:** *def callback(device)*") on_power_changed = event.Event("This event is called when panel power switches between AC and DC.\n\n**Callback definition:** *def callback(device, status)*") + on_ready_changed = event.Event("This event is called when panel ready state changes.\n\n**Callback definition:** *def callback(device, status)*") on_alarm = event.Event("This event is called when the alarm is triggered.\n\n**Callback definition:** *def callback(device, zone)*") on_alarm_restored = event.Event("This event is called when the alarm stops sounding.\n\n**Callback definition:** *def callback(device, zone)*") on_fire = event.Event("This event is called when a fire is detected.\n\n**Callback definition:** *def callback(device, status)*") @@ -140,6 +141,7 @@ def __init__(self, device, ignore_message_states=False): self._battery_timeout = AlarmDecoder.BATTERY_TIMEOUT self._fire_timeout = AlarmDecoder.FIRE_TIMEOUT self._power_status = None + self._ready_status = None self._alarm_status = None self._bypass_status = {} self._armed_status = None @@ -609,6 +611,7 @@ def _update_internal_states(self, message): """ if isinstance(message, Message) and not self._ignore_message_states: self._update_power_status(message) + self._update_ready_status(message) self._update_alarm_status(message) self._update_zone_bypass_status(message) self._update_armed_status(message) @@ -646,6 +649,32 @@ def _update_power_status(self, message=None, status=None): return self._power_status + def _update_ready_status(self, message=None, status=None): + """ + Uses the provided message to update the ready state. + + :param message: message to use to update + :type message: :py:class:`~alarmdecoder.messages.Message` + :param status: ready status, overrides message bits. + :type status: bool + + :returns: bool indicating the new status + """ + ready_status = status + if isinstance(message, Message): + ready_status = message.ready + + if ready_status is None: + return + + if ready_status != self._ready_status: + self._ready_status, old_status = ready_status, self._ready_status + + if old_status is not None: + self.on_ready_changed(status=self._ready_status) + + return self._ready_status + def _update_alarm_status(self, message=None, status=None, zone=None, user=None): """ Uses the provided message to update the alarm state. diff --git a/test/test_ad2.py b/test/test_ad2.py index 0ec6c52..71a22bd 100644 --- a/test/test_ad2.py +++ b/test/test_ad2.py @@ -20,6 +20,7 @@ def setUp(self): self._panicked = False self._relay_changed = False self._power_changed = False + self._ready_changed = False self._alarmed = False self._bypassed = False self._battery = False @@ -46,6 +47,7 @@ def setUp(self): self._decoder.on_panic += self.on_panic self._decoder.on_relay_changed += self.on_relay_changed self._decoder.on_power_changed += self.on_power_changed + self._decoder.on_ready_changed += self.on_ready_changed self._decoder.on_alarm += self.on_alarm self._decoder.on_alarm_restored += self.on_alarm_restored self._decoder.on_bypass += self.on_bypass @@ -79,6 +81,9 @@ def on_relay_changed(self, sender, *args, **kwargs): def on_power_changed(self, sender, *args, **kwargs): self._power_changed = kwargs['status'] + def on_ready_changed(self, sender, *args, **kwargs): + self._ready_changed = kwargs['status'] + def on_alarm(self, sender, *args, **kwargs): self._alarmed = True @@ -240,6 +245,17 @@ def test_power_changed_event(self): msg = self._decoder._handle_message(b'[0000000100000000----],000,[f707000600e5800c0c020000]," "') self.assertTrue(self._power_changed) + def test_ready_changed_event(self): + msg = self._decoder._handle_message(b'[0000000000000000----],000,[f707000600e5800c0c020000]," "') + self.assertFalse(self._ready_changed) # Not set first time we hit it. + + msg = self._decoder._handle_message(b'[1000000000000000----],000,[f707000600e5800c0c020000]," "') + self.assertFalse(self._ready_changed) + + msg = self._decoder._handle_message(b'[0000000000000000----],000,[f707000600e5800c0c020000]," "') + self.assertTrue(self._ready_changed) + + def test_alarm_event(self): msg = self._decoder._handle_message(b'[0000000000100000----],000,[f707000600e5800c0c020000]," "') self.assertFalse(self._alarmed) # Not set first time we hit it. From 3bef3c9a4c531a5eb2846475355655a68389a9c6 Mon Sep 17 00:00:00 2001 From: f34rdotcom Date: Thu, 5 Apr 2018 04:00:28 +0000 Subject: [PATCH 02/10] bug in test. Thanks for adding testinggit add test/test_ad2.py ! --- test/test_ad2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_ad2.py b/test/test_ad2.py index 71a22bd..28defcc 100644 --- a/test/test_ad2.py +++ b/test/test_ad2.py @@ -250,7 +250,7 @@ def test_ready_changed_event(self): self.assertFalse(self._ready_changed) # Not set first time we hit it. msg = self._decoder._handle_message(b'[1000000000000000----],000,[f707000600e5800c0c020000]," "') - self.assertFalse(self._ready_changed) + self.assertTrue(self._ready_changed) msg = self._decoder._handle_message(b'[0000000000000000----],000,[f707000600e5800c0c020000]," "') self.assertTrue(self._ready_changed) From ad3ff87c175768ed96a46f9cd3a02b9cb4bd51c3 Mon Sep 17 00:00:00 2001 From: f34rdotcom Date: Thu, 5 Apr 2018 07:04:21 +0000 Subject: [PATCH 03/10] again. --- test/test_ad2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_ad2.py b/test/test_ad2.py index 28defcc..9f53535 100644 --- a/test/test_ad2.py +++ b/test/test_ad2.py @@ -253,7 +253,7 @@ def test_ready_changed_event(self): self.assertTrue(self._ready_changed) msg = self._decoder._handle_message(b'[0000000000000000----],000,[f707000600e5800c0c020000]," "') - self.assertTrue(self._ready_changed) + self.assertFalse(self._ready_changed) def test_alarm_event(self): From 77c3bdb7d0010ddaaf173e333ada96583d23209b Mon Sep 17 00:00:00 2001 From: f34rdotcom Date: Fri, 6 Apr 2018 04:30:00 +0000 Subject: [PATCH 04/10] changed fire handeling to just use Fire bit and remove SYSTEM BATTERY LOW messages to avoid Ademco quirk. Removed processing of LRR messages as default behavior to avoid over processing of events from section #1 and LRR. --- alarmdecoder/decoder.py | 71 ++++++++++++----------------------------- test/test_ad2.py | 21 ++++-------- 2 files changed, 27 insertions(+), 65 deletions(-) diff --git a/alarmdecoder/decoder.py b/alarmdecoder/decoder.py index d06fd56..3392e53 100644 --- a/alarmdecoder/decoder.py +++ b/alarmdecoder/decoder.py @@ -123,7 +123,7 @@ class AlarmDecoder(object): version_flags = "" """Device flags enabled""" - def __init__(self, device, ignore_message_states=False): + def __init__(self, device, ignore_message_states=False, ignore_lrr_states=True): """ Constructor @@ -132,12 +132,15 @@ def __init__(self, device, ignore_message_states=False): :type device: Device :param ignore_message_states: Ignore regular panel messages when updating internal states :type ignore_message_states: bool + :param ignore_lrr_states: Ignore LRR panel messages when updating internal states + :type ignore_lrr_states: bool """ self._device = device self._zonetracker = Zonetracker(self) self._lrr_system = LRRSystem(self) self._ignore_message_states = ignore_message_states + self._ignore_lrr_states = ignore_lrr_states self._battery_timeout = AlarmDecoder.BATTERY_TIMEOUT self._fire_timeout = AlarmDecoder.FIRE_TIMEOUT self._power_status = None @@ -146,10 +149,7 @@ def __init__(self, device, ignore_message_states=False): self._bypass_status = {} self._armed_status = None self._armed_stay = False - self._fire_status = (False, 0) - self._fire_alarming = False - self._fire_alarming_changed = 0 - self._fire_state = FireState.NONE + self._fire_status = False self._battery_status = (False, 0) self._panic_status = False self._relay_status = {} @@ -470,8 +470,6 @@ def _handle_keypad_message(self, data): if self._internal_address_mask & msg.mask > 0: if not self._ignore_message_states: self._update_internal_states(msg) - else: - self._update_fire_status(status=None) self.on_message(message=msg) @@ -519,7 +517,8 @@ def _handle_lrr(self, data): """ msg = LRRMessage(data) - self._lrr_system.update(msg) + if not self._ignore_lrr_states: + self._lrr_system.update(msg) self.on_lrr_message(message=msg) return msg @@ -812,54 +811,26 @@ def _update_fire_status(self, message=None, status=None): :returns: boolean indicating the new status """ - is_lrr = status is not None fire_status = status + last_status = self._fire_status if isinstance(message, Message): - fire_status = message.fire_alarm - - last_status, last_update = self._fire_status - - if self._fire_state == FireState.NONE: - # Always move to a FIRE state if detected - if fire_status == True: - self._fire_state = FireState.ALARM - self._fire_status = (fire_status, time.time()) - - self.on_fire(status=FireState.ALARM) - - elif self._fire_state == FireState.ALARM: - # If we've received an LRR CANCEL message, move to ACKNOWLEDGED - if is_lrr and fire_status == False: - self._fire_state = FireState.ACKNOWLEDGED - self._fire_status = (fire_status, time.time()) - self.on_fire(status=FireState.ACKNOWLEDGED) + # Quirk in Ademco panels. The fire bit drops on "SYSTEM LO BAT" messages. + # needs to be done for different languages. + if self.mode == ADEMCO and message.text.startswith("SYSTEM"): + fire_status = last_status else: - # Handle bouncing status changes and timeout in order to revert back to NONE. - if last_status != fire_status or fire_status == True: - self._fire_status = (fire_status, time.time()) - - if fire_status == False and time.time() > last_update + self._fire_timeout: - self._fire_state = FireState.NONE - self.on_fire(status=FireState.NONE) - - elif self._fire_state == FireState.ACKNOWLEDGED: - # If we've received a second LRR FIRE message after a CANCEL, revert back to FIRE and trigger another event. - if is_lrr and fire_status == True: - self._fire_state = FireState.ALARM - self._fire_status = (fire_status, time.time()) - - self.on_fire(status=FireState.ALARM) - else: - # Handle bouncing status changes and timeout in order to revert back to NONE. - if last_status != fire_status or fire_status == True: - self._fire_status = (fire_status, time.time()) + fire_status = message.fire_alarm + + if fire_status is None: + return - if fire_status != True and time.time() > last_update + self._fire_timeout: - self._fire_state = FireState.NONE - self.on_fire(status=FireState.NONE) + if fire_status != self._fire_status: + self._fire_status, old_status = fire_status, self._fire_status - return self._fire_state == FireState.ALARM + if old_status is not None: + self.on_fire(status=self._fire_status) + return self._fire_status def _update_panic_status(self, status=None): """ diff --git a/test/test_ad2.py b/test/test_ad2.py index 9f53535..1448473 100644 --- a/test/test_ad2.py +++ b/test/test_ad2.py @@ -304,32 +304,23 @@ def test_battery_low_event(self): self.assertFalse(self._battery) def test_fire_alarm_event(self): - self._fire = FireState.NONE + msg = self._decoder._handle_message(b'[0000000000000000----],000,[f707000600e5800c0c020000]," "') + self.assertTrue(self._fire) # Not set the first time we hit it. msg = self._decoder._handle_message(b'[0000000000000100----],000,[f707000600e5800c0c020000]," "') - self.assertEquals(self._fire, FireState.ALARM) - - # force the timeout to expire. - with patch.object(time, 'time', return_value=self._decoder._fire_status[1] + 35): - msg = self._decoder._handle_message(b'[0000000000000000----],000,[f707000600e5800c0c020000]," "') - self.assertEquals(self._fire, FireState.NONE) + self.assertTrue(self._fire) def test_fire_lrr(self): - self._fire = FireState.NONE + self._fire = False msg = self._decoder._handle_message(b'!LRR:095,1,CID_1110,ff') # Fire: Non-specific self.assertIsInstance(msg, LRRMessage) - self.assertEquals(self._fire, FireState.ALARM) + self.assertTrue(self._fire) msg = self._decoder._handle_message(b'!LRR:001,1,CID_1406,ff') # Open/Close: Cancel self.assertIsInstance(msg, LRRMessage) - self.assertEquals(self._fire, FireState.ACKNOWLEDGED) - - # force the timeout to expire. - with patch.object(time, 'time', return_value=self._decoder._fire_status[1] + 35): - msg = self._decoder._handle_message(b'[0000000000000000----],000,[f707000600e5800c0c020000]," "') - self.assertEquals(self._fire, FireState.NONE) + self.assertFalse(self._fire) def test_hit_for_faults(self): self._decoder._handle_message(b'[0000000000000000----],000,[f707000600e5800c0c020000],"Hit * for faults "') From 6838f0ca8251162772c7f366774af65661f79c5a Mon Sep 17 00:00:00 2001 From: f34rdotcom Date: Fri, 6 Apr 2018 05:00:39 +0000 Subject: [PATCH 05/10] ok need to test locally.. how? --- test/test_ad2.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/test_ad2.py b/test/test_ad2.py index 1448473..8cf3e48 100644 --- a/test/test_ad2.py +++ b/test/test_ad2.py @@ -43,7 +43,7 @@ def setUp(self): self._device.on_read = EventHandler(Event(), self._device) self._device.on_write = EventHandler(Event(), self._device) - self._decoder = AlarmDecoder(self._device) + self._decoder = AlarmDecoder(self._device, ignore_lrr_states=False) self._decoder.on_panic += self.on_panic self._decoder.on_relay_changed += self.on_relay_changed self._decoder.on_power_changed += self.on_power_changed @@ -305,7 +305,7 @@ def test_battery_low_event(self): def test_fire_alarm_event(self): msg = self._decoder._handle_message(b'[0000000000000000----],000,[f707000600e5800c0c020000]," "') - self.assertTrue(self._fire) # Not set the first time we hit it. + self.assertFalse(self._fire) # Not set the first time we hit it. msg = self._decoder._handle_message(b'[0000000000000100----],000,[f707000600e5800c0c020000]," "') self.assertTrue(self._fire) From a05de69a3a1782aecbd481ccf75c77be10561f75 Mon Sep 17 00:00:00 2001 From: f34rdotcom Date: Sat, 7 Apr 2018 05:22:43 +0000 Subject: [PATCH 06/10] Ready and Arm change events were happening on the same message causing one message to be missing status bits of the other. Refactor to a new routine to avoid breaking anyone. New routine processes all state logic first for both ready and arm bits then calls event functions last. --- alarmdecoder/decoder.py | 53 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/alarmdecoder/decoder.py b/alarmdecoder/decoder.py index 3392e53..65dc146 100644 --- a/alarmdecoder/decoder.py +++ b/alarmdecoder/decoder.py @@ -609,11 +609,10 @@ def _update_internal_states(self, message): :type message: :py:class:`~alarmdecoder.messages.Message`, :py:class:`~alarmdecoder.messages.ExpanderMessage`, :py:class:`~alarmdecoder.messages.LRRMessage`, or :py:class:`~alarmdecoder.messages.RFMessage` """ if isinstance(message, Message) and not self._ignore_message_states: + self._update_armed_ready_status(message) self._update_power_status(message) - self._update_ready_status(message) self._update_alarm_status(message) self._update_zone_bypass_status(message) - self._update_armed_status(message) self._update_battery_status(message) self._update_fire_status(message) @@ -738,6 +737,52 @@ def _update_zone_bypass_status(self, message=None, status=None, zone=None): return bypass_status + def _update_armed_ready_status(self, message=None): + """ + Uses the provided message to update the armed state + and ready state at once as they can change in the same + message and we want both events to have the same states. + :param message: message to use to update + :type message: :py:class:`~alarmdecoder.messages.Message` + + """ + + arm_status = None + stay_status = None + ready_status = None + + send_ready = False + send_arm = False + + if isinstance(message, Message): + arm_status = message.armed_away + stay_status = message.armed_home + ready_status = message.ready + + if arm_status is None or stay_status is None or ready_status is None: + return + + self._armed_stay, old_stay = stay_status, self._armed_stay + self._armed_status, old_arm = arm_status, self._armed_status + self._ready_status, old_ready_status = ready_status, self._ready_status + + if old_arm is not None: + if arm_status != old_arm or stay_status != old_stay: + send_arm = True + + if old_ready_status is not None: + if ready_status != old_ready_status: + send_ready = True + + if send_ready: + self.on_ready_changed(status=self._ready_status) + + if send_arm: + if self._armed_status or self._armed_stay: + self.on_arm(stay=stay_status) + else: + self.on_disarm() + def _update_armed_status(self, message=None, status=None, status_stay=None): """ Uses the provided message to update the armed state. @@ -757,10 +802,8 @@ def _update_armed_status(self, message=None, status=None, status_stay=None): if isinstance(message, Message): arm_status = message.armed_away stay_status = message.armed_home - if arm_status is None or stay_status is None: return - self._armed_status, old_status = arm_status, self._armed_status self._armed_stay, old_stay = stay_status, self._armed_stay if arm_status != old_status or stay_status != old_stay: @@ -815,7 +858,7 @@ def _update_fire_status(self, message=None, status=None): last_status = self._fire_status if isinstance(message, Message): # Quirk in Ademco panels. The fire bit drops on "SYSTEM LO BAT" messages. - # needs to be done for different languages. + # FIXME: does not support non english panels. if self.mode == ADEMCO and message.text.startswith("SYSTEM"): fire_status = last_status else: From 940ba4b6597d2992fbb2100ab4f9602fa1ae6f86 Mon Sep 17 00:00:00 2001 From: f34rdotcom <=> Date: Thu, 12 Apr 2018 20:02:29 +0000 Subject: [PATCH 07/10] Not used, never used, private method. Switched to new _update_armed_ready_status. --- alarmdecoder/decoder.py | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/alarmdecoder/decoder.py b/alarmdecoder/decoder.py index 65dc146..6f92bdf 100644 --- a/alarmdecoder/decoder.py +++ b/alarmdecoder/decoder.py @@ -647,32 +647,6 @@ def _update_power_status(self, message=None, status=None): return self._power_status - def _update_ready_status(self, message=None, status=None): - """ - Uses the provided message to update the ready state. - - :param message: message to use to update - :type message: :py:class:`~alarmdecoder.messages.Message` - :param status: ready status, overrides message bits. - :type status: bool - - :returns: bool indicating the new status - """ - ready_status = status - if isinstance(message, Message): - ready_status = message.ready - - if ready_status is None: - return - - if ready_status != self._ready_status: - self._ready_status, old_status = ready_status, self._ready_status - - if old_status is not None: - self.on_ready_changed(status=self._ready_status) - - return self._ready_status - def _update_alarm_status(self, message=None, status=None, zone=None, user=None): """ Uses the provided message to update the alarm state. From 25bb5b29d47bfd6d09806a4fbb0de78d8417d6ec Mon Sep 17 00:00:00 2001 From: f34rdotcom <=> Date: Thu, 12 Apr 2018 20:10:36 +0000 Subject: [PATCH 08/10] revert --- alarmdecoder/decoder.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/alarmdecoder/decoder.py b/alarmdecoder/decoder.py index 6f92bdf..eeffb96 100644 --- a/alarmdecoder/decoder.py +++ b/alarmdecoder/decoder.py @@ -776,8 +776,10 @@ def _update_armed_status(self, message=None, status=None, status_stay=None): if isinstance(message, Message): arm_status = message.armed_away stay_status = message.armed_home + if arm_status is None or stay_status is None: return + self._armed_status, old_status = arm_status, self._armed_status self._armed_stay, old_stay = stay_status, self._armed_stay if arm_status != old_status or stay_status != old_stay: From cf9ed59cb7cc202219fbb5f85bd3923b6477f257 Mon Sep 17 00:00:00 2001 From: f34rdotcom Date: Sat, 14 Apr 2018 04:52:46 +0000 Subject: [PATCH 09/10] Failure is not an option. Raise an exception. --- alarmdecoder/decoder.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/alarmdecoder/decoder.py b/alarmdecoder/decoder.py index eeffb96..d927554 100644 --- a/alarmdecoder/decoder.py +++ b/alarmdecoder/decoder.py @@ -415,7 +415,10 @@ def _handle_message(self, data): :returns: :py:class:`~alarmdecoder.messages.Message` """ - data = data.decode('utf-8') + try: + data = data.decode('utf-8') + except: + raise InvalidMessageError('Decode failed for message: {0}'.format(data)) if data is not None: data = data.lstrip('\0') From d0d242030d8d445572b568c681bb1c5c24e4da57 Mon Sep 17 00:00:00 2001 From: f34rdotcom Date: Sat, 14 Apr 2018 05:18:45 +0000 Subject: [PATCH 10/10] version bump --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 64bc27c..b76f57b 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ def readme(): extra_requirements.append('future==0.14.3') setup(name='alarmdecoder', - version='1.13.2', + version='1.13.3', description='Python interface for the AlarmDecoder (AD2) family ' 'of alarm devices which includes the AD2USB, AD2SERIAL and AD2PI.', long_description=readme(),