diff --git a/alarmdecoder/decoder.py b/alarmdecoder/decoder.py index 123fbe4..d927554 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)*") @@ -122,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 @@ -131,23 +132,24 @@ 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 + self._ready_status = None self._alarm_status = None 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 = {} @@ -413,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') @@ -468,8 +473,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) @@ -517,7 +520,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 @@ -608,10 +612,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_alarm_status(message) self._update_zone_bypass_status(message) - self._update_armed_status(message) self._update_battery_status(message) self._update_fire_status(message) @@ -710,6 +714,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. @@ -783,54 +833,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. + # FIXME: does not support non english panels. + 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/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(), diff --git a/test/test_ad2.py b/test/test_ad2.py index 0ec6c52..8cf3e48 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 @@ -42,10 +43,11 @@ 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 + 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.assertTrue(self._ready_changed) + + msg = self._decoder._handle_message(b'[0000000000000000----],000,[f707000600e5800c0c020000]," "') + self.assertFalse(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. @@ -288,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.assertFalse(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 "')