diff --git a/custom_components/midea_meiju_codec/__init__.py b/custom_components/midea_meiju_codec/__init__.py index 241795b..e09e0c9 100644 --- a/custom_components/midea_meiju_codec/__init__.py +++ b/custom_components/midea_meiju_codec/__init__.py @@ -1,6 +1,11 @@ import logging import os import base64 +from homeassistant.util.json import load_json +try: + from homeassistant.helpers.json import save_json +except ImportError: + from homeassistant.util.json import save_json from homeassistant.core import HomeAssistant from homeassistant.const import ( Platform, @@ -11,19 +16,53 @@ from homeassistant.const import ( CONF_DEVICE_ID, CONF_PROTOCOL, CONF_TOKEN, - CONF_NAME + CONF_NAME, + CONF_DEVICE, + CONF_ENTITIES ) +from .device_map.device_mapping import DEVICE_MAPPING from .core.device import MiedaDevice from .const import ( DOMAIN, DEVICES, + CONFIG_PATH, CONF_KEY, CONF_ACCOUNT, ) +ALL_PLATFORM = [ + Platform.BINARY_SENSOR, + Platform.SENSOR, + Platform.SWITCH, + Platform.CLIMATE, +] + _LOGGER = logging.getLogger(__name__) +def load_device_config(hass, device_type, sn8): + os.makedirs(hass.config.path(CONFIG_PATH), exist_ok=True) + config_file = hass.config.path(f"{CONFIG_PATH}/{sn8}.json") + json_data = load_json(config_file, default={}) + d_type = "0x%02X" % device_type + if len(json_data) >0: + json_data = json_data.get(sn8) + elif d_type in DEVICE_MAPPING: + if sn8 in DEVICE_MAPPING[d_type]: + json_data = DEVICE_MAPPING[d_type][sn8] + save_data = {sn8: json_data} + save_json(config_file, save_data) + elif "default" in DEVICE_MAPPING[d_type]: + json_data = DEVICE_MAPPING[d_type]["default"] + save_data = {sn8: json_data} + save_json(config_file, save_data) + return json_data + + +async def update_listener(hass, config_entry): + pass + + async def async_setup(hass: HomeAssistant, hass_config: dict): hass.data.setdefault(DOMAIN, {}) cjson = os.getcwd() + "/cjson.lua" @@ -56,10 +95,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry): port = config_entry.data.get(CONF_PORT) model = config_entry.data.get(CONF_MODEL) protocol = config_entry.data.get(CONF_PROTOCOL) + subtype = config_entry.data.get("subtype") sn = config_entry.data.get("sn") sn8 = config_entry.data.get("sn8") lua_file = config_entry.data.get("lua_file") - _LOGGER.error(f"lua_file = {lua_file}") if protocol == 3 and (key is None or key is None): _LOGGER.error("For V3 devices, the key and the token is required.") return False @@ -73,6 +112,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry): key=key, protocol=protocol, model=model, + subtype=subtype, sn=sn, sn8=sn8, lua_file=lua_file, @@ -83,10 +123,38 @@ async def async_setup_entry(hass: HomeAssistant, config_entry): hass.data[DOMAIN] = {} if DEVICES not in hass.data[DOMAIN]: hass.data[DOMAIN][DEVICES] = {} - hass.data[DOMAIN][DEVICES][device_id] = device - for platform in [Platform.BINARY_SENSOR]: + hass.data[DOMAIN][DEVICES][device_id] = {} + hass.data[DOMAIN][DEVICES][device_id][CONF_DEVICE] = device + hass.data[DOMAIN][DEVICES][device_id][CONF_ENTITIES] = {} + config = load_device_config(hass, device_type, sn8) + if config is not None and len(config) > 0: + queries = config.get("queries") + if queries is not None and isinstance(queries, list): + device.queries = queries + centralized = config.get("centralized") + if centralized is not None and isinstance(centralized, list): + device.centralized = centralized + hass.data[DOMAIN][DEVICES][device_id]["manufacturer"] = config.get("manufacturer") + hass.data[DOMAIN][DEVICES][device_id][CONF_ENTITIES] = config.get(CONF_ENTITIES) + for platform in ALL_PLATFORM: hass.async_create_task(hass.config_entries.async_forward_entry_setup( config_entry, platform)) - #config_entry.add_update_listener(update_listener) + config_entry.add_update_listener(update_listener) return True - return False \ No newline at end of file + return False + + +async def async_unload_entry(hass: HomeAssistant, config_entry): + device_id = config_entry.data.get(CONF_DEVICE_ID) + lua_file = config_entry.data.get("lua_file") + os.remove(lua_file) + if device_id is not None: + device = hass.data[DOMAIN][DEVICES][device_id][CONF_DEVICE] + if device is not None: + config_file = hass.config.path(f"{CONFIG_PATH}/{device.sn8}.json") + os.remove(config_file) + device.close() + hass.data[DOMAIN][DEVICES].pop(device_id) + for platform in ALL_PLATFORM: + await hass.config_entries.async_forward_entry_unload(config_entry, platform) + return True diff --git a/custom_components/midea_meiju_codec/binary_sensor.py b/custom_components/midea_meiju_codec/binary_sensor.py index 549150b..351c1e6 100644 --- a/custom_components/midea_meiju_codec/binary_sensor.py +++ b/custom_components/midea_meiju_codec/binary_sensor.py @@ -1,48 +1,45 @@ -import logging -from homeassistant.const import ( - CONF_DEVICE_ID, - STATE_ON, - STATE_OFF +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorDeviceClass +) +from homeassistant.const import ( + Platform, + CONF_DEVICE_ID, + CONF_DEVICE, + CONF_ENTITIES ) -from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from .midea_entities import MideaEntity from .const import ( DOMAIN, - DEVICES, + DEVICES ) - -_LOGGER = logging.getLogger(__name__) +from .midea_entities import MideaBinaryBaseEntity async def async_setup_entry(hass, config_entry, async_add_entities): device_id = config_entry.data.get(CONF_DEVICE_ID) - device = hass.data[DOMAIN][DEVICES].get(device_id) - binary_sensors = [] - sensor = MideaDeviceStatusSensor(device, "status") - binary_sensors.append(sensor) - async_add_entities(binary_sensors) + device = hass.data[DOMAIN][DEVICES][device_id].get(CONF_DEVICE) + manufacturer = hass.data[DOMAIN][DEVICES][device_id].get("manufacturer") + entities = hass.data[DOMAIN][DEVICES][device_id].get(CONF_ENTITIES).get(Platform.BINARY_SENSOR) + devs = [MideaDeviceStatusSensorEntity(device, manufacturer,"Status", {})] + if entities is not None: + for entity_key, config in entities.items(): + devs.append(MideaBinarySensorEntity(device, manufacturer, entity_key, config)) + async_add_entities(devs) -class MideaDeviceStatusSensor(MideaEntity): +class MideaDeviceStatusSensorEntity(MideaBinaryBaseEntity, BinarySensorEntity): + @property def device_class(self): return BinarySensorDeviceClass.CONNECTIVITY - @property - def state(self): - return STATE_ON if self._device.connected else STATE_OFF - - @property - def name(self): - return f"{self._device_name} Status" - @property def icon(self): return "mdi:devices" @property def is_on(self): - return self.state == STATE_ON + return self._device.connected @property def available(self): @@ -57,3 +54,7 @@ class MideaDeviceStatusSensor(MideaEntity): self.schedule_update_ha_state() except Exception as e: pass + + +class MideaBinarySensorEntity(MideaBinaryBaseEntity, BinarySensorEntity): + pass diff --git a/custom_components/midea_meiju_codec/climate.py b/custom_components/midea_meiju_codec/climate.py new file mode 100644 index 0000000..730aa88 --- /dev/null +++ b/custom_components/midea_meiju_codec/climate.py @@ -0,0 +1,191 @@ +from homeassistant.components.climate import * +from homeassistant.const import ( + Platform, + CONF_DEVICE_ID, + CONF_ENTITIES, + CONF_DEVICE, +) +from .const import ( + DOMAIN, + DEVICES +) +from .core.logger import MideaLogger +from .midea_entities import MideaEntity, Rationale + + +async def async_setup_entry(hass, config_entry, async_add_entities): + device_id = config_entry.data.get(CONF_DEVICE_ID) + device = hass.data[DOMAIN][DEVICES][device_id].get(CONF_DEVICE) + manufacturer = hass.data[DOMAIN][DEVICES][device_id].get("manufacturer") + entities = hass.data[DOMAIN][DEVICES][device_id].get(CONF_ENTITIES).get(Platform.CLIMATE) + devs = [] + if entities is not None: + for entity_key, config in entities.items(): + devs.append(MideaClimateEntity(device, manufacturer, entity_key, config)) + async_add_entities(devs) + + +class MideaClimateEntity(MideaEntity, ClimateEntity): + def __init__(self, device, manufacturer, entity_key, config): + super().__init__(device, manufacturer, entity_key, config) + self._key_power = self._config.get("power") + self._key_hvac_modes = self._config.get("hvac_modes") + self._key_preset_modes = self._config.get("preset_modes") + self._key_aux_heat = self._config.get("aux_heat") + self._key_swing_modes = self._config.get("swing_modes") + self._key_fan_modes = self._config.get("fan_modes") + self._key_current_temperature_low = self._config.get("current_temperature_low") + self._key_min_temp = self._config.get("min_temp") + self._key_max_temp = self._config.get("max_temp") + self._key_target_temperature = self._config.get("target_temperature") + self._attr_temperature_unit = self._config.get("temperature_unit") + self._attr_precision = self._config.get("precision") + + @property + def state(self): + return self.hvac_mode + + @property + def supported_features(self): + features = 0 + if self._key_target_temperature is not None: + features |= ClimateEntityFeature.TARGET_TEMPERATURE + if self._key_preset_modes is not None: + features |= ClimateEntityFeature.PRESET_MODE + if self._key_aux_heat is not None: + features |= ClimateEntityFeature.AUX_HEAT + if self._key_swing_modes is not None: + features |= ClimateEntityFeature.SWING_MODE + if self._key_fan_modes is not None: + features |= ClimateEntityFeature.FAN_MODE + return features + + @property + def current_temperature(self): + return self._device.get_attribute("indoor_temperature") + + @property + def target_temperature(self): + if isinstance(self._key_target_temperature, list): + temp_int = self._device.get_attribute(self._key_target_temperature[0]) + tem_dec = self._device.get_attribute(self._key_target_temperature[1]) + if temp_int is not None and tem_dec is not None: + return temp_int + tem_dec + return None + else: + return self._device.get_attribute(self._key_target_temperature) + + @property + def min_temp(self): + if isinstance(self._key_min_temp, str): + return float(self._device.get_attribute(self._key_min_temp)) + else: + return float(self._key_min_temp) + + @property + def max_temp(self): + if isinstance(self._key_max_temp, str): + return float(self._device.get_attribute(self._key_max_temp)) + else: + return float(self._key_max_temp) + + @property + def target_temperature_low(self): + return self.min_temp + + @property + def target_temperature_high(self): + return self.max_temp + + @property + def preset_modes(self): + return list(self._key_preset_modes.keys()) + + @property + def preset_mode(self): + return self.get_mode(self._key_preset_modes) + + @property + def fan_modes(self): + return list(self._key_fan_modes.keys()) + + @property + def fan_mode(self): + return self.get_mode(self._key_fan_modes, Rationale.LESS) + + @property + def swing_modes(self): + return list(self._key_swing_modes.keys()) + + @property + def swing_mode(self): + return self.get_mode(self._key_swing_modes) + + @property + def is_on(self) -> bool: + return self.hvac_mode != HVACMode.OFF + + @property + def hvac_mode(self): + return self.get_mode(self._key_hvac_modes) + + @property + def hvac_modes(self): + return list(self._key_hvac_modes.keys()) + + @property + def is_aux_heat(self): + return self._device.get_attribute(self._key_aux_heat) == "on" + + def turn_on(self): + self._device.set_attribute(attribute=self._key_power, value="on") + + def turn_off(self): + self._device.set_attribute(attribute=self._key_power, value="off") + + def set_temperature(self, **kwargs): + if ATTR_TEMPERATURE not in kwargs: + return + temperature = kwargs.get(ATTR_TEMPERATURE) + temp_int, temp_dec = divmod(temperature, 1) + temp_int = int(temp_int) + hvac_mode = kwargs.get(ATTR_HVAC_MODE) + if hvac_mode is not None: + new_status = self._key_hvac_modes.get(hvac_mode) + else: + new_status = {} + if isinstance(self._key_target_temperature, list): + new_status[self._key_target_temperature[0]] = temp_int + new_status[self._key_target_temperature[1]] = temp_dec + else: + new_status[self._key_target_temperature] = temperature + MideaLogger.error(new_status) + self._device.set_attributes(new_status) + + def set_fan_mode(self, fan_mode: str): + new_statis = self._key_fan_modes.get(fan_mode) + self._device.set_attributes(new_statis) + + def set_preset_mode(self, preset_mode: str): + new_statis = self._key_preset_modes.get(preset_mode) + self._device.set_attributes(new_statis) + + def set_hvac_mode(self, hvac_mode: str): + new_status = self._key_hvac_modes.get(hvac_mode) + self._device.set_attributes(new_status) + + def set_swing_mode(self, swing_mode: str): + new_status = self._key_swing_modes.get(swing_mode) + self._device.set_attributes(new_status) + + def turn_aux_heat_on(self) -> None: + self._device.set_attribute(attr=self._key_aux_heat, value="on") + + def turn_aux_heat_off(self) -> None: + self._device.set_attribute(attr=self._key_aux_heat, value="off") + + def update_state(self, status): + try: + self.schedule_update_ha_state() + except Exception as e: + pass diff --git a/custom_components/midea_meiju_codec/config_flow.py b/custom_components/midea_meiju_codec/config_flow.py index 117f782..de14f07 100644 --- a/custom_components/midea_meiju_codec/config_flow.py +++ b/custom_components/midea_meiju_codec/config_flow.py @@ -129,6 +129,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): "sn8": device.get("sn8"), "sn": device.get("sn"), "model": device.get("productModel"), + "subtype": int(device.get("modelNumber")) if device.get("modelNumber") is not None else 0, "enterprise_code": device.get("enterpriseCode"), "online": device.get("onlineStatus") == "1" } @@ -181,6 +182,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): key=key, protocol=3, model=None, + subtype = None, sn=None, sn8=None, lua_file=None @@ -202,6 +204,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): key=None, protocol=2, model=None, + subtype=None, sn=None, sn8=None, lua_file=None @@ -220,19 +223,20 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): CONF_PROTOCOL: current_device.get("protocol"), CONF_IP_ADDRESS: current_device.get("ip_address"), CONF_PORT: current_device.get("port"), - CONF_MODEL: self._device.get("model"), CONF_TOKEN: use_token, CONF_KEY: use_key, - "lua_file": file, + CONF_MODEL: self._device.get("model"), + "subtype": self._device.get("subtype"), "sn": self._device.get("sn"), "sn8": self._device.get("sn8"), + "lua_file": file, }) else: return await self.async_step_discover(error="invalid_input") return self.async_show_form( step_id="discover", data_schema=vol.Schema({ - vol.Required(CONF_IP_ADDRESS): str + vol.Required(CONF_IP_ADDRESS, default="auto"): str }), errors={"base": error} if error else None ) diff --git a/custom_components/midea_meiju_codec/const.py b/custom_components/midea_meiju_codec/const.py index 10f9066..21ac0ea 100644 --- a/custom_components/midea_meiju_codec/const.py +++ b/custom_components/midea_meiju_codec/const.py @@ -1,5 +1,6 @@ DOMAIN = "midea_meiju_codec" -STORAGE_PATH = f".storage/{DOMAIN}" +STORAGE_PATH = f".storage/{DOMAIN}/lua" +CONFIG_PATH = f".storage/{DOMAIN}/config" DEVICES = "DEVICES" CONF_ACCOUNT = "account" CONF_HOME = "home" diff --git a/custom_components/midea_meiju_codec/core/cloud.py b/custom_components/midea_meiju_codec/core/cloud.py index 2454551..7376a29 100644 --- a/custom_components/midea_meiju_codec/core/cloud.py +++ b/custom_components/midea_meiju_codec/core/cloud.py @@ -1,18 +1,18 @@ -from aiohttp import ClientSession -from secrets import token_hex, token_urlsafe -from .security import CloudSecurity -from threading import Lock import datetime -import logging import time import json - -_LOGGER = logging.getLogger(__name__) +import logging +from aiohttp import ClientSession +from secrets import token_hex, token_urlsafe +from threading import Lock +from .security import CloudSecurity CLIENT_TYPE = 1 # Android FORMAT = 2 # JSON APP_KEY = "4675636b" +_LOGGER = logging.getLogger(__name__) + class MideaCloudBase: LANGUAGE = "en_US" @@ -76,7 +76,7 @@ class MideaCloudBase: response = json.loads(raw) break except Exception as e: - _LOGGER.debug(f"Cloud error: {repr(e)}") + _LOGGER.error(f"Cloud error: {repr(e)}") if int(response["code"]) == 0 and "data" in response: return response["data"] return None @@ -127,7 +127,7 @@ class MideaCloudBase: udpid = CloudSecurity.get_udpid(device_id.to_bytes(6, "big")) else: udpid = CloudSecurity.get_udpid(device_id.to_bytes(6, "little")) - _LOGGER.debug(f"The udpid of deivce [{device_id}] generated " + _LOGGER.error(f"The udpid of deivce [{device_id}] generated " f"with byte order '{'big' if byte_order_big else 'little'}': {udpid}") response = await self.api_request( "/v1/iot/secure/getToken", diff --git a/custom_components/midea_meiju_codec/core/device.py b/custom_components/midea_meiju_codec/core/device.py index 4931da6..8f42e29 100644 --- a/custom_components/midea_meiju_codec/core/device.py +++ b/custom_components/midea_meiju_codec/core/device.py @@ -1,14 +1,11 @@ import threading +import socket +import time from enum import IntEnum from .security import LocalSecurity, MSGTYPE_HANDSHAKE_REQUEST, MSGTYPE_ENCRYPTED_REQUEST from .packet_builder import PacketBuilder from .lua_runtime import MideaCodec -import socket -import logging -import json -import time - -_LOGGER = logging.getLogger(__name__) +from .logger import MideaLogger class AuthException(Exception): @@ -40,6 +37,7 @@ class MiedaDevice(threading.Thread): key: str | None, protocol: int, model: str | None, + subtype: int | None, sn: str | None, sn8: str | None, lua_file: str | None): @@ -57,17 +55,21 @@ class MiedaDevice(threading.Thread): self._protocol = protocol self._model = model self._updates = [] - self._unsupported_protocol = [] self._is_run = False - self._device_protocol_version = 0 - self._sub_type = None + self._subtype = subtype + self._sn = sn self._sn8 = sn8 - self._attributes = {} + self._attributes = { + "sn": sn, + "sn8": sn8, + "subtype": subtype + } self._refresh_interval = 30 self._heartbeat_interval = 10 - self._default_refresh_interval = 30 self._connected = False - self._lua_runtime = MideaCodec(lua_file, sn=sn) if lua_file is not None else None + self._queries = [{}] + self._centralized = [] + self._lua_runtime = MideaCodec(lua_file, sn=sn, subtype=subtype) if lua_file is not None else None @property def device_name(self): @@ -93,6 +95,10 @@ class MiedaDevice(threading.Thread): def sn8(self): return self._sn8 + @property + def subtype(self): + return self._subtype + @property def attributes(self): return self._attributes @@ -101,6 +107,50 @@ class MiedaDevice(threading.Thread): def connected(self): return self._connected + def set_refresh_interval(self, refresh_interval): + self._refresh_interval = refresh_interval + + @property + def queries(self): + return self._queries + + @queries.setter + def queries(self, queries: list): + self._queries = queries + + @property + def centralized(self): + return self._centralized + + @centralized.setter + def centralized(self, centralized: list): + self._centralized = centralized + + def get_attribute(self, attribute): + return self._attributes.get(attribute) + + def set_attribute(self, attribute, value): + if attribute in self._attributes.keys(): + new_status = {} + for attr in self._centralized: + new_status[attr] = self._attributes.get(attr) + new_status[attribute] = value + set_cmd = self._lua_runtime.build_control(new_status) + self.build_send(set_cmd) + + def set_attributes(self, attributes): + new_status = {} + for attr in self._centralized: + new_status[attr] = self._attributes.get(attr) + has_new = False + for attribute, value in attributes.items(): + if attribute in self._attributes.keys(): + has_new = True + new_status[attribute] = value + if has_new: + set_cmd = self._lua_runtime.build_control(new_status) + self.build_send(set_cmd) + @staticmethod def fetch_v2_message(msg): result = [] @@ -120,36 +170,36 @@ class MiedaDevice(threading.Thread): try: self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._socket.settimeout(10) - _LOGGER.debug(f"[{self._device_id}] Connecting to {self._ip_address}:{self._port}") + MideaLogger.debug(f"Connecting to {self._ip_address}:{self._port}", self._device_id) self._socket.connect((self._ip_address, self._port)) - _LOGGER.debug(f"[{self._device_id}] Connected") + MideaLogger.debug(f"Connected", self._device_id) if self._protocol == 3: self.authenticate() - _LOGGER.debug(f"[{self._device_id}] Authentication success") + MideaLogger.debug(f"Authentication success", self._device_id) self.device_connected(True) if refresh: self.refresh_status() return True except socket.timeout: - _LOGGER.debug(f"[{self._device_id}] Connection timed out") + MideaLogger.debug(f"Connection timed out", self._device_id) except socket.error: - _LOGGER.debug(f"[{self._device_id}] Connection error") + MideaLogger.debug(f"Connection error", self._device_id) except AuthException: - _LOGGER.debug(f"[{self._device_id}] Authentication failed") + MideaLogger.debug(f"Authentication failed", self._device_id) except ResponseException: - _LOGGER.debug(f"[{self._device_id}] Unexpected response received") + MideaLogger.debug(f"Unexpected response received", self._device_id) except RefreshFailed: - _LOGGER.debug(f"[{self._device_id}] Refresh status is timed out") + MideaLogger.debug(f"Refresh status is timed out", self._device_id) except Exception as e: - _LOGGER.error(f"[{self._device_id}] Unknown error: {e.__traceback__.tb_frame.f_globals['__file__']}, " - f"{e.__traceback__.tb_lineno}, {repr(e)}") + MideaLogger.error(f"Unknown error: {e.__traceback__.tb_frame.f_globals['__file__']}, " + f"{e.__traceback__.tb_lineno}, {repr(e)}") self.device_connected(False) return False def authenticate(self): request = self._security.encode_8370( self._token, MSGTYPE_HANDSHAKE_REQUEST) - _LOGGER.debug(f"[{self._device_id}] Handshaking") + MideaLogger.debug(f"Handshaking") self._socket.send(request) response = self._socket.recv(512) if len(response) < 20: @@ -167,21 +217,22 @@ class MiedaDevice(threading.Thread): if self._socket is not None: self._socket.send(data) else: - _LOGGER.debug(f"[{self._device_id}] Send failure, device disconnected, data: {data.hex()}") + MideaLogger.debug(f"Send failure, device disconnected, data: {data.hex()}") def send_message_v3(self, data, msg_type=MSGTYPE_ENCRYPTED_REQUEST): data = self._security.encode_8370(data, msg_type) self.send_message_v2(data) def build_send(self, cmd): - _LOGGER.debug(f"[{self._device_id}] Sending: {cmd}") + MideaLogger.debug(f"Sending: {cmd}") bytes_cmd = bytes.fromhex(cmd) msg = PacketBuilder(self._device_id, bytes_cmd).finalize() self.send_message(msg) def refresh_status(self): - query_cmd = self._lua_runtime.build_query() - self.build_send(query_cmd) + for query in self._queries: + query_cmd = self._lua_runtime.build_query(query) + self.build_send(query_cmd) def parse_message(self, msg): if self._protocol == 3: @@ -202,10 +253,10 @@ class MiedaDevice(threading.Thread): cryptographic = message[40:-16] if payload_len % 16 == 0: decrypted = self._security.aes_decrypt(cryptographic) - _LOGGER.debug(f"[{self._device_id}] Received: {decrypted.hex()}") + MideaLogger.debug(f"Received: {decrypted.hex()}") # 这就是最终消息 status = self._lua_runtime.decode_status(decrypted.hex()) - _LOGGER.debug(f"[{self._device_id}] Decoded: {status}") + MideaLogger.debug(f"Decoded: {status}") new_status = {} for single in status.keys(): value = status.get(single) @@ -229,7 +280,7 @@ class MiedaDevice(threading.Thread): self._updates.append(update) def update_all(self, status): - _LOGGER.debug(f"[{self._device_id}] Status update: {status}") + MideaLogger.debug(f"Status update: {status}") for update in self._updates: update(status) @@ -241,17 +292,17 @@ class MiedaDevice(threading.Thread): def close(self): if self._is_run: self._is_run = False + self._lua_runtime = None self.close_socket() def close_socket(self): - self._unsupported_protocol = [] self._buffer = b"" if self._socket: self._socket.close() self._socket = None def set_ip_address(self, ip_address): - _LOGGER.debug(f"[{self._device_id}] Update IP address to {ip_address}") + MideaLogger.debug(f"Update IP address to {ip_address}") self._ip_address = ip_address self.close_socket() @@ -283,7 +334,7 @@ class MiedaDevice(threading.Thread): raise socket.error("Connection closed by peer") result = self.parse_message(msg) if result == ParseMessageResult.ERROR: - _LOGGER.debug(f"[{self._device_id}] Message 'ERROR' received") + MideaLogger.debug(f"Message 'ERROR' received") self.close_socket() break elif result == ParseMessageResult.SUCCESS: @@ -291,16 +342,16 @@ class MiedaDevice(threading.Thread): except socket.timeout: timeout_counter = timeout_counter + 1 if timeout_counter >= 120: - _LOGGER.debug(f"[{self._device_id}] Heartbeat timed out") + MideaLogger.debug(f"Heartbeat timed out") self.close_socket() break except socket.error as e: - _LOGGER.debug(f"[{self._device_id}] Socket error {repr(e)}") + MideaLogger.debug(f"Socket error {repr(e)}") self.close_socket() break except Exception as e: - _LOGGER.error(f"[{self._device_id}] Unknown error :{e.__traceback__.tb_frame.f_globals['__file__']}, " - f"{e.__traceback__.tb_lineno}, {repr(e)}") + MideaLogger.error(f"Unknown error :{e.__traceback__.tb_frame.f_globals['__file__']}, " + f"{e.__traceback__.tb_lineno}, {repr(e)}") self.close_socket() break diff --git a/custom_components/midea_meiju_codec/core/discover.py b/custom_components/midea_meiju_codec/core/discover.py index 47fa2ab..a96e132 100644 --- a/custom_components/midea_meiju_codec/core/discover.py +++ b/custom_components/midea_meiju_codec/core/discover.py @@ -1,14 +1,13 @@ -import logging import socket import ifaddr from ipaddress import IPv4Network from .security import LocalSecurity +from .logger import MideaLogger try: import xml.etree.cElementTree as ET except ImportError: import xml.etree.ElementTree as ET -_LOGGER = logging.getLogger(__name__) BROADCAST_MSG = bytearray([ 0x5a, 0x5a, 0x01, 0x11, 0x48, 0x00, 0x92, 0x00, @@ -34,7 +33,7 @@ DEVICE_INFO_MSG = bytearray([ def discover(discover_type=None, ip_address=None): - _LOGGER.debug(f"Begin discover, type: {discover_type}, ip_address: {ip_address}") + MideaLogger.debug(f"Begin discover, type: {discover_type}, ip_address: {ip_address}") if discover_type is None: discover_type = [] security = LocalSecurity() @@ -55,7 +54,7 @@ def discover(discover_type=None, ip_address=None): try: data, addr = sock.recvfrom(512) ip = addr[0] - _LOGGER.debug(f"Received broadcast from {addr}: {data.hex()}") + MideaLogger.debug(f"Received broadcast from {addr}: {data.hex()}") if len(data) >= 104 and (data[:2].hex() == "5a5a" or data[8:10].hex() == "5a5a"): if data[:2].hex() == "5a5a": protocol = 2 @@ -70,7 +69,7 @@ def discover(discover_type=None, ip_address=None): continue encrypt_data = data[40:-16] reply = security.aes_decrypt(encrypt_data) - _LOGGER.debug(f"Declassified reply: {reply.hex()}") + MideaLogger.debug(f"Declassified reply: {reply.hex()}") ssid = reply[41:41 + reply[40]].decode("utf-8") device_type = ssid.split("_")[1] port = bytes2port(reply[4:8]) @@ -105,13 +104,13 @@ def discover(discover_type=None, ip_address=None): } if len(discover_type) == 0 or device.get("type") in discover_type: found_devices[device_id] = device - _LOGGER.debug(f"Found a supported device: {device}") + MideaLogger.debug(f"Found a supported device: {device}") else: - _LOGGER.debug(f"Found a unsupported device: {device}") + MideaLogger.debug(f"Found a unsupported device: {device}") except socket.timeout: break except socket.error as e: - _LOGGER.debug(f"Socket error: {repr(e)}") + MideaLogger.debug(f"Socket error: {repr(e)}") return found_devices @@ -147,15 +146,15 @@ def get_device_info(device_ip, device_port: int): sock.settimeout(8) device_address = (device_ip, device_port) sock.connect(device_address) - _LOGGER.debug(f"Sending to {device_ip}:{device_port} {DEVICE_INFO_MSG.hex()}") + MideaLogger.debug(f"Sending to {device_ip}:{device_port} {DEVICE_INFO_MSG.hex()}") sock.sendall(DEVICE_INFO_MSG) response = sock.recv(512) except socket.timeout: - _LOGGER.warning(f"Connect the device {device_ip}:{device_port} timed out for 8s. " + MideaLogger.warning(f"Connect the device {device_ip}:{device_port} timed out for 8s. " f"Don't care about a small amount of this. if many maybe not support." ) except socket.error: - _LOGGER.warning(f"Can't connect to Device {device_ip}:{device_port}") + MideaLogger.warning(f"Can't connect to Device {device_ip}:{device_port}") return response diff --git a/custom_components/midea_meiju_codec/core/logger.py b/custom_components/midea_meiju_codec/core/logger.py new file mode 100644 index 0000000..51df532 --- /dev/null +++ b/custom_components/midea_meiju_codec/core/logger.py @@ -0,0 +1,36 @@ +import inspect +import logging +from enum import IntEnum + + +class MideaLogType(IntEnum): + DEBUG = 1 + WARN = 2 + ERROR = 3 + + +class MideaLogger: + @staticmethod + def _log(log_type, log, device_id): + frm = inspect.stack()[2] + mod = inspect.getmodule(frm[0]) + if device_id is not None: + log = f"[{device_id}] {log}" + if log_type == MideaLogType.DEBUG: + logging.getLogger(mod.__name__).debug(log) + elif log_type == MideaLogType.WARN: + logging.getLogger(mod.__name__).warning(log) + elif log_type == MideaLogType.ERROR: + logging.getLogger(mod.__name__).error(log) + + @staticmethod + def debug(log, device_id=None): + MideaLogger._log(MideaLogType.DEBUG, log, device_id) + + @staticmethod + def warning(log, device_id=None): + MideaLogger._log(MideaLogType.WARN, log, device_id) + + @staticmethod + def error(log, device_id=None): + MideaLogger._log(MideaLogType.ERROR, log, device_id) \ No newline at end of file diff --git a/custom_components/midea_meiju_codec/core/lua_runtime.py b/custom_components/midea_meiju_codec/core/lua_runtime.py index a051ae9..e5f6203 100644 --- a/custom_components/midea_meiju_codec/core/lua_runtime.py +++ b/custom_components/midea_meiju_codec/core/lua_runtime.py @@ -1,10 +1,7 @@ import lupa -import logging import threading import json -_LOGGER = logging.getLogger(__name__) - class LuaRuntime: def __init__(self, file): @@ -27,17 +24,17 @@ class LuaRuntime: class MideaCodec(LuaRuntime): - def __init__(self, file, sn=None, sub_type=None): + def __init__(self, file, sn=None, subtype=None): super().__init__(file) self._sn = sn - self._sub_type = sub_type + self._subtype = subtype def _build_base_dict(self): device_info ={} if self._sn is not None: device_info["deviceSN"] = self._sn - if self._sub_type is not None: - device_info["deviceSubType"] = self._sub_type + if self._subtype is not None: + device_info["deviceSubType"] = self._subtype base_dict = { "deviceinfo": device_info } diff --git a/custom_components/midea_meiju_codec/core/packet_builder.py b/custom_components/midea_meiju_codec/core/packet_builder.py index 6fed16b..84eaada 100644 --- a/custom_components/midea_meiju_codec/core/packet_builder.py +++ b/custom_components/midea_meiju_codec/core/packet_builder.py @@ -6,7 +6,6 @@ class PacketBuilder: def __init__(self, device_id: int, command): self.command = None self.security = LocalSecurity() - # aa20ac00000000000003418100ff03ff000200000000000000000000000006f274 # Init the packet with the header data. self.packet = bytearray([ # 2 bytes - StaicHeader diff --git a/custom_components/midea_meiju_codec/core/security.py b/custom_components/midea_meiju_codec/core/security.py index 8a609c1..b04fd6f 100644 --- a/custom_components/midea_meiju_codec/core/security.py +++ b/custom_components/midea_meiju_codec/core/security.py @@ -1,20 +1,18 @@ +import hmac import logging from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad from Crypto.Util.strxor import strxor from Crypto.Random import get_random_bytes from hashlib import md5, sha256 -from urllib.parse import urlparse -import hmac -import urllib - -_LOGGER = logging.getLogger(__name__) MSGTYPE_HANDSHAKE_REQUEST = 0x0 MSGTYPE_HANDSHAKE_RESPONSE = 0x1 MSGTYPE_ENCRYPTED_RESPONSE = 0x3 MSGTYPE_ENCRYPTED_REQUEST = 0x6 +_LOGGER = logging.getLogger(__name__) + class CloudSecurity: diff --git a/custom_components/midea_meiju_codec/device_map/device_mapping.py b/custom_components/midea_meiju_codec/device_map/device_mapping.py new file mode 100644 index 0000000..0b48d81 --- /dev/null +++ b/custom_components/midea_meiju_codec/device_map/device_mapping.py @@ -0,0 +1,106 @@ +from homeassistant.const import * +from homeassistant.components.sensor import SensorStateClass, SensorDeviceClass +from homeassistant.components.switch import SwitchDeviceClass +from homeassistant.components.climate import ( + HVACMode, + PRESET_NONE, + PRESET_ECO, + PRESET_COMFORT, + PRESET_SLEEP, + PRESET_BOOST, + SWING_OFF, + SWING_BOTH, + SWING_VERTICAL, + SWING_HORIZONTAL, + FAN_AUTO, + FAN_LOW, + FAN_MEDIUM, + FAN_HIGH, +) + +DEVICE_MAPPING = { + "0xAC": { + "default": { + "manufacturer": "美的", + "queries": [{}, {"query_type": "prevent_straight_wind"}], + "centralized": ["power", "temperature", "small_temperature", "mode", "eco", "comfort_power_save", + "comfort_sleep", "strong_wind", "wind_swing_lr", "wind_swing_lr", "wind_speed", + "ptc", "dry"], + "entities": { + Platform.CLIMATE: { + "thermostat": { + "name": "Thermostat", + "power": "power", + "target_temperature": ["temperature", "small_temperature"], + "hvac_modes": { + HVACMode.OFF: {"power": "off"}, + HVACMode.HEAT: {"power": "on", "mode": "heat"}, + HVACMode.COOL: {"power": "on", "mode": "cool"}, + HVACMode.AUTO: {"power": "on", "mode": "auto"}, + HVACMode.DRY: {"power": "on", "mode": "dry"}, + HVACMode.FAN_ONLY: {"power": "on", "mode": "fan"} + }, + "preset_modes": { + PRESET_NONE: { + "eco": "off", + "comfort_power_save": "off", + "comfort_sleep": "off", + "strong_wind": "off" + }, + PRESET_ECO: {"eco": "on"}, + PRESET_COMFORT: {"comfort_power_save": "on"}, + PRESET_SLEEP: {"comfort_sleep": "on"}, + PRESET_BOOST: {"strong_wind": "on"} + }, + "swing_modes": { + SWING_OFF: {"wind_swing_lr": "off", "wind_swing_ud": "off"}, + SWING_BOTH: {"wind_swing_lr": "on", "wind_swing_ud": "on"}, + SWING_HORIZONTAL: {"wind_swing_lr": "on", "wind_swing_ud": "off"}, + SWING_VERTICAL: {"wind_swing_lr": "off", "wind_swing_ud": "on"}, + }, + "fan_modes": { + "silent": {"wind_speed": 20}, + FAN_LOW: {"wind_speed": 40}, + FAN_MEDIUM: {"wind_speed": 60}, + FAN_HIGH: {"wind_speed": 80}, + "full": {"wind_speed": 100}, + FAN_AUTO: {"wind_speed": 102} + }, + "current_temperature": "indoor_temperature", + "aux_heat": "ptc", + "min_temp": 17, + "max_temp": 30, + "temperature_unit": TEMP_CELSIUS, + "precision": PRECISION_HALVES, + } + }, + Platform.SWITCH: { + "dry": { + "name": "Dry", + "device_class": SwitchDeviceClass.SWITCH, + }, + "prevent_straight_wind": { + "binary_rationale": [1, 2] + } + }, + Platform.SENSOR: { + "indoor_temperature": { + "name": "室内温度", + "device_class": SensorDeviceClass.TEMPERATURE, + "unit": TEMP_CELSIUS, + "state_class": SensorStateClass.MEASUREMENT + }, + "outdoor_temperature": { + "name": "室外机温度", + "device_class": SensorDeviceClass.TEMPERATURE, + "unit": TEMP_CELSIUS, + "state_class": SensorStateClass.MEASUREMENT + }, + }, + Platform.BINARY_SENSOR: { + "power": {} + } + } + } + }, +} diff --git a/custom_components/midea_meiju_codec/manifest.json b/custom_components/midea_meiju_codec/manifest.json index 083ae4e..3fcf1c4 100644 --- a/custom_components/midea_meiju_codec/manifest.json +++ b/custom_components/midea_meiju_codec/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/georgezhao2010/midea-meiju-codec/issues", "requirements": ["lupa>=2.0"], - "version": "v0.0.2" + "version": "v0.0.3" } \ No newline at end of file diff --git a/custom_components/midea_meiju_codec/midea_entities.py b/custom_components/midea_meiju_codec/midea_entities.py index f11d776..4213265 100644 --- a/custom_components/midea_meiju_codec/midea_entities.py +++ b/custom_components/midea_meiju_codec/midea_entities.py @@ -1,32 +1,43 @@ +from enum import IntEnum from homeassistant.helpers.entity import Entity +from homeassistant.const import ( + STATE_ON, + STATE_OFF +) from .const import DOMAIN +from .core.logger import MideaLogger +class Rationale(IntEnum): + EQUALLY = 0 + GREATER = 1 + LESS = 2 + class MideaEntity(Entity): - def __init__(self, device, entity_key: str): + def __init__(self, device, manufacturer: str | None, entity_key: str, config: dict): self._device = device self._device.register_update(self.update_state) self._entity_key = entity_key - self._unique_id = f"{DOMAIN}.{self._device.device_id}_{entity_key}" - self.entity_id = self._unique_id + self._config = config self._device_name = self._device.device_name - - @property - def device(self): - return self._device - - @property - def device_info(self): - return { - "manufacturer": "Midea", - "model": f"{self._device.model} ({self._device.sn8})", + self._attr_native_unit_of_measurement = self._config.get("unit") + self._attr_device_class = self._config.get("device_class") + self._attr_state_class = self._config.get("state_class") + self._attr_unit_of_measurement = self._config.get("unit") + self._attr_icon = self._config.get("icon") + self._attr_unique_id = f"{DOMAIN}.{self._device.device_id}_{self._entity_key}" + MideaLogger.debug(self._attr_unique_id) + self._attr_device_info = { + "manufacturer": "Midea" if manufacturer is None else manufacturer, + "model": f"{self._device.model}", "identifiers": {(DOMAIN, self._device.device_id)}, "name": self._device_name } - - @property - def unique_id(self): - return self._unique_id + name = self._config.get("name") + if name is None: + name = self._entity_key.replace("_", " ").title() + self._attr_name = f"{self._device_name} {name}" + self.entity_id = self._attr_unique_id @property def should_poll(self): @@ -34,15 +45,52 @@ class MideaEntity(Entity): @property def state(self): - return self._device.get_attribute(self._entity_key) + raise NotImplementedError @property def available(self): return self._device.connected + def get_mode(self, key_of_modes, rationale: Rationale = Rationale.EQUALLY): + for mode, status in key_of_modes.items(): + match = True + for attr, value in status.items(): + state_value = self._device.get_attribute(attr) + if state_value is None: + match = False + break + if rationale is Rationale.EQUALLY and state_value != value: + match = False + break + if rationale is Rationale.GREATER and state_value < value: + match = False + break + if rationale is Rationale.LESS and state_value > value: + match = False + break + if match: + return mode + return None + def update_state(self, status): if self._entity_key in status or "connected" in status: + try: self.schedule_update_ha_state() except Exception as e: - pass \ No newline at end of file + pass + + +class MideaBinaryBaseEntity(MideaEntity): + def __init__(self, device, manufacturer: str | None, entity_key: str, config: dict): + super().__init__(device, manufacturer, entity_key, config) + binary_rationale = config.get("binary_rationale") + self._binary_rationale = binary_rationale if binary_rationale is not None else ["off", "on"] + + @property + def state(self): + return STATE_ON if self.is_on else STATE_OFF + + @property + def is_on(self): + return self._device.get_attribute(self._entity_key) == self._binary_rationale[1] \ No newline at end of file diff --git a/custom_components/midea_meiju_codec/sensor.py b/custom_components/midea_meiju_codec/sensor.py new file mode 100644 index 0000000..484e996 --- /dev/null +++ b/custom_components/midea_meiju_codec/sensor.py @@ -0,0 +1,31 @@ +from homeassistant.components.sensor import SensorEntity +from homeassistant.const import ( + Platform, + CONF_DEVICE_ID, + CONF_DEVICE, + CONF_ENTITIES +) +from .const import ( + DOMAIN, + DEVICES +) +from .midea_entities import MideaEntity + + +async def async_setup_entry(hass, config_entry, async_add_entities): + device_id = config_entry.data.get(CONF_DEVICE_ID) + device = hass.data[DOMAIN][DEVICES][device_id].get(CONF_DEVICE) + manufacturer = hass.data[DOMAIN][DEVICES][device_id].get("manufacturer") + entities = hass.data[DOMAIN][DEVICES][device_id].get(CONF_ENTITIES).get(Platform.SENSOR) + devs = [] + if entities is not None: + for entity_key, config in entities.items(): + devs.append(MideaSensorEntity(device, manufacturer, entity_key, config)) + async_add_entities(devs) + + +class MideaSensorEntity(MideaEntity, SensorEntity): + + @property + def state(self): + return self._device.get_attribute(self._entity_key) diff --git a/custom_components/midea_meiju_codec/switch.py b/custom_components/midea_meiju_codec/switch.py new file mode 100644 index 0000000..c97f593 --- /dev/null +++ b/custom_components/midea_meiju_codec/switch.py @@ -0,0 +1,35 @@ +from homeassistant.components.switch import SwitchEntity +from homeassistant.const import ( + Platform, + CONF_DEVICE_ID, + CONF_DEVICE, + CONF_ENTITIES, + STATE_ON, + STATE_OFF +) +from .const import ( + DOMAIN, + DEVICES +) +from .midea_entities import MideaBinaryBaseEntity + + +async def async_setup_entry(hass, config_entry, async_add_entities): + device_id = config_entry.data.get(CONF_DEVICE_ID) + device = hass.data[DOMAIN][DEVICES][device_id].get(CONF_DEVICE) + manufacturer = hass.data[DOMAIN][DEVICES][device_id].get("manufacturer") + entities = hass.data[DOMAIN][DEVICES][device_id].get(CONF_ENTITIES).get(Platform.SWITCH) + devs = [] + if entities is not None: + for entity_key, config in entities.items(): + devs.append(MideaSwitchEntity(device, manufacturer, entity_key, config)) + async_add_entities(devs) + + +class MideaSwitchEntity(MideaBinaryBaseEntity, SwitchEntity): + + def turn_on(self): + self._device.set_attribute(attribute=self._entity_key, value=self._binary_rationale[1]) + + def turn_off(self): + self._device.set_attribute(attribute=self._entity_key, value=self._binary_rationale[0])