|
- """
- 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.
- 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 self.alarmdecoder_object.mode != DSC and (message.check_zone or message.text.startswith("FAULT") or message.text.startswith("ALARM")):
- zone = message.parse_numeric_code()
-
- # 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:
- # Don't clear expander zones, expander messages will fix this
- if self._zones[z].expander is False:
- 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
|