From 7e2ad594cafea946beaa24c51ae5f589d44ab399 Mon Sep 17 00:00:00 2001 From: Scott Petersen Date: Tue, 18 Jun 2013 11:57:33 -0700 Subject: [PATCH] Added support for Expander messages. API changes to support address/channel targeting of faults. Moved messages into their own namespace. Bugfixes. --- pyad2usb/ad2usb.py | 263 ++++++--------------------------------- pyad2usb/messages.py | 197 +++++++++++++++++++++++++++++ pyad2usb/zonetracking.py | 90 ++++++++------ test.py | 10 +- 4 files changed, 300 insertions(+), 260 deletions(-) create mode 100644 pyad2usb/messages.py diff --git a/pyad2usb/ad2usb.py b/pyad2usb/ad2usb.py index 69bff97..a034004 100644 --- a/pyad2usb/ad2usb.py +++ b/pyad2usb/ad2usb.py @@ -10,6 +10,7 @@ from collections import OrderedDict from .event import event from . import devices from . import util +from . import messages from . import zonetracking class Overseer(object): @@ -266,6 +267,13 @@ class AD2USB(object): """ Faults a zone if we are emulating a zone expander. """ + # Allow ourselves to also be passed an address/channel combination + # for zone expanders. + # + # Format (expander index, channel) + if isinstance(zone, tuple): + zone = self._zonetracker._expander_to_zone(*zone) + status = 2 if simulate_wire_problem else 1 self._device.write("L{0:02}{1}\r".format(zone, status)) @@ -297,7 +305,7 @@ class AD2USB(object): msg = None if data[0] != '!': - msg = Message(data) + msg = messages.Message(data) if self.address_mask & msg.mask > 0: self._update_internal_states(msg) @@ -306,11 +314,12 @@ class AD2USB(object): header = data[0:4] if header == '!EXP' or header == '!REL': - msg = ExpanderMessage(data) + msg = messages.ExpanderMessage(data) + self._update_internal_states(msg) elif header == '!RFX': - msg = RFMessage(data) + msg = messages.RFMessage(data) elif header == '!LRR': - msg = LRRMessage(data) + msg = messages.LRRMessage(data) elif data.startswith('!Ready'): self.on_boot() elif data.startswith('!CONFIG'): @@ -349,47 +358,49 @@ class AD2USB(object): """ Updates internal device states. """ - if message.ac_power != self._power_status: - self._power_status, old_status = message.ac_power, self._power_status + if isinstance(message, messages.Message): + if message.ac_power != self._power_status: + self._power_status, old_status = message.ac_power, self._power_status - if old_status is not None: - self.on_power_changed(self._power_status) + if old_status is not None: + self.on_power_changed(self._power_status) - if message.alarm_sounding != self._alarm_status: - self._alarm_status, old_status = message.alarm_sounding, self._alarm_status + if message.alarm_sounding != self._alarm_status: + self._alarm_status, old_status = message.alarm_sounding, self._alarm_status - if old_status is not None: - self.on_alarm(self._alarm_status) + if old_status is not None: + self.on_alarm(self._alarm_status) - if message.zone_bypassed != self._bypass_status: - self._bypass_status, old_status = message.zone_bypassed, self._bypass_status + if message.zone_bypassed != self._bypass_status: + self._bypass_status, old_status = message.zone_bypassed, self._bypass_status - if old_status is not None: - self.on_bypass(self._bypass_status) + if old_status is not None: + self.on_bypass(self._bypass_status) - if (message.armed_away | message.armed_home) != self._armed_status: - self._armed_status, old_status = message.armed_away | message.armed_home, self._armed_status + if (message.armed_away | message.armed_home) != self._armed_status: + self._armed_status, old_status = message.armed_away | message.armed_home, self._armed_status - if old_status is not None: - if self._armed_status: - self.on_arm() - else: - self.on_disarm() + if old_status is not None: + if self._armed_status: + self.on_arm() + else: + self.on_disarm() - if message.fire_alarm != self._fire_status: - self._fire_status, old_status = message.fire_alarm, self._fire_status + if message.fire_alarm != self._fire_status: + self._fire_status, old_status = message.fire_alarm, self._fire_status - if old_status is not None: - self.on_fire(self._fire_status) + if old_status is not None: + self.on_fire(self._fire_status) self._update_zone_tracker(message) def _update_zone_tracker(self, message): # Retrieve a list of faults. # NOTE: This only happens on first boot or after exiting programming mode. - if not message.ready and "Hit * for faults" in message.text: - self._device.write('*') - return + if isinstance(message, messages.Message): + if not message.ready and "Hit * for faults" in message.text: + self._device.write('*') + return self._zonetracker.update(message) @@ -432,195 +443,3 @@ class AD2USB(object): Internal handler for zone restoration. """ self.on_zone_restore(args) - -class Message(object): - """ - Represents a message from the alarm panel. - """ - - def __init__(self, data=None): - """ - Constructor - """ - self.ready = False - self.armed_away = False - self.armed_home = False - self.backlight_on = False - self.programming_mode = False - self.beeps = -1 - self.zone_bypassed = False - self.ac_power = False - self.chime_on = False - self.alarm_event_occurred = False - self.alarm_sounding = False - self.battery_low = False - self.entry_delay_off = False - self.fire_alarm = False - self.check_zone = False - self.perimeter_only = False - self.numeric_code = "" - self.text = "" - self.cursor_location = -1 - self.data = "" - self.mask = "" - self.bitfield = "" - self.panel_data = "" - - self._regex = re.compile('("(?:[^"]|"")*"|[^,]*),("(?:[^"]|"")*"|[^,]*),("(?:[^"]|"")*"|[^,]*),("(?:[^"]|"")*"|[^,]*)') - - if data is not None: - self._parse_message(data) - - def _parse_message(self, data): - """ - Parse the message from the device. - """ - m = self._regex.match(data) - - if m is None: - raise util.InvalidMessageError('Received invalid message: {0}'.format(data)) - - self.bitfield, self.numeric_code, self.panel_data, alpha = m.group(1, 2, 3, 4) - self.mask = int(self.panel_data[3:3+8], 16) - - self.data = data - self.ready = not self.bitfield[1:2] == "0" - self.armed_away = not self.bitfield[2:3] == "0" - self.armed_home = not self.bitfield[3:4] == "0" - self.backlight_on = not self.bitfield[4:5] == "0" - self.programming_mode = not self.bitfield[5:6] == "0" - self.beeps = int(self.bitfield[6:7], 16) - self.zone_bypassed = not self.bitfield[7:8] == "0" - self.ac_power = not self.bitfield[8:9] == "0" - self.chime_on = not self.bitfield[9:10] == "0" - self.alarm_event_occurred = not self.bitfield[10:11] == "0" - self.alarm_sounding = not self.bitfield[11:12] == "0" - self.battery_low = not self.bitfield[12:13] == "0" - self.entry_delay_off = not self.bitfield[13:14] == "0" - self.fire_alarm = not self.bitfield[14:15] == "0" - self.check_zone = not self.bitfield[15:16] == "0" - self.perimeter_only = not self.bitfield[16:17] == "0" - # bits 17-20 unused. - self.text = alpha.strip('"') - - if int(self.panel_data[19:21], 16) & 0x01 > 0: - self.cursor_location = int(self.bitfield[21:23], 16) # Alpha character index that the cursor is on. - - def __str__(self): - """ - String conversion operator. - """ - return 'msg > {0:0<9} [{1}{2}{3}] -- ({4}) {5}'.format(hex(self.mask), 1 if self.ready else 0, 1 if self.armed_away else 0, 1 if self.armed_home else 0, self.numeric_code, self.text) - -class ExpanderMessage(object): - """ - Represents a message from a zone or relay expansion module. - """ - - ZONE = 0 - RELAY = 1 - - def __init__(self, data=None): - """ - Constructor - """ - self.type = None - self.address = None - self.channel = None - self.value = None - self.raw = None - - if data is not None: - self._parse_message(data) - - def __str__(self): - """ - String conversion operator. - """ - expander_type = 'UNKWN' - if self.type == ExpanderMessage.ZONE: - expander_type = 'ZONE' - elif self.type == ExpanderMessage.RELAY: - expander_type = 'RELAY' - - return 'exp > [{0: <5}] {1}/{2} -- {3}'.format(expander_type, self.address, self.channel, self.value) - - def _parse_message(self, data): - """ - Parse the raw message from the device. - """ - header, values = data.split(':') - address, channel, value = values.split(',') - - self.raw = data - self.address = address - self.channel = channel - self.value = value - - if header == '!EXP': - self.type = ExpanderMessage.ZONE - elif header == '!REL': - self.type = ExpanderMessage.RELAY - -class RFMessage(object): - """ - Represents a message from an RF receiver. - """ - - def __init__(self, data=None): - """ - Constructor - """ - self.raw = None - self.serial_number = None - self.value = None - - if data is not None: - self._parse_message(data) - - def __str__(self): - """ - String conversion operator. - """ - return 'rf > {0}: {1}'.format(self.serial_number, self.value) - - def _parse_message(self, data): - """ - Parses the raw message from the device. - """ - self.raw = data - - _, values = data.split(':') - self.serial_number, self.value = values.split(',') - -class LRRMessage(object): - """ - Represent a message from a Long Range Radio. - """ - - def __init__(self, data=None): - """ - Constructor - """ - self.raw = None - self._event_data = None - self._partition = None - self._event_type = None - - if data is not None: - self._parse_message(data) - - def __str__(self): - """ - String conversion operator. - """ - return 'lrr > {0} @ {1} -- {2}'.format() - - def _parse_message(self, data): - """ - Parses the raw message from the device. - """ - self.raw = data - - _, values = data.split(':') - self._event_data, self._partition, self._event_type = values.split(',') diff --git a/pyad2usb/messages.py b/pyad2usb/messages.py new file mode 100644 index 0000000..86e6e54 --- /dev/null +++ b/pyad2usb/messages.py @@ -0,0 +1,197 @@ +""" +Message representations received from the panel through the AD2USB. +""" + +import re + +class Message(object): + """ + Represents a message from the alarm panel. + """ + + def __init__(self, data=None): + """ + Constructor + """ + self.ready = False + self.armed_away = False + self.armed_home = False + self.backlight_on = False + self.programming_mode = False + self.beeps = -1 + self.zone_bypassed = False + self.ac_power = False + self.chime_on = False + self.alarm_event_occurred = False + self.alarm_sounding = False + self.battery_low = False + self.entry_delay_off = False + self.fire_alarm = False + self.check_zone = False + self.perimeter_only = False + self.numeric_code = "" + self.text = "" + self.cursor_location = -1 + self.data = "" + self.mask = "" + self.bitfield = "" + self.panel_data = "" + + self._regex = re.compile('("(?:[^"]|"")*"|[^,]*),("(?:[^"]|"")*"|[^,]*),("(?:[^"]|"")*"|[^,]*),("(?:[^"]|"")*"|[^,]*)') + + if data is not None: + self._parse_message(data) + + def _parse_message(self, data): + """ + Parse the message from the device. + """ + m = self._regex.match(data) + + if m is None: + raise util.InvalidMessageError('Received invalid message: {0}'.format(data)) + + self.bitfield, self.numeric_code, self.panel_data, alpha = m.group(1, 2, 3, 4) + self.mask = int(self.panel_data[3:3+8], 16) + + self.data = data + self.ready = not self.bitfield[1:2] == "0" + self.armed_away = not self.bitfield[2:3] == "0" + self.armed_home = not self.bitfield[3:4] == "0" + self.backlight_on = not self.bitfield[4:5] == "0" + self.programming_mode = not self.bitfield[5:6] == "0" + self.beeps = int(self.bitfield[6:7], 16) + self.zone_bypassed = not self.bitfield[7:8] == "0" + self.ac_power = not self.bitfield[8:9] == "0" + self.chime_on = not self.bitfield[9:10] == "0" + self.alarm_event_occurred = not self.bitfield[10:11] == "0" + self.alarm_sounding = not self.bitfield[11:12] == "0" + self.battery_low = not self.bitfield[12:13] == "0" + self.entry_delay_off = not self.bitfield[13:14] == "0" + self.fire_alarm = not self.bitfield[14:15] == "0" + self.check_zone = not self.bitfield[15:16] == "0" + self.perimeter_only = not self.bitfield[16:17] == "0" + # bits 17-20 unused. + self.text = alpha.strip('"') + + if int(self.panel_data[19:21], 16) & 0x01 > 0: + self.cursor_location = int(self.bitfield[21:23], 16) # Alpha character index that the cursor is on. + + def __str__(self): + """ + String conversion operator. + """ + return 'msg > {0:0<9} [{1}{2}{3}] -- ({4}) {5}'.format(hex(self.mask), 1 if self.ready else 0, 1 if self.armed_away else 0, 1 if self.armed_home else 0, self.numeric_code, self.text) + +class ExpanderMessage(object): + """ + Represents a message from a zone or relay expansion module. + """ + + ZONE = 0 + RELAY = 1 + + def __init__(self, data=None): + """ + Constructor + """ + self.type = None + self.address = None + self.channel = None + self.value = None + self.raw = None + + if data is not None: + self._parse_message(data) + + def __str__(self): + """ + String conversion operator. + """ + expander_type = 'UNKWN' + if self.type == ExpanderMessage.ZONE: + expander_type = 'ZONE' + elif self.type == ExpanderMessage.RELAY: + expander_type = 'RELAY' + + return 'exp > [{0: <5}] {1}/{2} -- {3}'.format(expander_type, self.address, self.channel, self.value) + + def _parse_message(self, data): + """ + Parse the raw message from the device. + """ + header, values = data.split(':') + address, channel, value = values.split(',') + + self.raw = data + self.address = address + self.channel = channel + self.value = value + + if header == '!EXP': + self.type = ExpanderMessage.ZONE + elif header == '!REL': + self.type = ExpanderMessage.RELAY + +class RFMessage(object): + """ + Represents a message from an RF receiver. + """ + + def __init__(self, data=None): + """ + Constructor + """ + self.raw = None + self.serial_number = None + self.value = None + + if data is not None: + self._parse_message(data) + + def __str__(self): + """ + String conversion operator. + """ + return 'rf > {0}: {1}'.format(self.serial_number, self.value) + + def _parse_message(self, data): + """ + Parses the raw message from the device. + """ + self.raw = data + + _, values = data.split(':') + self.serial_number, self.value = values.split(',') + +class LRRMessage(object): + """ + Represent a message from a Long Range Radio. + """ + + def __init__(self, data=None): + """ + Constructor + """ + self.raw = None + self._event_data = None + self._partition = None + self._event_type = None + + if data is not None: + self._parse_message(data) + + def __str__(self): + """ + String conversion operator. + """ + return 'lrr > {0} @ {1} -- {2}'.format() + + def _parse_message(self, data): + """ + Parses the raw message from the device. + """ + self.raw = data + + _, values = data.split(':') + self._event_data, self._partition, self._event_type = values.split(',') diff --git a/pyad2usb/zonetracking.py b/pyad2usb/zonetracking.py index 32d475d..db312a3 100644 --- a/pyad2usb/zonetracking.py +++ b/pyad2usb/zonetracking.py @@ -4,6 +4,7 @@ Provides zone tracking functionality for the AD2USB device family. import time from .event import event +from . import messages class Zone(object): """ @@ -50,40 +51,56 @@ class Zonetracker(object): """ Update zone statuses based on the current message. """ - # Panel is ready, restore all zones. - if message.ready: - for idx, z in enumerate(self._zones_faulted): - self._update_zone(z, Zone.CLEAR) + zone = -1 - self._last_zone_fault = 0 + if isinstance(message, messages.ExpanderMessage): + zone = self._expander_to_zone(int(message.address), int(message.channel)) - # Process fault - elif "FAULT" in message.text or message.check_zone: - zone = -1 + status = Zone.CLEAR + if int(message.value) == 1: + status = Zone.FAULT + elif int(message.value) == 2: + status = Zone.CHECK - # Apparently this representation can be both base 10 - # or base 16, depending on where the message came - # from. try: - zone = int(message.numeric_code) - except ValueError: - zone = int(message.numeric_code, 16) + self._update_zone(zone, status=status) + except IndexError: + self._add_zone(zone, status=status) - # Add new zones and clear expired ones. - if zone in self._zones_faulted: - self._update_zone(zone) - self._clear_zones(zone) - else: - status = Zone.FAULT - if message.check_zone: - status = Zone.CHECK + else: + # Panel is ready, restore all zones. + if message.ready: + for idx, z in enumerate(self._zones_faulted): + self._update_zone(z, Zone.CLEAR) + + self._last_zone_fault = 0 + + # Process fault + elif "FAULT" in message.text or message.check_zone: + # Apparently this representation can be both base 10 + # or base 16, depending on where the message came + # from. + try: + zone = int(message.numeric_code) + except ValueError: + zone = int(message.numeric_code, 16) + + # Add new zones and clear expired ones. + if zone in self._zones_faulted: + self._update_zone(zone) + self._clear_zones(zone) + else: + status = Zone.FAULT + if message.check_zone: + status = Zone.CHECK - self._add_zone(zone, status=status) + self._add_zone(zone, status=status) + self._zones_faulted.append(zone) + self._zones_faulted.sort() # Save our spot for the next message. self._last_zone_fault = zone - - self._clear_expired_zones() + self._clear_expired_zones() def _clear_zones(self, zone): """ @@ -92,8 +109,6 @@ class Zonetracker(object): cleared_zones = [] found_last = found_new = at_end = False - #print 'zones', self._zones - # First pass: Find our start spot. it = iter(self._zones_faulted) try: @@ -145,13 +160,13 @@ class Zonetracker(object): self._update_zone(z, Zone.CLEAR) def _clear_expired_zones(self): - cleared_zones = [] + zones = [] - for z in self._zones_faulted: - cleared_zones += [z] + for z in self._zones.keys(): + zones += [z] - for z in cleared_zones: - if self._zone_expired(z): + for z in zones: + if self._zones[z].status != Zone.CLEAR and self._zone_expired(z): self._update_zone(z, Zone.CLEAR) def _add_zone(self, zone, name='', status=Zone.CLEAR): @@ -162,8 +177,6 @@ class Zonetracker(object): self._zones[zone] = Zone(zone=zone, name=name, status=status) if status != Zone.CLEAR: - self._zones_faulted.append(zone) - self._zones_faulted.sort() self.on_fault(zone) def _update_zone(self, zone, status=None): @@ -179,7 +192,9 @@ class Zonetracker(object): self._zones[zone].timestamp = time.time() if status == Zone.CLEAR: - self._zones_faulted.remove(zone) + if zone in self._zones_faulted: + self._zones_faulted.remove(zone) + self.on_restore(zone) def _zone_expired(self, zone): @@ -187,3 +202,8 @@ class Zonetracker(object): return True return False + + def _expander_to_zone(self, address, channel): + idx = address - 7 # Expanders start at address 7. + + return address + channel + (idx * 7) + 1 diff --git a/test.py b/test.py index 21a9059..acdfa27 100755 --- a/test.py +++ b/test.py @@ -254,11 +254,15 @@ def test_socket(): #a2u.emulate_zone[1] = True #a2u.save_config() + time.sleep(1) + a2u.fault_zone(17, True) + + time.sleep(15) + a2u.clear_zone(17) + #time.sleep(1) - #a2u.fault_zone(17, True) + #a2u.fault_zone((2, 2), True) - #time.sleep(15) - #a2u.clear_zone(17) while running: time.sleep(0.1)