Browse Source

Merge branch 'zonetracking' into tweaks

Conflicts:
	pyad2usb/ad2usb.py
pyserial_fix
Scott Petersen 11 years ago
parent
commit
905f8f62e6
4 changed files with 499 additions and 213 deletions
  1. +63
    -213
      pyad2usb/ad2usb.py
  2. +197
    -0
      pyad2usb/messages.py
  3. +209
    -0
      pyad2usb/zonetracking.py
  4. +30
    -0
      test.py

+ 63
- 213
pyad2usb/ad2usb.py View File

@@ -6,9 +6,12 @@ import time
import threading import threading
import re import re
import logging import logging
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


class Overseer(object): class Overseer(object):
""" """
@@ -158,6 +161,8 @@ class AD2USB(object):
on_bypass = event.Event('Called when a zone is bypassed.') on_bypass = event.Event('Called when a zone is bypassed.')
on_boot = event.Event('Called when the device finishes bootings.') on_boot = event.Event('Called when the device finishes bootings.')
on_config_received = event.Event('Called when the device receives its configuration.') on_config_received = event.Event('Called when the device receives its configuration.')
on_zone_fault = event.Event('Called when the device detects a zone fault.')
on_zone_restore = event.Event('Called when the device detects that a fault is restored.')


# Mid-level Events # Mid-level Events
on_message = event.Event('Called when a message has been received from the device.') on_message = event.Event('Called when a message has been received from the device.')
@@ -179,6 +184,8 @@ class AD2USB(object):
Constructor Constructor
""" """
self._device = device self._device = device
self._zonetracker = zonetracking.Zonetracker()

self._power_status = None self._power_status = None
self._alarm_status = None self._alarm_status = None
self._bypass_status = None self._bypass_status = None
@@ -260,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))
@@ -278,6 +292,8 @@ class AD2USB(object):
self._device.on_close += self._on_close self._device.on_close += self._on_close
self._device.on_read += self._on_read self._device.on_read += self._on_read
self._device.on_write += self._on_write self._device.on_write += self._on_write
self._zonetracker.on_fault += self._on_zone_fault
self._zonetracker.on_restore += self._on_zone_restore


def _handle_message(self, data): def _handle_message(self, data):
""" """
@@ -289,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)
@@ -298,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'):
@@ -341,38 +358,51 @@ class AD2USB(object):
""" """
Updates internal device states. 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 isinstance(message, messages.Message):
if not message.ready and "Hit * for faults" in message.text:
self._device.write('*')
return

self._zonetracker.update(message)


def _on_open(self, sender, args): def _on_open(self, sender, args):
""" """
@@ -402,194 +432,14 @@ class AD2USB(object):
""" """
self.on_write(args) self.on_write(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):
def _on_zone_fault(self, sender, args):
""" """
String conversion operator.
Internal handler for zone faults.
""" """
return 'rf > {0}: {1}'.format(self.serial_number, self.value)
self.on_zone_fault(args)


def _parse_message(self, data):
def _on_zone_restore(self, sender, args):
""" """
Parses the raw message from the device.
Internal handler for zone restoration.
""" """
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(self._event_type, self._partition, self._event_data)

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(',')
self.on_zone_restore(args)

+ 197
- 0
pyad2usb/messages.py View File

@@ -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(',')

+ 209
- 0
pyad2usb/zonetracking.py View File

@@ -0,0 +1,209 @@
"""
Provides zone tracking functionality for the AD2USB device family.
"""

import time
from .event import event
from . import messages

class Zone(object):
"""
Representation of a panel zone.
"""

CLEAR = 0
FAULT = 1
CHECK = 2 # Wire fault

STATUS = { CLEAR: 'CLEAR', FAULT: 'FAULT', CHECK: 'CHECK' }

def __init__(self, zone=0, name='', status=CLEAR):
self.zone = zone
self.name = name
self.status = status
self.timestamp = time.time()

def __str__(self):
return 'Zone {0} {1}'.format(self.zone, self.name)

def __repr__(self):
return 'Zone({0}, {1}, ts {2})'.format(self.zone, Zone.STATUS[self.status], self.timestamp)

class Zonetracker(object):
"""
Handles tracking of zone and their statuses.
"""

on_fault = event.Event('Called when the device detects a zone fault.')
on_restore = event.Event('Called when the device detects that a fault is restored.')

EXPIRE = 30

def __init__(self):
"""
Constructor
"""
self._zones = {}
self._zones_faulted = []
self._last_zone_fault = 0

def update(self, message):
"""
Update zone statuses based on the current message.
"""
zone = -1

if isinstance(message, messages.ExpanderMessage):
zone = self._expander_to_zone(int(message.address), int(message.channel))

status = Zone.CLEAR
if int(message.value) == 1:
status = Zone.FAULT
elif int(message.value) == 2:
status = Zone.CHECK

try:
self._update_zone(zone, status=status)
except IndexError:
self._add_zone(zone, status=status)

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._zones_faulted.append(zone)
self._zones_faulted.sort()

# Save our spot for the next message.
self._last_zone_fault = zone
self._clear_expired_zones()

def _clear_zones(self, zone):
"""
Clear all expired zones from our status list.
"""
cleared_zones = []
found_last = found_new = at_end = False

# First pass: Find our start spot.
it = iter(self._zones_faulted)
try:
while not found_last:
z = it.next()

if z == self._last_zone_fault:
found_last = True
break

except StopIteration:
at_end = True

# Continue until we find our end point and add zones in
# between to our clear list.
try:
while not at_end and not found_new:
z = it.next()

if z == zone:
found_new = True
break
else:
cleared_zones += [z]

except StopIteration:
pass

# Second pass: roll through the list again if we didn't find
# our end point and remove everything until we do.
if not found_new:
it = iter(self._zones_faulted)

try:
while not found_new:
z = it.next()

if z == zone:
found_new = True
break
else:
cleared_zones += [z]

except StopIteration:
pass

# Actually remove the zones and trigger the restores.
for idx, z in enumerate(cleared_zones):
self._update_zone(z, Zone.CLEAR)

def _clear_expired_zones(self):
zones = []

for z in self._zones.keys():
zones += [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):
"""
Adds a zone to the internal zone list.
"""
if not zone in self._zones:
self._zones[zone] = Zone(zone=zone, name=name, status=status)

if status != Zone.CLEAR:
self.on_fault(zone)

def _update_zone(self, zone, status=None):
"""
Updates a zones status.
"""
if not zone in self._zones:
raise IndexError('Zone does not exist and cannot be updated: %d', zone)

if status is not None:
self._zones[zone].status = status

self._zones[zone].timestamp = time.time()

if status == Zone.CLEAR:
if zone in self._zones_faulted:
self._zones_faulted.remove(zone)

self.on_restore(zone)

def _zone_expired(self, zone):
if time.time() > self._zones[zone].timestamp + Zonetracker.EXPIRE:
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

+ 30
- 0
test.py View File

@@ -84,6 +84,12 @@ def handle_boot(sender, args):
def handle_config(sender, args): def handle_config(sender, args):
print 'config', args print 'config', args


def handle_fault(sender, args):
print 'zone fault', args

def handle_restore(sender, args):
print 'zone restored', args

def upload_usb(): def upload_usb():
dev = pyad2usb.ad2usb.devices.USBDevice() dev = pyad2usb.ad2usb.devices.USBDevice()


@@ -228,12 +234,36 @@ def test_socket():
a2u.on_config_received += handle_config a2u.on_config_received += handle_config
a2u.on_arm += handle_arm a2u.on_arm += handle_arm
a2u.on_disarm += handle_disarm a2u.on_disarm += handle_disarm
a2u.on_zone_fault += handle_fault
a2u.on_zone_restore += handle_restore


a2u.open() a2u.open()
#a2u.save_config() #a2u.save_config()
#a2u.reboot() #a2u.reboot()
a2u.get_config() a2u.get_config()


#a2u.address = 18
#a2u.configbits = 0xff00
#a2u.address_mask = 0xFFFFFFFF
#a2u.emulate_zone[0] = False
#a2u.emulate_relay[0] = False
#a2u.emulate_lrr = False
#a2u.deduplicate = False

#time.sleep(3)
#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((2, 2), True)


while running: while running:
time.sleep(0.1) time.sleep(0.1)




Loading…
Cancel
Save