alarmdecoder.zonetracking — alarmdecoder documentation

### Navigation

- index
- modules |
- alarmdecoder documentation »
- Module code »

# Source code for alarmdecoder.zonetracking

    """
    Provides zone tracking functionality for the `AlarmDecoder`_ (AD2) device family.

    .. _AlarmDecoder: http://www.alarmdecoder.com

    .. moduleauthor:: Scott Petersen <scott@nutech.com>
    """

    import re
    import time

    from .event import event
    from .messages import ExpanderMessage
    from .panels import ADEMCO, DSC

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

        # Constants
        CLEAR = 0
        """Status indicating that the zone is cleared."""
        FAULT = 1
        """Status indicating that the zone is faulted."""
        CHECK = 2   # Wire fault
        """Status indicating that there is a wiring issue with the zone."""

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

        # Attributes
        zone = 0
        """Zone ID"""
        name = ''
        """Zone name"""
        status = CLEAR
        """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, expander=False):
            """
            Constructor

            :param zone: zone number
            :type zone: int
            :param name: Human readable zone name
            :type name: string
            :param status: Initial zone state
            :type status: int
            """
            self.zone = zone
            self.name = name
            self.status = status
            self.timestamp = time.time()
            self.expander = expander

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

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

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

        on_fault = event.Event("This event is called when the device detects a zone fault.\n\n**Callback definition:** *def callback(device, zone)*")
        on_restore = event.Event("This event is called when the device detects that a fault is restored.\n\n**Callback definition:** *def callback(device, zone)*")

        EXPIRE = 30
        """Zone expiration timeout."""

        @property
        def zones(self):
            """
            Returns the current list of zones being tracked.

            :returns: dictionary of :py:class:`Zone` being tracked
            """
            return self._zones

        @zones.setter
        def zones(self, value):
            """
            Sets the current list of zones being tracked.

            :param value: new list of zones being tracked
            :type value: dictionary of :py:class:`Zone` being tracked
            """
            self._zones = value

        @property
        def faulted(self):
            """
            Retrieves the current list of faulted zones.

            :returns: list of faulted zones
            """
            return self._zones_faulted

        @faulted.setter
        def faulted(self, value):
            """
            Sets the current list of faulted zones.

            :param value: new list of faulted zones
            :type value: list of integers
            """
            self._zones_faulted = value

        def __init__(self, alarmdecoder_object):
            """
            Constructor
            """
            self._zones = {}
            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.

            :param message: message to use to update the zone tracking
            :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, self.alarmdecoder_object.mode)

                if zone != -1:
                    status = Zone.CLEAR
                    if message.value == 1:
                        status = Zone.FAULT
                    elif message.value == 2:
                        status = Zone.CHECK

                    # NOTE: Expander zone faults are handled differently than
                    #       regular messages.  We don't include them in
                    #       self._zones_faulted because they are not reported
                    #       by the panel in it's rolling list of faults.
                    try:
                        self._update_zone(zone, status=status)

                    except IndexError:
                        self._add_zone(zone, status=status, expander=True)

            else:
                # Panel is ready, restore all zones.
                #
                # NOTE: This will need to be updated to support panels with
                #       multiple partitions.  In it's current state a ready on
                #       partition #1 will end up clearing all zones, even if they
                #       exist elsewhere and it shouldn't.
                #
                # NOTE: SYSTEM messages provide inconsistent ready statuses.  This
                #       may need to be extended later for other panels.
                if message.ready and not message.text.startswith("SYSTEM"):
                    for zone in self._zones_faulted:
                        self._update_zone(zone, Zone.CLEAR)

                    self._last_zone_fault = 0

                # Process fault
                elif message.check_zone or message.text.startswith("FAULT") or message.text.startswith("ALARM"):
                    # 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)

                    # NOTE: Odd case for ECP failures.  Apparently they report as
                    #       zone 191 (0xBF) regardless of whether or not the
                    #       3-digit mode is enabled... so we have to pull it out
                    #       of the alpha message.
                    if zone == 191:
                        zone_regex = re.compile('^CHECK (\d+).*$')

                        match = zone_regex.match(message.text)
                        if match is None:
                            return

                        zone = match.group(1)

                    # 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 expander_to_zone(self, address, channel, panel_type=ADEMCO):
            """
            Convert an address and channel into a zone number.

            :param address: expander address
            :type address: int
            :param channel: channel
            :type channel: int

            :returns: zone number associated with an address and channel
            """

            zone = -1

            if panel_type == ADEMCO:
                # TODO: This is going to need to be reworked to support the larger
                #       panels without fixed addressing on the expanders.

                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):
            """
            Clear all expired zones from our status list.

            :param zone: current zone being processed
            :type zone: int
            """
            cleared_zones = []
            found_last_faulted = found_current = at_end = False

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

                    if z == self._last_zone_fault:
                        found_last_faulted = 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_current:
                    z = next(it)

                    if z == zone:
                        found_current = 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_current:
                it = iter(self._zones_faulted)

                try:
                    while not found_current:
                        z = next(it)

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

                except StopIteration:
                    pass

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

        def _clear_expired_zones(self):
            """
            Update zone status for all expired zones.
            """
            zones = []

            for z in list(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, expander=False):
            """
            Adds a zone to the internal zone list.

            :param zone: zone number
            :type zone: int
            :param name: human readable zone name
            :type name: string
            :param status: zone status
            :type status: int
            """
            if not zone in self._zones:
                self._zones[zone] = Zone(zone=zone, name=name, status=None, expander=expander)

            self._update_zone(zone, status=status)

        def _update_zone(self, zone, status=None):
            """
            Updates a zones status.

            :param zone: zone number
            :type zone: int
            :param status: zone status
            :type status: int

            :raises: IndexError
            """
            if not zone in self._zones:
                raise IndexError('Zone does not exist and cannot be updated: %d', zone)

            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:
                if zone in self._zones_faulted:
                    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):
            """
            Determine if a zone is expired or not.

            :param zone: zone number
            :type zone: int

            :returns: whether or not the zone is expired
            """
            return (time.time() > self._zones[zone].timestamp + Zonetracker.EXPIRE) and self._zones[zone].expander is False

### Navigation

- index
- modules |
- alarmdecoder documentation »
- Module code »