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.

648 lines
20KB

  1. # -*- coding: utf-8 -*-
  2. """
  3. maxcul.messages
  4. ~~~~~~~~~~~~~~~~~~~~~~~
  5. Definition of known messages, based on IDs from FHEM plugin
  6. :copyright: (c) 2014 by Markus Ullmann.
  7. :license: BSD, see LICENSE for more details.
  8. """
  9. # environment constants
  10. # python imports
  11. from datetime import datetime
  12. import struct
  13. # environment imports
  14. # custom imports
  15. from maxcul.exceptions import (
  16. MoritzError, LengthNotMatchingError,
  17. MissingPayloadParameterError, UnknownMessageError
  18. )
  19. # local constants
  20. DEVICE_TYPES = {
  21. 0: "Cube",
  22. 1: "HeatingThermostat",
  23. 2: "HeatingThermostatPlus",
  24. 3: "WallMountedThermostat",
  25. 4: "ShutterContact",
  26. 5: "PushButton"
  27. }
  28. DEVICE_TYPES_BY_NAME = dict((v, k) for k, v in DEVICE_TYPES.items())
  29. MODE_IDS = {
  30. 0: "auto",
  31. 1: "manual",
  32. 2: "temporary",
  33. 3: "boost",
  34. }
  35. SHUTTER_STATES = {
  36. 0: "close",
  37. 2: "open",
  38. }
  39. DECALC_DAYS = {
  40. "Sat" : 0,
  41. "Sun" : 1,
  42. "Mon" : 2,
  43. "Tue" : 3,
  44. "Wed" : 4,
  45. "Thu" : 5,
  46. "Fri" : 6
  47. }
  48. BOOST_DURATION = {
  49. 0 : 0,
  50. 5 : 1,
  51. 10: 2,
  52. 15: 3,
  53. 20: 4,
  54. 25: 5,
  55. 30: 6,
  56. 60: 7
  57. }
  58. class MoritzMessage(object):
  59. """Represents (de)coded message as seen on Moritz Wire"""
  60. def __init__(self):
  61. self.counter = 0
  62. self.flag = 0
  63. self.sender_id = 0
  64. self.receiver_id = 0
  65. self.group_id = 0
  66. self.payload = ""
  67. @property
  68. def decoded_payload(self):
  69. raise NotImplementedError()
  70. @property
  71. def is_broadcast(self):
  72. return self.receiver_id == 0
  73. @staticmethod
  74. def decode_message(input_string):
  75. """Decodes given message and returns content in matching message class"""
  76. if input_string.startswith("Zs"):
  77. # outgoing messages can be parsed too, just cut the Z off as it doesn't matter
  78. input_string = input_string[1:]
  79. # Split MAX message
  80. length = int(input_string[1:3], base=16)
  81. counter = int(input_string[3:5], base=16)
  82. flag = int(input_string[5:7], base=16)
  83. msgtype = int(input_string[7:9], base=16)
  84. sender_id = int(input_string[9:15], base=16)
  85. receiver_id = int(input_string[15:21], base=16)
  86. group_id = int(input_string[21:23], base=16)
  87. payload = input_string[23:]
  88. # Length: strlen(input_string) / 2 as HEX encoding, +3 for Z and length count
  89. if (len(input_string) - 3) != length * 2:
  90. raise LengthNotMatchingError(
  91. "Message length %i not matching indicated length %i" % ((len(input_string) - 3) / 2, length))
  92. try:
  93. message_class = MORITZ_MESSAGE_IDS[msgtype]
  94. except KeyError:
  95. raise UnknownMessageError("Unknown message with id %x found" % msgtype)
  96. message = message_class()
  97. message.counter = counter
  98. message.flag = flag
  99. message.group_id = group_id
  100. message.sender_id = sender_id
  101. message.receiver_id = receiver_id
  102. message.payload = payload
  103. return message
  104. def encode_message(self, payload={}):
  105. """Prepare message to be sent on wire"""
  106. msg_ids = dict((v, k) for k, v in MORITZ_MESSAGE_IDS.items())
  107. msg_id = msg_ids[self.__class__]
  108. message = ""
  109. if hasattr(self, 'encode_payload'):
  110. self.payload = self.encode_payload(payload)
  111. if hasattr(self, 'encode_flag'):
  112. self.flag = self.encode_flag()
  113. for (var, length) in (
  114. (self.counter, 2), (self.flag, 2), (msg_id, 2), (self.sender_id, 6), (self.receiver_id, 6),
  115. (self.group_id, 2)):
  116. content = "%X".upper() % var
  117. message += content.zfill(length)
  118. if self.payload:
  119. message += self.payload
  120. length = "%X".upper() % int(len(message) / 2)
  121. message = "Zs" + length.zfill(2) + message
  122. return message
  123. def __repr__(self):
  124. return "<%s counter:%x flag:%x sender:%x receiver:%x group:%x payload:%s>" % (
  125. self.__class__.__name__, self.counter, self.flag, self.sender_id, self.receiver_id, self.group_id,
  126. self.payload
  127. )
  128. class PairPingMessage(MoritzMessage):
  129. """Thermostats send this request on long boost keypress"""
  130. @property
  131. def decoded_payload(self):
  132. firmware_version, device_type, selftest_result = struct.unpack(">bBB", bytearray.fromhex(self.payload[:6]))
  133. device_serial = bytearray.fromhex(self.payload[6:]).decode()
  134. result = {
  135. 'firmware_version': "V%i.%i" % (firmware_version / 0x10, firmware_version % 0x10),
  136. 'device_type': DEVICE_TYPES[device_type],
  137. 'selftest_result': selftest_result,
  138. 'device_serial': device_serial,
  139. 'pairmode': 'pair' if self.is_broadcast else 're-pair'
  140. }
  141. return result
  142. class PairPongMessage(MoritzMessage):
  143. """Awaited after PairPingMessage is sent by component"""
  144. @property
  145. def decoded_payload(self):
  146. return {'devicetype': DEVICE_TYPES[int(self.payload)]}
  147. def encode_payload(self, payload):
  148. return str(DEVICE_TYPES_BY_NAME[payload['devicetype']]).zfill(2)
  149. class AckMessage(MoritzMessage):
  150. """Last command received and acknowledged.
  151. Occasionally if the communication is ongoing, this might get lost.
  152. So don't rely on it but check state afterwards instead"""
  153. @property
  154. def decoded_payload(self):
  155. result = {}
  156. if self.payload.startswith("01"):
  157. result["state"] = "ok"
  158. elif self.payload.startswith("81"):
  159. result["state"] = "invalid_command"
  160. if len(self.payload) == 8:
  161. # FIXME: temporarily accepting the fact that we only handle Thermostat results
  162. result.update(ThermostatStateMessage.decode_status(self.payload[2:]))
  163. return result
  164. def encode_flag(self):
  165. return 0x4 if self.group_id else 0x0
  166. def encode_payload(self, payload=None):
  167. return payload
  168. class TimeInformationMessage(MoritzMessage):
  169. """Current time is either requested or encoded. Request simply is empty payload"""
  170. @property
  171. def decoded_payload(self):
  172. (years_since_200, day, hour, month_minute, month_sec) = struct.unpack(">BBBBB",
  173. bytearray.fromhex(self.payload[:12]))
  174. return datetime(
  175. year=years_since_200 + 2000,
  176. minute=month_minute & 0x3F,
  177. month=((month_minute >> 4) & 0x0C) | ((month_sec >> 6) & 0x03),
  178. day=day,
  179. hour=hour,
  180. second=month_sec & 0x3F
  181. )
  182. def encode_flag(self):
  183. return 0x0A if not self.payload else 0x04
  184. def encode_payload(self, payload=None):
  185. # may contain empty payload to ask for timeinformation
  186. if payload is None:
  187. return ""
  188. encoded_payload = str("%X" % (payload.year - 2000)).zfill(2)
  189. encoded_payload += str("%X" % payload.day).zfill(2)
  190. encoded_payload += str("%X" % payload.hour).zfill(2)
  191. encoded_payload += str("%X" % (payload.minute | ((payload.month & 0x0C) << 4))).zfill(2)
  192. encoded_payload += str("%X" % (payload.second | ((payload.month & 0x03) << 6))).zfill(2)
  193. return encoded_payload
  194. class ConfigWeekProfileMessage(MoritzMessage):
  195. pass
  196. class ConfigTemperaturesMessage(MoritzMessage):
  197. """Sets temperatur config"""
  198. @property
  199. def decoded_payload(self):
  200. pass
  201. def encode_flag(self):
  202. return 0x4 if self.group_id else 0x0
  203. def encode_payload(self, payload):
  204. if "comfort_Temperature" not in payload:
  205. raise MissingPayloadParameterError("Missing comfort_Temperature in payload")
  206. if "eco_Temperature" not in payload:
  207. raise MissingPayloadParameterError("Missing eco_Temperature in payload")
  208. if "max_Temperature" not in payload:
  209. raise MissingPayloadParameterError("Missing max_Temperature in payload")
  210. if "min_Temperature" not in payload:
  211. raise MissingPayloadParameterError("Missing min_Temperature in payload")
  212. if "measurement_Offset" not in payload:
  213. raise MissingPayloadParameterError("Missing measurement_Offset in payload")
  214. if "window_Open_Temperatur" not in payload:
  215. raise MissingPayloadParameterError("Missing window_Open_Temperatur in payload")
  216. if "window_Open_Duration" not in payload:
  217. raise MissingPayloadParameterError("Missing window_Open_Duration in payload")
  218. comfort_Temperature = "%0.2X" % int(payload['comfort_Temperature']*2)
  219. eco_Temperature = "%0.2X" % int(payload['eco_Temperature']*2)
  220. max_Temperature = "%0.2X" % int(payload['max_Temperature']*2)
  221. min_Temperature = "%0.2X" % int(payload['min_Temperature']*2)
  222. measurement_Offset = "%0.2X" % int((payload['measurement_Offset'] + 3.5)*2)
  223. window_Open_Temperatur = "%0.2X" % int(payload['window_Open_Temperatur']*2)
  224. window_Open_Duration = "%0.2X" % int(payload['window_Open_Duration']/5)
  225. content = comfort_Temperature + eco_Temperature + max_Temperature + min_Temperature + measurement_Offset + window_Open_Temperatur +window_Open_Duration
  226. return content
  227. class ConfigValveMessage(MoritzMessage):
  228. """Sets valve config"""
  229. @property
  230. def decoded_payload(self):
  231. pass
  232. def encode_flag(self):
  233. return 0x4 if self.group_id else 0x0
  234. def encode_payload(self, payload):
  235. if "boost_duration" not in payload:
  236. raise MissingPayloadParameterError("Missing boost duration in payload")
  237. if "boost_valve_position" not in payload:
  238. raise MissingPayloadParameterError("Missing boost valve position in payload")
  239. if "decalc_day" not in payload:
  240. raise MissingPayloadParameterError("Missing decalc day in payload")
  241. if "decalc_hour" not in payload:
  242. raise MissingPayloadParameterError("Missing decalc hour in payload")
  243. if "max_valve_position" not in payload:
  244. raise MissingPayloadParameterError("Missing max valve position in payload")
  245. if "valve_offset" not in payload:
  246. raise MissingPayloadParameterError("Missing valve offset in payload")
  247. boost = "%0.2X" % ((BOOST_DURATION[payload["boost_duration"]] << 5) | int(payload["boost_valve_position"]/5))
  248. decalc = "%0.2X" % ((DECALC_DAYS[payload["decalc_day"]] << 5) | payload["decalc_hour"])
  249. max_valve_position = "%0.2X" % int(payload["max_valve_position"]*255/100)
  250. valve_offset = "%0.2X" % int(payload["valve_offset"]*255/100)
  251. content = boost + decalc + max_valve_position + valve_offset
  252. return content
  253. class AddLinkPartnerMessage(MoritzMessage):
  254. @property
  255. def decoded_payload(self):
  256. pass
  257. def encode_flag(self):
  258. return 0x4 if self.group_id else 0x0
  259. def encode_payload(self, payload):
  260. if "assocDevice" not in payload:
  261. raise MissingPayloadParameterError("Missing assocDevice in payload")
  262. if "assocDeviceType" not in payload:
  263. raise MissingPayloadParameterError("Missing assocDeviceType in payload")
  264. assocDevice = "%0.6X" % int(payload["assocDevice"])
  265. assocDeviceType = "%0.2X" % list(DEVICE_TYPES.keys())[list(DEVICE_TYPES.values()).index(payload["assocDeviceType"])]
  266. return assocDevice + assocDeviceType
  267. class RemoveLinkPartnerMessage(MoritzMessage):
  268. @property
  269. def decoded_payload(self):
  270. pass
  271. def encode_flag(self):
  272. return 0x4 if self.group_id else 0x0
  273. def encode_payload(self, payload):
  274. if "assocDevice" not in payload:
  275. raise MissingPayloadParameterError("Missing assocDevice in payload")
  276. if "assocDeviceType" not in payload:
  277. raise MissingPayloadParameterError("Missing assocDeviceType in payload")
  278. assocDevice = payload["assocDevice"]
  279. assocDeviceType = "%0.2X" % DEVICE_TYPES[payload["assocDeviceType"]]
  280. return assocDevice + assocDeviceType
  281. class SetGroupIdMessage(MoritzMessage):
  282. @property
  283. def decoded_payload(self):
  284. pass
  285. def encode_flag(self):
  286. return 0x4 if self.group_id else 0x0
  287. def encode_payload(self, payload):
  288. if "group_id" not in payload:
  289. raise MissingPayloadParameterError("Missing group id in payload")
  290. groupId = "%0.2X" % payload["group_id"]
  291. return groupId
  292. class RemoveGroupIdMessage(MoritzMessage):
  293. @property
  294. def decoded_payload(self):
  295. pass
  296. def encode_flag(self):
  297. return 0x4 if self.group_id else 0x0
  298. def encode_payload(self, payload):
  299. overrideGroupId = "00"
  300. return overrideGroupId
  301. class ShutterContactStateMessage(MoritzMessage):
  302. """Non-reculary sent by Thermostats to report when valve was moved or command received."""
  303. @staticmethod
  304. def decode_status(payload):
  305. status_bits = bin(int(payload, 16))[2:].zfill(8)
  306. state = int(status_bits[6:],2)
  307. unkbits = int(status_bits[2:6],2)
  308. rferror = int(status_bits[1],2)
  309. battery_low = int(status_bits[0],2)
  310. result = {
  311. "state": SHUTTER_STATES[state],
  312. "unkbits": unkbits,
  313. "rferror": bool(rferror),
  314. "battery_low": bool(battery_low)
  315. }
  316. return result
  317. @property
  318. def decoded_payload(self):
  319. result = ShutterContactStateMessage.decode_status(self.payload)
  320. return result
  321. class SetTemperatureMessage(MoritzMessage):
  322. """Sets temperature for manual mode as well as mode switch between manual, auto and boost"""
  323. @property
  324. def decoded_payload(self):
  325. payload = struct.unpack(">B", bytearray.fromhex(self.payload[0:4]))
  326. return {
  327. 'desired_temperature': ((payload[0] & 0x3F) / 2.0),
  328. 'mode': MODE_IDS[payload[0] >> 6]
  329. }
  330. def encode_flag(self):
  331. return 0x4 if self.group_id else 0x0
  332. def encode_payload(self, payload):
  333. if "desired_temperature" not in payload:
  334. raise MissingPayloadParameterError("Missing desired_temperature in payload")
  335. if "mode" not in payload:
  336. raise MissingPayloadParameterError("Missing mode in payload")
  337. if payload['desired_temperature'] > 30.5:
  338. desired_temperature = 30.5 # "ON"
  339. elif payload['desired_temperature'] < 4.5:
  340. desired_temperature = 4.5 # "OFF"
  341. else:
  342. # always round to nearest 0.5 first
  343. desired_temperature = round(payload['desired_temperature'] * 2) / 2.0
  344. int_temperature = int(desired_temperature * 2)
  345. modes = dict((v, k) for (k, v) in MODE_IDS.items())
  346. mode = modes[payload['mode']]
  347. # TODO: you can add a until time for chort changes
  348. # from fhem
  349. # $until = sprintf("%06x",MAX_DateTime2Internal($args[2]." ".$args[3]));
  350. # $payload .= $until if(defined($until));
  351. content = "%X".upper() % ((mode << 6) | int_temperature)
  352. return content.zfill(2)
  353. class WallThermostatControlMessage(MoritzMessage):
  354. @staticmethod
  355. def decode_status(payload):
  356. rawTempratures = bin(int(payload, 16))[2:].zfill(16)
  357. result = {
  358. "desired_temprature": int(rawTempratures[1:8], 2)/2,
  359. "temprature": ((int(rawTempratures[0], 2) << 8) + int(rawTempratures[8:], 2))/10
  360. }
  361. return result
  362. @property
  363. def decoded_payload(self):
  364. result = WallThermostatControlMessage.decode_status(self.payload)
  365. return result
  366. class SetComfortTemperatureMessage(MoritzMessage):
  367. pass
  368. class SetEcoTemperatureMessage(MoritzMessage):
  369. pass
  370. class PushButtonStateMessage(MoritzMessage):
  371. pass
  372. class ThermostatStateMessage(MoritzMessage):
  373. """Non-reculary sent by Thermostats to report when valve was moved or command received."""
  374. @staticmethod
  375. def decode_status(payload):
  376. status_bits, valve_position, desired_temperature = struct.unpack(">bBB", bytearray.fromhex(payload[0:6]))
  377. mode = status_bits & 0x3
  378. dstsetting = status_bits & 0x04
  379. langateway = status_bits & 0x08
  380. status_bits = status_bits >> 9
  381. is_locked = status_bits & 0x1
  382. rferror = status_bits & 0x2
  383. battery_low = status_bits & 0x4
  384. desired_temperature = (desired_temperature & 0x7F) / 2.0
  385. result = {
  386. "mode": MODE_IDS[mode],
  387. "dstsetting": bool(dstsetting),
  388. "langateway": bool(langateway),
  389. "is_locked": bool(is_locked),
  390. "rferror": bool(rferror),
  391. "battery_low": bool(battery_low),
  392. "desired_temperature": desired_temperature,
  393. "valve_position": valve_position,
  394. }
  395. return result
  396. @property
  397. def decoded_payload(self):
  398. result = ThermostatStateMessage.decode_status(self.payload)
  399. if len(self.payload) > 6:
  400. pending_payload = bytearray.fromhex(self.payload[6:])
  401. if len(pending_payload) == 3:
  402. # TODO handle date string
  403. pass
  404. elif len(pending_payload) == 2 and result['mode'] != 'temporary':
  405. result["measured_temperature"] = (((pending_payload[0] & 0x1) << 8) + pending_payload[1]) / 10.0
  406. else:
  407. # unknown....
  408. pass
  409. return result
  410. class WallThermostatStateMessage(MoritzMessage):
  411. """Non-reculary sent by Thermostats to report when valve was moved or command received."""
  412. @staticmethod
  413. def decode_status(payload):
  414. status_bits = bin(int(payload[:2], 16))[2:].zfill(8)
  415. mode = int(status_bits[:2], 2)
  416. dstsetting = int(status_bits[2:3], 2)
  417. langateway = int(status_bits[3:4], 2)
  418. is_locked = int(status_bits[4:5], 2)
  419. rferror = int(status_bits[5:6], 2)
  420. battery_low = int(status_bits[6:7], 2)
  421. display_actual_temperature = bool(int(payload[2:4], 16))
  422. desired_temperature_raw = bin(int(payload[4:6], 16))[2:].zfill(8)
  423. desired_temperature = int(desired_temperature_raw[1:8], 2)/2
  424. heater_temperature = ""
  425. null1 = False
  426. if len(payload) > 6:
  427. null1 = payload[6:8]
  428. if len(payload) > 8:
  429. heater_temperature = payload[8:10]
  430. null2 = False
  431. if len(payload) > 10:
  432. null2 = payload[10:12]
  433. if len(payload) > 12:
  434. temperature = ((int(desired_temperature_raw[0], 2) << 8) + int(payload[12:], 16)) / 10
  435. until_str = ""
  436. if null1 and null2:
  437. until_str = parseDateTime(null1, heater_temperature, null2)
  438. else:
  439. temperature = int(heater_temperature, 16)/10
  440. result = {
  441. "mode": MODE_IDS[mode],
  442. "dstsetting": bool(dstsetting),
  443. "langateway": bool(langateway),
  444. "is_locked": bool(is_locked),
  445. "rferror": bool(rferror),
  446. "battery_low": bool(battery_low),
  447. "desired_temperature": desired_temperature,
  448. "display_actual_temperature": display_actual_temperature,
  449. "temperature": temperature,
  450. "until_str": until_str
  451. }
  452. return result
  453. @property
  454. def decoded_payload(self):
  455. result = WallThermostatStateMessage.decode_status(self.payload)
  456. return result
  457. def parseDateTime(byte1, byte2, byte3):
  458. day = int(byte1, 16) & 0x1F
  459. month = ((int(byte1, 16) & 0xE0) >> 4) | (int(byte2, 16) >> 7)
  460. year = int(byte2, 16) & 0x3F
  461. time = int(byte3, 16) & 0x3F
  462. if time%2:
  463. time = int(time/2) + ':30'
  464. else:
  465. time = int(time/2) + ":00"
  466. return {
  467. "day": day,
  468. "month": month,
  469. "year": year,
  470. "time": time
  471. }
  472. class SetDisplayActualTemperatureMessage(MoritzMessage):
  473. pass
  474. class WakeUpMessage(MoritzMessage):
  475. pass
  476. class ResetMessage(MoritzMessage):
  477. """Perform a factory reset on given device"""
  478. pass
  479. # Define at bottom so we can use the class types right away
  480. # Based on FHEM CUL_MAX module
  481. MORITZ_MESSAGE_IDS = {
  482. 0x00: PairPingMessage,
  483. 0x01: PairPongMessage,
  484. 0x02: AckMessage,
  485. 0x03: TimeInformationMessage,
  486. 0x10: ConfigWeekProfileMessage,
  487. 0x11: ConfigTemperaturesMessage,
  488. 0x12: ConfigValveMessage,
  489. 0x20: AddLinkPartnerMessage,
  490. 0x21: RemoveLinkPartnerMessage,
  491. 0x22: SetGroupIdMessage,
  492. 0x23: RemoveGroupIdMessage,
  493. 0x30: ShutterContactStateMessage,
  494. 0x40: SetTemperatureMessage,
  495. 0x42: WallThermostatControlMessage,
  496. 0x43: SetComfortTemperatureMessage,
  497. 0x44: SetEcoTemperatureMessage,
  498. 0x50: PushButtonStateMessage,
  499. 0x60: ThermostatStateMessage,
  500. 0x70: WallThermostatStateMessage,
  501. 0x82: SetDisplayActualTemperatureMessage,
  502. 0xF1: WakeUpMessage,
  503. 0xF0: ResetMessage,
  504. }