diff --git a/MAXCube.py b/MAXCube.py index 70ceb02..8ac61cd 100644 --- a/MAXCube.py +++ b/MAXCube.py @@ -18,7 +18,7 @@ class MAXCube: self.client.write(req.encode()) self.client.write(b"\n") if DEBUG: - print(">>> {}".format(req)) + print("SENT: {}".format(req)) else: print("Request while not connected!") @@ -51,7 +51,7 @@ class CUN(MAXCube): self.client.write(req.encode()) self.client.write(b"\n") if DEBUG: - print(">>> {}".format(req)) + print("SENT: {}".format(req)) else: print("Request while not connected!") @@ -59,7 +59,7 @@ class CUN(MAXCube): if self.client is not None: response = self.client.read_some().decode() if DEBUG: - print("<<< {}".format(response)) + print("RECV: {}".format(response)) return response else: print("Waiting for response while not connected!") @@ -80,7 +80,7 @@ class CUL(MAXCube): if self.client is not None: response = self.client.read(100).decode() if DEBUG: - print("<<< {}".format(response)) + print("RECV: {}".format(response)) return response else: print("Waiting for response while not connected!") diff --git a/MAXDevice.py b/MAXDevice.py index f88f0ad..1476322 100644 --- a/MAXDevice.py +++ b/MAXDevice.py @@ -2,11 +2,36 @@ from MAXPacket import PushButtonState class MAXDevice: - def __init__(self, address): - self.address = address + def __init__(self, name, address): + self.address: str = address + self.name: str = name + + def to_string(self): + return "{};{}".format(self.name, self.address.lower()) + + def from_string(line): + splitted = line.split(';') + if len(splitted) >= 3: + if splitted[0] == "1": + new_dev = MAXThermostat(splitted[1], splitted[2].lower()) + return new_dev + elif splitted[0] == "5": + new_dev = MAXPushButton(splitted[1], splitted[2].lower()) + return new_dev class MAXPushButton(MAXDevice): - def __init__(self, address): - super(MAXPushButton, self).__init__(address) + def __init__(self, name, address): + super(MAXPushButton, self).__init__(name, address) self.state = PushButtonState.UNKNOWN + + def serialize(self): + return "5;{}".format(self.to_string()) + + +class MAXThermostat(MAXDevice): + def __init__(self, name, address): + super(MAXThermostat, self).__init__(name, address) + + def serialize(self): + return "1;{}".format(self.to_string()) diff --git a/MAXPacket.py b/MAXPacket.py index e3f1098..2535b10 100644 --- a/MAXPacket.py +++ b/MAXPacket.py @@ -1,6 +1,7 @@ import math from enum import Enum + class MAXPacketFactory: def create_packet(rec: str): pkt_type = int(rec[7:9], 16) @@ -18,6 +19,8 @@ class MAXPacketFactory: return MAXResetPacket(rec) elif pkt_type == 0x50: return MAXPushButtonPacket(rec) + elif pkt_type == 0x60: + return MAXThermostatStatePacket(rec) else: print("Unknown message type: {}".format(pkt_type)) result = MAXPacket() @@ -97,14 +100,52 @@ class MAXPairPongPacket(MAXPacket): class MAXSetGroupIdPacket(MAXPacket): def __init__(self, message_counter: str, message_flag: str, sender_address: str, dest_address: str, group_id: str): - self.set_values(message_counter, "01", message_flag, sender_address, dest_address, group_id) - self.payload = "00" + self.set_values(message_counter, "22", message_flag, sender_address, dest_address, group_id) + self.payload = "01" def to_string(self): result = "{}\nMAXSetGroupIdPacket: payload={}".format(super().to_string(), self.payload) return result +class MAXRadiatorControlMode(Enum): + AUTO = 0, + MANUAL = 1, + TEMPORARY = 2, + BOOST = 3 + + +class MAXSetTempPacket(MAXPacket): + def __init__(self, message_counter: str, message_flag: str, sender_address: str, dest_address: str, + group_id: str, temp: float, mode: MAXRadiatorControlMode): + self.set_values(message_counter, "40", message_flag, sender_address, dest_address, group_id) + + if temp > 30.5: + temp = 30.5 + elif temp < 4.5: + temp = 4.5 + + payload_int = round(temp*2).to_bytes(1, 'big')[0] + payload_int = payload_int | ((mode.value[0] & 3) << 6) + self.payload = format(payload_int, 'x') + + def to_string(self): + result = "{}\nMAXSetTempPacket: payload={}".format(super().to_string(), self.payload) + return result + + +class MAXAddLinkPartnerPacket(MAXPacket): + def __init__(self, message_counter: str, message_flag: str, sender_address: str, dest_address: str, + group_id: str, partner_addr: str, partner_type: int): + self.set_values(message_counter, "40", message_flag, sender_address, dest_address, group_id) + type_str = format(partner_type, 'x').zfill(2) + self.payload = "{}{}".format(partner_addr, type_str) + + def to_string(self): + result = "{}\nMAXAddLinkPartnerPacket: payload={}".format(super().to_string(), self.payload) + return result + + class MAXAckPacket(MAXPacket): def __init__(self, rec): super().__init__() @@ -118,10 +159,54 @@ class MAXAckPacket(MAXPacket): return result +class MAXThermostatStatePacket(MAXPacket): + def __init__(self, rec): + super().__init__() + self.from_received(rec) + payload_1 = int(rec[23:25], 16) + payload_2 = int(rec[25:27], 16) + payload_3 = int(rec[27:29], 16) + payload_4 = int(rec[29:31], 16) + payload_5 = int(rec[31:33], 16) + + ctrl_mode_bits = payload_1 & 3 + if ctrl_mode_bits == 0: + self.ctrl_mode = MAXRadiatorControlMode.AUTO + elif ctrl_mode_bits == 1: + self.ctrl_mode = MAXRadiatorControlMode.MANUAL + elif ctrl_mode_bits == 2: + self.ctrl_mode = MAXRadiatorControlMode.TEMPORARY + elif ctrl_mode_bits == 3: + self.ctrl_mode = MAXRadiatorControlMode.BOOST + else: + print("Invalid control mode: {}".format(ctrl_mode_bits)) + + self.dst_active = (payload_1 & 0b100) >> 2 + self.lan_gateway = (payload_1 & 0b1000) >> 3 + self.locked = (payload_1 & 0b10000) >> 4 + self.rf_error = (payload_1 & 0b100000) >> 5 + self.bat_low = (payload_1 & 0b1000000) >> 6 + self.valve_pos = payload_2 + self.target_temp = (payload_3 & 0x7f) / 2 + self.current_temp = payload_4 & 1 + self.current_temp = self.current_temp << 8 + self.current_temp = self.current_temp | (payload_5 & 0xff) + self.current_temp = self.current_temp / 10.0 + + + def to_string(self): + result = "{}\nMAXThermostatStatePacket: dst_active={} lan_gateway={} locked={} rf_error={} bat_low={} " \ + "valve_pos={} target_temp={} " \ + "current_temp={}".format(super().to_string(), self.dst_active, self.lan_gateway, + self.locked, self.rf_error, self.bat_low, self.valve_pos, + self.target_temp, self.current_temp) + return result + + class MAXCubeAckPacket(MAXPacket): def __init__(self, message_counter: str, message_flag: str, sender_address: str, dest_address: str, group_id: str, acknowledged): - self.set_values(message_counter, "01", message_flag, sender_address, dest_address, group_id) + self.set_values(message_counter, "02", message_flag, sender_address, dest_address, group_id) if acknowledged: self.payload = "01" else: diff --git a/MAXPacketHandler.py b/MAXPacketHandler.py index 3f3d257..8c79d37 100644 --- a/MAXPacketHandler.py +++ b/MAXPacketHandler.py @@ -1,9 +1,9 @@ +from typing import List from MAXCube import MAXCube from MAXPacket import MAXPacket, MAXPacketFactory, MAXPairPingPacket, MAXPairPongPacket, MAXAckPacket, \ - MAXResetPacket, MAXWakeUpPacket, MAXSetGroupIdPacket, MAXPushButtonPacket, MAXCubeAckPacket -from MAXDevice import MAXDevice, MAXPushButton - -from time import sleep + MAXSetGroupIdPacket, MAXPushButtonPacket, MAXCubeAckPacket, MAXSetTempPacket, \ + MAXRadiatorControlMode, MAXAddLinkPartnerPacket +from MAXDevice import MAXDevice, MAXPushButton, MAXThermostat from enum import Enum @@ -13,6 +13,10 @@ class HandshakeState(Enum): PONG_ACK = 3 GROUP_ID_SENT = 4 GROUP_ID_ACK = 5 + CONFIG_TEMP_SENT = 6 + CONFIG_TEMP_ACK = 7 + ADD_LINK_PARTNER_SENT = 8 + ADD_LINK_PARTNER_ACK = 9 class Handshake: @@ -23,11 +27,11 @@ class Handshake: class MAXPacketHandler: - def __init__(self, cube: MAXCube): + def __init__(self, cube, devices: MAXCube): self.cube = cube self.quit_flag = False self.handshakes = [] - self.devices: list[MAXDevice] = [] + self.devices: List[MAXDevice] = devices def handle_msg(self, pkt: MAXPacket): print(pkt.to_string()) @@ -38,7 +42,7 @@ class MAXPacketHandler: pong = MAXPairPongPacket(message_counter="00", message_flag="00", sender_address=self.cube.addr, dest_address=pkt.sender_address, group_id="00") pong_str = pong.serialize() - #print(pong.to_string()) + print(pong.to_string()) self.cube.request(pong_str) handshake.state = HandshakeState.PONG_SENT elif isinstance(pkt, MAXAckPacket): @@ -50,44 +54,82 @@ class MAXPacketHandler: if cur_handshake is not None: if cur_handshake.state == HandshakeState.PONG_SENT: cur_handshake.state = HandshakeState.PONG_ACK - print(pkt.to_string()) + #print(pkt.to_string()) if cur_handshake.dev_type == "05": # Handshake is finished print("Paired device with addr={} and type={}".format(cur_handshake.partner_addr, cur_handshake.dev_type)) + found = False + for dev in self.devices: + if dev.address == cur_handshake.partner_addr: + found = True + break + if not found: + new_dev = MAXPushButton("PushButton_{}".format(cur_handshake.partner_addr), + cur_handshake.partner_addr) + self.devices.append(new_dev) self.handshakes.remove(cur_handshake) + else: set_group_id = MAXSetGroupIdPacket(message_counter="00", message_flag="00", sender_address=self.cube.addr, dest_address=pkt.sender_address, group_id="00") set_group_id_str = set_group_id.serialize() - print(set_group_id_str) + print(set_group_id.to_string()) self.cube.request(set_group_id_str) cur_handshake.state = HandshakeState.GROUP_ID_SENT + return if cur_handshake.state == HandshakeState.GROUP_ID_SENT: cur_handshake.state = HandshakeState.GROUP_ID_ACK + + set_temp = MAXSetTempPacket(message_counter="00", message_flag="00", sender_address=self.cube.addr, + dest_address=pkt.sender_address, group_id="00", temp=21.5, + mode=MAXRadiatorControlMode.MANUAL) + set_temp_str = set_temp.serialize() + print(set_temp.to_string()) + self.cube.request(set_temp_str) + + cur_handshake.state = HandshakeState.CONFIG_TEMP_SENT + return + if cur_handshake.state == HandshakeState.CONFIG_TEMP_SENT: + cur_handshake.state = HandshakeState.CONFIG_TEMP_ACK + + add_link = MAXAddLinkPartnerPacket(message_counter="00", message_flag="00", sender_address=self.cube.addr, + dest_address=pkt.sender_address, group_id="00", partner_addr=self.cube.addr, partner_type=0) + add_link_str = add_link.serialize() + print(add_link.to_string()) + self.cube.request(add_link_str) + cur_handshake.state = HandshakeState.ADD_LINK_PARTNER_SENT + return + if cur_handshake.state == HandshakeState.ADD_LINK_PARTNER_SENT: + cur_handshake.state = HandshakeState.ADD_LINK_PARTNER_ACK + found = False + for dev in self.devices: + if dev.address == cur_handshake.partner_addr: + found = True + break + if not found: + new_dev = MAXThermostat("Thermostat_{}".format(cur_handshake.partner_addr), + cur_handshake.partner_addr) + self.devices.append(new_dev) + print("Paired device with addr={} and type={}".format(cur_handshake.partner_addr, + cur_handshake.dev_type)) + self.handshakes.remove(cur_handshake) + return elif isinstance(pkt, MAXPushButtonPacket): dev = None for known_dev in self.devices: if known_dev.address == pkt.sender_address: dev = known_dev break - if dev is None: - dev = MAXPushButton(pkt.sender_address) - dev.state = pkt.button_state - print("Button {} state={}".format(dev.address, dev.state)) - if not pkt.retransmit: - act_pkt = MAXCubeAckPacket(pkt.counter, "00", self.cube.addr, - pkt.sender_address, pkt.group_id, True) - act_str = act_pkt.serialize() - #print(act_str) - self.cube.request(act_str) + if dev is not None: + dev.state = pkt.button_state + print("Button {} state={}".format(dev.address, dev.state)) + if not pkt.retransmit: + act_pkt = MAXCubeAckPacket(pkt.counter, "00", self.cube.addr, + pkt.sender_address, pkt.group_id, True) + act_str = act_pkt.serialize() + #print(act_str) + #self.cube.request(act_str) + #ToDo: Does not work properly else: print("Paket is not ours!") - - def receive_loop(self): - while not self.quit_flag and self.cube.is_connected(): - resp = self.cube.response() - if resp is not None and resp[0:1] == "Z": - pkt = MAXPacketFactory.create_packet(resp) - self.handle_msg(pkt) - sleep(0.1) \ No newline at end of file diff --git a/devices.conf b/devices.conf new file mode 100644 index 0000000..953db04 --- /dev/null +++ b/devices.conf @@ -0,0 +1,6 @@ +5;PushButton_085757;085757 +1;ku_tuer;1aaefe +1;wz_links;1babec +1;wz_rechts;1ae849 +1;ku_fenster;1babb6 +1;bz;1babdc \ No newline at end of file diff --git a/main.py b/main.py index d73ca78..7f44f15 100644 --- a/main.py +++ b/main.py @@ -1,12 +1,62 @@ +from typing import List +from MAXDevice import MAXDevice, MAXThermostat, MAXPushButton from MAXCube import CUL from MAXPacketHandler import MAXPacketHandler +from MAXPacket import MAXPacketFactory +from time import sleep +import sys +import signal DEBUG = True +devices: List[MAXDevice] = [] +quit_flag = False +cube: CUL = None +handler: MAXPacketHandler = None + + +def read_devices(file_name): + file = open(file_name, "r") + lines = file.readlines() + file.close() + + for line in lines: + if not line.startswith("#") and line.strip() != "\n": + new_dev = MAXDevice.from_string(line) + if new_dev is not None: + devices.append(new_dev) + + +def write_devices(file_name): + file = open(file_name, "w") + for device in devices: + dev_str = device.serialize().replace("\n", "") + file.write(dev_str) + file.write("\n") + file.close() + + +def receive_loop(): + global quit_flag + while not quit_flag and cube.is_connected(): + try: + resp = cube.response() + if resp is not None and resp[0:1] == "Z": + pkt = MAXPacketFactory.create_packet(resp) + handler.handle_msg(pkt) + sleep(0.1) + except KeyboardInterrupt: + quit_flag = True + return + + if __name__ == '__main__': + read_devices("devices.conf") cube = CUL("123456") - cube.connect("COM5") + cube.connect("COM10") print(cube.version_string()) - handler = MAXPacketHandler(cube) - handler.receive_loop() + handler = MAXPacketHandler(cube, devices) + signal.signal(signal.SIGINT, signal.default_int_handler) + receive_loop() cube.disconnect() + write_devices("devices.conf")