# -*- coding: utf-8 -*- """ maxcul.communication ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ There are two communication classes available which should run in their own thread. CULComThread performs low-level serial communication, CULMessageThread performs high-level communication and spawns a CULComThread for its low-level needs. Generally just use CULMessageThread unless you have a good reason not to. :copyright: (c) 2014 by Markus Ullmann. :license: BSD, see LICENSE for more details. """ # environment constants # python imports from collections import defaultdict from datetime import datetime import queue import threading import time # environment imports import logbook from serial import Serial # custom imports from maxcul.exceptions import MoritzError from maxcul.messages import ( MoritzMessage, MoritzError, PairPingMessage, PairPongMessage, TimeInformationMessage, SetTemperatureMessage, ThermostatStateMessage, AckMessage, ShutterContactStateMessage, ConfigValveMessage, SetGroupIdMessage, AddLinkPartnerMessage, WallThermostatStateMessage, WallThermostatControlMessage ) from maxcul.signals import thermostatstate_received, device_pair_accepted, device_pair_request # local constants com_logger = logbook.Logger("CUL Serial") message_logger = logbook.Logger("CUL Messaging") # Hardcodings based on FHEM recommendations CUBE_ID = 0x123456 class CULComThread(threading.Thread): """Low-level serial communication thread base""" def __init__(self, send_queue, read_queue, device_path, baudrate): super(CULComThread, self).__init__() self.send_queue = send_queue self.read_queue = read_queue self.device_path = device_path self.baudrate = baudrate self.pending_line = [] self.stop_requested = threading.Event() self.cul_version = "" self._pending_budget = 0 self._pending_message = None def run(self): self._init_cul() while not self.stop_requested.isSet(): # Send budget request if we don't know it if self._pending_budget == 0: self.send_command("X") for i in range(10): read_line = self._read_result() if read_line is not None: if read_line.startswith("21 "): self._pending_budget = int(read_line[3:].strip()) * 10 or 1 com_logger.info("Got pending budget message: %sms" % self._pending_budget) else: com_logger.info("Got unhandled response from CUL: '%s'" % read_line) if self._pending_budget > 0: com_logger.debug("Finished fetching budget, having %sms now" % self._pending_budget) break time.sleep(0.05) # Process pending received messages (if any) read_line = self._read_result() if read_line is not None: if read_line.startswith("21 "): self._pending_budget = int(read_line[3:].strip()) * 10 or 1 com_logger.info("Got pending budget: %sms" % self._pending_budget) else: com_logger.info("Got unhandled response from CUL: '%s'" % read_line) if self._pending_message is None and not self.send_queue.empty(): com_logger.debug("Fetching message from queue") self._pending_message = self.send_queue.get(True, 0.05) if self._pending_message is None: com_logger.debug("Failed fetching message due to thread lock, deferring") # send queued messages yet respecting send budget of 1% if self._pending_message: com_logger.debug("Checking quota for outgoing message") if self._pending_budget > len(self._pending_message)*10: com_logger.debug("Queueing pre-fetched command %s" % self._pending_message) self.send_command(self._pending_message) self._pending_message = None else: self._pending_budget = 0 com_logger.debug("Not enough quota, re-check enforced") # give the system 200ms to do something else, we're embedded.... time.sleep(0.2) def join(self, timeout=None): self.stop_requested.set() super(CULComThread, self).join(timeout) def _init_cul(self): """Ensure CUL reports reception strength and does not do FS messages""" self.com_port = Serial(self.device_path, self.baudrate) self._read_result() # get CUL FW version def _get_cul_ver(): self.send_command("V") time.sleep(0.3) self.cul_version = self._read_result() or "" for i in range(10): _get_cul_ver() if self.cul_version: com_logger.info("CUL reported version %s" % self.cul_version) break else: com_logger.info("No version from CUL reported?") if not self.cul_version: com_logger.info("No version from CUL reported. Closing and re-opening port") self.com_port.close() self.com_port = Serial(self.device_path, self.baudrate) for i in range(10): _get_cul_ver() if self.cul_version: com_logger.info("CUL reported version %s" % self.cul_version) else: com_logger.info("No version from CUL reported?") com_logger.error("No version from CUL, cannot communicate") self.stop_requested.set() return # enable reporting of message strength self.send_command("X21") time.sleep(0.3) # receive Moritz messages self.send_command("Zr") time.sleep(0.3) # disable FHT mode by setting station to 0000 self.send_command("T01") time.sleep(0.3) self._read_result() @property def has_send_budget(self): """Ask CUL if we have enough budget of the 1 percent rule left""" return self._pending_budget >= 2000 def send_command(self, command): """Sends given command to CUL. Invalidates has_send_budget if command starts with Zs""" if command.startswith("Zs"): self._pending_budget = 0 self.com_port.write((command + "\r\n").encode()) com_logger.debug("sent: %s" % command) def _read_result(self): """Reads data from port, if it's a Moritz message, forward directly, otherwise return to caller""" while self.com_port.inWaiting(): self.pending_line.append(self.com_port.read(1).decode("utf-8")) if self.pending_line[-1] == "\n": # remove newlines at the end completed_line = "".join(self.pending_line[:-2]) com_logger.debug("received: %s" % completed_line) self.pending_line = [] if completed_line.startswith("Z"): self.read_queue.put(completed_line) else: return completed_line class CULMessageThread(threading.Thread): """High level message processing""" def __init__(self, command_queue, device_path, baudrate): super(CULMessageThread, self).__init__() self.command_queue = command_queue self.thermostat_states = defaultdict(dict) self.thermostat_states_lock = threading.Lock() self.shuttercontact_states = defaultdict(dict) self.shuttercontact_states_lock = threading.Lock() self.wallthermostat_states = defaultdict(dict) self.wallthermostat_states_lock = threading.Lock() self.com_send_queue = queue.Queue() self.com_receive_queue = queue.Queue() self.com_thread = CULComThread(self.com_send_queue, self.com_receive_queue, device_path, baudrate) self.stop_requested = threading.Event() self.pair_as_cube = True self.pair_as_wallthermostat = False self.pair_as_ShutterContact = False def run(self): self.com_thread.start() while not self.stop_requested.isSet(): message = None try: received_msg = self.com_receive_queue.get(True, 0.05) message = MoritzMessage.decode_message(received_msg[:-2]) signal_strength = int(received_msg[-2:], base=16) self.respond_to_message(message, signal_strength) except queue.Empty: pass except MoritzError as e: message_logger.error("Message parsing failed, ignoring message '%s'. Reason: %s" % (received_msg, str(e))) try: tempMsg = self.command_queue.get(True, 0.05) msg, payload = tempMsg raw_message = msg.encode_message(payload) message_logger.debug("send type %s" % msg) message_logger.debug("send raw line %s" % raw_message) self.com_send_queue.put(raw_message) except queue.Empty: pass except MoritzError as e: message_logger.error("Message sending failed, ignoring message '%s'. Reason: %s" % (msg, str(e))) time.sleep(0.3) def join(self, timeout=None): self.com_thread.join(timeout) self.stop_requested.set() super(CULMessageThread, self).join(timeout) def ackReact(self, msg): ack_msg = AckMessage() ack_msg.counter = msg.counter + 1 ack_msg.sender_id = CUBE_ID ack_msg.receiver_id = msg.sender_id ack_msg.group_id = msg.group_id message_logger.info("ack requested by 0x%X, responding" % msg.sender_id) self.command_queue.put((ack_msg, "00")) def respond_to_message(self, msg, signal_strenth): """Internal function to respond to incoming messages where appropriate""" if isinstance(msg, PairPingMessage): message_logger.info("received PairPing") # Some peer wants to pair. Let's see... device_pair_request.send(self, msg=msg) if msg.receiver_id == 0x0: # pairing after factory reset if not (self.pair_as_cube or self.pair_as_wallthermostat or self.pair_as_ShutterContact): message_logger.info("Pairing to new device but we should ignore it") return resp_msg = PairPongMessage() resp_msg.counter = 1 resp_msg.sender_id = CUBE_ID resp_msg.receiver_id = msg.sender_id resp_msg.group_id = msg.group_id if self.com_thread.has_send_budget: message_logger.info("responding to pair after factory reset") self.command_queue.put((resp_msg, {"devicetype": "Cube"})) device_pair_accepted.send(self, resp_msg=resp_msg) else: message_logger.info("NOT responding to pair after factory reset as no send budget to be on time") return elif msg.receiver_id == CUBE_ID: # pairing after battery replacement resp_msg = PairPongMessage() resp_msg.counter = 1 resp_msg.sender_id = CUBE_ID resp_msg.receiver_id = msg.sender_id resp_msg.group_id = msg.group_id if self.com_thread.has_send_budget: message_logger.info("responding to pair after battery replacement") self.command_queue.put((resp_msg, {"devicetype": "Cube"})) device_pair_accepted.send(self, resp_msg=resp_msg) else: message_logger.info("NOT responding to pair after battery replacement as no send budget to be on time") return else: # pair to someone else after battery replacement, don't care message_logger.info("pair after battery replacement sent to other device 0x%X, ignoring" % msg.receiver_id) return elif isinstance(msg, TimeInformationMessage): if not msg.payload and msg.receiver_id == CUBE_ID: # time information requested resp_msg = TimeInformationMessage() resp_msg.counter = 1 resp_msg.sender_id = CUBE_ID resp_msg.receiver_id = msg.sender_id resp_msg.group_id = msg.group_id message_logger.info("time information requested by 0x%X, responding" % msg.sender_id) self.command_queue.put((resp_msg, datetime.now())) return elif isinstance(msg, ThermostatStateMessage): with self.thermostat_states_lock: message_logger.info("thermostat state updated for 0x%X" % msg.sender_id) self.thermostat_states[msg.sender_id].update(msg.decoded_payload) self.thermostat_states[msg.sender_id]['last_updated'] = datetime.now() self.thermostat_states[msg.sender_id]['signal_strenth'] = signal_strenth if msg.receiver_id == CUBE_ID: self.ackReact(msg) thermostatstate_received.send(self, msg=msg) return elif isinstance(msg, AckMessage): if msg.decoded_payload["state"] == "ok": thermostatstate_received.send(self, msg=msg) with self.thermostat_states_lock: message_logger.info("ack and thermostat state updated for 0x%X" % msg.sender_id) self.thermostat_states[msg.sender_id].update(msg.decoded_payload) self.thermostat_states[msg.sender_id]['last_updated'] = datetime.now() self.thermostat_states[msg.sender_id]['signal_strenth'] = signal_strenth return elif isinstance(msg, ShutterContactStateMessage): with self.shuttercontact_states_lock: message_logger.info("shuttercontact updated %s" % msg) self.shuttercontact_states[msg.sender_id].update(msg.decoded_payload) self.shuttercontact_states[msg.sender_id]['last_updated'] = datetime.now() self.shuttercontact_states[msg.sender_id]['signal_strenth'] = signal_strenth if msg.receiver_id == CUBE_ID: self.ackReact(msg) return elif isinstance(msg, WallThermostatStateMessage): with self.wallthermostat_states_lock: message_logger.info("wallthermostat updated %s" % msg) self.wallthermostat_states[msg.sender_id].update(msg.decoded_payload) self.wallthermostat_states[msg.sender_id]['last_updated'] = datetime.now() self.wallthermostat_states[msg.sender_id]['signal_strenth'] = signal_strenth if msg.receiver_id == CUBE_ID: self.ackReact(msg) return elif isinstance(msg, SetTemperatureMessage): with self.wallthermostat_states_lock: message_logger.info("wallthermostat updated %s" % msg) self.wallthermostat_states[msg.sender_id].update(msg.decoded_payload) self.wallthermostat_states[msg.sender_id]['last_updated'] = datetime.now() self.wallthermostat_states[msg.sender_id]['signal_strenth'] = signal_strenth if msg.receiver_id == CUBE_ID: self.ackReact(msg) return elif isinstance(msg, WallThermostatControlMessage): with self.wallthermostat_states_lock: message_logger.info("wallthemostat control update %s" % msg) self.wallthermostat_states[msg.sender_id].update(msg.decoded_payload) self.wallthermostat_states[msg.sender_id]['last_updated'] = datetime.now() self.wallthermostat_states[msg.sender_id]['signal_strenth'] = signal_strenth if msg.receiver_id == CUBE_ID: self.ackReact(msg) return message_logger.warning("Unhandled Message of type %s, contains %s" % (msg.__class__.__name__, str(msg)))