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.

291 lines
8.4 KiB

  1. """
  2. Provides zone tracking functionality for the AD2USB device family.
  3. """
  4. import re
  5. import time
  6. from .event import event
  7. from . import messages
  8. class Zone(object):
  9. """
  10. Representation of a panel zone.
  11. """
  12. CLEAR = 0
  13. """Status indicating that the zone is cleared."""
  14. FAULT = 1
  15. """Status indicating that the zone is faulted."""
  16. CHECK = 2 # Wire fault
  17. """Status indicating that there is a wiring issue with the zone."""
  18. STATUS = { CLEAR: 'CLEAR', FAULT: 'FAULT', CHECK: 'CHECK' }
  19. def __init__(self, zone=0, name='', status=CLEAR):
  20. """
  21. Constructor
  22. :param zone: The zone number.
  23. :type zone: int
  24. :param name: Human readable zone name.
  25. :type name: str
  26. :param status: Initial zone state.
  27. :type status: int
  28. """
  29. self.zone = zone
  30. self.name = name
  31. self.status = status
  32. self.timestamp = time.time()
  33. def __str__(self):
  34. """
  35. String conversion operator.
  36. """
  37. return 'Zone {0} {1}'.format(self.zone, self.name)
  38. def __repr__(self):
  39. """
  40. Human readable representation operator.
  41. """
  42. return 'Zone({0}, {1}, ts {2})'.format(self.zone, Zone.STATUS[self.status], self.timestamp)
  43. class Zonetracker(object):
  44. """
  45. Handles tracking of zone and their statuses.
  46. """
  47. on_fault = event.Event('Called when the device detects a zone fault.')
  48. on_restore = event.Event('Called when the device detects that a fault is restored.')
  49. EXPIRE = 30
  50. """Zone expiration timeout."""
  51. def __init__(self):
  52. """
  53. Constructor
  54. """
  55. self._zones = {}
  56. self._zones_faulted = []
  57. self._last_zone_fault = 0
  58. def update(self, message):
  59. """
  60. Update zone statuses based on the current message.
  61. :param message: Message to use to update the zone tracking.
  62. :type message: Message or ExpanderMessage
  63. """
  64. zone = -1
  65. if isinstance(message, messages.ExpanderMessage):
  66. if message.type == messages.ExpanderMessage.ZONE:
  67. zone = self._expander_to_zone(int(message.address), int(message.channel))
  68. status = Zone.CLEAR
  69. if int(message.value) == 1:
  70. status = Zone.FAULT
  71. elif int(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 idx, z in enumerate(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. # TODO: parse message text.
  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 = found_new = at_end = False
  124. # First pass: Find our start spot.
  125. it = iter(self._zones_faulted)
  126. try:
  127. while not found_last:
  128. z = it.next()
  129. if z == self._last_zone_fault:
  130. found_last = 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_new:
  138. z = it.next()
  139. if z == zone:
  140. found_new = 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_new:
  149. it = iter(self._zones_faulted)
  150. try:
  151. while not found_new:
  152. z = it.next()
  153. if z == zone:
  154. found_new = 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 idx, z in enumerate(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