""" Provides zone tracking functionality for the AD2USB device family. .. moduleauthor:: Scott Petersen """ import re import time from .event import event from . import messages class Zone(object): """ Representation of a panel zone. """ 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' } def __init__(self, zone=0, name='', status=CLEAR): """ Constructor :param zone: The zone number. :type zone: int :param name: Human readable zone name. :type name: str :param status: Initial zone state. :type status: int """ self.zone = zone self.name = name self.status = status self.timestamp = time.time() 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 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 """Zone expiration timeout.""" 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. :param message: Message to use to update the zone tracking. :type message: Message or ExpanderMessage """ if isinstance(message, messages.ExpanderMessage): if message.type == messages.ExpanderMessage.ZONE: zone = self._expander_to_zone(message.address, message.channel) 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) else: # Panel is ready, restore all zones. if message.ready: for z in 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) # 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+).*$') m = zone_regex.match(message.text) if m is None: return zone = m.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 _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 = it.next() 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 = it.next() 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 = it.next() 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 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. :param zone: The zone number. :type zone: int :param name: Human readable zone name. :type name: str :param status: The zone status. :type status: int """ 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. :param zone: The zone number. :type zone: int :param status: The 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) 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): """ Determine if a zone is expired or not. :param zone: The zone number. :type zone: int :returns: Whether or not the zone is expired. """ return time.time() > self._zones[zone].timestamp + Zonetracker.EXPIRE def _expander_to_zone(self, address, channel): """ Convert an address and channel into a zone number. :param address: The expander address :type address: int :param channel: The channel :type channel: int :returns: The 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. idx = address - 7 # Expanders start at address 7. return address + channel + (idx * 7) + 1