# -*- coding: utf-8 -*- """ maxcul.messages ~~~~~~~~~~~~~~~~~~~~~~~ Definition of known messages, based on IDs from FHEM plugin :copyright: (c) 2014 by Markus Ullmann. :license: BSD, see LICENSE for more details. """ # environment constants # python imports from datetime import datetime import struct # environment imports # custom imports from maxcul.exceptions import ( MoritzError, LengthNotMatchingError, MissingPayloadParameterError, UnknownMessageError ) # local constants DEVICE_TYPES = { 0: "Cube", 1: "HeatingThermostat", 2: "HeatingThermostatPlus", 3: "WallMountedThermostat", 4: "ShutterContact", 5: "PushButton" } DEVICE_TYPES_BY_NAME = dict((v, k) for k, v in DEVICE_TYPES.items()) MODE_IDS = { 0: "auto", 1: "manual", 2: "temporary", 3: "boost", } SHUTTER_STATES = { 0: "close", 2: "open", } DECALC_DAYS = { "Sat" : 0, "Sun" : 1, "Mon" : 2, "Tue" : 3, "Wed" : 4, "Thu" : 5, "Fri" : 6 } BOOST_DURATION = { 0 : 0, 5 : 1, 10: 2, 15: 3, 20: 4, 25: 5, 30: 6, 60: 7 } class MoritzMessage(object): """Represents (de)coded message as seen on Moritz Wire""" def __init__(self): self.counter = 0 self.flag = 0 self.sender_id = 0 self.receiver_id = 0 self.group_id = 0 self.payload = "" @property def decoded_payload(self): raise NotImplementedError() @property def is_broadcast(self): return self.receiver_id == 0 @staticmethod def decode_message(input_string): """Decodes given message and returns content in matching message class""" if input_string.startswith("Zs"): # outgoing messages can be parsed too, just cut the Z off as it doesn't matter input_string = input_string[1:] # Split MAX message length = int(input_string[1:3], base=16) counter = int(input_string[3:5], base=16) flag = int(input_string[5:7], base=16) msgtype = int(input_string[7:9], base=16) sender_id = int(input_string[9:15], base=16) receiver_id = int(input_string[15:21], base=16) group_id = int(input_string[21:23], base=16) payload = input_string[23:] # Length: strlen(input_string) / 2 as HEX encoding, +3 for Z and length count if (len(input_string) - 3) != length * 2: raise LengthNotMatchingError( "Message length %i not matching indicated length %i" % ((len(input_string) - 3) / 2, length)) try: message_class = MORITZ_MESSAGE_IDS[msgtype] except KeyError: raise UnknownMessageError("Unknown message with id %x found" % msgtype) message = message_class() message.counter = counter message.flag = flag message.group_id = group_id message.sender_id = sender_id message.receiver_id = receiver_id message.payload = payload return message def encode_message(self, payload={}): """Prepare message to be sent on wire""" msg_ids = dict((v, k) for k, v in MORITZ_MESSAGE_IDS.items()) msg_id = msg_ids[self.__class__] message = "" if hasattr(self, 'encode_payload'): self.payload = self.encode_payload(payload) if hasattr(self, 'encode_flag'): self.flag = self.encode_flag() for (var, length) in ( (self.counter, 2), (self.flag, 2), (msg_id, 2), (self.sender_id, 6), (self.receiver_id, 6), (self.group_id, 2)): content = "%X".upper() % var message += content.zfill(length) if self.payload: message += self.payload length = "%X".upper() % int(len(message) / 2) message = "Zs" + length.zfill(2) + message return message def __repr__(self): return "<%s counter:%x flag:%x sender:%x receiver:%x group:%x payload:%s>" % ( self.__class__.__name__, self.counter, self.flag, self.sender_id, self.receiver_id, self.group_id, self.payload ) class PairPingMessage(MoritzMessage): """Thermostats send this request on long boost keypress""" @property def decoded_payload(self): firmware_version, device_type, selftest_result = struct.unpack(">bBB", bytearray.fromhex(self.payload[:6])) device_serial = bytearray.fromhex(self.payload[6:]).decode() result = { 'firmware_version': "V%i.%i" % (firmware_version / 0x10, firmware_version % 0x10), 'device_type': DEVICE_TYPES[device_type], 'selftest_result': selftest_result, 'device_serial': device_serial, 'pairmode': 'pair' if self.is_broadcast else 're-pair' } return result class PairPongMessage(MoritzMessage): """Awaited after PairPingMessage is sent by component""" @property def decoded_payload(self): return {'devicetype': DEVICE_TYPES[int(self.payload)]} def encode_payload(self, payload): return str(DEVICE_TYPES_BY_NAME[payload['devicetype']]).zfill(2) class AckMessage(MoritzMessage): """Last command received and acknowledged. Occasionally if the communication is ongoing, this might get lost. So don't rely on it but check state afterwards instead""" @property def decoded_payload(self): result = {} if self.payload.startswith("01"): result["state"] = "ok" elif self.payload.startswith("81"): result["state"] = "invalid_command" if len(self.payload) == 8: # FIXME: temporarily accepting the fact that we only handle Thermostat results result.update(ThermostatStateMessage.decode_status(self.payload[2:])) return result def encode_flag(self): return 0x4 if self.group_id else 0x0 def encode_payload(self, payload=None): return payload class TimeInformationMessage(MoritzMessage): """Current time is either requested or encoded. Request simply is empty payload""" @property def decoded_payload(self): (years_since_200, day, hour, month_minute, month_sec) = struct.unpack(">BBBBB", bytearray.fromhex(self.payload[:12])) return datetime( year=years_since_200 + 2000, minute=month_minute & 0x3F, month=((month_minute >> 4) & 0x0C) | ((month_sec >> 6) & 0x03), day=day, hour=hour, second=month_sec & 0x3F ) def encode_flag(self): return 0x0A if not self.payload else 0x04 def encode_payload(self, payload=None): # may contain empty payload to ask for timeinformation if payload is None: return "" encoded_payload = str("%X" % (payload.year - 2000)).zfill(2) encoded_payload += str("%X" % payload.day).zfill(2) encoded_payload += str("%X" % payload.hour).zfill(2) encoded_payload += str("%X" % (payload.minute | ((payload.month & 0x0C) << 4))).zfill(2) encoded_payload += str("%X" % (payload.second | ((payload.month & 0x03) << 6))).zfill(2) return encoded_payload class ConfigWeekProfileMessage(MoritzMessage): pass class ConfigTemperaturesMessage(MoritzMessage): """Sets temperatur config""" @property def decoded_payload(self): pass def encode_flag(self): return 0x4 if self.group_id else 0x0 def encode_payload(self, payload): if "comfort_Temperature" not in payload: raise MissingPayloadParameterError("Missing comfort_Temperature in payload") if "eco_Temperature" not in payload: raise MissingPayloadParameterError("Missing eco_Temperature in payload") if "max_Temperature" not in payload: raise MissingPayloadParameterError("Missing max_Temperature in payload") if "min_Temperature" not in payload: raise MissingPayloadParameterError("Missing min_Temperature in payload") if "measurement_Offset" not in payload: raise MissingPayloadParameterError("Missing measurement_Offset in payload") if "window_Open_Temperatur" not in payload: raise MissingPayloadParameterError("Missing window_Open_Temperatur in payload") if "window_Open_Duration" not in payload: raise MissingPayloadParameterError("Missing window_Open_Duration in payload") comfort_Temperature = "%0.2X" % int(payload['comfort_Temperature']*2) eco_Temperature = "%0.2X" % int(payload['eco_Temperature']*2) max_Temperature = "%0.2X" % int(payload['max_Temperature']*2) min_Temperature = "%0.2X" % int(payload['min_Temperature']*2) measurement_Offset = "%0.2X" % int((payload['measurement_Offset'] + 3.5)*2) window_Open_Temperatur = "%0.2X" % int(payload['window_Open_Temperatur']*2) window_Open_Duration = "%0.2X" % int(payload['window_Open_Duration']/5) content = comfort_Temperature + eco_Temperature + max_Temperature + min_Temperature + measurement_Offset + window_Open_Temperatur +window_Open_Duration return content class ConfigValveMessage(MoritzMessage): """Sets valve config""" @property def decoded_payload(self): pass def encode_flag(self): return 0x4 if self.group_id else 0x0 def encode_payload(self, payload): if "boost_duration" not in payload: raise MissingPayloadParameterError("Missing boost duration in payload") if "boost_valve_position" not in payload: raise MissingPayloadParameterError("Missing boost valve position in payload") if "decalc_day" not in payload: raise MissingPayloadParameterError("Missing decalc day in payload") if "decalc_hour" not in payload: raise MissingPayloadParameterError("Missing decalc hour in payload") if "max_valve_position" not in payload: raise MissingPayloadParameterError("Missing max valve position in payload") if "valve_offset" not in payload: raise MissingPayloadParameterError("Missing valve offset in payload") boost = "%0.2X" % ((BOOST_DURATION[payload["boost_duration"]] << 5) | int(payload["boost_valve_position"]/5)) decalc = "%0.2X" % ((DECALC_DAYS[payload["decalc_day"]] << 5) | payload["decalc_hour"]) max_valve_position = "%0.2X" % int(payload["max_valve_position"]*255/100) valve_offset = "%0.2X" % int(payload["valve_offset"]*255/100) content = boost + decalc + max_valve_position + valve_offset return content class AddLinkPartnerMessage(MoritzMessage): @property def decoded_payload(self): pass def encode_flag(self): return 0x4 if self.group_id else 0x0 def encode_payload(self, payload): if "assocDevice" not in payload: raise MissingPayloadParameterError("Missing assocDevice in payload") if "assocDeviceType" not in payload: raise MissingPayloadParameterError("Missing assocDeviceType in payload") assocDevice = "%0.6X" % int(payload["assocDevice"]) assocDeviceType = "%0.2X" % list(DEVICE_TYPES.keys())[list(DEVICE_TYPES.values()).index(payload["assocDeviceType"])] return assocDevice + assocDeviceType class RemoveLinkPartnerMessage(MoritzMessage): @property def decoded_payload(self): pass def encode_flag(self): return 0x4 if self.group_id else 0x0 def encode_payload(self, payload): if "assocDevice" not in payload: raise MissingPayloadParameterError("Missing assocDevice in payload") if "assocDeviceType" not in payload: raise MissingPayloadParameterError("Missing assocDeviceType in payload") assocDevice = payload["assocDevice"] assocDeviceType = "%0.2X" % DEVICE_TYPES[payload["assocDeviceType"]] return assocDevice + assocDeviceType class SetGroupIdMessage(MoritzMessage): @property def decoded_payload(self): pass def encode_flag(self): return 0x4 if self.group_id else 0x0 def encode_payload(self, payload): if "group_id" not in payload: raise MissingPayloadParameterError("Missing group id in payload") groupId = "%0.2X" % payload["group_id"] return groupId class RemoveGroupIdMessage(MoritzMessage): @property def decoded_payload(self): pass def encode_flag(self): return 0x4 if self.group_id else 0x0 def encode_payload(self, payload): overrideGroupId = "00" return overrideGroupId class ShutterContactStateMessage(MoritzMessage): """Non-reculary sent by Thermostats to report when valve was moved or command received.""" @staticmethod def decode_status(payload): status_bits = bin(int(payload, 16))[2:].zfill(8) state = int(status_bits[6:],2) unkbits = int(status_bits[2:6],2) rferror = int(status_bits[1],2) battery_low = int(status_bits[0],2) result = { "state": SHUTTER_STATES[state], "unkbits": unkbits, "rferror": bool(rferror), "battery_low": bool(battery_low) } return result @property def decoded_payload(self): result = ShutterContactStateMessage.decode_status(self.payload) return result class SetTemperatureMessage(MoritzMessage): """Sets temperature for manual mode as well as mode switch between manual, auto and boost""" @property def decoded_payload(self): payload = struct.unpack(">B", bytearray.fromhex(self.payload[0:4])) return { 'desired_temperature': ((payload[0] & 0x3F) / 2.0), 'mode': MODE_IDS[payload[0] >> 6] } def encode_flag(self): return 0x4 if self.group_id else 0x0 def encode_payload(self, payload): if "desired_temperature" not in payload: raise MissingPayloadParameterError("Missing desired_temperature in payload") if "mode" not in payload: raise MissingPayloadParameterError("Missing mode in payload") if payload['desired_temperature'] > 30.5: desired_temperature = 30.5 # "ON" elif payload['desired_temperature'] < 4.5: desired_temperature = 4.5 # "OFF" else: # always round to nearest 0.5 first desired_temperature = round(payload['desired_temperature'] * 2) / 2.0 int_temperature = int(desired_temperature * 2) modes = dict((v, k) for (k, v) in MODE_IDS.items()) mode = modes[payload['mode']] # TODO: you can add a until time for chort changes # from fhem # $until = sprintf("%06x",MAX_DateTime2Internal($args[2]." ".$args[3])); # $payload .= $until if(defined($until)); content = "%X".upper() % ((mode << 6) | int_temperature) return content.zfill(2) class WallThermostatControlMessage(MoritzMessage): @staticmethod def decode_status(payload): rawTempratures = bin(int(payload, 16))[2:].zfill(16) result = { "desired_temprature": int(rawTempratures[1:8], 2)/2, "temprature": ((int(rawTempratures[0], 2) << 8) + int(rawTempratures[8:], 2))/10 } return result @property def decoded_payload(self): result = WallThermostatControlMessage.decode_status(self.payload) return result class SetComfortTemperatureMessage(MoritzMessage): pass class SetEcoTemperatureMessage(MoritzMessage): pass class PushButtonStateMessage(MoritzMessage): pass class ThermostatStateMessage(MoritzMessage): """Non-reculary sent by Thermostats to report when valve was moved or command received.""" @staticmethod def decode_status(payload): status_bits, valve_position, desired_temperature = struct.unpack(">bBB", bytearray.fromhex(payload[0:6])) mode = status_bits & 0x3 dstsetting = status_bits & 0x04 langateway = status_bits & 0x08 status_bits = status_bits >> 9 is_locked = status_bits & 0x1 rferror = status_bits & 0x2 battery_low = status_bits & 0x4 desired_temperature = (desired_temperature & 0x7F) / 2.0 result = { "mode": MODE_IDS[mode], "dstsetting": bool(dstsetting), "langateway": bool(langateway), "is_locked": bool(is_locked), "rferror": bool(rferror), "battery_low": bool(battery_low), "desired_temperature": desired_temperature, "valve_position": valve_position, } return result @property def decoded_payload(self): result = ThermostatStateMessage.decode_status(self.payload) if len(self.payload) > 6: pending_payload = bytearray.fromhex(self.payload[6:]) if len(pending_payload) == 3: # TODO handle date string pass elif len(pending_payload) == 2 and result['mode'] != 'temporary': result["measured_temperature"] = (((pending_payload[0] & 0x1) << 8) + pending_payload[1]) / 10.0 else: # unknown.... pass return result class WallThermostatStateMessage(MoritzMessage): """Non-reculary sent by Thermostats to report when valve was moved or command received.""" @staticmethod def decode_status(payload): status_bits = bin(int(payload[:2], 16))[2:].zfill(8) mode = int(status_bits[:2], 2) dstsetting = int(status_bits[2:3], 2) langateway = int(status_bits[3:4], 2) is_locked = int(status_bits[4:5], 2) rferror = int(status_bits[5:6], 2) battery_low = int(status_bits[6:7], 2) display_actual_temperature = bool(int(payload[2:4], 16)) desired_temperature_raw = bin(int(payload[4:6], 16))[2:].zfill(8) desired_temperature = int(desired_temperature_raw[1:8], 2)/2 heater_temperature = "" null1 = False if len(payload) > 6: null1 = payload[6:8] if len(payload) > 8: heater_temperature = payload[8:10] null2 = False if len(payload) > 10: null2 = payload[10:12] if len(payload) > 12: temperature = ((int(desired_temperature_raw[0], 2) << 8) + int(payload[12:], 16)) / 10 until_str = "" if null1 and null2: until_str = parseDateTime(null1, heater_temperature, null2) else: temperature = int(heater_temperature, 16)/10 result = { "mode": MODE_IDS[mode], "dstsetting": bool(dstsetting), "langateway": bool(langateway), "is_locked": bool(is_locked), "rferror": bool(rferror), "battery_low": bool(battery_low), "desired_temperature": desired_temperature, "display_actual_temperature": display_actual_temperature, "temperature": temperature, "until_str": until_str } return result @property def decoded_payload(self): result = WallThermostatStateMessage.decode_status(self.payload) return result def parseDateTime(byte1, byte2, byte3): day = int(byte1, 16) & 0x1F month = ((int(byte1, 16) & 0xE0) >> 4) | (int(byte2, 16) >> 7) year = int(byte2, 16) & 0x3F time = int(byte3, 16) & 0x3F if time%2: time = int(time/2) + ':30' else: time = int(time/2) + ":00" return { "day": day, "month": month, "year": year, "time": time } class SetDisplayActualTemperatureMessage(MoritzMessage): pass class WakeUpMessage(MoritzMessage): pass class ResetMessage(MoritzMessage): """Perform a factory reset on given device""" pass # Define at bottom so we can use the class types right away # Based on FHEM CUL_MAX module MORITZ_MESSAGE_IDS = { 0x00: PairPingMessage, 0x01: PairPongMessage, 0x02: AckMessage, 0x03: TimeInformationMessage, 0x10: ConfigWeekProfileMessage, 0x11: ConfigTemperaturesMessage, 0x12: ConfigValveMessage, 0x20: AddLinkPartnerMessage, 0x21: RemoveLinkPartnerMessage, 0x22: SetGroupIdMessage, 0x23: RemoveGroupIdMessage, 0x30: ShutterContactStateMessage, 0x40: SetTemperatureMessage, 0x42: WallThermostatControlMessage, 0x43: SetComfortTemperatureMessage, 0x44: SetEcoTemperatureMessage, 0x50: PushButtonStateMessage, 0x60: ThermostatStateMessage, 0x70: WallThermostatStateMessage, 0x82: SetDisplayActualTemperatureMessage, 0xF1: WakeUpMessage, 0xF0: ResetMessage, }