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.

294 lines
8.4 KiB

  1. """
  2. Provides zone tracking functionality for the AD2USB device family.
  3. .. moduleauthor:: Scott Petersen <scott@nutech.com>
  4. """
  5. import re
  6. import time
  7. from .event import event
  8. from . import messages
  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. zone = -1
  66. if isinstance(message, messages.ExpanderMessage):
  67. if message.type == messages.ExpanderMessage.ZONE:
  68. zone = self._expander_to_zone(message.address, message.channel)
  69. status = Zone.CLEAR
  70. if message.value == 1:
  71. status = Zone.FAULT
  72. elif message.value == 2:
  73. status = Zone.CHECK
  74. try:
  75. self._update_zone(zone, status=status)
  76. except IndexError:
  77. self._add_zone(zone, status=status)
  78. else:
  79. # Panel is ready, restore all zones.
  80. if message.ready:
  81. for z in self._zones_faulted:
  82. self._update_zone(z, Zone.CLEAR)
  83. self._last_zone_fault = 0
  84. # Process fault
  85. elif "FAULT" in message.text or message.check_zone:
  86. # Apparently this representation can be both base 10
  87. # or base 16, depending on where the message came
  88. # from.
  89. try:
  90. zone = int(message.numeric_code)
  91. except ValueError:
  92. zone = int(message.numeric_code, 16)
  93. # NOTE: Odd case for ECP failures. Apparently they report as zone 191 (0xBF) regardless
  94. # of whether or not the 3-digit mode is enabled... so we have to pull it out of the
  95. # alpha message.
  96. if zone == 191:
  97. zone_regex = re.compile('^CHECK (\d+).*$')
  98. m = zone_regex.match(message.text)
  99. if m is None:
  100. return
  101. zone = m.group(1)
  102. # Add new zones and clear expired ones.
  103. if zone in self._zones_faulted:
  104. self._update_zone(zone)
  105. self._clear_zones(zone)
  106. else:
  107. status = Zone.FAULT
  108. if message.check_zone:
  109. status = Zone.CHECK
  110. self._add_zone(zone, status=status)
  111. self._zones_faulted.append(zone)
  112. self._zones_faulted.sort()
  113. # Save our spot for the next message.
  114. self._last_zone_fault = zone
  115. self._clear_expired_zones()
  116. def _clear_zones(self, zone):
  117. """
  118. Clear all expired zones from our status list.
  119. :param zone: current zone being processed.
  120. :type zone: int
  121. """
  122. cleared_zones = []
  123. found_last_faulted = found_current = at_end = False
  124. # First pass: Find our start spot.
  125. it = iter(self._zones_faulted)
  126. try:
  127. while not found_last_faulted:
  128. z = it.next()
  129. if z == self._last_zone_fault:
  130. found_last_faulted = True
  131. break
  132. except StopIteration:
  133. at_end = True
  134. # Continue until we find our end point and add zones in
  135. # between to our clear list.
  136. try:
  137. while not at_end and not found_current:
  138. z = it.next()
  139. if z == zone:
  140. found_current = True
  141. break
  142. else:
  143. cleared_zones += [z]
  144. except StopIteration:
  145. pass
  146. # Second pass: roll through the list again if we didn't find
  147. # our end point and remove everything until we do.
  148. if not found_current:
  149. it = iter(self._zones_faulted)
  150. try:
  151. while not found_current:
  152. z = it.next()
  153. if z == zone:
  154. found_current = True
  155. break
  156. else:
  157. cleared_zones += [z]
  158. except StopIteration:
  159. pass
  160. # Actually remove the zones and trigger the restores.
  161. for z in cleared_zones:
  162. self._update_zone(z, Zone.CLEAR)
  163. def _clear_expired_zones(self):
  164. """
  165. Update zone status for all expired zones.
  166. """
  167. zones = []
  168. for z in self._zones.keys():
  169. zones += [z]
  170. for z in zones:
  171. if self._zones[z].status != Zone.CLEAR and self._zone_expired(z):
  172. self._update_zone(z, Zone.CLEAR)
  173. def _add_zone(self, zone, name='', status=Zone.CLEAR):
  174. """
  175. Adds a zone to the internal zone list.
  176. :param zone: The zone number.
  177. :type zone: int
  178. :param name: Human readable zone name.
  179. :type name: str
  180. :param status: The zone status.
  181. :type status: int
  182. """
  183. if not zone in self._zones:
  184. self._zones[zone] = Zone(zone=zone, name=name, status=status)
  185. if status != Zone.CLEAR:
  186. self.on_fault(zone)
  187. def _update_zone(self, zone, status=None):
  188. """
  189. Updates a zones status.
  190. :param zone: The zone number.
  191. :type zone: int
  192. :param status: The zone status.
  193. :type status: int
  194. :raises: IndexError
  195. """
  196. if not zone in self._zones:
  197. raise IndexError('Zone does not exist and cannot be updated: %d', zone)
  198. if status is not None:
  199. self._zones[zone].status = status
  200. self._zones[zone].timestamp = time.time()
  201. if status == Zone.CLEAR:
  202. if zone in self._zones_faulted:
  203. self._zones_faulted.remove(zone)
  204. self.on_restore(zone)
  205. def _zone_expired(self, zone):
  206. """
  207. Determine if a zone is expired or not.
  208. :param zone: The zone number.
  209. :type zone: int
  210. :returns: Whether or not the zone is expired.
  211. """
  212. if time.time() > self._zones[zone].timestamp + Zonetracker.EXPIRE:
  213. return True
  214. return False
  215. def _expander_to_zone(self, address, channel):
  216. """
  217. Convert an address and channel into a zone number.
  218. :param address: The expander address
  219. :type address: int
  220. :param channel: The channel
  221. :type channel: int
  222. :returns: The zone number associated with an address and channel.
  223. """
  224. # TODO: This is going to need to be reworked to support the larger
  225. # panels without fixed addressing on the expanders.
  226. idx = address - 7 # Expanders start at address 7.
  227. return address + channel + (idx * 7) + 1