| @@ -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) | |||