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.

604 lines
20 KiB

  1. """
  2. Provides the main AlarmDecoder class.
  3. .. _Alarm Decoder: http://www.alarmdecoder.com
  4. .. moduleauthor:: Scott Petersen <scott@nutech.com>
  5. """
  6. import time
  7. from .event import event
  8. from .util import InvalidMessageError
  9. from .messages import Message, ExpanderMessage, RFMessage, LRRMessage
  10. from .zonetracking import Zonetracker
  11. class AlarmDecoder(object):
  12. """
  13. High-level wrapper around `Alarm Decoder`_ (AD2) devices.
  14. """
  15. # High-level Events
  16. on_arm = event.Event('This event is called when the panel is armed.')
  17. on_disarm = event.Event('This event is called when the panel is disarmed.')
  18. on_power_changed = event.Event('This event is called when panel power switches between AC and DC.')
  19. on_alarm = event.Event('This event is called when the alarm is triggered.')
  20. on_fire = event.Event('This event is called when a fire is detected.')
  21. on_bypass = event.Event('This event is called when a zone is bypassed.')
  22. on_boot = event.Event('This event is called when the device finishes booting.')
  23. on_config_received = event.Event('This event is called when the device receives its configuration.')
  24. on_zone_fault = event.Event('This event is called when :py:class:`alarmdecoder.zonetracking.Zonetracker` detects a zone fault.')
  25. on_zone_restore = event.Event('This event is called when :py:class:`alarmdecoder.zonetracking.Zonetracker` detects that a fault is restored.')
  26. on_low_battery = event.Event('This event is called when the device detects a low battery.')
  27. on_panic = event.Event('This event is called when the device detects a panic.')
  28. on_relay_changed = event.Event('This event is called when a relay is opened or closed on an expander board.')
  29. # Mid-level Events
  30. on_message = event.Event('This event is called when any message is received.')
  31. on_lrr_message = event.Event('This event is called when an :py:class:`alarmdecoder.messages.LRRMessage` is received.')
  32. on_rfx_message = event.Event('This event is called when an :py:class:`alarmdecoder.messages.RFMessage` is received.')
  33. # Low-level Events
  34. on_open = event.Event('This event is called when the device has been opened.')
  35. on_close = event.Event('This event is called when the device has been closed.')
  36. on_read = event.Event('This event is called when a line has been read from the device.')
  37. on_write = event.Event('This event is called when data has been written to the device.')
  38. # Constants
  39. KEY_F1 = unichr(1) + unichr(1) + unichr(1)
  40. """Represents panel function key #1"""
  41. KEY_F2 = unichr(2) + unichr(2) + unichr(2)
  42. """Represents panel function key #2"""
  43. KEY_F3 = unichr(3) + unichr(3) + unichr(3)
  44. """Represents panel function key #3"""
  45. KEY_F4 = unichr(4) + unichr(4) + unichr(4)
  46. """Represents panel function key #4"""
  47. BATTERY_TIMEOUT = 30
  48. """Default timeout (in seconds) before the battery status reverts."""
  49. FIRE_TIMEOUT = 30
  50. """Default tTimeout (in seconds) before the fire status reverts."""
  51. # Attributes
  52. address = 18
  53. """The keypad address in use by the device."""
  54. configbits = 0xFF00
  55. """The configuration bits set on the device."""
  56. address_mask = 0x00000000
  57. """The address mask configured on the device."""
  58. emulate_zone = [False for _ in range(5)]
  59. """List containing the devices zone emulation status."""
  60. emulate_relay = [False for _ in range(4)]
  61. """List containing the devices relay emulation status."""
  62. emulate_lrr = False
  63. """The status of the devices LRR emulation."""
  64. deduplicate = False
  65. """The status of message deduplication as configured on the device."""
  66. def __init__(self, device):
  67. """
  68. Constructor
  69. :param device: The low-level device used for this `Alarm Decoder`_
  70. interface.
  71. :type device: Device
  72. """
  73. self._device = device
  74. self._zonetracker = Zonetracker()
  75. self._battery_timeout = AlarmDecoder.BATTERY_TIMEOUT
  76. self._fire_timeout = AlarmDecoder.FIRE_TIMEOUT
  77. self._power_status = None
  78. self._alarm_status = None
  79. self._bypass_status = None
  80. self._armed_status = None
  81. self._fire_status = (False, 0)
  82. self._battery_status = (False, 0)
  83. self._panic_status = None
  84. self._relay_status = {}
  85. self.address = 18
  86. self.configbits = 0xFF00
  87. self.address_mask = 0x00000000
  88. self.emulate_zone = [False for x in range(5)]
  89. self.emulate_relay = [False for x in range(4)]
  90. self.emulate_lrr = False
  91. self.deduplicate = False
  92. def __enter__(self):
  93. """
  94. Support for context manager __enter__.
  95. """
  96. return self
  97. def __exit__(self, exc_type, exc_value, traceback):
  98. """
  99. Support for context manager __exit__.
  100. """
  101. self.close()
  102. return False
  103. @property
  104. def id(self):
  105. """
  106. The ID of the `Alarm Decoder`_ device.
  107. :returns: identification string for the device
  108. """
  109. return self._device.id
  110. @property
  111. def battery_timeout(self):
  112. """
  113. Retrieves the timeout for restoring the battery status, in seconds.
  114. :returns: battery status timeout
  115. """
  116. return self._battery_timeout
  117. @battery_timeout.setter
  118. def battery_timeout(self, value):
  119. """
  120. Sets the timeout for restoring the battery status, in seconds.
  121. :param value: timeout in seconds
  122. :type value: int
  123. """
  124. self._battery_timeout = value
  125. @property
  126. def fire_timeout(self):
  127. """
  128. Retrieves the timeout for restoring the fire status, in seconds.
  129. :returns: fire status timeout
  130. """
  131. return self._fire_timeout
  132. @fire_timeout.setter
  133. def fire_timeout(self, value):
  134. """
  135. Sets the timeout for restoring the fire status, in seconds.
  136. :param value: timeout in seconds
  137. :type value: int
  138. """
  139. self._fire_timeout = value
  140. def open(self, baudrate=None, no_reader_thread=False):
  141. """
  142. Opens the device.
  143. :param baudrate: baudrate used for the device. Defaults to the lower-level device default.
  144. :type baudrate: int
  145. :param no_reader_thread: Specifies whether or not the automatic reader
  146. thread should be started.
  147. :type no_reader_thread: bool
  148. """
  149. self._wire_events()
  150. self._device.open(baudrate=baudrate, no_reader_thread=no_reader_thread)
  151. return self
  152. def close(self):
  153. """
  154. Closes the device.
  155. """
  156. if self._device:
  157. self._device.close()
  158. del self._device
  159. self._device = None
  160. def send(self, data):
  161. """
  162. Sends data to the `Alarm Decoder`_ device.
  163. :param data: data to send
  164. :type data: string
  165. """
  166. if self._device:
  167. self._device.write(data)
  168. def get_config(self):
  169. """
  170. Retrieves the configuration from the device. Called automatically by :py:meth:`_on_open`.
  171. """
  172. self.send("C\r")
  173. def save_config(self):
  174. """
  175. Sets configuration entries on the device.
  176. """
  177. config_string = ''
  178. config_entries = []
  179. # HACK: This is ugly.. but I can't think of an elegant way of doing it.
  180. config_entries.append(('ADDRESS',
  181. '{0}'.format(self.address)))
  182. config_entries.append(('CONFIGBITS',
  183. '{0:x}'.format(self.configbits)))
  184. config_entries.append(('MASK',
  185. '{0:x}'.format(self.address_mask)))
  186. config_entries.append(('EXP',
  187. ''.join(['Y' if z else 'N' for z in self.emulate_zone])))
  188. config_entries.append(('REL',
  189. ''.join(['Y' if r else 'N' for r in self.emulate_relay])))
  190. config_entries.append(('LRR',
  191. 'Y' if self.emulate_lrr else 'N'))
  192. config_entries.append(('DEDUPLICATE',
  193. 'Y' if self.deduplicate else 'N'))
  194. config_string = '&'.join(['='.join(t) for t in config_entries])
  195. self.send("C{0}\r".format(config_string))
  196. def reboot(self):
  197. """
  198. Reboots the device.
  199. """
  200. self.send('=')
  201. def fault_zone(self, zone, simulate_wire_problem=False):
  202. """
  203. Faults a zone if we are emulating a zone expander.
  204. :param zone: zone to fault
  205. :type zone: int
  206. :param simulate_wire_problem: Whether or not to simulate a wire fault
  207. :type simulate_wire_problem: bool
  208. """
  209. # Allow ourselves to also be passed an address/channel combination
  210. # for zone expanders.
  211. #
  212. # Format (expander index, channel)
  213. if isinstance(zone, tuple):
  214. expander_idx, channel = zone
  215. zone = self._zonetracker.expander_to_zone(expander_idx, channel)
  216. status = 2 if simulate_wire_problem else 1
  217. self.send("L{0:02}{1}\r".format(zone, status))
  218. def clear_zone(self, zone):
  219. """
  220. Clears a zone if we are emulating a zone expander.
  221. :param zone: zone to clear
  222. :type zone: int
  223. """
  224. self.send("L{0:02}0\r".format(zone))
  225. def _wire_events(self):
  226. """
  227. Wires up the internal device events.
  228. """
  229. self._device.on_open += self._on_open
  230. self._device.on_close += self._on_close
  231. self._device.on_read += self._on_read
  232. self._device.on_write += self._on_write
  233. self._zonetracker.on_fault += self._on_zone_fault
  234. self._zonetracker.on_restore += self._on_zone_restore
  235. def _handle_message(self, data):
  236. """
  237. Parses messages from the panel.
  238. :param data: panel data to parse
  239. :type data: string
  240. :returns: :py:class:`alarmdecoder.messages.Message`
  241. """
  242. if data is None:
  243. raise InvalidMessageError()
  244. msg = None
  245. header = data[0:4]
  246. if header[0] != '!' or header == '!KPE':
  247. msg = Message(data)
  248. if self.address_mask & msg.mask > 0:
  249. self._update_internal_states(msg)
  250. elif header == '!EXP' or header == '!REL':
  251. msg = ExpanderMessage(data)
  252. self._update_internal_states(msg)
  253. elif header == '!RFX':
  254. msg = self._handle_rfx(data)
  255. elif header == '!LRR':
  256. msg = self._handle_lrr(data)
  257. elif data.startswith('!Ready'):
  258. self.on_boot()
  259. elif data.startswith('!CONFIG'):
  260. self._handle_config(data)
  261. return msg
  262. def _handle_rfx(self, data):
  263. """
  264. Handle RF messages.
  265. :param data: RF message to parse
  266. :type data: string
  267. :returns: :py:class:`alarmdecoder.messages.RFMessage`
  268. """
  269. msg = RFMessage(data)
  270. self.on_rfx_message(message=msg)
  271. return msg
  272. def _handle_lrr(self, data):
  273. """
  274. Handle Long Range Radio messages.
  275. :param data: LRR message to parse
  276. :type data: string
  277. :returns: :py:class:`alarmdecoder.messages.LRRMessage`
  278. """
  279. msg = LRRMessage(data)
  280. if msg.event_type == 'ALARM_PANIC':
  281. self._panic_status = True
  282. self.on_panic(status=True)
  283. elif msg.event_type == 'CANCEL':
  284. if self._panic_status is True:
  285. self._panic_status = False
  286. self.on_panic(status=False)
  287. self.on_lrr_message(message=msg)
  288. return msg
  289. def _handle_config(self, data):
  290. """
  291. Handles received configuration data.
  292. :param data: Configuration string to parse
  293. :type data: string
  294. """
  295. _, config_string = data.split('>')
  296. for setting in config_string.split('&'):
  297. key, val = setting.split('=')
  298. if key == 'ADDRESS':
  299. self.address = int(val)
  300. elif key == 'CONFIGBITS':
  301. self.configbits = int(val, 16)
  302. elif key == 'MASK':
  303. self.address_mask = int(val, 16)
  304. elif key == 'EXP':
  305. self.emulate_zone = [val[z] == 'Y' for z in range(5)]
  306. elif key == 'REL':
  307. self.emulate_relay = [val[r] == 'Y' for r in range(4)]
  308. elif key == 'LRR':
  309. self.emulate_lrr = (val == 'Y')
  310. elif key == 'DEDUPLICATE':
  311. self.deduplicate = (val == 'Y')
  312. self.on_config_received()
  313. def _update_internal_states(self, message):
  314. """
  315. Updates internal device states.
  316. :param message: :py:class:`alarmdecoder.messages.Message` to update internal states with
  317. :type message: :py:class:`alarmdecoder.messages.Message`, :py:class:`alarmdecoder.messages.ExpanderMessage`, :py:class:`alarmdecoder.messages.LRRMessage`, or :py:class:`alarmdecoder.messages.RFMessage`
  318. """
  319. if isinstance(message, Message):
  320. self._update_power_status(message)
  321. self._update_alarm_status(message)
  322. self._update_zone_bypass_status(message)
  323. self._update_armed_status(message)
  324. self._update_battery_status(message)
  325. self._update_fire_status(message)
  326. elif isinstance(message, ExpanderMessage):
  327. self._update_expander_status(message)
  328. self._update_zone_tracker(message)
  329. def _update_power_status(self, message):
  330. """
  331. Uses the provided message to update the AC power state.
  332. :param message: message to use to update
  333. :type message: :py:class:`alarmdecoder.messages.Message`
  334. :returns: bool indicating the new status
  335. """
  336. if message.ac_power != self._power_status:
  337. self._power_status, old_status = message.ac_power, self._power_status
  338. if old_status is not None:
  339. self.on_power_changed(status=self._power_status)
  340. return self._power_status
  341. def _update_alarm_status(self, message):
  342. """
  343. Uses the provided message to update the alarm state.
  344. :param message: message to use to update
  345. :type message: :py:class:`alarmdecoder.messages.Message`
  346. :returns: bool indicating the new status
  347. """
  348. if message.alarm_sounding != self._alarm_status:
  349. self._alarm_status, old_status = message.alarm_sounding, self._alarm_status
  350. if old_status is not None:
  351. self.on_alarm(status=self._alarm_status)
  352. return self._alarm_status
  353. def _update_zone_bypass_status(self, message):
  354. """
  355. Uses the provided message to update the zone bypass state.
  356. :param message: message to use to update
  357. :type message: :py:class:`alarmdecoder.messages.Message`
  358. :returns: bool indicating the new status
  359. """
  360. if message.zone_bypassed != self._bypass_status:
  361. self._bypass_status, old_status = message.zone_bypassed, self._bypass_status
  362. if old_status is not None:
  363. self.on_bypass(status=self._bypass_status)
  364. return self._bypass_status
  365. def _update_armed_status(self, message):
  366. """
  367. Uses the provided message to update the armed state.
  368. :param message: message to use to update
  369. :type message: :py:class:`alarmdecoder.messages.Message`
  370. :returns: bool indicating the new status
  371. """
  372. message_status = message.armed_away | message.armed_home
  373. if message_status != self._armed_status:
  374. self._armed_status, old_status = message_status, self._armed_status
  375. if old_status is not None:
  376. if self._armed_status:
  377. self.on_arm()
  378. else:
  379. self.on_disarm()
  380. return self._armed_status
  381. def _update_battery_status(self, message):
  382. """
  383. Uses the provided message to update the battery state.
  384. :param message: message to use to update
  385. :type message: :py:class:`alarmdecoder.messages.Message`
  386. :returns: boolean indicating the new status
  387. """
  388. last_status, last_update = self._battery_status
  389. if message.battery_low == last_status:
  390. self._battery_status = (last_status, time.time())
  391. else:
  392. if message.battery_low is True or time.time() > last_update + self._battery_timeout:
  393. self._battery_status = (message.battery_low, time.time())
  394. self.on_low_battery(status=message.battery_low)
  395. return self._battery_status[0]
  396. def _update_fire_status(self, message):
  397. """
  398. Uses the provided message to update the fire alarm state.
  399. :param message: message to use to update
  400. :type message: :py:class:`alarmdecoder.messages.Message`
  401. :returns: boolean indicating the new status
  402. """
  403. last_status, last_update = self._fire_status
  404. if message.fire_alarm == last_status:
  405. self._fire_status = (last_status, time.time())
  406. else:
  407. if message.fire_alarm is True or time.time() > last_update + self._fire_timeout:
  408. self._fire_status = (message.fire_alarm, time.time())
  409. self.on_fire(status=message.fire_alarm)
  410. return self._fire_status[0]
  411. def _update_expander_status(self, message):
  412. """
  413. Uses the provided message to update the expander states.
  414. :param message: message to use to update
  415. :type message: :py:class:`alarmdecoder.messages.ExpanderMessage`
  416. :returns: boolean indicating the new status
  417. """
  418. if message.type == ExpanderMessage.RELAY:
  419. self._relay_status[(message.address, message.channel)] = message.value
  420. self.on_relay_changed(message=message)
  421. return self._relay_status[(message.address, message.channel)]
  422. def _update_zone_tracker(self, message):
  423. """
  424. Trigger an update of the :py:class:`alarmdecoder.messages.Zonetracker`.
  425. :param message: message to update the zonetracker with
  426. :type message: :py:class:`alarmdecoder.messages.Message`, :py:class:`alarmdecoder.messages.ExpanderMessage`, :py:class:`alarmdecoder.messages.LRRMessage`, or :py:class:`alarmdecoder.messages.RFMessage`
  427. """
  428. # Retrieve a list of faults.
  429. # NOTE: This only happens on first boot or after exiting programming mode.
  430. if isinstance(message, Message):
  431. if not message.ready and "Hit * for faults" in message.text:
  432. self.send('*')
  433. return
  434. self._zonetracker.update(message)
  435. def _on_open(self, sender, *args, **kwargs):
  436. """
  437. Internal handler for opening the device.
  438. """
  439. self.get_config()
  440. self.on_open(args, kwargs)
  441. def _on_close(self, sender, *args, **kwargs):
  442. """
  443. Internal handler for closing the device.
  444. """
  445. self.on_close(args, kwargs)
  446. def _on_read(self, sender, *args, **kwargs):
  447. """
  448. Internal handler for reading from the device.
  449. """
  450. self.on_read(args, kwargs)
  451. msg = self._handle_message(kwargs.get('data', None))
  452. if msg:
  453. self.on_message(message=msg)
  454. def _on_write(self, sender, *args, **kwargs):
  455. """
  456. Internal handler for writing to the device.
  457. """
  458. self.on_write(args, kwargs)
  459. def _on_zone_fault(self, sender, *args, **kwargs):
  460. """
  461. Internal handler for zone faults.
  462. """
  463. self.on_zone_fault(*args, **kwargs)
  464. def _on_zone_restore(self, sender, *args, **kwargs):
  465. """
  466. Internal handler for zone restoration.
  467. """
  468. self.on_zone_restore(*args, **kwargs)