|
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647 |
- # -*- 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,
- }
|