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.

373 lines
16KB

  1. # -*- coding: utf-8 -*-
  2. """
  3. maxcul.communication
  4. ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  5. There are two communication classes available which should run in their own thread.
  6. CULComThread performs low-level serial communication, CULMessageThread performs high-level
  7. communication and spawns a CULComThread for its low-level needs.
  8. Generally just use CULMessageThread unless you have a good reason not to.
  9. :copyright: (c) 2014 by Markus Ullmann.
  10. :license: BSD, see LICENSE for more details.
  11. """
  12. # environment constants
  13. # python imports
  14. from collections import defaultdict
  15. from datetime import datetime
  16. import queue
  17. import threading
  18. import time
  19. # environment imports
  20. import logbook
  21. from serial import Serial
  22. # custom imports
  23. from maxcul.exceptions import MoritzError
  24. from maxcul.messages import (
  25. MoritzMessage, MoritzError,
  26. PairPingMessage, PairPongMessage,
  27. TimeInformationMessage,
  28. SetTemperatureMessage,
  29. ThermostatStateMessage,
  30. AckMessage,
  31. ShutterContactStateMessage,
  32. ConfigValveMessage,
  33. SetGroupIdMessage,
  34. AddLinkPartnerMessage,
  35. WallThermostatStateMessage,
  36. WallThermostatControlMessage
  37. )
  38. from maxcul.signals import thermostatstate_received, device_pair_accepted, device_pair_request
  39. # local constants
  40. com_logger = logbook.Logger("CUL Serial")
  41. message_logger = logbook.Logger("CUL Messaging")
  42. # Hardcodings based on FHEM recommendations
  43. CUBE_ID = 0x123456
  44. class CULComThread(threading.Thread):
  45. """Low-level serial communication thread base"""
  46. def __init__(self, send_queue, read_queue, device_path, baudrate):
  47. super(CULComThread, self).__init__()
  48. self.send_queue = send_queue
  49. self.read_queue = read_queue
  50. self.device_path = device_path
  51. self.baudrate = baudrate
  52. self.pending_line = []
  53. self.stop_requested = threading.Event()
  54. self.cul_version = ""
  55. self._pending_budget = 0
  56. self._pending_message = None
  57. def run(self):
  58. self._init_cul()
  59. while not self.stop_requested.isSet():
  60. # Send budget request if we don't know it
  61. if self._pending_budget == 0:
  62. self.send_command("X")
  63. for i in range(10):
  64. read_line = self._read_result()
  65. if read_line is not None:
  66. if read_line.startswith("21 "):
  67. self._pending_budget = int(read_line[3:].strip()) * 10 or 1
  68. com_logger.info("Got pending budget message: %sms" % self._pending_budget)
  69. else:
  70. com_logger.info("Got unhandled response from CUL: '%s'" % read_line)
  71. if self._pending_budget > 0:
  72. com_logger.debug("Finished fetching budget, having %sms now" % self._pending_budget)
  73. break
  74. time.sleep(0.05)
  75. # Process pending received messages (if any)
  76. read_line = self._read_result()
  77. if read_line is not None:
  78. if read_line.startswith("21 "):
  79. self._pending_budget = int(read_line[3:].strip()) * 10 or 1
  80. com_logger.info("Got pending budget: %sms" % self._pending_budget)
  81. else:
  82. com_logger.info("Got unhandled response from CUL: '%s'" % read_line)
  83. if self._pending_message is None and not self.send_queue.empty():
  84. com_logger.debug("Fetching message from queue")
  85. self._pending_message = self.send_queue.get(True, 0.05)
  86. if self._pending_message is None:
  87. com_logger.debug("Failed fetching message due to thread lock, deferring")
  88. # send queued messages yet respecting send budget of 1%
  89. if self._pending_message:
  90. com_logger.debug("Checking quota for outgoing message")
  91. if self._pending_budget > len(self._pending_message)*10:
  92. com_logger.debug("Queueing pre-fetched command %s" % self._pending_message)
  93. self.send_command(self._pending_message)
  94. self._pending_message = None
  95. else:
  96. self._pending_budget = 0
  97. com_logger.debug("Not enough quota, re-check enforced")
  98. # give the system 200ms to do something else, we're embedded....
  99. time.sleep(0.2)
  100. def join(self, timeout=None):
  101. self.stop_requested.set()
  102. super(CULComThread, self).join(timeout)
  103. def _init_cul(self):
  104. """Ensure CUL reports reception strength and does not do FS messages"""
  105. self.com_port = Serial(self.device_path, self.baudrate)
  106. self._read_result()
  107. # get CUL FW version
  108. def _get_cul_ver():
  109. self.send_command("V")
  110. time.sleep(0.3)
  111. self.cul_version = self._read_result() or ""
  112. for i in range(10):
  113. _get_cul_ver()
  114. if self.cul_version:
  115. com_logger.info("CUL reported version %s" % self.cul_version)
  116. break
  117. else:
  118. com_logger.info("No version from CUL reported?")
  119. if not self.cul_version:
  120. com_logger.info("No version from CUL reported. Closing and re-opening port")
  121. self.com_port.close()
  122. self.com_port = Serial(self.device_path, self.baudrate)
  123. for i in range(10):
  124. _get_cul_ver()
  125. if self.cul_version:
  126. com_logger.info("CUL reported version %s" % self.cul_version)
  127. else:
  128. com_logger.info("No version from CUL reported?")
  129. com_logger.error("No version from CUL, cannot communicate")
  130. self.stop_requested.set()
  131. return
  132. # enable reporting of message strength
  133. self.send_command("X21")
  134. time.sleep(0.3)
  135. # receive Moritz messages
  136. self.send_command("Zr")
  137. time.sleep(0.3)
  138. # disable FHT mode by setting station to 0000
  139. self.send_command("T01")
  140. time.sleep(0.3)
  141. self._read_result()
  142. @property
  143. def has_send_budget(self):
  144. """Ask CUL if we have enough budget of the 1 percent rule left"""
  145. return self._pending_budget >= 2000
  146. def send_command(self, command):
  147. """Sends given command to CUL. Invalidates has_send_budget if command starts with Zs"""
  148. if command.startswith("Zs"):
  149. self._pending_budget = 0
  150. self.com_port.write((command + "\r\n").encode())
  151. com_logger.debug("sent: %s" % command)
  152. def _read_result(self):
  153. """Reads data from port, if it's a Moritz message, forward directly, otherwise return to caller"""
  154. while self.com_port.inWaiting():
  155. self.pending_line.append(self.com_port.read(1).decode("utf-8"))
  156. if self.pending_line[-1] == "\n":
  157. # remove newlines at the end
  158. completed_line = "".join(self.pending_line[:-2])
  159. com_logger.debug("received: %s" % completed_line)
  160. self.pending_line = []
  161. if completed_line.startswith("Z"):
  162. self.read_queue.put(completed_line)
  163. else:
  164. return completed_line
  165. class CULMessageThread(threading.Thread):
  166. """High level message processing"""
  167. def __init__(self, command_queue, device_path, baudrate):
  168. super(CULMessageThread, self).__init__()
  169. self.command_queue = command_queue
  170. self.thermostat_states = defaultdict(dict)
  171. self.thermostat_states_lock = threading.Lock()
  172. self.shuttercontact_states = defaultdict(dict)
  173. self.shuttercontact_states_lock = threading.Lock()
  174. self.wallthermostat_states = defaultdict(dict)
  175. self.wallthermostat_states_lock = threading.Lock()
  176. self.com_send_queue = queue.Queue()
  177. self.com_receive_queue = queue.Queue()
  178. self.com_thread = CULComThread(self.com_send_queue, self.com_receive_queue, device_path, baudrate)
  179. self.stop_requested = threading.Event()
  180. self.pair_as_cube = True
  181. self.pair_as_wallthermostat = False
  182. self.pair_as_ShutterContact = False
  183. def run(self):
  184. self.com_thread.start()
  185. while not self.stop_requested.isSet():
  186. message = None
  187. try:
  188. received_msg = self.com_receive_queue.get(True, 0.05)
  189. message = MoritzMessage.decode_message(received_msg[:-2])
  190. signal_strength = int(received_msg[-2:], base=16)
  191. self.respond_to_message(message, signal_strength)
  192. except queue.Empty:
  193. pass
  194. except MoritzError as e:
  195. message_logger.error("Message parsing failed, ignoring message '%s'. Reason: %s" % (received_msg, str(e)))
  196. try:
  197. tempMsg = self.command_queue.get(True, 0.05)
  198. msg, payload = tempMsg
  199. raw_message = msg.encode_message(payload)
  200. message_logger.debug("send type %s" % msg)
  201. message_logger.debug("send raw line %s" % raw_message)
  202. self.com_send_queue.put(raw_message)
  203. except queue.Empty:
  204. pass
  205. except MoritzError as e:
  206. message_logger.error("Message sending failed, ignoring message '%s'. Reason: %s" % (msg, str(e)))
  207. time.sleep(0.3)
  208. def join(self, timeout=None):
  209. self.com_thread.join(timeout)
  210. self.stop_requested.set()
  211. super(CULMessageThread, self).join(timeout)
  212. def ackReact(self, msg):
  213. ack_msg = AckMessage()
  214. ack_msg.counter = msg.counter + 1
  215. ack_msg.sender_id = CUBE_ID
  216. ack_msg.receiver_id = msg.sender_id
  217. ack_msg.group_id = msg.group_id
  218. message_logger.info("ack requested by 0x%X, responding" % msg.sender_id)
  219. self.command_queue.put((ack_msg, "00"))
  220. def respond_to_message(self, msg, signal_strenth):
  221. """Internal function to respond to incoming messages where appropriate"""
  222. if isinstance(msg, PairPingMessage):
  223. message_logger.info("received PairPing")
  224. # Some peer wants to pair. Let's see...
  225. device_pair_request.send(self, msg=msg)
  226. if msg.receiver_id == 0x0:
  227. # pairing after factory reset
  228. if not (self.pair_as_cube or self.pair_as_wallthermostat or self.pair_as_ShutterContact):
  229. message_logger.info("Pairing to new device but we should ignore it")
  230. return
  231. resp_msg = PairPongMessage()
  232. resp_msg.counter = 1
  233. resp_msg.sender_id = CUBE_ID
  234. resp_msg.receiver_id = msg.sender_id
  235. resp_msg.group_id = msg.group_id
  236. if self.com_thread.has_send_budget:
  237. message_logger.info("responding to pair after factory reset")
  238. self.command_queue.put((resp_msg, {"devicetype": "Cube"}))
  239. device_pair_accepted.send(self, resp_msg=resp_msg)
  240. else:
  241. message_logger.info("NOT responding to pair after factory reset as no send budget to be on time")
  242. return
  243. elif msg.receiver_id == CUBE_ID:
  244. # pairing after battery replacement
  245. resp_msg = PairPongMessage()
  246. resp_msg.counter = 1
  247. resp_msg.sender_id = CUBE_ID
  248. resp_msg.receiver_id = msg.sender_id
  249. resp_msg.group_id = msg.group_id
  250. if self.com_thread.has_send_budget:
  251. message_logger.info("responding to pair after battery replacement")
  252. self.command_queue.put((resp_msg, {"devicetype": "Cube"}))
  253. device_pair_accepted.send(self, resp_msg=resp_msg)
  254. else:
  255. message_logger.info("NOT responding to pair after battery replacement as no send budget to be on time")
  256. return
  257. else:
  258. # pair to someone else after battery replacement, don't care
  259. message_logger.info("pair after battery replacement sent to other device 0x%X, ignoring" % msg.receiver_id)
  260. return
  261. elif isinstance(msg, TimeInformationMessage):
  262. if not msg.payload and msg.receiver_id == CUBE_ID:
  263. # time information requested
  264. resp_msg = TimeInformationMessage()
  265. resp_msg.counter = 1
  266. resp_msg.sender_id = CUBE_ID
  267. resp_msg.receiver_id = msg.sender_id
  268. resp_msg.group_id = msg.group_id
  269. message_logger.info("time information requested by 0x%X, responding" % msg.sender_id)
  270. self.command_queue.put((resp_msg, datetime.now()))
  271. return
  272. elif isinstance(msg, ThermostatStateMessage):
  273. with self.thermostat_states_lock:
  274. message_logger.info("thermostat state updated for 0x%X" % msg.sender_id)
  275. self.thermostat_states[msg.sender_id].update(msg.decoded_payload)
  276. self.thermostat_states[msg.sender_id]['last_updated'] = datetime.now()
  277. self.thermostat_states[msg.sender_id]['signal_strenth'] = signal_strenth
  278. if msg.receiver_id == CUBE_ID:
  279. self.ackReact(msg)
  280. thermostatstate_received.send(self, msg=msg)
  281. return
  282. elif isinstance(msg, AckMessage):
  283. if msg.decoded_payload["state"] == "ok":
  284. thermostatstate_received.send(self, msg=msg)
  285. with self.thermostat_states_lock:
  286. message_logger.info("ack and thermostat state updated for 0x%X" % msg.sender_id)
  287. self.thermostat_states[msg.sender_id].update(msg.decoded_payload)
  288. self.thermostat_states[msg.sender_id]['last_updated'] = datetime.now()
  289. self.thermostat_states[msg.sender_id]['signal_strenth'] = signal_strenth
  290. return
  291. elif isinstance(msg, ShutterContactStateMessage):
  292. with self.shuttercontact_states_lock:
  293. message_logger.info("shuttercontact updated %s" % msg)
  294. self.shuttercontact_states[msg.sender_id].update(msg.decoded_payload)
  295. self.shuttercontact_states[msg.sender_id]['last_updated'] = datetime.now()
  296. self.shuttercontact_states[msg.sender_id]['signal_strenth'] = signal_strenth
  297. if msg.receiver_id == CUBE_ID:
  298. self.ackReact(msg)
  299. return
  300. elif isinstance(msg, WallThermostatStateMessage):
  301. with self.wallthermostat_states_lock:
  302. message_logger.info("wallthermostat updated %s" % msg)
  303. self.wallthermostat_states[msg.sender_id].update(msg.decoded_payload)
  304. self.wallthermostat_states[msg.sender_id]['last_updated'] = datetime.now()
  305. self.wallthermostat_states[msg.sender_id]['signal_strenth'] = signal_strenth
  306. if msg.receiver_id == CUBE_ID:
  307. self.ackReact(msg)
  308. return
  309. elif isinstance(msg, SetTemperatureMessage):
  310. with self.wallthermostat_states_lock:
  311. message_logger.info("wallthermostat updated %s" % msg)
  312. self.wallthermostat_states[msg.sender_id].update(msg.decoded_payload)
  313. self.wallthermostat_states[msg.sender_id]['last_updated'] = datetime.now()
  314. self.wallthermostat_states[msg.sender_id]['signal_strenth'] = signal_strenth
  315. if msg.receiver_id == CUBE_ID:
  316. self.ackReact(msg)
  317. return
  318. elif isinstance(msg, WallThermostatControlMessage):
  319. with self.wallthermostat_states_lock:
  320. message_logger.info("wallthemostat control update %s" % msg)
  321. self.wallthermostat_states[msg.sender_id].update(msg.decoded_payload)
  322. self.wallthermostat_states[msg.sender_id]['last_updated'] = datetime.now()
  323. self.wallthermostat_states[msg.sender_id]['signal_strenth'] = signal_strenth
  324. if msg.receiver_id == CUBE_ID:
  325. self.ackReact(msg)
  326. return
  327. message_logger.warning("Unhandled Message of type %s, contains %s" % (msg.__class__.__name__, str(msg)))