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.

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