A clone of: https://github.com/nutechsoftware/alarmdecoder This is requires as they dropped support for older firmware releases w/o building in backward compatibility code, and they had previously hardcoded pyserial to a python2 only version.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

297 lines
8.8 KiB

  1. """
  2. Provides zone tracking functionality for the Alarm Decoder (AD2) device family.
  3. .. moduleauthor:: Scott Petersen <scott@nutech.com>
  4. """
  5. import re
  6. import time
  7. from .event import event
  8. from .messages import ExpanderMessage
  9. class Zone(object):
  10. """
  11. Representation of a panel zone.
  12. """
  13. CLEAR = 0
  14. """Status indicating that the zone is cleared."""
  15. FAULT = 1
  16. """Status indicating that the zone is faulted."""
  17. CHECK = 2 # Wire fault
  18. """Status indicating that there is a wiring issue with the zone."""
  19. STATUS = { CLEAR: 'CLEAR', FAULT: 'FAULT', CHECK: 'CHECK' }
  20. def __init__(self, zone=0, name='', status=CLEAR):
  21. """
  22. Constructor
  23. :param zone: The zone number.
  24. :type zone: int
  25. :param name: Human readable zone name.
  26. :type name: str
  27. :param status: Initial zone state.
  28. :type status: int
  29. """
  30. self.zone = zone
  31. self.name = name
  32. self.status = status
  33. self.timestamp = time.time()
  34. def __str__(self):
  35. """
  36. String conversion operator.
  37. """
  38. return 'Zone {0} {1}'.format(self.zone, self.name)
  39. def __repr__(self):
  40. """
  41. Human readable representation operator.
  42. """
  43. return 'Zone({0}, {1}, ts {2})'.format(self.zone, Zone.STATUS[self.status], self.timestamp)
  44. class Zonetracker(object):
  45. """
  46. Handles tracking of zone and their statuses.
  47. """
  48. on_fault = event.Event('Called when the device detects a zone fault.')
  49. on_restore = event.Event('Called when the device detects that a fault is restored.')
  50. EXPIRE = 30
  51. """Zone expiration timeout."""
  52. def __init__(self):
  53. """
  54. Constructor
  55. """
  56. self._zones = {}
  57. self._zones_faulted = []
  58. self._last_zone_fault = 0
  59. def update(self, message):
  60. """
  61. Update zone statuses based on the current message.
  62. :param message: Message to use to update the zone tracking.
  63. :type message: Message or ExpanderMessage
  64. """
  65. if isinstance(message, ExpanderMessage):
  66. if message.type == ExpanderMessage.ZONE:
  67. zone = self._expander_to_zone(message.address, message.channel)
  68. status = Zone.CLEAR
  69. if message.value == 1:
  70. status = Zone.FAULT
  71. elif message.value == 2:
  72. status = Zone.CHECK
  73. # NOTE: Expander zone faults are handled differently than regular messages.
  74. # We don't include them in self._zones_faulted because they are not reported
  75. # by the panel in it's rolling list of faults.
  76. try:
  77. self._update_zone(zone, status=status)
  78. except IndexError:
  79. self._add_zone(zone, status=status)
  80. else:
  81. # Panel is ready, restore all zones.
  82. #
  83. # NOTE: This will need to be updated to support panels with multiple partitions.
  84. # In it's current state a ready on partition #1 will end up clearing all zones, even
  85. # if they exist elsewhere and it shouldn't.
  86. if message.ready:
  87. for z in self._zones_faulted:
  88. self._update_zone(z, Zone.CLEAR)
  89. self._last_zone_fault = 0
  90. # Process fault
  91. elif "FAULT" in message.text or message.check_zone:
  92. # Apparently this representation can be both base 10
  93. # or base 16, depending on where the message came
  94. # from.
  95. try:
  96. zone = int(message.numeric_code)
  97. except ValueError:
  98. zone = int(message.numeric_code, 16)
  99. # NOTE: Odd case for ECP failures. Apparently they report as zone 191 (0xBF) regardless
  100. # of whether or not the 3-digit mode is enabled... so we have to pull it out of the
  101. # alpha message.
  102. if zone == 191:
  103. zone_regex = re.compile('^CHECK (\d+).*$')
  104. m = zone_regex.match(message.text)
  105. if m is None:
  106. return
  107. zone = m.group(1)
  108. # Add new zones and clear expired ones.
  109. if zone in self._zones_faulted:
  110. self._update_zone(zone)
  111. self._clear_zones(zone)
  112. else:
  113. status = Zone.FAULT
  114. if message.check_zone:
  115. status = Zone.CHECK
  116. self._add_zone(zone, status=status)
  117. self._zones_faulted.append(zone)
  118. self._zones_faulted.sort()
  119. # Save our spot for the next message.
  120. self._last_zone_fault = zone
  121. self._clear_expired_zones()
  122. def _clear_zones(self, zone):
  123. """
  124. Clear all expired zones from our status list.
  125. :param zone: current zone being processed.
  126. :type zone: int
  127. """
  128. cleared_zones = []
  129. found_last_faulted = found_current = at_end = False
  130. # First pass: Find our start spot.
  131. it = iter(self._zones_faulted)
  132. try:
  133. while not found_last_faulted:
  134. z = it.next()
  135. if z == self._last_zone_fault:
  136. found_last_faulted = True
  137. break
  138. except StopIteration:
  139. at_end = True
  140. # Continue until we find our end point and add zones in
  141. # between to our clear list.
  142. try:
  143. while not at_end and not found_current:
  144. z = it.next()
  145. if z == zone:
  146. found_current = True
  147. break
  148. else:
  149. cleared_zones += [z]
  150. except StopIteration:
  151. pass
  152. # Second pass: roll through the list again if we didn't find
  153. # our end point and remove everything until we do.
  154. if not found_current:
  155. it = iter(self._zones_faulted)
  156. try:
  157. while not found_current:
  158. z = it.next()
  159. if z == zone:
  160. found_current = True
  161. break
  162. else:
  163. cleared_zones += [z]
  164. except StopIteration:
  165. pass
  166. # Actually remove the zones and trigger the restores.
  167. for z in cleared_zones:
  168. self._update_zone(z, Zone.CLEAR)
  169. def _clear_expired_zones(self):
  170. """
  171. Update zone status for all expired zones.
  172. """
  173. zones = []
  174. for z in self._zones.keys():
  175. zones += [z]
  176. for z in zones:
  177. if self._zones[z].status != Zone.CLEAR and self._zone_expired(z):
  178. self._update_zone(z, Zone.CLEAR)
  179. def _add_zone(self, zone, name='', status=Zone.CLEAR):
  180. """
  181. Adds a zone to the internal zone list.
  182. :param zone: The zone number.
  183. :type zone: int
  184. :param name: Human readable zone name.
  185. :type name: str
  186. :param status: The zone status.
  187. :type status: int
  188. """
  189. if not zone in self._zones:
  190. self._zones[zone] = Zone(zone=zone, name=name, status=status)
  191. if status != Zone.CLEAR:
  192. self.on_fault(zone=zone)
  193. def _update_zone(self, zone, status=None):
  194. """
  195. Updates a zones status.
  196. :param zone: The zone number.
  197. :type zone: int
  198. :param status: The zone status.
  199. :type status: int
  200. :raises: IndexError
  201. """
  202. if not zone in self._zones:
  203. raise IndexError('Zone does not exist and cannot be updated: %d', zone)
  204. if status is not None:
  205. self._zones[zone].status = status
  206. self._zones[zone].timestamp = time.time()
  207. if status == Zone.CLEAR:
  208. if zone in self._zones_faulted:
  209. self._zones_faulted.remove(zone)
  210. self.on_restore(zone=zone)
  211. def _zone_expired(self, zone):
  212. """
  213. Determine if a zone is expired or not.
  214. :param zone: The zone number.
  215. :type zone: int
  216. :returns: Whether or not the zone is expired.
  217. """
  218. return time.time() > self._zones[zone].timestamp + Zonetracker.EXPIRE
  219. def _expander_to_zone(self, address, channel):
  220. """
  221. Convert an address and channel into a zone number.
  222. :param address: The expander address
  223. :type address: int
  224. :param channel: The channel
  225. :type channel: int
  226. :returns: The zone number associated with an address and channel.
  227. """
  228. # TODO: This is going to need to be reworked to support the larger
  229. # panels without fixed addressing on the expanders.
  230. idx = address - 7 # Expanders start at address 7.
  231. return address + channel + (idx * 7) + 1