From f02c9ab2c4d558a65a1d9bada6621ee5f76a2154 Mon Sep 17 00:00:00 2001 From: Felipe Martins Diel Date: Wed, 20 Jan 2021 18:49:14 -0300 Subject: [PATCH 01/14] Add support for Tornado 16X SQ air conditioner --- broadlink/__init__.py | 4 +- broadlink/climate.py | 272 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 274 insertions(+), 2 deletions(-) diff --git a/broadlink/__init__.py b/broadlink/__init__.py index 1b2f0abd..e40f71ce 100644 --- a/broadlink/__init__.py +++ b/broadlink/__init__.py @@ -4,7 +4,7 @@ from typing import Generator, List, Union, Tuple from .alarm import S1C -from .climate import hysen +from .climate import hysen, sq1 from .cover import dooya from .device import device, ping, scan from .exceptions import exception @@ -105,12 +105,12 @@ 0x60C8: (lb1, "LB1", "Broadlink"), 0x6112: (lb1, "LB1", "Broadlink"), 0x2722: (S1C, "S2KIT", "Broadlink"), + 0X4E2A: (sq1, "SQ", "Tornado"), 0x4EAD: (hysen, "HY02B05H", "Hysen"), 0x4E4D: (dooya, "DT360E-45/20", "Dooya"), 0x51E3: (bg1, "BG800/BG900", "BG Electrical"), } - def gendevice( dev_type: int, host: Tuple[str, int], diff --git a/broadlink/climate.py b/broadlink/climate.py index b510db9d..892b7fea 100644 --- a/broadlink/climate.py +++ b/broadlink/climate.py @@ -1,5 +1,8 @@ """Support for climate control.""" from typing import List +import logging +from enum import IntEnum, unique +import struct from .device import device from .exceptions import check_error @@ -235,3 +238,272 @@ def set_schedule(self, weekday: List[dict], weekend: List[dict]) -> None: input_payload.append(int(weekend[i]["temp"] * 2)) self.send_request(input_payload) + + +class sq1(device): + """Controls Tornado SMART X SQ series air conditioners.""" + + @unique + class Mode(IntEnum): + AUTO = 0 + COOLING = 0x20 + DRYING = 0x40 + HEATING = 0x80 + FAN = 0xc0 + + @unique + class Speed(IntEnum): + HIGH = 0x20 + MID = 0x40 + LOW = 0x60 + AUTO = 0xa0 + + @unique + class SwingH(IntEnum): + ON = 0b000, + OFF = 0b111 + + @unique + class SwingV(IntEnum): + ON = 0b000, + POS1 = 1 + POS2 = 2 + POS3 = 3 + POS4 = 4 + POS5 = 5 + OFF = 0b111 + + def __init__(self, *args, **kwargs): + device.__init__(self, *args, **kwargs) + self.type = "Tornado SQ air conditioner" + + def _decode(self, response) -> bytes: + # RESPONSE_PREFIX = bytes([0xbb, 0x00, 0x07, 0x00, 0x00, 0x00]) + payload = self.decrypt(bytes(response[0x38:])) + + length = int.from_bytes(payload[:2], 'little') + checksum = self._calculate_checksum( + payload[2:length]).to_bytes(2, 'little') + if checksum == payload[length:length+2]: + logging.debug("Checksum incorrect (calculated %s actual %s).", + checksum.hex(), payload[length:length+2].hex()) + + return payload + + def _calculate_checksum(self, payload: bytes) -> int: + """Calculate checksum of given array, + by adding little endian words and subtracting from 0xffff. + + The first two bytes of most packets in the class are the length of the + payload and should be cropped out when using this function. + + Args: + payload (bytes): the payload + """ + s = sum([v if i % 2 == 0 else v << 8 for i, v in enumerate(payload)]) + # trim the overflow and add it to smallest bit + s = (s & 0xffff) + (s >> 16) + return (0xffff - s) + + def _encode(self, data: bytes) -> bytes: + """Encode data for transport.""" + payload = struct.pack("HHHH", 0x00BB, 0x8006, 0x0000, len(data)) + data + logging.debug("Payload:\n%s", payload.hex(' ')) + checksum = self._calculate_checksum(payload).to_bytes(2, 'little') + return (len(payload) + 2).to_bytes(2, 'little') + payload + checksum + + def _send_command(self, command: int, data: bytes = b'') -> bytes: + """Send a command to the unit. + + Known commands: + - Get AC info: 0x0121 + - Get states: 0x0111 + - Get sleep info: 0x0141 + """ + packet = self._encode(command.to_bytes(2, "little") + data) + logging.debug("Payload:\n%s", packet.hex(' ')) + response = self.send_packet(0x6a, packet) + check_error(response[0x22:0x24]) + return self._decode(response) + + def get_state(self) -> dict: + """Returns a dictionary with the unit's parameters. + + Returns: + dict: + power (bool): + target_temp (float): temperature set point 16> 3) + + (0.0 if (payload[0xe] & 0b10000000) == 0 else 0.5)) # noqa E501 + + data['swing_h'] = self.SwingH((payload[0x0d] & 0b11100000) >> 5) + data['swing_v'] = self.SwingV(payload[0x0c] & 0b111) + + data['mode'] = self.Mode(payload[0x11] & ~ 0b111) + + data['speed'] = self.Speed(payload[0x0f]) + + data['mute'] = bool(payload[0x10] == 0x80) + data['turbo'] = bool(payload[0x10] == 0x40) + + data['sleep'] = bool(payload[0x11] & 0b100) + + data['health'] = bool(payload[0x14] & 0b10) + data['clean'] = bool(payload[0x14] & 0b100) + + data['display'] = bool(payload[0x16] & 0b10000) + data['mildew'] = bool(payload[0x16] & 0b1000) + + logging.debug("Data: %s", data) + + return data + + def get_ac_info(self) -> dict: + """Returns dictionary with AC info. + + Returns: + dict: + state (bool): power + ambient_temp (float): ambient temperature + """ + payload = self._send_command(0x121) + if (len(payload) != 48): + raise ValueError(f"unexpected payload size: {len(payload)}") + + logging.debug("Received payload:\n%s", payload.hex(' ')) + + # Length is 34 (0x22), the next 11 bytes are + # the same: bb 00 07 00 00 00 18 00 01 21 c0, + # bytes 0x23,0x24 are the checksum. + data = {} + data['state'] = payload[0x0d] & 0b1 == 0b1 + + ambient_temp = payload[0x11] & 0b00011111 + if ambient_temp: + data['ambient_temp'] = (ambient_temp + + float(payload[0x21] & 0b00011111) / 10.0) + + logging.debug("Data: %s", data) + return data + + def set_state(self, state: dict) -> bool: + """Set parameters of unit. + + Args: + state (dict): if any are missing the current value will be retrived + power (bool): + target_temp (float): temperature set point 16 0: + raise ValueError(f"unknown argument(s) {unknown_keys}") + + missing_keys = [key for key in keys if key not in state] + if len(missing_keys) > 0: + try: + received_state = self.get_state() + except RuntimeError as e: + if "unexpected payload size: 48" in str(e): + # Occasionally a 48 byte payload gets mixed in, + # a retry should suffice. + received_state = self.get_state() + else: + raise e + logging.debug("Raw state %s", state) + received_state.update(state) + state = received_state + logging.debug("Filled state %s", state) + + state['target_temp'] = round(state['target_temp'] * 2) / 2 + if not (16 <= state['target_temp'] <= 32): + raise ValueError(f"target_temp out of range: {state['target_temp']}") # noqa E501 + + # Creating a new instance verifies the type + swing_R = self.SwingH(state['swing_h']) + swing_L = self.SwingV(state['swing_v']) + + mode = self.Mode(state['mode']) + + if state['mute'] and state['turbo']: + raise ValueError("mute and turbo can't be on at once") + elif state['mute']: + speed_R = 0x80 + if state['mode'] != 'fan': + raise ValueError("mute is only available in fan mode") + state['speed'] = self.Speed.LOW + elif state['turbo']: + speed_R = 0x40 + if state['mode'] not in ('cooling', 'heating'): + raise ValueError("turbo is only available in cooling/heating") + state['speed'] = self.Speed.HIGH + else: + speed_R = 0x00 + + speed_L = self.Speed(state['speed']) + + data = bytes( + [ + (int(state['target_temp']) - 8 << 3) | swing_L, + (swing_R << 5) | CMND_0B_RMASK, + ((state['target_temp'] % 1 == 0.5) << 7) | CMND_0C_RMASK, + speed_L, + speed_R, + mode | (state['sleep'] << 2), + 0x00, + 0x00, + (state['power'] << 5 | state['clean'] << 2 | 0b11 if state['health'] else 0b00), + 0x00, + state['display'] << 4 | state['mildew'] << 3, + 0x00, + CMND_16 + ] + ) + logging.debug("Constructed payload data:\n%s", data.hex(' ')) + + response_payload = self._send_command(0x0101, data) + logging.debug("Response payload:\n%s", response_payload.hex(' ')) + # Response payloads are 16 bytes long, + # Bytes 0d-0e are the checksum of the sent command. From f4707c07c3cef9ac596f426d43afee3246400bba Mon Sep 17 00:00:00 2001 From: Felipe Martins Diel Date: Wed, 20 Jan 2021 21:51:55 -0300 Subject: [PATCH 02/14] Make Tornado a generic HVAC class --- broadlink/__init__.py | 4 ++-- broadlink/climate.py | 26 +++++++++++++++----------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/broadlink/__init__.py b/broadlink/__init__.py index e40f71ce..516ff88a 100644 --- a/broadlink/__init__.py +++ b/broadlink/__init__.py @@ -4,7 +4,7 @@ from typing import Generator, List, Union, Tuple from .alarm import S1C -from .climate import hysen, sq1 +from .climate import hysen, hvac from .cover import dooya from .device import device, ping, scan from .exceptions import exception @@ -105,7 +105,7 @@ 0x60C8: (lb1, "LB1", "Broadlink"), 0x6112: (lb1, "LB1", "Broadlink"), 0x2722: (S1C, "S2KIT", "Broadlink"), - 0X4E2A: (sq1, "SQ", "Tornado"), + 0X4E2A: (hvac, "HVAC", "Licensed manufacturer"), 0x4EAD: (hysen, "HY02B05H", "Hysen"), 0x4E4D: (dooya, "DT360E-45/20", "Dooya"), 0x51E3: (bg1, "BG800/BG900", "BG Electrical"), diff --git a/broadlink/climate.py b/broadlink/climate.py index 892b7fea..be28a8e8 100644 --- a/broadlink/climate.py +++ b/broadlink/climate.py @@ -240,8 +240,12 @@ def set_schedule(self, weekday: List[dict], weekend: List[dict]) -> None: self.send_request(input_payload) -class sq1(device): - """Controls Tornado SMART X SQ series air conditioners.""" +class hvac(device): + """Controls a HVAC. + + Supported models: + - Tornado SMART X SQ series. + """ @unique class Mode(IntEnum): @@ -275,7 +279,7 @@ class SwingV(IntEnum): def __init__(self, *args, **kwargs): device.__init__(self, *args, **kwargs) - self.type = "Tornado SQ air conditioner" + self.type = "HVAC" def _decode(self, response) -> bytes: # RESPONSE_PREFIX = bytes([0xbb, 0x00, 0x07, 0x00, 0x00, 0x00]) @@ -333,12 +337,12 @@ def get_state(self) -> dict: dict: power (bool): target_temp (float): temperature set point 16 bool: state (dict): if any are missing the current value will be retrived power (bool): target_temp (float): temperature set point 16 Date: Wed, 20 Jan 2021 21:57:50 -0300 Subject: [PATCH 03/14] Better names --- broadlink/climate.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/broadlink/climate.py b/broadlink/climate.py index be28a8e8..f543468d 100644 --- a/broadlink/climate.py +++ b/broadlink/climate.py @@ -263,12 +263,12 @@ class Speed(IntEnum): AUTO = 0xa0 @unique - class SwingH(IntEnum): - ON = 0b000, + class SwHoriz(IntEnum): + ON = 0b000 OFF = 0b111 @unique - class SwingV(IntEnum): + class SwVert(IntEnum): ON = 0b000, POS1 = 1 POS2 = 2 @@ -362,8 +362,8 @@ def get_state(self) -> dict: data['target_temp'] = (8 + (payload[0x0c] >> 3) + (0.0 if (payload[0xe] & 0b10000000) == 0 else 0.5)) # noqa E501 - data['swing_h'] = self.SwingH((payload[0x0d] & 0b11100000) >> 5) - data['swing_v'] = self.SwingV(payload[0x0c] & 0b111) + data['swing_h'] = self.SwHoriz((payload[0x0d] & 0b11100000) >> 5) + data['swing_v'] = self.SwVert(payload[0x0c] & 0b111) data['mode'] = self.Mode(payload[0x11] & ~ 0b111) @@ -466,35 +466,35 @@ def set_state(self, state: dict) -> bool: raise ValueError(f"target_temp out of range: {state['target_temp']}") # noqa E501 # Creating a new instance verifies the type - swing_R = self.SwingH(state['swing_h']) - swing_L = self.SwingV(state['swing_v']) + swing_r = self.SwHoriz(state['swing_h']) + swing_l = self.SwVert(state['swing_v']) mode = self.Mode(state['mode']) if state['mute'] and state['turbo']: raise ValueError("mute and turbo can't be on at once") elif state['mute']: - speed_R = 0x80 + speed_r = 0x80 if state['mode'] != 'fan': raise ValueError("mute is only available in fan mode") state['speed'] = self.Speed.LOW elif state['turbo']: - speed_R = 0x40 + speed_r = 0x40 if state['mode'] not in ('cooling', 'heating'): raise ValueError("turbo is only available in cooling/heating") state['speed'] = self.Speed.HIGH else: - speed_R = 0x00 + speed_r = 0x00 - speed_L = self.Speed(state['speed']) + speed_l = self.Speed(state['speed']) data = bytes( [ - (int(state['target_temp']) - 8 << 3) | swing_L, - (swing_R << 5) | CMND_0B_RMASK, + (int(state['target_temp']) - 8 << 3) | swing_l, + (swing_r << 5) | CMND_0B_RMASK, ((state['target_temp'] % 1 == 0.5) << 7) | CMND_0C_RMASK, - speed_L, - speed_R, + speed_l, + speed_r, mode | (state['sleep'] << 2), 0x00, 0x00, From 1d357e28c448557accea2103716fb055051889ff Mon Sep 17 00:00:00 2001 From: Felipe Martins Diel Date: Thu, 21 Jan 2021 00:30:25 -0300 Subject: [PATCH 04/14] Clean up IntEnums --- broadlink/climate.py | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/broadlink/climate.py b/broadlink/climate.py index f543468d..d402c044 100644 --- a/broadlink/climate.py +++ b/broadlink/climate.py @@ -250,32 +250,32 @@ class hvac(device): @unique class Mode(IntEnum): AUTO = 0 - COOLING = 0x20 - DRYING = 0x40 - HEATING = 0x80 - FAN = 0xc0 + COOLING = 1 + DRYING = 2 + HEATING = 3 + FAN = 4 @unique class Speed(IntEnum): - HIGH = 0x20 - MID = 0x40 - LOW = 0x60 - AUTO = 0xa0 + HIGH = 1 + MID = 2 + LOW = 3 + AUTO = 5 @unique class SwHoriz(IntEnum): - ON = 0b000 - OFF = 0b111 + ON = 0 + OFF = 7 @unique class SwVert(IntEnum): - ON = 0b000, + ON = 0 POS1 = 1 POS2 = 2 POS3 = 3 POS4 = 4 POS5 = 5 - OFF = 0b111 + OFF = 7 def __init__(self, *args, **kwargs): device.__init__(self, *args, **kwargs) @@ -362,12 +362,12 @@ def get_state(self) -> dict: data['target_temp'] = (8 + (payload[0x0c] >> 3) + (0.0 if (payload[0xe] & 0b10000000) == 0 else 0.5)) # noqa E501 - data['swing_h'] = self.SwHoriz((payload[0x0d] & 0b11100000) >> 5) data['swing_v'] = self.SwVert(payload[0x0c] & 0b111) + data['swing_h'] = self.SwHoriz(payload[0x0d] >> 5) - data['mode'] = self.Mode(payload[0x11] & ~ 0b111) + data['mode'] = self.Mode(payload[0x11] >> 5) - data['speed'] = self.Speed(payload[0x0f]) + data['speed'] = self.Speed(payload[0x0f] >> 5) data['mute'] = bool(payload[0x10] == 0x80) data['turbo'] = bool(payload[0x10] == 0x40) @@ -486,16 +486,14 @@ def set_state(self, state: dict) -> bool: else: speed_r = 0x00 - speed_l = self.Speed(state['speed']) - data = bytes( [ (int(state['target_temp']) - 8 << 3) | swing_l, (swing_r << 5) | CMND_0B_RMASK, ((state['target_temp'] % 1 == 0.5) << 7) | CMND_0C_RMASK, - speed_l, + self.Speed(state['speed']) << 5, speed_r, - mode | (state['sleep'] << 2), + mode << 5 | (state['sleep'] << 2), 0x00, 0x00, (state['power'] << 5 | state['clean'] << 2 | 0b11 if state['health'] else 0b00), From 179133c3ff0e7f11aa20927a0da25df3653b0e3c Mon Sep 17 00:00:00 2001 From: Felipe Martins Diel Date: Fri, 22 Jan 2021 19:08:05 -0300 Subject: [PATCH 05/14] Clean up encoders --- broadlink/climate.py | 199 ++++++++++++++++++------------------------- 1 file changed, 83 insertions(+), 116 deletions(-) diff --git a/broadlink/climate.py b/broadlink/climate.py index d402c044..609016ee 100644 --- a/broadlink/climate.py +++ b/broadlink/climate.py @@ -249,6 +249,7 @@ class hvac(device): @unique class Mode(IntEnum): + """Enumerates modes.""" AUTO = 0 COOLING = 1 DRYING = 2 @@ -257,6 +258,7 @@ class Mode(IntEnum): @unique class Speed(IntEnum): + """Enumerates fan speed.""" HIGH = 1 MID = 2 LOW = 3 @@ -264,11 +266,13 @@ class Speed(IntEnum): @unique class SwHoriz(IntEnum): + """Enumerates horizontal swing.""" ON = 0 OFF = 7 @unique class SwVert(IntEnum): + """Enumerates vertical swing.""" ON = 0 POS1 = 1 POS2 = 2 @@ -281,50 +285,40 @@ def __init__(self, *args, **kwargs): device.__init__(self, *args, **kwargs) self.type = "HVAC" - def _decode(self, response) -> bytes: - # RESPONSE_PREFIX = bytes([0xbb, 0x00, 0x07, 0x00, 0x00, 0x00]) - payload = self.decrypt(bytes(response[0x38:])) - - length = int.from_bytes(payload[:2], 'little') - checksum = self._calculate_checksum( - payload[2:length]).to_bytes(2, 'little') - if checksum == payload[length:length+2]: - logging.debug("Checksum incorrect (calculated %s actual %s).", - checksum.hex(), payload[length:length+2].hex()) - - return payload - - def _calculate_checksum(self, payload: bytes) -> int: - """Calculate checksum of given array, - by adding little endian words and subtracting from 0xffff. - - The first two bytes of most packets in the class are the length of the - payload and should be cropped out when using this function. - - Args: - payload (bytes): the payload - """ - s = sum([v if i % 2 == 0 else v << 8 for i, v in enumerate(payload)]) + def _crc(self, data: bytes) -> int: + """Calculate CRC of a byte object.""" + s = sum([v if i % 2 == 0 else v << 8 for i, v in enumerate(data)]) # trim the overflow and add it to smallest bit s = (s & 0xffff) + (s >> 16) - return (0xffff - s) + return (0xffff - s) # TODO: fix: we can't return negative values def _encode(self, data: bytes) -> bytes: """Encode data for transport.""" - payload = struct.pack("HHHH", 0x00BB, 0x8006, 0x0000, len(data)) + data - logging.debug("Payload:\n%s", payload.hex(' ')) - checksum = self._calculate_checksum(payload).to_bytes(2, 'little') - return (len(payload) + 2).to_bytes(2, 'little') + payload + checksum - - def _send_command(self, command: int, data: bytes = b'') -> bytes: - """Send a command to the unit. - - Known commands: - - Get AC info: 0x0121 - - Get states: 0x0111 - - Get sleep info: 0x0141 - """ - packet = self._encode(command.to_bytes(2, "little") + data) + packet = bytearray(10) + p_len = 8 + len(data) + struct.pack_into(" bytes: + """Decode data from transport.""" + # payload[2:10] == bytes([0xbb, 0x00, 0x07, 0x00, 0x00, 0x00]) + payload = self.decrypt(response[0x38:]) + p_len = int.from_bytes(payload[:2], "little") + checksum = int.from_bytes(payload[p_len:p_len+2], "little") + + if checksum != self._crc(payload[2:p_len]): + logging.debug( + "Checksum incorrect (calculated %s actual %s).", + checksum.hex(), payload[p_len:p_len+2].hex() + ) + return payload # TODO: return payload[10:p_len] + + def _send(self, command: int, data: bytes = b'') -> bytes: + """Send a command to the unit.""" + command = bytes([((command << 4) | 1), 1]) + packet = self._encode(command + data) logging.debug("Payload:\n%s", packet.hex(' ')) response = self.send_packet(0x6a, packet) check_error(response[0x22:0x24]) @@ -349,40 +343,33 @@ def get_state(self) -> dict: clean (bool): mildew (bool): """ - payload = self._send_command(0x111) - if (len(payload) != 32): - raise RuntimeError(f"unexpected payload size: {len(payload)}") - - logging.debug("Received payload:\n%s", payload.hex(' ')) - logging.debug("0b[R] mask: %x, 0c[R] mask: %x, cmnd_16: %x", - payload[0x0d] & 0xf, payload[0x0e] & 0xf, payload[0x18]) - - data = {} - data['power'] = payload[0x14] & 0x20 == 0x20 - data['target_temp'] = (8 + (payload[0x0c] >> 3) - + (0.0 if (payload[0xe] & 0b10000000) == 0 else 0.5)) # noqa E501 - - data['swing_v'] = self.SwVert(payload[0x0c] & 0b111) - data['swing_h'] = self.SwHoriz(payload[0x0d] >> 5) - - data['mode'] = self.Mode(payload[0x11] >> 5) - - data['speed'] = self.Speed(payload[0x0f] >> 5) - - data['mute'] = bool(payload[0x10] == 0x80) - data['turbo'] = bool(payload[0x10] == 0x40) + resp = self._send(0x1) - data['sleep'] = bool(payload[0x11] & 0b100) + if (len(resp) != 32): + raise ValueError(f"unexpected resp size: {len(resp)}") - data['health'] = bool(payload[0x14] & 0b10) - data['clean'] = bool(payload[0x14] & 0b100) - - data['display'] = bool(payload[0x16] & 0b10000) - data['mildew'] = bool(payload[0x16] & 0b1000) - - logging.debug("Data: %s", data) - - return data + logging.debug("Received resp:\n%s", resp.hex(' ')) + logging.debug("0b[R] mask: %x, 0c[R] mask: %x, cmnd_16: %x", + resp[0xD] & 0xF, resp[0xE] & 0xF, resp[0xE]) + + state = {} + state['power'] = resp[0x14] & 0x20 == 0x20 + state['target_temp'] = 8 + (resp[0x0C] >> 3) + (resp[0xE] >> 7) * 0.5 + state['swing_v'] = self.SwVert(resp[0x0C] & 0b111) + state['swing_h'] = self.SwHoriz(resp[0x0D] >> 5) + state['mode'] = self.Mode(resp[0x11] >> 5) + state['speed'] = self.Speed(resp[0x0F] >> 5) + state['mute'] = bool(resp[0x10] == 0x80) + state['turbo'] = bool(resp[0x10] == 0x40) + state['sleep'] = bool(resp[0x11] & 1 << 2) + state['health'] = bool(resp[0x14] & 1 << 1) + state['clean'] = bool(resp[0x14] & 1 << 2) + state['display'] = bool(resp[0x16] & 1 << 4) + state['mildew'] = bool(resp[0x16] & 1 << 3) + + logging.debug("State: %s", state) + + return state def get_ac_info(self) -> dict: """Returns dictionary with AC info. @@ -392,27 +379,26 @@ def get_ac_info(self) -> dict: state (bool): power ambient_temp (float): ambient temperature """ - payload = self._send_command(0x121) - if (len(payload) != 48): - raise ValueError(f"unexpected payload size: {len(payload)}") + resp = self._send(2) + if (len(resp) != 48): + raise ValueError(f"unexpected resp size: {len(resp)}") - logging.debug("Received payload:\n%s", payload.hex(' ')) + logging.debug("Received resp:\n%s", resp.hex(' ')) # Length is 34 (0x22), the next 11 bytes are # the same: bb 00 07 00 00 00 18 00 01 21 c0, # bytes 0x23,0x24 are the checksum. - data = {} - data['state'] = payload[0x0d] & 0b1 == 0b1 + ac_info = {} + ac_info["state"] = resp[0x0D] & 1 - ambient_temp = payload[0x11] & 0b00011111 - if ambient_temp: - data['ambient_temp'] = (ambient_temp - + float(payload[0x21] & 0b00011111) / 10.0) + ambient_temp = resp[0x11] & 0b11111, resp[0x21] & 0b11111 + if any(ambient_temp): + ac_info["ambient_temp"] = ambient_temp[0] + ambient_temp[1] / 10.0 - logging.debug("Data: %s", data) - return data + logging.debug("AC info: %s", ac_info) + return ac_info - def set_state(self, state: dict) -> bool: + def set_state(self, state: dict) -> None: """Set parameters of unit. Args: @@ -430,9 +416,6 @@ def set_state(self, state: dict) -> bool: health (bool): clean (bool): mildew (bool): - - Returns: - True for success, verified by the unit's response. """ CMND_0B_RMASK = 0b100 CMND_0C_RMASK = 0b1101 @@ -447,15 +430,7 @@ def set_state(self, state: dict) -> bool: missing_keys = [key for key in keys if key not in state] if len(missing_keys) > 0: - try: - received_state = self.get_state() - except RuntimeError as e: - if "unexpected payload size: 48" in str(e): - # Occasionally a 48 byte payload gets mixed in, - # a retry should suffice. - received_state = self.get_state() - else: - raise e + received_state = self.get_state() logging.debug("Raw state %s", state) received_state.update(state) state = received_state @@ -486,26 +461,18 @@ def set_state(self, state: dict) -> bool: else: speed_r = 0x00 - data = bytes( - [ - (int(state['target_temp']) - 8 << 3) | swing_l, - (swing_r << 5) | CMND_0B_RMASK, - ((state['target_temp'] % 1 == 0.5) << 7) | CMND_0C_RMASK, - self.Speed(state['speed']) << 5, - speed_r, - mode << 5 | (state['sleep'] << 2), - 0x00, - 0x00, - (state['power'] << 5 | state['clean'] << 2 | 0b11 if state['health'] else 0b00), - 0x00, - state['display'] << 4 | state['mildew'] << 3, - 0x00, - CMND_16 - ] - ) + data = bytearray(0xD) + data[0x0] = (int(state['target_temp']) - 8 << 3) | swing_l + data[0x1] = (swing_r << 5) | CMND_0B_RMASK + data[0x2] = ((state['target_temp'] % 1 == 0.5) << 7) | CMND_0C_RMASK + data[0x3] = self.Speed(state['speed']) << 5 + data[0x4] = speed_r + data[0x5] = mode << 5 | (state['sleep'] << 2) + data[0x8] = (state['power'] << 5 | state['clean'] << 2 | state['health'] * 0b11) + data[0xA] = state['display'] << 4 | state['mildew'] << 3 + data[0xC] = CMND_16 + logging.debug("Constructed payload data:\n%s", data.hex(' ')) - response_payload = self._send_command(0x0101, data) + response_payload = self._send(0, data) logging.debug("Response payload:\n%s", response_payload.hex(' ')) - # Response payloads are 16 bytes long, - # Bytes 0d-0e are the checksum of the sent command. From 9a366e56e3a9a2327504ffe83eb7bc5ba9aad92e Mon Sep 17 00:00:00 2001 From: Felipe Martins Diel Date: Fri, 22 Jan 2021 22:12:17 -0300 Subject: [PATCH 06/14] Fix indexes --- broadlink/climate.py | 51 ++++++++++++++++++++++---------------------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/broadlink/climate.py b/broadlink/climate.py index 609016ee..35f1d5f9 100644 --- a/broadlink/climate.py +++ b/broadlink/climate.py @@ -303,17 +303,19 @@ def _encode(self, data: bytes) -> bytes: def _decode(self, response: bytes) -> bytes: """Decode data from transport.""" - # payload[2:10] == bytes([0xbb, 0x00, 0x07, 0x00, 0x00, 0x00]) + # payload[0x2:0x8] == bytes([0xbb, 0x00, 0x07, 0x00, 0x00, 0x00]) payload = self.decrypt(response[0x38:]) - p_len = int.from_bytes(payload[:2], "little") + p_len = int.from_bytes(payload[:0x2], "little") checksum = int.from_bytes(payload[p_len:p_len+2], "little") - if checksum != self._crc(payload[2:p_len]): + if checksum != self._crc(payload[0x2:p_len]): logging.debug( "Checksum incorrect (calculated %s actual %s).", checksum.hex(), payload[p_len:p_len+2].hex() ) - return payload # TODO: return payload[10:p_len] + + d_len = int.from_bytes(payload[0x8:0xA], "little") + return payload[0xA:0xA+d_len] def _send(self, command: int, data: bytes = b'') -> bytes: """Send a command to the unit.""" @@ -322,7 +324,7 @@ def _send(self, command: int, data: bytes = b'') -> bytes: logging.debug("Payload:\n%s", packet.hex(' ')) response = self.send_packet(0x6a, packet) check_error(response[0x22:0x24]) - return self._decode(response) + return self._decode(response)[0x2:] def get_state(self) -> dict: """Returns a dictionary with the unit's parameters. @@ -345,27 +347,27 @@ def get_state(self) -> dict: """ resp = self._send(0x1) - if (len(resp) != 32): + if (len(resp) != 0xF): raise ValueError(f"unexpected resp size: {len(resp)}") logging.debug("Received resp:\n%s", resp.hex(' ')) logging.debug("0b[R] mask: %x, 0c[R] mask: %x, cmnd_16: %x", - resp[0xD] & 0xF, resp[0xE] & 0xF, resp[0xE]) + resp[0x3] & 0xF, resp[0x4] & 0xF, resp[0x4]) state = {} - state['power'] = resp[0x14] & 0x20 == 0x20 - state['target_temp'] = 8 + (resp[0x0C] >> 3) + (resp[0xE] >> 7) * 0.5 - state['swing_v'] = self.SwVert(resp[0x0C] & 0b111) - state['swing_h'] = self.SwHoriz(resp[0x0D] >> 5) - state['mode'] = self.Mode(resp[0x11] >> 5) - state['speed'] = self.Speed(resp[0x0F] >> 5) - state['mute'] = bool(resp[0x10] == 0x80) - state['turbo'] = bool(resp[0x10] == 0x40) - state['sleep'] = bool(resp[0x11] & 1 << 2) - state['health'] = bool(resp[0x14] & 1 << 1) - state['clean'] = bool(resp[0x14] & 1 << 2) - state['display'] = bool(resp[0x16] & 1 << 4) - state['mildew'] = bool(resp[0x16] & 1 << 3) + state['power'] = resp[0x8] & 0x20 + state['target_temp'] = 8 + (resp[0x0] >> 3) + (resp[0x4] >> 7) * 0.5 + state['swing_v'] = self.SwVert(resp[0x0] & 0b111) + state['swing_h'] = self.SwHoriz(resp[0x1] >> 5) + state['mode'] = self.Mode(resp[0x5] >> 5) + state['speed'] = self.Speed(resp[0x3] >> 5) + state['mute'] = bool(resp[0x4] == 0x80) + state['turbo'] = bool(resp[0x4] == 0x40) + state['sleep'] = bool(resp[0x5] & 1 << 2) + state['health'] = bool(resp[0x8] & 1 << 1) + state['clean'] = bool(resp[0x8] & 1 << 2) + state['display'] = bool(resp[0xA] & 1 << 4) + state['mildew'] = bool(resp[0xA] & 1 << 3) logging.debug("State: %s", state) @@ -380,18 +382,15 @@ def get_ac_info(self) -> dict: ambient_temp (float): ambient temperature """ resp = self._send(2) - if (len(resp) != 48): + if (len(resp) != 0x18): raise ValueError(f"unexpected resp size: {len(resp)}") logging.debug("Received resp:\n%s", resp.hex(' ')) - # Length is 34 (0x22), the next 11 bytes are - # the same: bb 00 07 00 00 00 18 00 01 21 c0, - # bytes 0x23,0x24 are the checksum. ac_info = {} - ac_info["state"] = resp[0x0D] & 1 + ac_info["state"] = resp[0x1] & 1 - ambient_temp = resp[0x11] & 0b11111, resp[0x21] & 0b11111 + ambient_temp = resp[0x5] & 0b11111, resp[0x15] & 0b11111 if any(ambient_temp): ac_info["ambient_temp"] = ambient_temp[0] + ambient_temp[1] / 10.0 From 34c95a6daf2d0b936884c351ab7dfe8202f53771 Mon Sep 17 00:00:00 2001 From: Felipe Martins Diel Date: Fri, 22 Jan 2021 23:46:12 -0300 Subject: [PATCH 07/14] Improve set_state() interface --- broadlink/climate.py | 112 ++++++++++++++++++------------------------- 1 file changed, 46 insertions(+), 66 deletions(-) diff --git a/broadlink/climate.py b/broadlink/climate.py index 35f1d5f9..a97765b9 100644 --- a/broadlink/climate.py +++ b/broadlink/climate.py @@ -251,9 +251,9 @@ class hvac(device): class Mode(IntEnum): """Enumerates modes.""" AUTO = 0 - COOLING = 1 - DRYING = 2 - HEATING = 3 + COOL = 1 + DRY = 2 + HEAT = 3 FAN = 4 @unique @@ -355,7 +355,7 @@ def get_state(self) -> dict: resp[0x3] & 0xF, resp[0x4] & 0xF, resp[0x4]) state = {} - state['power'] = resp[0x8] & 0x20 + state['power'] = resp[0x8] & 1 << 5 state['target_temp'] = 8 + (resp[0x0] >> 3) + (resp[0x4] >> 7) * 0.5 state['swing_v'] = self.SwVert(resp[0x0] & 0b111) state['swing_h'] = self.SwHoriz(resp[0x1] >> 5) @@ -397,79 +397,59 @@ def get_ac_info(self) -> dict: logging.debug("AC info: %s", ac_info) return ac_info - def set_state(self, state: dict) -> None: - """Set parameters of unit. + def set_state( + self, + power: bool, + target_temp: float, # 16<=target_temp<=32 + mode: int, # hvac.Mode + speed: int, # hvac.Speed + mute: bool, + turbo: bool, + swing_h: int, # hvac.SwHoriz + swing_v: int, # hvac.SwVert + sleep: bool, + display: bool, + health: bool, + clean: bool, + mildew: bool, + ) -> None: + """Set the state of the device.""" + # TODO: What does these values represent? + UNK0 = 0b100 + UNK1 = 0b1101 + UNK2 = 0b101 - Args: - state (dict): if any are missing the current value will be retrived - power (bool): - target_temp (float): temperature set point 16 0: - raise ValueError(f"unknown argument(s) {unknown_keys}") - - missing_keys = [key for key in keys if key not in state] - if len(missing_keys) > 0: - received_state = self.get_state() - logging.debug("Raw state %s", state) - received_state.update(state) - state = received_state - logging.debug("Filled state %s", state) - - state['target_temp'] = round(state['target_temp'] * 2) / 2 - if not (16 <= state['target_temp'] <= 32): - raise ValueError(f"target_temp out of range: {state['target_temp']}") # noqa E501 - - # Creating a new instance verifies the type - swing_r = self.SwHoriz(state['swing_h']) - swing_l = self.SwVert(state['swing_v']) - - mode = self.Mode(state['mode']) - - if state['mute'] and state['turbo']: + target_temp = round(target_temp * 2) / 2 + if not (16 <= target_temp <= 32): + raise ValueError(f"target_temp out of range: {target_temp}") + + mode = self.Mode(mode) + + if mute and turbo: raise ValueError("mute and turbo can't be on at once") - elif state['mute']: + elif mute: speed_r = 0x80 - if state['mode'] != 'fan': + if mode.name != "FAN": raise ValueError("mute is only available in fan mode") - state['speed'] = self.Speed.LOW - elif state['turbo']: + speed = self.Speed.LOW + elif turbo: speed_r = 0x40 - if state['mode'] not in ('cooling', 'heating'): + if mode.name not in {"COOL", "HEAT"}: raise ValueError("turbo is only available in cooling/heating") - state['speed'] = self.Speed.HIGH + speed = self.Speed.HIGH else: speed_r = 0x00 data = bytearray(0xD) - data[0x0] = (int(state['target_temp']) - 8 << 3) | swing_l - data[0x1] = (swing_r << 5) | CMND_0B_RMASK - data[0x2] = ((state['target_temp'] % 1 == 0.5) << 7) | CMND_0C_RMASK - data[0x3] = self.Speed(state['speed']) << 5 + data[0x0] = (int(target_temp) - 8 << 3) | swing_v + data[0x1] = (swing_h << 5) | UNK0 + data[0x2] = ((target_temp % 1 == 0.5) << 7) | UNK1 + data[0x3] = speed << 5 data[0x4] = speed_r - data[0x5] = mode << 5 | (state['sleep'] << 2) - data[0x8] = (state['power'] << 5 | state['clean'] << 2 | state['health'] * 0b11) - data[0xA] = state['display'] << 4 | state['mildew'] << 3 - data[0xC] = CMND_16 + data[0x5] = mode << 5 | (sleep << 2) + data[0x8] = (power << 5 | clean << 2 | health * 0b11) + data[0xA] = display << 4 | mildew << 3 + data[0xC] = UNK2 logging.debug("Constructed payload data:\n%s", data.hex(' ')) From 0e44f7124de4ba01169bee434e97b7e5e7e463f0 Mon Sep 17 00:00:00 2001 From: Felipe Martins Diel Date: Sat, 23 Jan 2021 01:46:36 -0300 Subject: [PATCH 08/14] Enumerate presets --- broadlink/climate.py | 43 ++++++++++++++++++++----------------------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/broadlink/climate.py b/broadlink/climate.py index a97765b9..d4944302 100644 --- a/broadlink/climate.py +++ b/broadlink/climate.py @@ -264,6 +264,13 @@ class Speed(IntEnum): LOW = 3 AUTO = 5 + @unique + class Preset(IntEnum): + """Enumerates presets.""" + NORMAL = 0 + TURBO = 1 + MUTE = 2 + @unique class SwHoriz(IntEnum): """Enumerates horizontal swing.""" @@ -333,12 +340,11 @@ def get_state(self) -> dict: dict: power (bool): target_temp (float): temperature set point 16 dict: state['swing_h'] = self.SwHoriz(resp[0x1] >> 5) state['mode'] = self.Mode(resp[0x5] >> 5) state['speed'] = self.Speed(resp[0x3] >> 5) - state['mute'] = bool(resp[0x4] == 0x80) - state['turbo'] = bool(resp[0x4] == 0x40) + state['preset'] = self.Preset(resp[0x4] >> 6) state['sleep'] = bool(resp[0x5] & 1 << 2) state['health'] = bool(resp[0x8] & 1 << 1) state['clean'] = bool(resp[0x8] & 1 << 2) @@ -403,8 +408,7 @@ def set_state( target_temp: float, # 16<=target_temp<=32 mode: int, # hvac.Mode speed: int, # hvac.Speed - mute: bool, - turbo: bool, + preset: int, # hvac.Preset swing_h: int, # hvac.SwHoriz swing_v: int, # hvac.SwVert sleep: bool, @@ -423,29 +427,22 @@ def set_state( if not (16 <= target_temp <= 32): raise ValueError(f"target_temp out of range: {target_temp}") - mode = self.Mode(mode) - - if mute and turbo: - raise ValueError("mute and turbo can't be on at once") - elif mute: - speed_r = 0x80 - if mode.name != "FAN": + if preset == self.Preset.MUTE: + if mode != self.Mode.FAN: raise ValueError("mute is only available in fan mode") speed = self.Speed.LOW - elif turbo: - speed_r = 0x40 - if mode.name not in {"COOL", "HEAT"}: + + elif preset == self.Preset.TURBO: + if mode not in {self.Mode.COOL, self.Mode.HEAT}: raise ValueError("turbo is only available in cooling/heating") speed = self.Speed.HIGH - else: - speed_r = 0x00 data = bytearray(0xD) data[0x0] = (int(target_temp) - 8 << 3) | swing_v data[0x1] = (swing_h << 5) | UNK0 data[0x2] = ((target_temp % 1 == 0.5) << 7) | UNK1 data[0x3] = speed << 5 - data[0x4] = speed_r + data[0x4] = preset << 6 data[0x5] = mode << 5 | (sleep << 2) data[0x8] = (power << 5 | clean << 2 | health * 0b11) data[0xA] = display << 4 | mildew << 3 From 1ef9eec7968fb958d9e6ec152fa893f06d548667 Mon Sep 17 00:00:00 2001 From: Felipe Martins Diel Date: Sat, 23 Jan 2021 01:47:30 -0300 Subject: [PATCH 09/14] Rename state to power in get_ac_info() --- broadlink/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/broadlink/climate.py b/broadlink/climate.py index d4944302..4ee91bc4 100644 --- a/broadlink/climate.py +++ b/broadlink/climate.py @@ -383,7 +383,7 @@ def get_ac_info(self) -> dict: Returns: dict: - state (bool): power + power (bool): power ambient_temp (float): ambient temperature """ resp = self._send(2) @@ -393,7 +393,7 @@ def get_ac_info(self) -> dict: logging.debug("Received resp:\n%s", resp.hex(' ')) ac_info = {} - ac_info["state"] = resp[0x1] & 1 + ac_info["power"] = resp[0x1] & 1 ambient_temp = resp[0x5] & 0b11111, resp[0x15] & 0b11111 if any(ambient_temp): From bcde9f644f1488bde2923a5d249f4ee94a2d3cc3 Mon Sep 17 00:00:00 2001 From: Felipe Martins Diel Date: Wed, 17 Apr 2024 00:31:23 -0300 Subject: [PATCH 10/14] Paint it black --- broadlink/__init__.py | 2 +- broadlink/climate.py | 104 ++++++++++++++++++++++++------------------ 2 files changed, 60 insertions(+), 46 deletions(-) diff --git a/broadlink/__init__.py b/broadlink/__init__.py index a4bc0a92..8af3e4c6 100644 --- a/broadlink/__init__.py +++ b/broadlink/__init__.py @@ -178,7 +178,7 @@ 0xA64D: ("S3", "Broadlink"), }, hvac: { - 0X4E2A: ("HVAC", "Licensed manufacturer"), + 0x4E2A: ("HVAC", "Licensed manufacturer"), }, hysen: { 0x4EAD: ("HY02/HY03", "Hysen"), diff --git a/broadlink/climate.py b/broadlink/climate.py index 09e63555..bc3ee934 100755 --- a/broadlink/climate.py +++ b/broadlink/climate.py @@ -39,10 +39,12 @@ def send_request(self, request: Sequence[int]) -> bytes: "hysen_response_error", "first byte of response is not length" ) - nom_crc = int.from_bytes(payload[p_len : p_len + 2], "little") + nom_crc = int.from_bytes(payload[p_len:p_len+2], "little") real_crc = CRC16.calculate(payload[0x02:p_len]) if nom_crc != real_crc: - raise ValueError("hysen_response_error", "CRC check on response failed") + raise ValueError( + "hysen_response_error", "CRC check on response failed" + ) return payload[0x02:p_len] @@ -77,7 +79,7 @@ def get_full_status(self) -> dict: data["heating_cooling"] = (payload[4] >> 7) & 1 data["room_temp"] = self._decode_temp(payload, 5) data["thermostat_temp"] = payload[6] / 2.0 - data["auto_mode"] = payload[7] & 0xF + data["auto_mode"] = payload[7] & 0x0F data["loop_mode"] = payload[7] >> 4 data["sensor"] = payload[8] data["osv"] = payload[9] @@ -128,7 +130,9 @@ def get_full_status(self) -> dict: # E.g. loop_mode = 0 ("12345,67") means Saturday and Sunday (weekend schedule) # loop_mode = 2 ("1234567") means every day, including Saturday and Sunday (weekday schedule) # The sensor command is currently experimental - def set_mode(self, auto_mode: int, loop_mode: int, sensor: int = 0) -> None: + def set_mode( + self, auto_mode: int, loop_mode: int, sensor: int = 0 + ) -> None: """Set the mode of the device.""" mode_byte = ((loop_mode + 1) << 4) + auto_mode self.send_request([0x01, 0x06, 0x00, 0x02, mode_byte, sensor]) @@ -255,6 +259,7 @@ class hvac(Device): @enum.unique class Mode(enum.IntEnum): """Enumerates modes.""" + AUTO = 0 COOL = 1 DRY = 2 @@ -264,6 +269,7 @@ class Mode(enum.IntEnum): @enum.unique class Speed(enum.IntEnum): """Enumerates fan speed.""" + HIGH = 1 MID = 2 LOW = 3 @@ -272,6 +278,7 @@ class Speed(enum.IntEnum): @enum.unique class Preset(enum.IntEnum): """Enumerates presets.""" + NORMAL = 0 TURBO = 1 MUTE = 2 @@ -279,12 +286,14 @@ class Preset(enum.IntEnum): @enum.unique class SwHoriz(enum.IntEnum): """Enumerates horizontal swing.""" + ON = 0 OFF = 7 @enum.unique class SwVert(enum.IntEnum): """Enumerates vertical swing.""" + ON = 0 POS1 = 1 POS2 = 2 @@ -315,26 +324,27 @@ def _decode(self, response: bytes) -> bytes: """Decode data from transport.""" # payload[0x2:0x8] == bytes([0xbb, 0x00, 0x07, 0x00, 0x00, 0x00]) payload = self.decrypt(response[0x38:]) - p_len = int.from_bytes(payload[:0x2], "little") + p_len = int.from_bytes(payload[:0x02], "little") checksum = int.from_bytes(payload[p_len:p_len+2], "little") - if checksum != self._crc(payload[0x2:p_len]): + if checksum != self._crc(payload[0x02:p_len]): logging.debug( "Checksum incorrect (calculated %s actual %s).", - checksum.hex(), payload[p_len:p_len+2].hex() + checksum.hex(), + payload[p_len:p_len+2].hex(), ) - d_len = int.from_bytes(payload[0x8:0xA], "little") - return payload[0xA:0xA+d_len] + d_len = int.from_bytes(payload[0x08:0x0A], "little") + return payload[0x0A:0x0A+d_len] - def _send(self, command: int, data: bytes = b'') -> bytes: + def _send(self, command: int, data: bytes = b"") -> bytes: """Send a command to the unit.""" command = bytes([((command << 4) | 1), 1]) packet = self._encode(command + data) - logging.debug("Payload:\n%s", packet.hex(' ')) - response = self.send_packet(0x6a, packet) + logging.debug("Payload:\n%s", packet.hex(" ")) + response = self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) - return self._decode(response)[0x2:] + return self._decode(response)[0x02:] def get_state(self) -> dict: """Returns a dictionary with the unit's parameters. @@ -356,26 +366,30 @@ def get_state(self) -> dict: """ resp = self._send(0x1) - if (len(resp) != 0xF): + if len(resp) != 0x0F: raise ValueError(f"unexpected resp size: {len(resp)}") - logging.debug("Received resp:\n%s", resp.hex(' ')) - logging.debug("0b[R] mask: %x, 0c[R] mask: %x, cmnd_16: %x", - resp[0x3] & 0xF, resp[0x4] & 0xF, resp[0x4]) + logging.debug("Received resp:\n%s", resp.hex(" ")) + logging.debug( + "0b[R] mask: %x, 0c[R] mask: %x, cmnd_16: %x", + resp[0x03] & 0x0F, + resp[0x04] & 0x0F, + resp[0x04], + ) state = {} - state['power'] = resp[0x8] & 1 << 5 - state['target_temp'] = 8 + (resp[0x0] >> 3) + (resp[0x4] >> 7) * 0.5 - state['swing_v'] = self.SwVert(resp[0x0] & 0b111) - state['swing_h'] = self.SwHoriz(resp[0x1] >> 5) - state['mode'] = self.Mode(resp[0x5] >> 5) - state['speed'] = self.Speed(resp[0x3] >> 5) - state['preset'] = self.Preset(resp[0x4] >> 6) - state['sleep'] = bool(resp[0x5] & 1 << 2) - state['health'] = bool(resp[0x8] & 1 << 1) - state['clean'] = bool(resp[0x8] & 1 << 2) - state['display'] = bool(resp[0xA] & 1 << 4) - state['mildew'] = bool(resp[0xA] & 1 << 3) + state["power"] = resp[0x08] & 1 << 5 + state["target_temp"] = 8 + (resp[0x00] >> 3) + (resp[0x04] >> 7) * 0.5 + state["swing_v"] = self.SwVert(resp[0x00] & 0b111) + state["swing_h"] = self.SwHoriz(resp[0x01] >> 5) + state["mode"] = self.Mode(resp[0x05] >> 5) + state["speed"] = self.Speed(resp[0x03] >> 5) + state["preset"] = self.Preset(resp[0x04] >> 6) + state["sleep"] = bool(resp[0x05] & 1 << 2) + state["health"] = bool(resp[0x08] & 1 << 1) + state["clean"] = bool(resp[0x08] & 1 << 2) + state["display"] = bool(resp[0x0A] & 1 << 4) + state["mildew"] = bool(resp[0x0A] & 1 << 3) logging.debug("State: %s", state) @@ -390,15 +404,15 @@ def get_ac_info(self) -> dict: ambient_temp (float): ambient temperature """ resp = self._send(2) - if (len(resp) != 0x18): + if len(resp) != 0x18: raise ValueError(f"unexpected resp size: {len(resp)}") - logging.debug("Received resp:\n%s", resp.hex(' ')) + logging.debug("Received resp:\n%s", resp.hex(" ")) ac_info = {} ac_info["power"] = resp[0x1] & 1 - ambient_temp = resp[0x5] & 0b11111, resp[0x15] & 0b11111 + ambient_temp = resp[0x05] & 0b11111, resp[0x15] & 0b11111 if any(ambient_temp): ac_info["ambient_temp"] = ambient_temp[0] + ambient_temp[1] / 10.0 @@ -427,7 +441,7 @@ def set_state( UNK2 = 0b101 target_temp = round(target_temp * 2) / 2 - if not (16 <= target_temp <= 32): + if not 16 <= target_temp <= 32: raise ValueError(f"target_temp out of range: {target_temp}") if preset == self.Preset.MUTE: @@ -440,18 +454,18 @@ def set_state( raise ValueError("turbo is only available in cooling/heating") speed = self.Speed.HIGH - data = bytearray(0xD) - data[0x0] = (int(target_temp) - 8 << 3) | swing_v - data[0x1] = (swing_h << 5) | UNK0 - data[0x2] = ((target_temp % 1 == 0.5) << 7) | UNK1 - data[0x3] = speed << 5 - data[0x4] = preset << 6 - data[0x5] = mode << 5 | (sleep << 2) - data[0x8] = (power << 5 | clean << 2 | health * 0b11) - data[0xA] = display << 4 | mildew << 3 - data[0xC] = UNK2 + data = bytearray(0x0D) + data[0x00] = (int(target_temp) - 8 << 3) | swing_v + data[0x01] = (swing_h << 5) | UNK0 + data[0x02] = ((target_temp % 1 == 0.5) << 7) | UNK1 + data[0x03] = speed << 5 + data[0x04] = preset << 6 + data[0x05] = mode << 5 | (sleep << 2) + data[0x08] = power << 5 | clean << 2 | health and 0b11 + data[0x0A] = display << 4 | mildew << 3 + data[0x0C] = UNK2 - logging.debug("Constructed payload data:\n%s", data.hex(' ')) + logging.debug("Constructed payload data:\n%s", data.hex(" ")) response_payload = self._send(0, data) - logging.debug("Response payload:\n%s", response_payload.hex(' ')) + logging.debug("Response payload:\n%s", response_payload.hex(" ")) From d97f887888bbd8b327163125fdd9e00c6926bcfb Mon Sep 17 00:00:00 2001 From: Felipe Martins Diel Date: Wed, 17 Apr 2024 01:10:48 -0300 Subject: [PATCH 11/14] Use CRC16 helper class --- broadlink/climate.py | 53 ++++++++++++++++++++++++-------------------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/broadlink/climate.py b/broadlink/climate.py index bc3ee934..2c8cc9fd 100755 --- a/broadlink/climate.py +++ b/broadlink/climate.py @@ -34,16 +34,14 @@ def send_request(self, request: Sequence[int]) -> bytes: payload = self.decrypt(response[0x38:]) p_len = int.from_bytes(payload[:0x02], "little") - if p_len + 2 > len(payload): - raise ValueError( - "hysen_response_error", "first byte of response is not length" - ) - nom_crc = int.from_bytes(payload[p_len:p_len+2], "little") real_crc = CRC16.calculate(payload[0x02:p_len]) + if nom_crc != real_crc: - raise ValueError( - "hysen_response_error", "CRC check on response failed" + raise e.DataValidationError( + -4008, + "Received data packet check error", + f"Expected a checksum of {nom_crc} and received {real_crc}", ) return payload[0x02:p_len] @@ -213,7 +211,19 @@ def set_power( def set_time(self, hour: int, minute: int, second: int, day: int) -> None: """Set the time.""" self.send_request( - [0x01, 0x10, 0x00, 0x08, 0x00, 0x02, 0x04, hour, minute, second, day] + [ + 0x01, + 0x10, + 0x00, + 0x08, + 0x00, + 0x02, + 0x04, + hour, + minute, + second, + day + ] ) # Set timer schedule @@ -302,13 +312,6 @@ class SwVert(enum.IntEnum): POS5 = 5 OFF = 7 - def _crc(self, data: bytes) -> int: - """Calculate CRC of a byte object.""" - s = sum([v if i % 2 == 0 else v << 8 for i, v in enumerate(data)]) - # trim the overflow and add it to smallest bit - s = (s & 0xFFFF) + (s >> 16) - return (0xFFFF - s) & 0xFFFF - def _encode(self, data: bytes) -> bytes: """Encode data for transport.""" packet = bytearray(10) @@ -317,7 +320,8 @@ def _encode(self, data: bytes) -> bytes: " bytes: @@ -325,13 +329,14 @@ def _decode(self, response: bytes) -> bytes: # payload[0x2:0x8] == bytes([0xbb, 0x00, 0x07, 0x00, 0x00, 0x00]) payload = self.decrypt(response[0x38:]) p_len = int.from_bytes(payload[:0x02], "little") - checksum = int.from_bytes(payload[p_len:p_len+2], "little") + nom_crc = int.from_bytes(payload[p_len:p_len+2], "little") + real_crc = CRC16.calculate(payload[0x02:p_len], polynomial=0x9BE4) - if checksum != self._crc(payload[0x02:p_len]): - logging.debug( - "Checksum incorrect (calculated %s actual %s).", - checksum.hex(), - payload[p_len:p_len+2].hex(), + if nom_crc != real_crc: + raise e.DataValidationError( + -4008, + "Received data packet check error", + f"Expected a checksum of {nom_crc} and received {real_crc}", ) d_len = int.from_bytes(payload[0x08:0x0A], "little") @@ -364,7 +369,7 @@ def get_state(self) -> dict: clean (bool): mildew (bool): """ - resp = self._send(0x1) + resp = self._send(1) if len(resp) != 0x0F: raise ValueError(f"unexpected resp size: {len(resp)}") @@ -461,7 +466,7 @@ def set_state( data[0x03] = speed << 5 data[0x04] = preset << 6 data[0x05] = mode << 5 | (sleep << 2) - data[0x08] = power << 5 | clean << 2 | health and 0b11 + data[0x08] = power << 5 | clean << 2 | (health and 0b11) data[0x0A] = display << 4 | mildew << 3 data[0x0C] = UNK2 From f840d5d187a3a920a7234276721b720951027fde Mon Sep 17 00:00:00 2001 From: Felipe Martins Diel Date: Wed, 17 Apr 2024 01:16:41 -0300 Subject: [PATCH 12/14] Remove log messages --- broadlink/climate.py | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/broadlink/climate.py b/broadlink/climate.py index 2c8cc9fd..91604041 100755 --- a/broadlink/climate.py +++ b/broadlink/climate.py @@ -262,6 +262,8 @@ class hvac(Device): Supported models: - Tornado SMART X SQ series. + - Aux ASW-H12U3/JIR1DI-US + - Aux ASW-H36U2/LFR1DI-US """ TYPE = "HVAC" @@ -346,7 +348,6 @@ def _send(self, command: int, data: bytes = b"") -> bytes: """Send a command to the unit.""" command = bytes([((command << 4) | 1), 1]) packet = self._encode(command + data) - logging.debug("Payload:\n%s", packet.hex(" ")) response = self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) return self._decode(response)[0x02:] @@ -374,14 +375,6 @@ def get_state(self) -> dict: if len(resp) != 0x0F: raise ValueError(f"unexpected resp size: {len(resp)}") - logging.debug("Received resp:\n%s", resp.hex(" ")) - logging.debug( - "0b[R] mask: %x, 0c[R] mask: %x, cmnd_16: %x", - resp[0x03] & 0x0F, - resp[0x04] & 0x0F, - resp[0x04], - ) - state = {} state["power"] = resp[0x08] & 1 << 5 state["target_temp"] = 8 + (resp[0x00] >> 3) + (resp[0x04] >> 7) * 0.5 @@ -396,8 +389,6 @@ def get_state(self) -> dict: state["display"] = bool(resp[0x0A] & 1 << 4) state["mildew"] = bool(resp[0x0A] & 1 << 3) - logging.debug("State: %s", state) - return state def get_ac_info(self) -> dict: @@ -412,8 +403,6 @@ def get_ac_info(self) -> dict: if len(resp) != 0x18: raise ValueError(f"unexpected resp size: {len(resp)}") - logging.debug("Received resp:\n%s", resp.hex(" ")) - ac_info = {} ac_info["power"] = resp[0x1] & 1 @@ -421,7 +410,6 @@ def get_ac_info(self) -> dict: if any(ambient_temp): ac_info["ambient_temp"] = ambient_temp[0] + ambient_temp[1] / 10.0 - logging.debug("AC info: %s", ac_info) return ac_info def set_state( @@ -470,7 +458,4 @@ def set_state( data[0x0A] = display << 4 | mildew << 3 data[0x0C] = UNK2 - logging.debug("Constructed payload data:\n%s", data.hex(" ")) - - response_payload = self._send(0, data) - logging.debug("Response payload:\n%s", response_payload.hex(" ")) + self._send(0, data) From fbc0f0199475d0c39ac7842de5277c6acd84821c Mon Sep 17 00:00:00 2001 From: Felipe Martins Diel Date: Wed, 17 Apr 2024 01:52:52 -0300 Subject: [PATCH 13/14] Fix bugs --- broadlink/climate.py | 47 ++++++++++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/broadlink/climate.py b/broadlink/climate.py index 91604041..abf61045 100755 --- a/broadlink/climate.py +++ b/broadlink/climate.py @@ -1,6 +1,5 @@ """Support for climate control.""" import enum -import logging import struct from typing import List, Sequence @@ -261,7 +260,7 @@ class hvac(Device): """Controls a HVAC. Supported models: - - Tornado SMART X SQ series. + - Tornado SMART X SQ series - Aux ASW-H12U3/JIR1DI-US - Aux ASW-H36U2/LFR1DI-US """ @@ -317,7 +316,7 @@ class SwVert(enum.IntEnum): def _encode(self, data: bytes) -> bytes: """Encode data for transport.""" packet = bytearray(10) - p_len = 8 + len(data) + p_len = 10 + len(data) struct.pack_into( " bytes: def _send(self, command: int, data: bytes = b"") -> bytes: """Send a command to the unit.""" - command = bytes([((command << 4) | 1), 1]) - packet = self._encode(command + data) + prefix = bytes([((command << 4) | 1), 1]) + packet = self._encode(prefix + data) response = self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) return self._decode(response)[0x02:] @@ -365,6 +364,7 @@ def get_state(self) -> dict: swing_h (hvac.SwHoriz): swing_v (hvac.SwVert): sleep (bool): + ifeel (bool): display (bool): health (bool): clean (bool): @@ -372,11 +372,15 @@ def get_state(self) -> dict: """ resp = self._send(1) - if len(resp) != 0x0F: - raise ValueError(f"unexpected resp size: {len(resp)}") + if len(resp) < 13: + raise e.DataValidationError( + -4007, + "Received data packet length error", + f"Expected at least 15 bytes and received {len(resp) + 2}", + ) state = {} - state["power"] = resp[0x08] & 1 << 5 + state["power"] = bool(resp[0x08] & 1 << 5) state["target_temp"] = 8 + (resp[0x00] >> 3) + (resp[0x04] >> 7) * 0.5 state["swing_v"] = self.SwVert(resp[0x00] & 0b111) state["swing_h"] = self.SwHoriz(resp[0x01] >> 5) @@ -384,6 +388,7 @@ def get_state(self) -> dict: state["speed"] = self.Speed(resp[0x03] >> 5) state["preset"] = self.Preset(resp[0x04] >> 6) state["sleep"] = bool(resp[0x05] & 1 << 2) + state["ifeel"] = bool(resp[0x05] & 1 << 3) state["health"] = bool(resp[0x08] & 1 << 1) state["clean"] = bool(resp[0x08] & 1 << 2) state["display"] = bool(resp[0x0A] & 1 << 4) @@ -400,8 +405,13 @@ def get_ac_info(self) -> dict: ambient_temp (float): ambient temperature """ resp = self._send(2) - if len(resp) != 0x18: - raise ValueError(f"unexpected resp size: {len(resp)}") + + if len(resp) < 22: + raise e.DataValidationError( + -4007, + "Received data packet length error", + f"Expected at least 24 bytes and received {len(resp) + 2}", + ) ac_info = {} ac_info["power"] = resp[0x1] & 1 @@ -416,26 +426,25 @@ def set_state( self, power: bool, target_temp: float, # 16<=target_temp<=32 - mode: int, # hvac.Mode - speed: int, # hvac.Speed - preset: int, # hvac.Preset - swing_h: int, # hvac.SwHoriz - swing_v: int, # hvac.SwVert + mode: Mode, + speed: Speed, + preset: Preset, + swing_h: SwHoriz, + swing_v: SwVert, sleep: bool, + ifeel: bool, display: bool, health: bool, clean: bool, mildew: bool, ) -> None: """Set the state of the device.""" - # TODO: What does these values represent? + # TODO: decode unknown bits UNK0 = 0b100 UNK1 = 0b1101 UNK2 = 0b101 target_temp = round(target_temp * 2) / 2 - if not 16 <= target_temp <= 32: - raise ValueError(f"target_temp out of range: {target_temp}") if preset == self.Preset.MUTE: if mode != self.Mode.FAN: @@ -453,7 +462,7 @@ def set_state( data[0x02] = ((target_temp % 1 == 0.5) << 7) | UNK1 data[0x03] = speed << 5 data[0x04] = preset << 6 - data[0x05] = mode << 5 | (sleep << 2) + data[0x05] = mode << 5 | sleep << 2 | ifeel << 3 data[0x08] = power << 5 | clean << 2 | (health and 0b11) data[0x0A] = display << 4 | mildew << 3 data[0x0C] = UNK2 From b07de4cc29531f96f455d92e4ba1f1595ffee80b Mon Sep 17 00:00:00 2001 From: Felipe Martins Diel Date: Wed, 17 Apr 2024 02:01:26 -0300 Subject: [PATCH 14/14] Return state in set_state() --- broadlink/climate.py | 130 ++++++++++++++++++++++--------------------- 1 file changed, 67 insertions(+), 63 deletions(-) diff --git a/broadlink/climate.py b/broadlink/climate.py index abf61045..1a0c6006 100755 --- a/broadlink/climate.py +++ b/broadlink/climate.py @@ -351,6 +351,72 @@ def _send(self, command: int, data: bytes = b"") -> bytes: e.check_error(response[0x22:0x24]) return self._decode(response)[0x02:] + def _parse_state(self, data: bytes) -> dict: + """Parse state.""" + state = {} + state["power"] = bool(data[0x08] & 1 << 5) + state["target_temp"] = 8 + (data[0x00] >> 3) + (data[0x04] >> 7) * 0.5 + state["swing_v"] = self.SwVert(data[0x00] & 0b111) + state["swing_h"] = self.SwHoriz(data[0x01] >> 5) + state["mode"] = self.Mode(data[0x05] >> 5) + state["speed"] = self.Speed(data[0x03] >> 5) + state["preset"] = self.Preset(data[0x04] >> 6) + state["sleep"] = bool(data[0x05] & 1 << 2) + state["ifeel"] = bool(data[0x05] & 1 << 3) + state["health"] = bool(data[0x08] & 1 << 1) + state["clean"] = bool(data[0x08] & 1 << 2) + state["display"] = bool(data[0x0A] & 1 << 4) + state["mildew"] = bool(data[0x0A] & 1 << 3) + return state + + def set_state( + self, + power: bool, + target_temp: float, # 16<=target_temp<=32 + mode: Mode, + speed: Speed, + preset: Preset, + swing_h: SwHoriz, + swing_v: SwVert, + sleep: bool, + ifeel: bool, + display: bool, + health: bool, + clean: bool, + mildew: bool, + ) -> dict: + """Set the state of the device.""" + # TODO: decode unknown bits + UNK0 = 0b100 + UNK1 = 0b1101 + UNK2 = 0b101 + + target_temp = round(target_temp * 2) / 2 + + if preset == self.Preset.MUTE: + if mode != self.Mode.FAN: + raise ValueError("mute is only available in fan mode") + speed = self.Speed.LOW + + elif preset == self.Preset.TURBO: + if mode not in {self.Mode.COOL, self.Mode.HEAT}: + raise ValueError("turbo is only available in cooling/heating") + speed = self.Speed.HIGH + + data = bytearray(0x0D) + data[0x00] = (int(target_temp) - 8 << 3) | swing_v + data[0x01] = (swing_h << 5) | UNK0 + data[0x02] = ((target_temp % 1 == 0.5) << 7) | UNK1 + data[0x03] = speed << 5 + data[0x04] = preset << 6 + data[0x05] = mode << 5 | sleep << 2 | ifeel << 3 + data[0x08] = power << 5 | clean << 2 | (health and 0b11) + data[0x0A] = display << 4 | mildew << 3 + data[0x0C] = UNK2 + + resp = self._send(0, data) + return self._parse_state(resp) + def get_state(self) -> dict: """Returns a dictionary with the unit's parameters. @@ -379,22 +445,7 @@ def get_state(self) -> dict: f"Expected at least 15 bytes and received {len(resp) + 2}", ) - state = {} - state["power"] = bool(resp[0x08] & 1 << 5) - state["target_temp"] = 8 + (resp[0x00] >> 3) + (resp[0x04] >> 7) * 0.5 - state["swing_v"] = self.SwVert(resp[0x00] & 0b111) - state["swing_h"] = self.SwHoriz(resp[0x01] >> 5) - state["mode"] = self.Mode(resp[0x05] >> 5) - state["speed"] = self.Speed(resp[0x03] >> 5) - state["preset"] = self.Preset(resp[0x04] >> 6) - state["sleep"] = bool(resp[0x05] & 1 << 2) - state["ifeel"] = bool(resp[0x05] & 1 << 3) - state["health"] = bool(resp[0x08] & 1 << 1) - state["clean"] = bool(resp[0x08] & 1 << 2) - state["display"] = bool(resp[0x0A] & 1 << 4) - state["mildew"] = bool(resp[0x0A] & 1 << 3) - - return state + return self._parse_state(resp) def get_ac_info(self) -> dict: """Returns dictionary with AC info. @@ -421,50 +472,3 @@ def get_ac_info(self) -> dict: ac_info["ambient_temp"] = ambient_temp[0] + ambient_temp[1] / 10.0 return ac_info - - def set_state( - self, - power: bool, - target_temp: float, # 16<=target_temp<=32 - mode: Mode, - speed: Speed, - preset: Preset, - swing_h: SwHoriz, - swing_v: SwVert, - sleep: bool, - ifeel: bool, - display: bool, - health: bool, - clean: bool, - mildew: bool, - ) -> None: - """Set the state of the device.""" - # TODO: decode unknown bits - UNK0 = 0b100 - UNK1 = 0b1101 - UNK2 = 0b101 - - target_temp = round(target_temp * 2) / 2 - - if preset == self.Preset.MUTE: - if mode != self.Mode.FAN: - raise ValueError("mute is only available in fan mode") - speed = self.Speed.LOW - - elif preset == self.Preset.TURBO: - if mode not in {self.Mode.COOL, self.Mode.HEAT}: - raise ValueError("turbo is only available in cooling/heating") - speed = self.Speed.HIGH - - data = bytearray(0x0D) - data[0x00] = (int(target_temp) - 8 << 3) | swing_v - data[0x01] = (swing_h << 5) | UNK0 - data[0x02] = ((target_temp % 1 == 0.5) << 7) | UNK1 - data[0x03] = speed << 5 - data[0x04] = preset << 6 - data[0x05] = mode << 5 | sleep << 2 | ifeel << 3 - data[0x08] = power << 5 | clean << 2 | (health and 0b11) - data[0x0A] = display << 4 | mildew << 3 - data[0x0C] = UNK2 - - self._send(0, data)