@@ -10,6 +10,7 @@ from collections import OrderedDict | |||||
from .event import event | from .event import event | ||||
from . import devices | from . import devices | ||||
from . import util | from . import util | ||||
from . import messages | |||||
from . import zonetracking | from . import zonetracking | ||||
class Overseer(object): | class Overseer(object): | ||||
@@ -266,6 +267,13 @@ class AD2USB(object): | |||||
""" | """ | ||||
Faults a zone if we are emulating a zone expander. | 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 | status = 2 if simulate_wire_problem else 1 | ||||
self._device.write("L{0:02}{1}\r".format(zone, status)) | self._device.write("L{0:02}{1}\r".format(zone, status)) | ||||
@@ -297,7 +305,7 @@ class AD2USB(object): | |||||
msg = None | msg = None | ||||
if data[0] != '!': | if data[0] != '!': | ||||
msg = Message(data) | msg = messages.Message(data) | ||||
if self.address_mask & msg.mask > 0: | if self.address_mask & msg.mask > 0: | ||||
self._update_internal_states(msg) | self._update_internal_states(msg) | ||||
@@ -306,11 +314,12 @@ class AD2USB(object): | |||||
header = data[0:4] | header = data[0:4] | ||||
if header == '!EXP' or header == '!REL': | if header == '!EXP' or header == '!REL': | ||||
msg = ExpanderMessage(data) | msg = messages.ExpanderMessage(data) | ||||
self._update_internal_states(msg) | |||||
elif header == '!RFX': | elif header == '!RFX': | ||||
msg = RFMessage(data) | msg = messages.RFMessage(data) | ||||
elif header == '!LRR': | elif header == '!LRR': | ||||
msg = LRRMessage(data) | msg = messages.LRRMessage(data) | ||||
elif data.startswith('!Ready'): | elif data.startswith('!Ready'): | ||||
self.on_boot() | self.on_boot() | ||||
elif data.startswith('!CONFIG'): | elif data.startswith('!CONFIG'): | ||||
@@ -349,47 +358,49 @@ class AD2USB(object): | |||||
""" | """ | ||||
Updates internal device states. | Updates internal device states. | ||||
""" | """ | ||||
if message.ac_power != self._power_status: | if isinstance(message, messages.Message): | ||||
self._power_status, old_status = message.ac_power, self._power_status | if message.ac_power != self._power_status: | ||||
self._power_status, old_status = message.ac_power, self._power_status | |||||
if old_status is not None: | if old_status is not None: | ||||
self.on_power_changed(self._power_status) | self.on_power_changed(self._power_status) | ||||
if message.alarm_sounding != self._alarm_status: | if message.alarm_sounding != self._alarm_status: | ||||
self._alarm_status, old_status = message.alarm_sounding, self._alarm_status | self._alarm_status, old_status = message.alarm_sounding, self._alarm_status | ||||
if old_status is not None: | if old_status is not None: | ||||
self.on_alarm(self._alarm_status) | self.on_alarm(self._alarm_status) | ||||
if message.zone_bypassed != self._bypass_status: | if message.zone_bypassed != self._bypass_status: | ||||
self._bypass_status, old_status = message.zone_bypassed, self._bypass_status | self._bypass_status, old_status = message.zone_bypassed, self._bypass_status | ||||
if old_status is not None: | if old_status is not None: | ||||
self.on_bypass(self._bypass_status) | self.on_bypass(self._bypass_status) | ||||
if (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 | self._armed_status, old_status = message.armed_away | message.armed_home, self._armed_status | ||||
if old_status is not None: | if old_status is not None: | ||||
if self._armed_status: | if self._armed_status: | ||||
self.on_arm() | self.on_arm() | ||||
else: | else: | ||||
self.on_disarm() | self.on_disarm() | ||||
if message.fire_alarm != self._fire_status: | if message.fire_alarm != self._fire_status: | ||||
self._fire_status, old_status = message.fire_alarm, self._fire_status | self._fire_status, old_status = message.fire_alarm, self._fire_status | ||||
if old_status is not None: | if old_status is not None: | ||||
self.on_fire(self._fire_status) | self.on_fire(self._fire_status) | ||||
self._update_zone_tracker(message) | self._update_zone_tracker(message) | ||||
def _update_zone_tracker(self, message): | def _update_zone_tracker(self, message): | ||||
# Retrieve a list of faults. | # Retrieve a list of faults. | ||||
# NOTE: This only happens on first boot or after exiting programming mode. | # NOTE: This only happens on first boot or after exiting programming mode. | ||||
if not message.ready and "Hit * for faults" in message.text: | if isinstance(message, messages.Message): | ||||
self._device.write('*') | if not message.ready and "Hit * for faults" in message.text: | ||||
return | self._device.write('*') | ||||
return | |||||
self._zonetracker.update(message) | self._zonetracker.update(message) | ||||
@@ -432,195 +443,3 @@ class AD2USB(object): | |||||
Internal handler for zone restoration. | Internal handler for zone restoration. | ||||
""" | """ | ||||
self.on_zone_restore(args) | 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(',') |
@@ -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(',') |
@@ -4,6 +4,7 @@ Provides zone tracking functionality for the AD2USB device family. | |||||
import time | import time | ||||
from .event import event | from .event import event | ||||
from . import messages | |||||
class Zone(object): | class Zone(object): | ||||
""" | """ | ||||
@@ -50,40 +51,56 @@ class Zonetracker(object): | |||||
""" | """ | ||||
Update zone statuses based on the current message. | Update zone statuses based on the current message. | ||||
""" | """ | ||||
# Panel is ready, restore all zones. | zone = -1 | ||||
if message.ready: | |||||
for idx, z in enumerate(self._zones_faulted): | |||||
self._update_zone(z, Zone.CLEAR) | |||||
self._last_zone_fault = 0 | if isinstance(message, messages.ExpanderMessage): | ||||
zone = self._expander_to_zone(int(message.address), int(message.channel)) | |||||
# Process fault | status = Zone.CLEAR | ||||
elif "FAULT" in message.text or message.check_zone: | if int(message.value) == 1: | ||||
zone = -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: | try: | ||||
zone = int(message.numeric_code) | self._update_zone(zone, status=status) | ||||
except ValueError: | except IndexError: | ||||
zone = int(message.numeric_code, 16) | self._add_zone(zone, status=status) | ||||
# Add new zones and clear expired ones. | else: | ||||
if zone in self._zones_faulted: | # Panel is ready, restore all zones. | ||||
self._update_zone(zone) | if message.ready: | ||||
self._clear_zones(zone) | for idx, z in enumerate(self._zones_faulted): | ||||
else: | self._update_zone(z, Zone.CLEAR) | ||||
status = Zone.FAULT | self._last_zone_fault = 0 | ||||
if message.check_zone: | # Process fault | ||||
status = Zone.CHECK | 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. | # Save our spot for the next message. | ||||
self._last_zone_fault = zone | self._last_zone_fault = zone | ||||
self._clear_expired_zones() | |||||
self._clear_expired_zones() | |||||
def _clear_zones(self, zone): | def _clear_zones(self, zone): | ||||
""" | """ | ||||
@@ -92,8 +109,6 @@ class Zonetracker(object): | |||||
cleared_zones = [] | cleared_zones = [] | ||||
found_last = found_new = at_end = False | found_last = found_new = at_end = False | ||||
#print 'zones', self._zones | |||||
# First pass: Find our start spot. | # First pass: Find our start spot. | ||||
it = iter(self._zones_faulted) | it = iter(self._zones_faulted) | ||||
try: | try: | ||||
@@ -145,13 +160,13 @@ class Zonetracker(object): | |||||
self._update_zone(z, Zone.CLEAR) | self._update_zone(z, Zone.CLEAR) | ||||
def _clear_expired_zones(self): | def _clear_expired_zones(self): | ||||
cleared_zones = [] | zones = [] | ||||
for z in self._zones_faulted: | for z in self._zones.keys(): | ||||
cleared_zones += [z] | zones += [z] | ||||
for z in cleared_zones: | for z in zones: | ||||
if self._zone_expired(z): | if self._zones[z].status != Zone.CLEAR and self._zone_expired(z): | ||||
self._update_zone(z, Zone.CLEAR) | self._update_zone(z, Zone.CLEAR) | ||||
def _add_zone(self, zone, name='', status=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) | self._zones[zone] = Zone(zone=zone, name=name, status=status) | ||||
if status != Zone.CLEAR: | if status != Zone.CLEAR: | ||||
self._zones_faulted.append(zone) | |||||
self._zones_faulted.sort() | |||||
self.on_fault(zone) | self.on_fault(zone) | ||||
def _update_zone(self, zone, status=None): | def _update_zone(self, zone, status=None): | ||||
@@ -179,7 +192,9 @@ class Zonetracker(object): | |||||
self._zones[zone].timestamp = time.time() | self._zones[zone].timestamp = time.time() | ||||
if status == Zone.CLEAR: | if status == Zone.CLEAR: | ||||
self._zones_faulted.remove(zone) | if zone in self._zones_faulted: | ||||
self._zones_faulted.remove(zone) | |||||
self.on_restore(zone) | self.on_restore(zone) | ||||
def _zone_expired(self, zone): | def _zone_expired(self, zone): | ||||
@@ -187,3 +202,8 @@ class Zonetracker(object): | |||||
return True | return True | ||||
return False | return False | ||||
def _expander_to_zone(self, address, channel): | |||||
idx = address - 7 # Expanders start at address 7. | |||||
return address + channel + (idx * 7) + 1 |
@@ -254,11 +254,15 @@ def test_socket(): | |||||
#a2u.emulate_zone[1] = True | #a2u.emulate_zone[1] = True | ||||
#a2u.save_config() | #a2u.save_config() | ||||
time.sleep(1) | |||||
a2u.fault_zone(17, True) | |||||
time.sleep(15) | |||||
a2u.clear_zone(17) | |||||
#time.sleep(1) | #time.sleep(1) | ||||
#a2u.fault_zone(17, True) | #a2u.fault_zone((2, 2), True) | ||||
#time.sleep(15) | |||||
#a2u.clear_zone(17) | |||||
while running: | while running: | ||||
time.sleep(0.1) | time.sleep(0.1) | ||||