@@ -94,7 +94,7 @@ class AlarmDecoder(object): | |||
:type device: Device | |||
""" | |||
self._device = device | |||
self._zonetracker = Zonetracker() | |||
self._zonetracker = Zonetracker(self) | |||
self._battery_timeout = AlarmDecoder.BATTERY_TIMEOUT | |||
self._fire_timeout = AlarmDecoder.FIRE_TIMEOUT | |||
@@ -106,6 +106,7 @@ class AlarmDecoder(object): | |||
self._battery_status = (False, 0) | |||
self._panic_status = None | |||
self._relay_status = {} | |||
self._internal_address_mask = 0xFFFFFFFF | |||
self.address = 18 | |||
self.configbits = 0xFF00 | |||
@@ -177,6 +178,25 @@ class AlarmDecoder(object): | |||
""" | |||
self._fire_timeout = value | |||
@property | |||
def internal_address_mask(self): | |||
""" | |||
Retrieves the address mask used for updating internal status. | |||
:returns: address mask | |||
""" | |||
return self._internal_address_mask | |||
@internal_address_mask.setter | |||
def internal_address_mask(self, value): | |||
""" | |||
Sets the address mask used internally for updating status. | |||
:param value: address mask | |||
:type value: int | |||
""" | |||
self._internal_address_mask = value | |||
def open(self, baudrate=None, no_reader_thread=False): | |||
""" | |||
Opens the device. | |||
@@ -344,10 +364,10 @@ class AlarmDecoder(object): | |||
""" | |||
msg = Message(data) | |||
if self.address_mask & msg.mask > 0: | |||
if self._internal_address_mask & msg.mask > 0: | |||
self._update_internal_states(msg) | |||
self.on_message(message=msg) | |||
self.on_message(message=msg) | |||
return msg | |||
@@ -15,19 +15,35 @@ This module contains different types of devices belonging to the `AlarmDecoder`_ | |||
.. moduleauthor:: Scott Petersen <scott@nutech.com> | |||
""" | |||
import usb.core | |||
import usb.util | |||
import time | |||
import threading | |||
import serial | |||
import serial.tools.list_ports | |||
import socket | |||
from OpenSSL import SSL, crypto | |||
from pyftdi.pyftdi.ftdi import Ftdi, FtdiError | |||
from .util import CommError, TimeoutError, NoDeviceError, InvalidMessageError | |||
from .event import event | |||
try: | |||
from pyftdi.pyftdi.ftdi import Ftdi, FtdiError | |||
import usb.core | |||
import usb.util | |||
have_pyftdi = True | |||
except ImportError: | |||
have_pyftdi = False | |||
try: | |||
from OpenSSL import SSL, crypto | |||
have_openssl = True | |||
except ImportError: | |||
from collections import namedtuple | |||
SSL = namedtuple('SSL', ['Error', 'WantReadError', 'SysCallError']) | |||
have_openssl = False | |||
class Device(object): | |||
""" | |||
@@ -198,6 +214,9 @@ class USBDevice(Device): | |||
:returns: list of devices | |||
:raises: :py:class:`~alarmdecoder.util.CommError` | |||
""" | |||
if not have_pyftdi: | |||
raise ImportError('The USBDevice class has been disabled due to missing requirement: pyftdi or pyusb.') | |||
cls.__devices = [] | |||
query = cls.PRODUCT_IDS | |||
@@ -234,6 +253,9 @@ class USBDevice(Device): | |||
:returns: :py:class:`USBDevice` object utilizing the specified device | |||
:raises: :py:class:`~alarmdecoder.util.NoDeviceError` | |||
""" | |||
if not have_pyftdi: | |||
raise ImportError('The USBDevice class has been disabled due to missing requirement: pyftdi or pyusb.') | |||
cls.find_all() | |||
if len(cls.__devices) == 0: | |||
@@ -257,6 +279,9 @@ class USBDevice(Device): | |||
:type on_detached: function | |||
""" | |||
if not have_pyftdi: | |||
raise ImportError('The USBDevice class has been disabled due to missing requirement: pyftdi or pyusb.') | |||
cls.__detect_thread = USBDevice.DetectThread(on_attached, on_detached) | |||
try: | |||
@@ -271,6 +296,9 @@ class USBDevice(Device): | |||
""" | |||
Stops the device detection thread. | |||
""" | |||
if not have_pyftdi: | |||
raise ImportError('The USBDevice class has been disabled due to missing requirement: pyftdi or pyusb.') | |||
try: | |||
cls.__detect_thread.stop() | |||
@@ -347,6 +375,9 @@ class USBDevice(Device): | |||
index. | |||
:type interface: string or int | |||
""" | |||
if not have_pyftdi: | |||
raise ImportError('The USBDevice class has been disabled due to missing requirement: pyftdi or pyusb.') | |||
Device.__init__(self) | |||
self._device = Ftdi() | |||
@@ -1120,6 +1151,9 @@ class SocketDevice(Device): | |||
:raises: :py:class:`~alarmdecoder.util.CommError` | |||
""" | |||
if not have_openssl: | |||
raise ImportError('SSL sockets have been disabled due to missing requirement: pyopenssl.') | |||
try: | |||
ctx = SSL.Context(SSL.TLSv1_METHOD) | |||
@@ -167,10 +167,9 @@ class Message(BaseMessage): | |||
self.panel_type = PANEL_TYPES[self.bitfield[18]] | |||
# pos 20-21 - Unused. | |||
self.text = alpha.strip('"') | |||
self.mask = int(self.panel_data[3:3+8], 16) | |||
if self.panel_type == ADEMCO: | |||
self.mask = int(self.panel_data[3:3+8], 16) | |||
if int(self.panel_data[19:21], 16) & 0x01 > 0: | |||
# Current cursor location on the alpha display. | |||
self.cursor_location = int(self.panel_data[21:23], 16) | |||
@@ -11,6 +11,7 @@ import time | |||
from .event import event | |||
from .messages import ExpanderMessage | |||
from .panels import ADEMCO, DSC | |||
class Zone(object): | |||
@@ -37,8 +38,10 @@ class Zone(object): | |||
"""Zone status""" | |||
timestamp = None | |||
"""Timestamp of last update""" | |||
expander = False | |||
"""Does this zone exist on an expander?""" | |||
def __init__(self, zone=0, name='', status=CLEAR): | |||
def __init__(self, zone=0, name='', status=CLEAR, expander=False): | |||
""" | |||
Constructor | |||
@@ -53,6 +56,7 @@ class Zone(object): | |||
self.name = name | |||
self.status = status | |||
self.timestamp = time.time() | |||
self.expander = expander | |||
def __str__(self): | |||
""" | |||
@@ -116,7 +120,7 @@ class Zonetracker(object): | |||
""" | |||
self._zones_faulted = value | |||
def __init__(self): | |||
def __init__(self, alarmdecoder_object): | |||
""" | |||
Constructor | |||
""" | |||
@@ -124,6 +128,8 @@ class Zonetracker(object): | |||
self._zones_faulted = [] | |||
self._last_zone_fault = 0 | |||
self.alarmdecoder_object = alarmdecoder_object | |||
def update(self, message): | |||
""" | |||
Update zone statuses based on the current message. | |||
@@ -132,9 +138,12 @@ class Zonetracker(object): | |||
:type message: :py:class:`~alarmdecoder.messages.Message` or :py:class:`~alarmdecoder.messages.ExpanderMessage` | |||
""" | |||
if isinstance(message, ExpanderMessage): | |||
zone = -1 | |||
if message.type == ExpanderMessage.ZONE: | |||
zone = self.expander_to_zone(message.address, message.channel) | |||
zone = self.expander_to_zone(message.address, message.channel, self.alarmdecoder_object.mode) | |||
if zone != -1: | |||
status = Zone.CLEAR | |||
if message.value == 1: | |||
status = Zone.FAULT | |||
@@ -149,7 +158,7 @@ class Zonetracker(object): | |||
self._update_zone(zone, status=status) | |||
except IndexError: | |||
self._add_zone(zone, status=status) | |||
self._add_zone(zone, status=status, expander=True) | |||
else: | |||
# Panel is ready, restore all zones. | |||
@@ -209,7 +218,7 @@ class Zonetracker(object): | |||
self._clear_expired_zones() | |||
def expander_to_zone(self, address, channel): | |||
def expander_to_zone(self, address, channel, panel_type=ADEMCO): | |||
""" | |||
Convert an address and channel into a zone number. | |||
@@ -221,12 +230,19 @@ class Zonetracker(object): | |||
:returns: zone number associated with an address and channel | |||
""" | |||
# TODO: This is going to need to be reworked to support the larger | |||
# panels without fixed addressing on the expanders. | |||
zone = -1 | |||
idx = address - 7 # Expanders start at address 7. | |||
if panel_type == ADEMCO: | |||
# TODO: This is going to need to be reworked to support the larger | |||
# panels without fixed addressing on the expanders. | |||
return address + channel + (idx * 7) + 1 | |||
idx = address - 7 # Expanders start at address 7. | |||
zone = address + channel + (idx * 7) + 1 | |||
elif panel_type == DSC: | |||
zone = (address * 8) + channel | |||
return zone | |||
def _clear_zones(self, zone): | |||
""" | |||
@@ -301,7 +317,7 @@ class Zonetracker(object): | |||
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): | |||
def _add_zone(self, zone, name='', status=Zone.CLEAR, expander=False): | |||
""" | |||
Adds a zone to the internal zone list. | |||
@@ -313,10 +329,9 @@ class Zonetracker(object): | |||
:type status: int | |||
""" | |||
if not zone in self._zones: | |||
self._zones[zone] = Zone(zone=zone, name=name, status=status) | |||
self._zones[zone] = Zone(zone=zone, name=name, status=None, expander=expander) | |||
if status != Zone.CLEAR: | |||
self.on_fault(zone=zone) | |||
self._update_zone(zone, status=status) | |||
def _update_zone(self, zone, status=None): | |||
""" | |||
@@ -332,9 +347,11 @@ class Zonetracker(object): | |||
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 | |||
old_status = self._zones[zone].status | |||
if status is None: | |||
status = old_status | |||
self._zones[zone].status = status | |||
self._zones[zone].timestamp = time.time() | |||
if status == Zone.CLEAR: | |||
@@ -342,6 +359,9 @@ class Zonetracker(object): | |||
self._zones_faulted.remove(zone) | |||
self.on_restore(zone=zone) | |||
else: | |||
if old_status != status and status is not None: | |||
self.on_fault(zone=zone) | |||
def _zone_expired(self, zone): | |||
""" | |||
@@ -352,4 +372,4 @@ class Zonetracker(object): | |||
:returns: whether or not the zone is expired | |||
""" | |||
return time.time() > self._zones[zone].timestamp + Zonetracker.EXPIRE | |||
return (time.time() > self._zones[zone].timestamp + Zonetracker.EXPIRE) and self._zones[zone].expander is False |
@@ -37,7 +37,7 @@ def main(): | |||
baudrate = 115200 | |||
if len(sys.argv) < 2: | |||
print "Syntax: {0} <firmware> [interface] [baudrate]".format(sys.argv[0]) | |||
print "Syntax: {0} <firmware> [device path or hostname:port] [baudrate]".format(sys.argv[0]) | |||
sys.exit(1) | |||
firmware = sys.argv[1] | |||
@@ -49,8 +49,13 @@ def main(): | |||
print "Flashing device: {0} - {2} baud\r\nFirmware: {1}".format(device, firmware, baudrate) | |||
dev = alarmdecoder.devices.SerialDevice(interface=device) | |||
dev.open(baudrate=baudrate, no_reader_thread=True) | |||
if ':' in device: | |||
hostname, port = device.split(':') | |||
dev = alarmdecoder.devices.SocketDevice(interface=(hostname, int(port))) | |||
dev.open() | |||
else: | |||
dev = alarmdecoder.devices.SerialDevice(interface=device) | |||
dev.open(baudrate=baudrate, no_reader_thread=True) | |||
time.sleep(3) | |||
alarmdecoder.util.Firmware.upload(dev, firmware, handle_firmware) | |||
@@ -2,7 +2,7 @@ import time | |||
import smtplib | |||
from email.mime.text import MIMEText | |||
from alarmdecoder import AlarmDecoder | |||
from alarmdecoder.devices import USBDevice | |||
from alarmdecoder.devices import SerialDevice | |||
# Configuration values | |||
SUBJECT = "AlarmDecoder - ALARM" | |||
@@ -13,6 +13,9 @@ SMTP_SERVER = "localhost" | |||
SMTP_USERNAME = None | |||
SMTP_PASSWORD = None | |||
SERIAL_DEVICE = '/dev/ttyUSB0' | |||
BAUDRATE = 115200 | |||
def main(): | |||
""" | |||
Example application that sends an email when an alarm event is | |||
@@ -20,11 +23,11 @@ def main(): | |||
""" | |||
try: | |||
# Retrieve the first USB device | |||
device = AlarmDecoder(USBDevice.find()) | |||
device = AlarmDecoder(SerialDevice(interface=SERIAL_DEVICE)) | |||
# Set up an event handler and open the device | |||
device.on_alarm += handle_alarm | |||
with device.open(): | |||
with device.open(baudrate=BAUDRATE): | |||
while True: | |||
time.sleep(1) | |||
@@ -1,6 +1,9 @@ | |||
import time | |||
from alarmdecoder import AlarmDecoder | |||
from alarmdecoder.devices import USBDevice | |||
from alarmdecoder.devices import SerialDevice | |||
SERIAL_DEVICE = '/dev/ttyUSB0' | |||
BAUDRATE = 115200 | |||
def main(): | |||
""" | |||
@@ -8,11 +11,11 @@ def main(): | |||
""" | |||
try: | |||
# Retrieve the first USB device | |||
device = AlarmDecoder(USBDevice.find()) | |||
device = AlarmDecoder(SerialDevice(interface=SERIAL_DEVICE)) | |||
# Set up an event handler and open the device | |||
device.on_lrr_message += handle_lrr_message | |||
with device.open(): | |||
with device.open(baudrate=BAUDRATE): | |||
while True: | |||
time.sleep(1) | |||
@@ -1,9 +1,12 @@ | |||
import time | |||
from alarmdecoder import AlarmDecoder | |||
from alarmdecoder.devices import USBDevice | |||
from alarmdecoder.devices import SerialDevice | |||
RF_DEVICE_SERIAL_NUMBER = '0252254' | |||
SERIAL_DEVICE = '/dev/ttyUSB0' | |||
BAUDRATE = 115200 | |||
def main(): | |||
""" | |||
Example application that watches for an event from a specific RF device. | |||
@@ -18,11 +21,11 @@ def main(): | |||
""" | |||
try: | |||
# Retrieve the first USB device | |||
device = AlarmDecoder(USBDevice.find()) | |||
device = AlarmDecoder(SerialDevice(interface=SERIAL_DEVICE)) | |||
# Set up an event handler and open the device | |||
device.on_rfx_message += handle_rfx | |||
with device.open(): | |||
with device.open(baudrate=BAUDRATE): | |||
while True: | |||
time.sleep(1) | |||
@@ -1,11 +1,14 @@ | |||
import time | |||
from alarmdecoder import AlarmDecoder | |||
from alarmdecoder.devices import USBDevice | |||
from alarmdecoder.devices import SerialDevice | |||
# Configuration values | |||
TARGET_ZONE = 41 | |||
WAIT_TIME = 10 | |||
SERIAL_DEVICE = '/dev/ttyUSB0' | |||
BAUDRATE = 115200 | |||
def main(): | |||
""" | |||
Example application that periodically faults a virtual zone and then | |||
@@ -28,13 +31,13 @@ def main(): | |||
""" | |||
try: | |||
# Retrieve the first USB device | |||
device = AlarmDecoder(USBDevice.find()) | |||
device = AlarmDecoder(SerialDevice(interface=SERIAL_DEVICE)) | |||
# Set up an event handlers and open the device | |||
device.on_zone_fault += handle_zone_fault | |||
device.on_zone_restore += handle_zone_restore | |||
with device.open(): | |||
with device.open(baudrate=BAUDRATE): | |||
last_update = time.time() | |||
while True: | |||
if time.time() - last_update > WAIT_TIME: | |||
@@ -1,16 +1 @@ | |||
Jinja2==2.7.2 | |||
MarkupSafe==0.21 | |||
Pygments==1.6 | |||
Sphinx==1.2.2 | |||
argparse==1.2.1 | |||
cffi==0.8.2 | |||
cryptography==0.3 | |||
distribute==0.7.3 | |||
docutils==0.11 | |||
pyOpenSSL==0.14 | |||
pycparser==2.10 | |||
pyftdi==0.9.0 | |||
pyserial==2.7 | |||
pyusb==1.0.0b1 | |||
six==1.6.1 | |||
wsgiref==0.1.2 |
@@ -30,13 +30,7 @@ setup(name='alarmdecoder', | |||
license='MIT', | |||
packages=['alarmdecoder', 'alarmdecoder.event'], | |||
install_requires=[ | |||
'pyopenssl', | |||
'pyusb>=1.0.0b1', | |||
'pyserial>=2.7', | |||
'pyftdi>=0.9.0', | |||
], | |||
dependency_links=[ | |||
'https://github.com/eblot/pyftdi/archive/v0.9.0.tar.gz#egg=pyftdi-0.9.0' | |||
], | |||
test_suite='nose.collector', | |||
tests_require=['nose', 'mock'], | |||
@@ -24,6 +24,12 @@ class TestAlarmDecoder(TestCase): | |||
self._message_received = False | |||
self._rfx_message_received = False | |||
self._lrr_message_received = False | |||
self._expander_message_received = False | |||
self._sending_received_status = None | |||
self._alarm_restored = False | |||
self._on_boot_received = False | |||
self._zone_faulted = None | |||
self._zone_restored = None | |||
self._device = Mock(spec=USBDevice) | |||
self._device.on_open = EventHandler(Event(), self._device) | |||
@@ -32,15 +38,11 @@ class TestAlarmDecoder(TestCase): | |||
self._device.on_write = EventHandler(Event(), self._device) | |||
self._decoder = AlarmDecoder(self._device) | |||
self._decoder._zonetracker = Mock(spec=Zonetracker) | |||
self._decoder._zonetracker.on_fault = EventHandler(Event(), self._decoder._zonetracker) | |||
self._decoder._zonetracker.on_restore = EventHandler(Event(), self._decoder._zonetracker) | |||
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_alarm += self.on_alarm | |||
self._decoder.on_alarm_restored += self.on_alarm_restored | |||
self._decoder.on_bypass += self.on_bypass | |||
self._decoder.on_low_battery += self.on_battery | |||
self._decoder.on_fire += self.on_fire | |||
@@ -50,6 +52,11 @@ class TestAlarmDecoder(TestCase): | |||
self._decoder.on_message += self.on_message | |||
self._decoder.on_rfx_message += self.on_rfx_message | |||
self._decoder.on_lrr_message += self.on_lrr_message | |||
self._decoder.on_expander_message += self.on_expander_message | |||
self._decoder.on_sending_received += self.on_sending_received | |||
self._decoder.on_boot += self.on_boot | |||
self._decoder.on_zone_fault += self.on_zone_fault | |||
self._decoder.on_zone_restore += self.on_zone_restore | |||
self._decoder.address_mask = int('ffffffff', 16) | |||
self._decoder.open() | |||
@@ -67,7 +74,10 @@ class TestAlarmDecoder(TestCase): | |||
self._power_changed = kwargs['status'] | |||
def on_alarm(self, sender, *args, **kwargs): | |||
self._alarmed = kwargs['status'] | |||
self._alarmed = True | |||
def on_alarm_restored(self, sender, *args, **kwargs): | |||
self._alarm_restored = True | |||
def on_bypass(self, sender, *args, **kwargs): | |||
self._bypassed = kwargs['status'] | |||
@@ -96,6 +106,21 @@ class TestAlarmDecoder(TestCase): | |||
def on_lrr_message(self, sender, *args, **kwargs): | |||
self._lrr_message_received = True | |||
def on_expander_message(self, sender, *args, **kwargs): | |||
self._expander_message_received = True | |||
def on_sending_received(self, sender, *args, **kwargs): | |||
self._sending_received_status = kwargs['status'] | |||
def on_boot(self, sender, *args, **kwargs): | |||
self._on_boot_received = True | |||
def on_zone_fault(self, sender, *args, **kwargs): | |||
self._zone_faulted = kwargs['zone'] | |||
def on_zone_restore(self, sender, *args, **kwargs): | |||
self._zone_restored = kwargs['zone'] | |||
def test_open(self): | |||
self._decoder.open() | |||
self._device.open.assert_any_calls() | |||
@@ -141,7 +166,7 @@ class TestAlarmDecoder(TestCase): | |||
self._decoder._on_read(self, data='[0000000000000000----],000,[f707000600e5800c0c020000]," "') | |||
self.assertTrue(self._message_received) | |||
def test_message_kpe(self): | |||
def test_message_kpm(self): | |||
msg = self._decoder._handle_message('!KPM:[0000000000000000----],000,[f707000600e5800c0c020000]," "') | |||
self.assertIsInstance(msg, Message) | |||
@@ -152,6 +177,9 @@ class TestAlarmDecoder(TestCase): | |||
msg = self._decoder._handle_message('!EXP:07,01,01') | |||
self.assertIsInstance(msg, ExpanderMessage) | |||
self._decoder._on_read(self, data='!EXP:07,01,01') | |||
self.assertTrue(self._expander_message_received) | |||
def test_relay_message(self): | |||
self._decoder.open() | |||
msg = self._decoder._handle_message('!REL:12,01,01') | |||
@@ -203,6 +231,7 @@ class TestAlarmDecoder(TestCase): | |||
msg = self._decoder._handle_message('[0000000000000000----],000,[f707000600e5800c0c020000]," "') | |||
self.assertEquals(self._alarmed, False) | |||
self.assertEquals(self._alarm_restored, True) | |||
msg = self._decoder._handle_message('[0000000000100000----],000,[f707000600e5800c0c020000]," "') | |||
self.assertEquals(self._alarmed, True) | |||
@@ -261,6 +290,26 @@ class TestAlarmDecoder(TestCase): | |||
self._decoder._device.write.assert_called_with('*') | |||
def test_zonetracker_update(self): | |||
msg = self._decoder._handle_message('[0000000000000000----],000,[f707000600e5800c0c020000]," "') | |||
self._decoder._zonetracker.update.assert_called_with(msg) | |||
def test_sending_received(self): | |||
self._decoder._on_read(self, data='!Sending.done') | |||
self.assertTrue(self._sending_received_status) | |||
self._decoder._on_read(self, data='!Sending.....done') | |||
self.assertFalse(self._sending_received_status) | |||
def test_boot(self): | |||
self._decoder._on_read(self, data='!Ready') | |||
self.assertTrue(self._on_boot_received) | |||
def test_zone_fault_and_restore(self): | |||
self._decoder._on_read(self, data='[00010001000000000A--],003,[f70000051003000008020000000000],"FAULT 03 "') | |||
self.assertEquals(self._zone_faulted, 3) | |||
self._decoder._on_read(self, data='[00010001000000000A--],004,[f70000051003000008020000000000],"FAULT 04 "') | |||
self.assertEquals(self._zone_faulted, 4) | |||
self._decoder._on_read(self, data='[00010001000000000A--],005,[f70000051003000008020000000000],"FAULT 05 "') | |||
self.assertEquals(self._zone_faulted, 5) | |||
self._decoder._on_read(self, data='[00010001000000000A--],004,[f70000051003000008020000000000],"FAULT 04 "') | |||
self.assertEquals(self._zone_restored, 3) |