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.

370 lines
11 KiB

  1. """
  2. Provides zone tracking functionality for the `AlarmDecoder`_ (AD2) device family.
  3. .. _AlarmDecoder: http://www.alarmdecoder.com
  4. .. moduleauthor:: Scott Petersen <scott@nutech.com>
  5. """
  6. import re
  7. import time
  8. from .event import event
  9. from .messages import ExpanderMessage
  10. from .panels import ADEMCO, DSC
  11. class Zone(object):
  12. """
  13. Representation of a panel zone.
  14. """
  15. # Constants
  16. CLEAR = 0
  17. """Status indicating that the zone is cleared."""
  18. FAULT = 1
  19. """Status indicating that the zone is faulted."""
  20. CHECK = 2 # Wire fault
  21. """Status indicating that there is a wiring issue with the zone."""
  22. STATUS = {CLEAR: 'CLEAR', FAULT: 'FAULT', CHECK: 'CHECK'}
  23. # Attributes
  24. zone = 0
  25. """Zone ID"""
  26. name = ''
  27. """Zone name"""
  28. status = CLEAR
  29. """Zone status"""
  30. timestamp = None
  31. """Timestamp of last update"""
  32. expander = False
  33. """Does this zone exist on an expander?"""
  34. def __init__(self, zone=0, name='', status=CLEAR, expander=False):
  35. """
  36. Constructor
  37. :param zone: zone number
  38. :type zone: int
  39. :param name: Human readable zone name
  40. :type name: string
  41. :param status: Initial zone state
  42. :type status: int
  43. """
  44. self.zone = zone
  45. self.name = name
  46. self.status = status
  47. self.timestamp = time.time()
  48. self.expander = expander
  49. def __str__(self):
  50. """
  51. String conversion operator.
  52. """
  53. return 'Zone {0} {1}'.format(self.zone, self.name)
  54. def __repr__(self):
  55. """
  56. Human readable representation operator.
  57. """
  58. return 'Zone({0}, {1}, ts {2})'.format(self.zone, Zone.STATUS[self.status], self.timestamp)
  59. class Zonetracker(object):
  60. """
  61. Handles tracking of zones and their statuses.
  62. """
  63. on_fault = event.Event("This event is called when the device detects a zone fault.\n\n**Callback definition:** *def callback(device, zone)*")
  64. on_restore = event.Event("This event is called when the device detects that a fault is restored.\n\n**Callback definition:** *def callback(device, zone)*")
  65. EXPIRE = 30
  66. """Zone expiration timeout."""
  67. @property
  68. def zones(self):
  69. """
  70. Returns the current list of zones being tracked.
  71. :returns: dictionary of :py:class:`Zone` being tracked
  72. """
  73. return self._zones
  74. @zones.setter
  75. def zones(self, value):
  76. """
  77. Sets the current list of zones being tracked.
  78. :param value: new list of zones being tracked
  79. :type value: dictionary of :py:class:`Zone` being tracked
  80. """
  81. self._zones = value
  82. @property
  83. def faulted(self):
  84. """
  85. Retrieves the current list of faulted zones.
  86. :returns: list of faulted zones
  87. """
  88. return self._zones_faulted
  89. @faulted.setter
  90. def faulted(self, value):
  91. """
  92. Sets the current list of faulted zones.
  93. :param value: new list of faulted zones
  94. :type value: list of integers
  95. """
  96. self._zones_faulted = value
  97. def __init__(self, alarmdecoder_object):
  98. """
  99. Constructor
  100. """
  101. self._zones = {}
  102. self._zones_faulted = []
  103. self._last_zone_fault = 0
  104. self.alarmdecoder_object = alarmdecoder_object
  105. def update(self, message):
  106. """
  107. Update zone statuses based on the current message.
  108. :param message: message to use to update the zone tracking
  109. :type message: :py:class:`~alarmdecoder.messages.Message` or :py:class:`~alarmdecoder.messages.ExpanderMessage`
  110. """
  111. if isinstance(message, ExpanderMessage):
  112. zone = -1
  113. if message.type == ExpanderMessage.ZONE:
  114. zone = self.expander_to_zone(message.address, message.channel, self.alarmdecoder_object.mode)
  115. if zone != -1:
  116. status = Zone.CLEAR
  117. if message.value == 1:
  118. status = Zone.FAULT
  119. elif message.value == 2:
  120. status = Zone.CHECK
  121. # NOTE: Expander zone faults are handled differently than
  122. # regular messages.
  123. try:
  124. self._update_zone(zone, status=status)
  125. except IndexError:
  126. self._add_zone(zone, status=status, expander=True)
  127. else:
  128. # Panel is ready, restore all zones.
  129. #
  130. # NOTE: This will need to be updated to support panels with
  131. # multiple partitions. In it's current state a ready on
  132. # partition #1 will end up clearing all zones, even if they
  133. # exist elsewhere and it shouldn't.
  134. #
  135. # NOTE: SYSTEM messages provide inconsistent ready statuses. This
  136. # may need to be extended later for other panels.
  137. if message.ready and not message.text.startswith("SYSTEM"):
  138. for zone in self._zones_faulted:
  139. self._update_zone(zone, Zone.CLEAR)
  140. self._last_zone_fault = 0
  141. # Process fault
  142. elif self.alarmdecoder_object.mode != DSC and (message.check_zone or message.text.startswith("FAULT") or message.text.startswith("ALARM")):
  143. zone = message.parse_numeric_code()
  144. # NOTE: Odd case for ECP failures. Apparently they report as
  145. # zone 191 (0xBF) regardless of whether or not the
  146. # 3-digit mode is enabled... so we have to pull it out
  147. # of the alpha message.
  148. if zone == 191:
  149. zone_regex = re.compile('^CHECK (\d+).*$')
  150. match = zone_regex.match(message.text)
  151. if match is None:
  152. return
  153. zone = match.group(1)
  154. # Add new zones and clear expired ones.
  155. if zone in self._zones_faulted:
  156. self._update_zone(zone)
  157. self._clear_zones(zone)
  158. else:
  159. status = Zone.FAULT
  160. if message.check_zone:
  161. status = Zone.CHECK
  162. self._add_zone(zone, status=status)
  163. self._zones_faulted.append(zone)
  164. self._zones_faulted.sort()
  165. # Save our spot for the next message.
  166. self._last_zone_fault = zone
  167. self._clear_expired_zones()
  168. def expander_to_zone(self, address, channel, panel_type=ADEMCO):
  169. """
  170. Convert an address and channel into a zone number.
  171. :param address: expander address
  172. :type address: int
  173. :param channel: channel
  174. :type channel: int
  175. :returns: zone number associated with an address and channel
  176. """
  177. zone = -1
  178. if panel_type == ADEMCO:
  179. # TODO: This is going to need to be reworked to support the larger
  180. # panels without fixed addressing on the expanders.
  181. idx = address - 7 # Expanders start at address 7.
  182. zone = address + channel + (idx * 7) + 1
  183. elif panel_type == DSC:
  184. zone = (address * 8) + channel
  185. return zone
  186. def _clear_zones(self, zone):
  187. """
  188. Clear all expired zones from our status list.
  189. :param zone: current zone being processed
  190. :type zone: int
  191. """
  192. cleared_zones = []
  193. found_last_faulted = found_current = at_end = False
  194. # First pass: Find our start spot.
  195. it = iter(self._zones_faulted)
  196. try:
  197. while not found_last_faulted:
  198. z = next(it)
  199. if z == self._last_zone_fault:
  200. found_last_faulted = True
  201. break
  202. except StopIteration:
  203. at_end = True
  204. # Continue until we find our end point and add zones in
  205. # between to our clear list.
  206. try:
  207. while not at_end and not found_current:
  208. z = next(it)
  209. if z == zone:
  210. found_current = True
  211. break
  212. else:
  213. cleared_zones += [z]
  214. except StopIteration:
  215. pass
  216. # Second pass: roll through the list again if we didn't find
  217. # our end point and remove everything until we do.
  218. if not found_current:
  219. it = iter(self._zones_faulted)
  220. try:
  221. while not found_current:
  222. z = next(it)
  223. if z == zone:
  224. found_current = True
  225. break
  226. else:
  227. cleared_zones += [z]
  228. except StopIteration:
  229. pass
  230. # Actually remove the zones and trigger the restores.
  231. for z in cleared_zones:
  232. # Don't clear expander zones, expander messages will fix this
  233. if self._zones[z].expander is False:
  234. self._update_zone(z, Zone.CLEAR)
  235. def _clear_expired_zones(self):
  236. """
  237. Update zone status for all expired zones.
  238. """
  239. zones = []
  240. for z in list(self._zones.keys()):
  241. zones += [z]
  242. for z in zones:
  243. if self._zones[z].status != Zone.CLEAR and self._zone_expired(z):
  244. self._update_zone(z, Zone.CLEAR)
  245. def _add_zone(self, zone, name='', status=Zone.CLEAR, expander=False):
  246. """
  247. Adds a zone to the internal zone list.
  248. :param zone: zone number
  249. :type zone: int
  250. :param name: human readable zone name
  251. :type name: string
  252. :param status: zone status
  253. :type status: int
  254. """
  255. if not zone in self._zones:
  256. self._zones[zone] = Zone(zone=zone, name=name, status=None, expander=expander)
  257. self._update_zone(zone, status=status)
  258. def _update_zone(self, zone, status=None):
  259. """
  260. Updates a zones status.
  261. :param zone: zone number
  262. :type zone: int
  263. :param status: zone status
  264. :type status: int
  265. :raises: IndexError
  266. """
  267. if not zone in self._zones:
  268. raise IndexError('Zone does not exist and cannot be updated: %d', zone)
  269. old_status = self._zones[zone].status
  270. if status is None:
  271. status = old_status
  272. self._zones[zone].status = status
  273. self._zones[zone].timestamp = time.time()
  274. if status == Zone.CLEAR:
  275. if zone in self._zones_faulted:
  276. self._zones_faulted.remove(zone)
  277. self.on_restore(zone=zone)
  278. else:
  279. if old_status != status and status is not None:
  280. self.on_fault(zone=zone)
  281. def _zone_expired(self, zone):
  282. """
  283. Determine if a zone is expired or not.
  284. :param zone: zone number
  285. :type zone: int
  286. :returns: whether or not the zone is expired
  287. """
  288. return (time.time() > self._zones[zone].timestamp + Zonetracker.EXPIRE) and self._zones[zone].expander is False