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.

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