# # Neil Trimboy 2011 # Assume Python 2.7.x + # import sys import serial import yaml import logging from . import constants import importlib_resources config_yml = importlib_resources.files('heatmiserv3').joinpath('config.yml') logging.basicConfig(level=logging.INFO) # # Believe this is known as CCITT (0xFFFF) # This is the CRC function converted directly from the Heatmiser C code # provided in their API class CRC16: """This is the CRC hashing mechanism used by the V3 protocol.""" LookupHigh = [ 0x00, 0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x81, 0x91, 0xA1, 0xB1, 0xC1, 0xD1, 0xE1, 0xF1, ] LookupLow = [ 0x00, 0x21, 0x42, 0x63, 0x84, 0xA5, 0xC6, 0xE7, 0x08, 0x29, 0x4A, 0x6B, 0x8C, 0xAD, 0xCE, 0xEF, ] def __init__(self): self.high = constants.BYTEMASK self.low = constants.BYTEMASK def extract_bits(self, val): """Extras the 4 bits, XORS the message data, and does table lookups.""" # Step one, extract the Most significant 4 bits of the CRC register thisval = self.high >> 4 # XOR in the Message Data into the extracted bits thisval = thisval ^ val # Shift the CRC Register left 4 bits self.high = (self.high << 4) | (self.low >> 4) self.high = self.high & constants.BYTEMASK # force char self.low = self.low << 4 self.low = self.low & constants.BYTEMASK # force char # Do the table lookups and XOR the result into the CRC tables self.high = self.high ^ self.LookupHigh[thisval] self.high = self.high & constants.BYTEMASK # force char self.low = self.low ^ self.LookupLow[thisval] self.low = self.low & constants.BYTEMASK # force char def update(self, val): """Updates the CRC value using bitwise operations.""" self.extract_bits(val >> 4) # High nibble first self.extract_bits(val & 0x0F) # Low nibble def run(self, message): """Calculates a CRC""" for value in message: self.update(value) return [self.low, self.high] class HeatmiserThermostat(object): """Initialises a heatmiser thermostat, by taking an address and model.""" def __init__(self, address, model, uh1): self.address = address self.model = model try: with open(config_yml) as config_file: self.config = yaml.safe_load(config_file)[model] except yaml.YAMLError as exc: logging.info("The YAML file is invalid: %s", exc) self.conn = uh1.registerThermostat(self) self.dcb = "" self.read_dcb() def _hm_form_message( self, thermostat_id, protocol, source, function, start, payload ): """Forms a message payload, excluding CRC""" if protocol == constants.HMV3_ID: start_low = start & constants.BYTEMASK start_high = (start >> 8) & constants.BYTEMASK if function == constants.FUNC_READ: payload_length = 0 length_low = constants.RW_LENGTH_ALL & constants.BYTEMASK length_high = (constants.RW_LENGTH_ALL >> 8) & constants.BYTEMASK else: payload_length = len(payload) length_low = payload_length & constants.BYTEMASK length_high = (payload_length >> 8) & constants.BYTEMASK msg = [ thermostat_id, 10 + payload_length, source, function, start_low, start_high, length_low, length_high, ] if function == constants.FUNC_WRITE: msg = msg + payload type(msg) return msg else: assert 0, "Un-supported protocol found %s" % protocol def _hm_form_message_crc( self, thermostat_id, protocol, source, function, start, payload ): """Forms a message payload, including CRC""" data = self._hm_form_message( thermostat_id, protocol, source, function, start, payload ) crc = CRC16() data = data + crc.run(data) return data def _hm_verify_message_crc_uk( self, thermostat_id, protocol, source, expectedFunction, expectedLength, datal ): """Verifies message appears legal""" # expectedLength only used for read msgs as always 7 for write assert expectedLength == expectedLength badresponse = 0 if protocol == constants.HMV3_ID: checksum = datal[len(datal) - 2:] rxmsg = datal[: len(datal) - 2] crc = CRC16() # Initialises the CRC expectedchecksum = crc.run(rxmsg) if expectedchecksum == checksum: logging.info("CRC is correct") else: logging.error("CRC is INCORRECT") serror = "Incorrect CRC" sys.stderr.write(serror) badresponse += 1 dest_addr = datal[0] frame_len_l = datal[1] frame_len_h = datal[2] frame_len = (frame_len_h << 8) | frame_len_l source_addr = datal[3] func_code = datal[4] if dest_addr != 129 and dest_addr != 160: logging.info("dest_addr is ILLEGAL") serror = "Illegal Dest Addr: %s\n" % (dest_addr) sys.stderr.write(serror) badresponse += 1 if dest_addr != thermostat_id: logging.info("dest_addr is INCORRECT") serror = "Incorrect Dest Addr: %s\n" % (dest_addr) sys.stderr.write(serror) badresponse += 1 if source_addr < 1 or source_addr > 32: logging.info("source_addr is ILLEGAL") serror = "Illegal Src Addr: %s\n" % (source_addr) sys.stderr.write(serror) badresponse += 1 if source_addr != source: logging.info("source addr is INCORRECT") serror = "Incorrect Src Addr: %s\n" % (source_addr) sys.stderr.write(serror) badresponse += 1 if func_code != constants.FUNC_WRITE and func_code != constants.FUNC_READ: logging.info("Func Code is UNKNWON") serror = "Unknown Func Code: %s\n" % (func_code) sys.stderr.write(serror) badresponse += 1 if func_code != expectedFunction: logging.info("Func Code is UNEXPECTED") serror = "Unexpected Func Code: %s\n" % (func_code) sys.stderr.write(serror) badresponse += 1 if func_code == constants.FUNC_WRITE and frame_len != 7: # Reply to Write is always 7 long logging.info("response length is INCORRECT") serror = "Incorrect length: %s\n" % (frame_len) sys.stderr.write(serror) badresponse += 1 if len(datal) != frame_len: logging.info("response length MISMATCHES header") serror = "Mismatch length: %s %s\n" % (len(datal), frame_len) sys.stderr.write(serror) badresponse += 1 # if (func_code == constants.FUNC_READ and # expectedLength !=len(datal) ): # # Read response length is wrong # logging.info("response length # not EXPECTED value") # logging.info("Got %s when expecting %s", # len(datal), expectedLength) # logging.info("Data is:\n %s", datal) # s = "Incorrect length: %s\n" % (frame_len) # sys.stderr.write(s) # badresponse += 1 if badresponse == 0: return True else: return False else: assert 0, "Un-supported protocol found %s" % protocol def _hm_send_msg(self, message): """This is the only interface to the serial connection.""" try: serial_message = message self.conn.write(serial_message) # Write a string except serial.SerialTimeoutException: serror = "Write timeout error: \n" sys.stderr.write(serror) # Now wait for reply byteread = self.conn.read(159) # NB max return is 75 in 5/2 mode or 159 in 7day mode datal = list(byteread) return datal def _hm_send_address(self, thermostat_id, address, state, readwrite): protocol = constants.HMV3_ID if protocol == constants.HMV3_ID: payload = [state] msg = self._hm_form_message_crc( thermostat_id, protocol, constants.RW_MASTER_ADDRESS, readwrite, address, payload, ) else: assert 0, "Un-supported protocol found %s" % protocol string = bytes(msg) datal = self._hm_send_msg(string) pro = protocol if readwrite == 1: verification = self._hm_verify_message_crc_uk( 0x81, pro, thermostat_id, readwrite, 1, datal ) if verification is False: logging.info("OH DEAR BAD RESPONSE") return datal else: verification = self._hm_verify_message_crc_uk( 0x81, pro, thermostat_id, readwrite, 75, datal ) if verification is False: logging.info("OH DEAR BAD RESPONSE") return datal def _hm_read_address(self): """Reads from the DCB and maps to yaml config file.""" response = self._hm_send_address(self.address, 0, 0, 0) lookup = self.config["keys"] offset = self.config["offset"] keydata = {} for i in lookup: try: kdata = lookup[i] ddata = response[i + offset] keydata[i] = {"label": kdata, "value": ddata} except IndexError: logging.info("Finished processing at %d", i) return keydata def read_dcb(self): """ Returns the full DCB, only use for non read-only operations Use self.dcb for read-only operations. """ logging.info("Getting latest data from DCB....") self.dcb = self._hm_read_address() return self.dcb def get_frost_temp(self): """ Returns the temperature """ return self._hm_read_address()[17]["value"] def get_target_temp(self): """ Returns the temperature """ return self._hm_read_address()[18]["value"] def get_floormax_temp(self): """ Returns the temperature """ return self._hm_read_address()[19]["value"] def get_status(self): return self._hm_read_address()[21]["value"] def get_heating(self): return self._hm_read_address()[23]["value"] def get_thermostat_id(self): return self.dcb[11]["value"] def get_temperature_format(self): temp_format = self.dcb[5]["value"] if temp_format == 00: return "C" else: return "F" def get_sensor_selection(self): sensor = self.dcb[13]["value"] answers = { 0: "Built in air sensor", 1: "Remote air sensor", 2: "Floor sensor", 3: "Built in + floor", 4: "Remote + floor", } return answers[sensor] def get_program_mode(self): mode = self.dcb[16]["value"] modes = {0: "5/2 mode", 1: "7 day mode"} return modes[mode] def get_frost_protection(self): pass def get_floor_temp(self): return ( (self.dcb[31]["value"] / 10) if (int(self.dcb[13]["value"]) > 1) else (self.dcb[33]["value"] / 10) ) def get_sensor_error(self): return self.dcb[34]["value"] def get_current_state(self): return self.dcb[35]["value"] def set_frost_protect_mode(self, onoff): self._hm_send_address(self.address, 23, onoff, 1) return True def set_frost_protect_temp(self, frost_temp): if 17 < frost_temp < 7: logging.info("Refusing to set temp outside of allowed range") return False else: self._hm_send_address(self.address, 17, frost_temp, 1) return True def set_target_temp(self, temperature): """ Sets the target temperature, to the requested int """ if 35 < temperature < 5: logging.info("Refusing to set temp outside of allowed range") return False else: self._hm_send_address(self.address, 18, temperature, 1) return True def set_floormax_temp(self, floor_max): if 45 < floor_max < 20: logging.info("Refusing to set temp outside of allowed range") return False else: self._hm_send_address(self.address, 19, floor_max, 1) return True