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.

212 lines
6.0 KiB

  1. """
  2. Provides zone tracking functionality for the AD2USB device family.
  3. """
  4. import time
  5. from .event import event
  6. from . import messages
  7. class Zone(object):
  8. """
  9. Representation of a panel zone.
  10. """
  11. CLEAR = 0
  12. FAULT = 1
  13. CHECK = 2 # Wire fault
  14. STATUS = { CLEAR: 'CLEAR', FAULT: 'FAULT', CHECK: 'CHECK' }
  15. def __init__(self, zone=0, name='', status=CLEAR):
  16. self.zone = zone
  17. self.name = name
  18. self.status = status
  19. self.timestamp = time.time()
  20. def __str__(self):
  21. return 'Zone {0} {1}'.format(self.zone, self.name)
  22. def __repr__(self):
  23. return 'Zone({0}, {1}, ts {2})'.format(self.zone, Zone.STATUS[self.status], self.timestamp)
  24. class Zonetracker(object):
  25. """
  26. Handles tracking of zone and their statuses.
  27. """
  28. on_fault = event.Event('Called when the device detects a zone fault.')
  29. on_restore = event.Event('Called when the device detects that a fault is restored.')
  30. EXPIRE = 30
  31. def __init__(self):
  32. """
  33. Constructor
  34. """
  35. self._zones = {}
  36. self._zones_faulted = []
  37. self._last_zone_fault = 0
  38. def update(self, message):
  39. """
  40. Update zone statuses based on the current message.
  41. """
  42. zone = -1
  43. if isinstance(message, messages.ExpanderMessage):
  44. if message.type == messages.ExpanderMessage.ZONE:
  45. zone = self._expander_to_zone(int(message.address), int(message.channel))
  46. status = Zone.CLEAR
  47. if int(message.value) == 1:
  48. status = Zone.FAULT
  49. elif int(message.value) == 2:
  50. status = Zone.CHECK
  51. try:
  52. self._update_zone(zone, status=status)
  53. except IndexError:
  54. self._add_zone(zone, status=status)
  55. else:
  56. # Panel is ready, restore all zones.
  57. if message.ready:
  58. for idx, z in enumerate(self._zones_faulted):
  59. self._update_zone(z, Zone.CLEAR)
  60. self._last_zone_fault = 0
  61. # Process fault
  62. elif "FAULT" in message.text or message.check_zone:
  63. # Apparently this representation can be both base 10
  64. # or base 16, depending on where the message came
  65. # from.
  66. try:
  67. zone = int(message.numeric_code)
  68. except ValueError:
  69. zone = int(message.numeric_code, 16)
  70. # Add new zones and clear expired ones.
  71. if zone in self._zones_faulted:
  72. self._update_zone(zone)
  73. self._clear_zones(zone)
  74. else:
  75. status = Zone.FAULT
  76. if message.check_zone:
  77. status = Zone.CHECK
  78. self._add_zone(zone, status=status)
  79. self._zones_faulted.append(zone)
  80. self._zones_faulted.sort()
  81. # Save our spot for the next message.
  82. self._last_zone_fault = zone
  83. self._clear_expired_zones()
  84. def _clear_zones(self, zone):
  85. """
  86. Clear all expired zones from our status list.
  87. """
  88. cleared_zones = []
  89. found_last = found_new = at_end = False
  90. # First pass: Find our start spot.
  91. it = iter(self._zones_faulted)
  92. try:
  93. while not found_last:
  94. z = it.next()
  95. if z == self._last_zone_fault:
  96. found_last = True
  97. break
  98. except StopIteration:
  99. at_end = True
  100. # Continue until we find our end point and add zones in
  101. # between to our clear list.
  102. try:
  103. while not at_end and not found_new:
  104. z = it.next()
  105. if z == zone:
  106. found_new = True
  107. break
  108. else:
  109. cleared_zones += [z]
  110. except StopIteration:
  111. pass
  112. # Second pass: roll through the list again if we didn't find
  113. # our end point and remove everything until we do.
  114. if not found_new:
  115. it = iter(self._zones_faulted)
  116. try:
  117. while not found_new:
  118. z = it.next()
  119. if z == zone:
  120. found_new = True
  121. break
  122. else:
  123. cleared_zones += [z]
  124. except StopIteration:
  125. pass
  126. # Actually remove the zones and trigger the restores.
  127. for idx, z in enumerate(cleared_zones):
  128. self._update_zone(z, Zone.CLEAR)
  129. def _clear_expired_zones(self):
  130. zones = []
  131. for z in self._zones.keys():
  132. zones += [z]
  133. for z in zones:
  134. if self._zones[z].status != Zone.CLEAR and self._zone_expired(z):
  135. self._update_zone(z, Zone.CLEAR)
  136. def _add_zone(self, zone, name='', status=Zone.CLEAR):
  137. """
  138. Adds a zone to the internal zone list.
  139. """
  140. if not zone in self._zones:
  141. self._zones[zone] = Zone(zone=zone, name=name, status=status)
  142. if status != Zone.CLEAR:
  143. self.on_fault(zone)
  144. def _update_zone(self, zone, status=None):
  145. """
  146. Updates a zones status.
  147. """
  148. if not zone in self._zones:
  149. raise IndexError('Zone does not exist and cannot be updated: %d', zone)
  150. if status is not None:
  151. self._zones[zone].status = status
  152. self._zones[zone].timestamp = time.time()
  153. if status == Zone.CLEAR:
  154. if zone in self._zones_faulted:
  155. self._zones_faulted.remove(zone)
  156. self.on_restore(zone)
  157. def _zone_expired(self, zone):
  158. if time.time() > self._zones[zone].timestamp + Zonetracker.EXPIRE:
  159. return True
  160. return False
  161. def _expander_to_zone(self, address, channel):
  162. idx = address - 7 # Expanders start at address 7.
  163. return address + channel + (idx * 7) + 1