From 2246948ddf06f63e3a59a984cd24589c6af01897 Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 10 Sep 2023 12:08:27 +0800 Subject: [PATCH] v0.0.4 --- .../midea_meiju_codec/__init__.py | 71 +++++--- .../midea_meiju_codec/binary_sensor.py | 6 +- .../midea_meiju_codec/climate.py | 52 +++--- .../midea_meiju_codec/config_flow.py | 12 +- .../midea_meiju_codec/core/cloud.py | 17 +- .../midea_meiju_codec/core/device.py | 33 ++-- .../midea_meiju_codec/core/lua_runtime.py | 38 +++-- .../device_map/device_mapping.py | 106 ------------ .../midea_meiju_codec/device_mapping/T0xAC.py | 152 ++++++++++++++++++ custom_components/midea_meiju_codec/fan.py | 112 +++++++++++++ .../midea_meiju_codec/midea_entities.py | 63 ++++++-- custom_components/midea_meiju_codec/select.py | 50 ++++++ custom_components/midea_meiju_codec/sensor.py | 5 +- custom_components/midea_meiju_codec/switch.py | 9 +- .../midea_meiju_codec/water_heater.py | 128 +++++++++++++++ 15 files changed, 636 insertions(+), 218 deletions(-) delete mode 100644 custom_components/midea_meiju_codec/device_map/device_mapping.py create mode 100644 custom_components/midea_meiju_codec/device_mapping/T0xAC.py create mode 100644 custom_components/midea_meiju_codec/fan.py create mode 100644 custom_components/midea_meiju_codec/select.py create mode 100644 custom_components/midea_meiju_codec/water_heater.py diff --git a/custom_components/midea_meiju_codec/__init__.py b/custom_components/midea_meiju_codec/__init__.py index e09e0c9..2fd345c 100644 --- a/custom_components/midea_meiju_codec/__init__.py +++ b/custom_components/midea_meiju_codec/__init__.py @@ -1,11 +1,13 @@ -import logging import os import base64 +from importlib import import_module +from homeassistant.config_entries import ConfigEntry 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.helpers.typing import ConfigType from homeassistant.core import HomeAssistant from homeassistant.const import ( Platform, @@ -20,7 +22,7 @@ from homeassistant.const import ( CONF_DEVICE, CONF_ENTITIES ) -from .device_map.device_mapping import DEVICE_MAPPING +from .core.logger import MideaLogger from .core.device import MiedaDevice from .const import ( DOMAIN, @@ -35,35 +37,56 @@ ALL_PLATFORM = [ Platform.SENSOR, Platform.SWITCH, Platform.CLIMATE, + Platform.SELECT, + Platform.WATER_HEATER, + Platform.FAN ] -_LOGGER = logging.getLogger(__name__) + +def get_sn8_used(hass: HomeAssistant, sn8): + entries = hass.config_entries.async_entries(DOMAIN) + count = 0 + for entry in entries: + if sn8 == entry.data.get("sn8"): + count += 1 + return count -def load_device_config(hass, device_type, sn8): +def remove_device_config(hass: HomeAssistant, sn8): + config_file = hass.config.path(f"{CONFIG_PATH}/{sn8}.json") + try: + os.remove(config_file) + except FileNotFoundError: + pass + + +def load_device_config(hass: HomeAssistant, 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: + 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) + else: + device_path = f".device_mapping.{'T0x%02X' % device_type}" + try: + mapping_module = import_module(device_path, __package__) + if sn8 in mapping_module.DEVICE_MAPPING.keys(): + json_data = mapping_module.DEVICE_MAPPING[sn8] + elif "default" in mapping_module.DEVICE_MAPPING: + json_data = mapping_module.DEVICE_MAPPING["default"] + if len(json_data) > 0: + save_data = {sn8: json_data} + save_json(config_file, save_data) + except ModuleNotFoundError: + MideaLogger.warning(f"Can't load mapping file for type {'T0x%02X' % device_type}") return json_data -async def update_listener(hass, config_entry): +async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry): pass -async def async_setup(hass: HomeAssistant, hass_config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType): hass.data.setdefault(DOMAIN, {}) cjson = os.getcwd() + "/cjson.lua" bit = os.getcwd() + "/bit.lua" @@ -80,7 +103,7 @@ async def async_setup(hass: HomeAssistant, hass_config: dict): return True -async def async_setup_entry(hass: HomeAssistant, config_entry): +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): device_type = config_entry.data.get(CONF_TYPE) if device_type == CONF_ACCOUNT: return True @@ -100,7 +123,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry): sn8 = config_entry.data.get("sn8") lua_file = config_entry.data.get("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.") + MideaLogger.error("For V3 devices, the key and the token is required.") return False device = MiedaDevice( name=name, @@ -144,15 +167,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry): return False -async def async_unload_entry(hass: HomeAssistant, config_entry): +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry): 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) + if get_sn8_used(hass, device.sn8) == 1: + lua_file = config_entry.data.get("lua_file") + os.remove(lua_file) + remove_device_config(hass, device.sn8) device.close() hass.data[DOMAIN][DEVICES].pop(device_id) for platform in ALL_PLATFORM: diff --git a/custom_components/midea_meiju_codec/binary_sensor.py b/custom_components/midea_meiju_codec/binary_sensor.py index 351c1e6..edc44ca 100644 --- a/custom_components/midea_meiju_codec/binary_sensor.py +++ b/custom_components/midea_meiju_codec/binary_sensor.py @@ -8,6 +8,7 @@ from homeassistant.const import ( CONF_DEVICE, CONF_ENTITIES ) + from .const import ( DOMAIN, DEVICES @@ -19,11 +20,12 @@ 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") + rationale = hass.data[DOMAIN][DEVICES][device_id].get("rationale") entities = hass.data[DOMAIN][DEVICES][device_id].get(CONF_ENTITIES).get(Platform.BINARY_SENSOR) - devs = [MideaDeviceStatusSensorEntity(device, manufacturer,"Status", {})] + devs = [MideaDeviceStatusSensorEntity(device, manufacturer, rationale,"Status", {})] if entities is not None: for entity_key, config in entities.items(): - devs.append(MideaBinarySensorEntity(device, manufacturer, entity_key, config)) + devs.append(MideaBinarySensorEntity(device, manufacturer, rationale, entity_key, config)) async_add_entities(devs) diff --git a/custom_components/midea_meiju_codec/climate.py b/custom_components/midea_meiju_codec/climate.py index 730aa88..610408b 100644 --- a/custom_components/midea_meiju_codec/climate.py +++ b/custom_components/midea_meiju_codec/climate.py @@ -1,15 +1,21 @@ -from homeassistant.components.climate import * +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, + ATTR_HVAC_MODE, +) from homeassistant.const import ( Platform, CONF_DEVICE_ID, CONF_ENTITIES, CONF_DEVICE, + ATTR_TEMPERATURE ) + from .const import ( DOMAIN, DEVICES ) -from .core.logger import MideaLogger from .midea_entities import MideaEntity, Rationale @@ -17,34 +23,31 @@ 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") + rationale = hass.data[DOMAIN][DEVICES][device_id].get("rationale") 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)) + devs.append(MideaClimateEntity(device, manufacturer, rationale, 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) + def __init__(self, device, manufacturer, rationale, entity_key, config): + super().__init__(device, manufacturer, rationale, 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_current_temperature = self._config.get("current_temperature") 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 @@ -62,7 +65,7 @@ class MideaClimateEntity(MideaEntity, ClimateEntity): @property def current_temperature(self): - return self._device.get_attribute("indoor_temperature") + return self._device.get_attribute(self._key_current_temperature) @property def target_temperature(self): @@ -103,7 +106,7 @@ class MideaClimateEntity(MideaEntity, ClimateEntity): @property def preset_mode(self): - return self.get_mode(self._key_preset_modes) + return self._dict_get_selected(self._key_preset_modes) @property def fan_modes(self): @@ -111,7 +114,7 @@ class MideaClimateEntity(MideaEntity, ClimateEntity): @property def fan_mode(self): - return self.get_mode(self._key_fan_modes, Rationale.LESS) + return self._dict_get_selected(self._key_fan_modes, Rationale.LESS) @property def swing_modes(self): @@ -119,7 +122,7 @@ class MideaClimateEntity(MideaEntity, ClimateEntity): @property def swing_mode(self): - return self.get_mode(self._key_swing_modes) + return self._dict_get_selected(self._key_swing_modes) @property def is_on(self) -> bool: @@ -127,7 +130,7 @@ class MideaClimateEntity(MideaEntity, ClimateEntity): @property def hvac_mode(self): - return self.get_mode(self._key_hvac_modes) + return self._dict_get_selected(self._key_hvac_modes) @property def hvac_modes(self): @@ -135,13 +138,13 @@ class MideaClimateEntity(MideaEntity, ClimateEntity): @property def is_aux_heat(self): - return self._device.get_attribute(self._key_aux_heat) == "on" + return self._get_status_on_off(self._key_aux_heat) def turn_on(self): - self._device.set_attribute(attribute=self._key_power, value="on") + self._set_status_on_off(self._key_power, True) def turn_off(self): - self._device.set_attribute(attribute=self._key_power, value="off") + self._set_status_on_off(self._key_power, False) def set_temperature(self, **kwargs): if ATTR_TEMPERATURE not in kwargs: @@ -159,16 +162,15 @@ class MideaClimateEntity(MideaEntity, ClimateEntity): 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) + new_status = self._key_fan_modes.get(fan_mode) + self._device.set_attributes(new_status) def set_preset_mode(self, preset_mode: str): - new_statis = self._key_preset_modes.get(preset_mode) - self._device.set_attributes(new_statis) + new_status = self._key_preset_modes.get(preset_mode) + self._device.set_attributes(new_status) def set_hvac_mode(self, hvac_mode: str): new_status = self._key_hvac_modes.get(hvac_mode) @@ -179,10 +181,10 @@ class MideaClimateEntity(MideaEntity, ClimateEntity): self._device.set_attributes(new_status) def turn_aux_heat_on(self) -> None: - self._device.set_attribute(attr=self._key_aux_heat, value="on") + self._set_status_on_off(self._key_aux_heat, True) def turn_aux_heat_off(self) -> None: - self._device.set_attribute(attr=self._key_aux_heat, value="off") + self._set_status_on_off(self._key_aux_heat, False) def update_state(self, status): try: diff --git a/custom_components/midea_meiju_codec/config_flow.py b/custom_components/midea_meiju_codec/config_flow.py index de14f07..5b4c8f8 100644 --- a/custom_components/midea_meiju_codec/config_flow.py +++ b/custom_components/midea_meiju_codec/config_flow.py @@ -122,15 +122,19 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): device_list = {} for device in devices: if not self._device_configured(int(device.get("applianceCode"))): + try: + subtype = int(device.get("modelNumber")) if device.get("modelNumber") is not None else 0 + except ValueError: + subtype = 0 self._device_list[int(device.get("applianceCode"))] = { "device_id": int(device.get("applianceCode")), "name": device.get("name"), "type": int(device.get("type"), 16), - "sn8": device.get("sn8"), + "sn8": device.get("sn8", "00000000"), "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"), + "model": device.get("productModel", "0"), + "subtype": subtype, + "enterprise_code": device.get("enterpriseCode","0000"), "online": device.get("onlineStatus") == "1" } device_list[int(device.get("applianceCode"))] = \ diff --git a/custom_components/midea_meiju_codec/core/cloud.py b/custom_components/midea_meiju_codec/core/cloud.py index 7376a29..15a234f 100644 --- a/custom_components/midea_meiju_codec/core/cloud.py +++ b/custom_components/midea_meiju_codec/core/cloud.py @@ -35,7 +35,7 @@ class MideaCloudBase: self.security = security self.server = server - async def api_request(self, endpoint, args=None, data=None): + async def api_request(self, endpoint, args=None, data=None) -> dict | None: args = args or {} headers = {} if data is 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.error(f"The udpid of deivce [{device_id}] generated " + _LOGGER.debug(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", @@ -171,11 +171,12 @@ class MeijuCloudExtend(MideaCloudBase): response = await self.api_request("/v1/appliance/home/list/get", args={ 'homegroupId': home }) - for h in response.get("homeList") or []: - for r in h.get("roomList") or []: - for a in r.get("applianceList"): - a["sn"] = CloudSecurity.decrypt(bytes.fromhex(a["sn"]), self.key).decode() - devices.append(a) + if response: + for h in response.get("homeList") or []: + for r in h.get("roomList") or []: + for a in r.get("applianceList"): + a["sn"] = CloudSecurity.decrypt(bytes.fromhex(a["sn"]), self.key).decode() + devices.append(a) return devices async def get_lua(self, sn, device_type, path, enterprise_code=None): @@ -183,7 +184,7 @@ class MeijuCloudExtend(MideaCloudBase): "/v1/appliance/protocol/lua/luaGet", data={ "applianceSn": sn, - "applianceType": f"0x{'%02X' % device_type}", + "applianceType": "0x%02X" % device_type, "applianceMFCode": enterprise_code if enterprise_code else "0000", 'version': "0", "iotAppId": "900", diff --git a/custom_components/midea_meiju_codec/core/device.py b/custom_components/midea_meiju_codec/core/device.py index 8f42e29..f9924c9 100644 --- a/custom_components/midea_meiju_codec/core/device.py +++ b/custom_components/midea_meiju_codec/core/device.py @@ -60,6 +60,7 @@ class MiedaDevice(threading.Thread): self._sn = sn self._sn8 = sn8 self._attributes = { + "device_type": "T0x%02X" % device_type, "sn": sn, "sn8": sn8, "subtype": subtype @@ -135,8 +136,8 @@ class MiedaDevice(threading.Thread): 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) + if set_cmd := self._lua_runtime.build_control(new_status): + self.build_send(set_cmd) def set_attributes(self, attributes): new_status = {} @@ -148,8 +149,8 @@ class MiedaDevice(threading.Thread): has_new = True new_status[attribute] = value if has_new: - set_cmd = self._lua_runtime.build_control(new_status) - self.build_send(set_cmd) + if set_cmd := self._lua_runtime.build_control(new_status): + self.build_send(set_cmd) @staticmethod def fetch_v2_message(msg): @@ -231,8 +232,8 @@ class MiedaDevice(threading.Thread): def refresh_status(self): for query in self._queries: - query_cmd = self._lua_runtime.build_query(query) - self.build_send(query_cmd) + if query_cmd := self._lua_runtime.build_query(query): + self.build_send(query_cmd) def parse_message(self, msg): if self._protocol == 3: @@ -255,16 +256,16 @@ class MiedaDevice(threading.Thread): decrypted = self._security.aes_decrypt(cryptographic) MideaLogger.debug(f"Received: {decrypted.hex()}") # 这就是最终消息 - status = self._lua_runtime.decode_status(decrypted.hex()) - MideaLogger.debug(f"Decoded: {status}") - new_status = {} - for single in status.keys(): - value = status.get(single) - if single not in self._attributes or self._attributes[single] != value: - self._attributes[single] = value - new_status[single] = value - if len(new_status) > 0: - self.update_all(new_status) + if status := self._lua_runtime.decode_status(decrypted.hex()): + MideaLogger.debug(f"Decoded: {status}") + new_status = {} + for single in status.keys(): + value = status.get(single) + if single not in self._attributes or self._attributes[single] != value: + self._attributes[single] = value + new_status[single] = value + if len(new_status) > 0: + self.update_all(new_status) return ParseMessageResult.SUCCESS def send_heartbeat(self): diff --git a/custom_components/midea_meiju_codec/core/lua_runtime.py b/custom_components/midea_meiju_codec/core/lua_runtime.py index e5f6203..70ecebb 100644 --- a/custom_components/midea_meiju_codec/core/lua_runtime.py +++ b/custom_components/midea_meiju_codec/core/lua_runtime.py @@ -1,7 +1,8 @@ import lupa import threading import json - +from .logger import MideaLogger +lupa.LuaMemoryError class LuaRuntime: def __init__(self, file): @@ -44,22 +45,34 @@ class MideaCodec(LuaRuntime): query_dict = self._build_base_dict() query_dict["query"] = {} if append is None else append json_str = json.dumps(query_dict) - result = self.json_to_data(json_str) - return result + try: + result = self.json_to_data(json_str) + return result + except lupa.LuaError as e: + MideaLogger.error(f"LuaRuntimeError in build_query {json_str}: {repr(e)}") + return None def build_control(self, append=None): query_dict = self._build_base_dict() query_dict["control"] = {} if append is None else append json_str = json.dumps(query_dict) - result = self.json_to_data(json_str) - return result + try: + result = self.json_to_data(json_str) + return result + except lupa.LuaError as e: + MideaLogger.error(f"LuaRuntimeError in build_control {json_str}: {repr(e)}") + return None def build_status(self, append=None): query_dict = self._build_base_dict() query_dict["status"] = {} if append is None else append json_str = json.dumps(query_dict) - result = self.json_to_data(json_str) - return result + try: + result = self.json_to_data(json_str) + return result + except lupa.LuaError as e: + MideaLogger.error(f"LuaRuntimeError in build_status {json_str}: {repr(e)}") + return None def decode_status(self, data: str): data_dict = self._build_base_dict() @@ -67,6 +80,11 @@ class MideaCodec(LuaRuntime): "data": data } json_str = json.dumps(data_dict) - result = self.data_to_json(json_str) - status = json.loads(result) - return status.get("status") \ No newline at end of file + try: + result = self.data_to_json(json_str) + status = json.loads(result) + return status.get("status") + except lupa.LuaError as e: + MideaLogger.error(f"LuaRuntimeError in decode_status {data}: {repr(e)}") + return None + diff --git a/custom_components/midea_meiju_codec/device_map/device_mapping.py b/custom_components/midea_meiju_codec/device_map/device_mapping.py deleted file mode 100644 index 0b48d81..0000000 --- a/custom_components/midea_meiju_codec/device_map/device_mapping.py +++ /dev/null @@ -1,106 +0,0 @@ -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/device_mapping/T0xAC.py b/custom_components/midea_meiju_codec/device_mapping/T0xAC.py new file mode 100644 index 0000000..7320a44 --- /dev/null +++ b/custom_components/midea_meiju_codec/device_mapping/T0xAC.py @@ -0,0 +1,152 @@ +from homeassistant.const import * +from homeassistant.components.sensor import SensorStateClass, SensorDeviceClass +from homeassistant.components.switch import SwitchDeviceClass + +DEVICE_MAPPING = { + "default": { + "manufacturer": "美的", + "rationale": ["off", "on"], + "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", + "hvac_modes": { + "off": {"power": "off"}, + "heat": {"power": "on", "mode": "heat"}, + "cool": {"power": "on", "mode": "cool"}, + "auto": {"power": "on", "mode": "auto"}, + "dry": {"power": "on", "mode": "dry"}, + "fan_only": {"power": "on", "mode": "fan"} + }, + "preset_modes": { + "none": { + "eco": "off", + "comfort_power_save": "off", + "comfort_sleep": "off", + "strong_wind": "off" + }, + "eco": {"eco": "on"}, + "comfort": {"comfort_power_save": "on"}, + "sleep": {"comfort_sleep": "on"}, + "boost": {"strong_wind": "on"} + }, + "swing_modes": { + "off": {"wind_swing_lr": "off", "wind_swing_ud": "off"}, + "both": {"wind_swing_lr": "on", "wind_swing_ud": "on"}, + "horizontal": {"wind_swing_lr": "on", "wind_swing_ud": "off"}, + "vertical": {"wind_swing_lr": "off", "wind_swing_ud": "on"}, + }, + "fan_modes": { + "silent": {"wind_speed": 20}, + "low": {"wind_speed": 40}, + "medium": {"wind_speed": 60}, + "high": {"wind_speed": 80}, + "full": {"wind_speed": 100}, + "auto": {"wind_speed": 102} + }, + "target_temperature": ["temperature", "small_temperature"], + "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": { + "name": "防直吹", + "device_class": SwitchDeviceClass.SWITCH, + "rationale": [1, 2] + } + }, + Platform.SENSOR: { + "indoor_temperature": { + "name": "室内温度", + "device_class": SensorDeviceClass.TEMPERATURE, + "unit_of_measurement": TEMP_CELSIUS, + "state_class": SensorStateClass.MEASUREMENT + }, + "outdoor_temperature": { + "name": "室外机温度", + "device_class": SensorDeviceClass.TEMPERATURE, + "unit_of_measurement": TEMP_CELSIUS, + "state_class": SensorStateClass.MEASUREMENT + }, + }, + Platform.BINARY_SENSOR: { + "power": {} + }, + Platform.SELECT: { + "preset_modes": { + "options": { + "none": { + "eco": "off", + "comfort_power_save": "off", + "comfort_sleep": "off", + "strong_wind": "off" + }, + "eco": {"eco": "on"}, + "comfort": {"comfort_power_save": "on"}, + "sleep": {"comfort_sleep": "on"}, + "boost": {"strong_wind": "on"} + } + }, + "hvac_modes": { + "options": { + "off": {"power": "off"}, + "heat": {"power": "on", "mode": "heat"}, + "cool": {"power": "on", "mode": "cool"}, + "auto": {"power": "on", "mode": "auto"}, + "dry": {"power": "on", "mode": "dry"}, + "fan_only": {"power": "on", "mode": "fan"} + } + } + }, + Platform.WATER_HEATER:{ + "water_heater": { + "name": "热水器", + "power": "power", + "operation_list": { + "off": {"power": "off"}, + "heat": {"power": "on", "mode": "heat"}, + "cool": {"power": "on", "mode": "cool"}, + "auto": {"power": "on", "mode": "auto"}, + "dry": {"power": "on", "mode": "dry"}, + "fan_only": {"power": "on", "mode": "fan"} + }, + "target_temperature": ["temperature", "small_temperature"], + "current_temperature": "indoor_temperature", + "min_temp": 17, + "max_temp": 30, + "temperature_unit": TEMP_CELSIUS, + "precision": PRECISION_HALVES, + } + }, + Platform.FAN: { + "fan": { + "power": "power", + "preset_modes": { + "off": {"power": "off"}, + "heat": {"power": "on", "mode": "heat"}, + "cool": {"power": "on", "mode": "cool"}, + "auto": {"power": "on", "mode": "auto"}, + "dry": {"power": "on", "mode": "dry"}, + "fan_only": {"power": "on", "mode": "fan"} + }, + "oscillate": "wind_swing_lr", + "speeds": list({"wind_speed": value + 1} for value in range(0, 100)), + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/midea_meiju_codec/fan.py b/custom_components/midea_meiju_codec/fan.py new file mode 100644 index 0000000..22f0262 --- /dev/null +++ b/custom_components/midea_meiju_codec/fan.py @@ -0,0 +1,112 @@ +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.const import ( + Platform, + CONF_DEVICE_ID, + CONF_DEVICE, + CONF_ENTITIES, +) +from .const import ( + DOMAIN, + DEVICES +) +from .midea_entities import MideaEntity +from .core.logger import MideaLogger + + +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") + rationale = hass.data[DOMAIN][DEVICES][device_id].get("rationale") + entities = hass.data[DOMAIN][DEVICES][device_id].get(CONF_ENTITIES).get(Platform.FAN) + devs = [] + if entities is not None: + for entity_key, config in entities.items(): + devs.append(MideaFanEntity(device, manufacturer, rationale, entity_key, config)) + async_add_entities(devs) + + +class MideaFanEntity(MideaEntity, FanEntity): + def __init__(self, device, manufacturer, rationale, entity_key, config): + super().__init__(device, manufacturer, rationale, entity_key, config) + self._key_power = self._config.get("power") + self._key_preset_modes = self._config.get("preset_modes") + self._key_speeds = self._config.get("speeds") + self._key_oscillate = self._config.get("oscillate") + self._key_directions = self._config.get("directions") + self._attr_speed_count = len(self._key_speeds) if self._key_speeds else 0 + + @property + def supported_features(self): + features = 0 + if self._key_preset_modes is not None and len(self._key_preset_modes) > 0: + features |= FanEntityFeature.PRESET_MODE + if self._key_speeds is not None and len(self._key_speeds) > 0: + features |= FanEntityFeature.SET_SPEED + if self._key_oscillate is not None: + features |= FanEntityFeature.OSCILLATE + if self._key_directions is not None and len(self._key_directions) > 0: + features |= FanEntityFeature.DIRECTION + return features + + @property + def is_on(self) -> bool: + return self._get_status_on_off(self._key_power) + + @property + def preset_modes(self): + return list(self._key_preset_modes.keys()) + + @property + def preset_mode(self): + return self._dict_get_selected(self._key_preset_modes) + + @property + def percentage(self): + index = self._list_get_selected(self._key_speeds) + if index is None: + return None + return round((index + 1) * 100 / self._attr_speed_count) + + @property + def oscillating(self): + return self._get_status_on_off(self._key_oscillate) + + def turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs, + ): + if preset_mode is not None: + new_status = self._key_preset_modes.get(preset_mode) + else: + new_status = {} + if percentage is not None: + index = round(percentage * self._attr_speed_count / 100) - 1 + new_status.update(self._key_speeds[index]) + new_status[self._key_power] = self._rationale[1] + self._device.set_attributes(new_status) + + def turn_off(self): + self._set_status_on_off(self._key_power, False) + + def set_percentage(self, percentage: int): + index = round(percentage * self._attr_speed_count / 100) + if 0 < index < len(self._key_speeds): + new_status = self._key_speeds[index - 1] + self._device.set_attributes(new_status) + + def set_preset_mode(self, preset_mode: str): + new_status = self._key_preset_modes.get(preset_mode) + self._device.set_attributes(new_status) + + def oscillate(self, oscillating: bool): + if self.oscillating != oscillating: + self._set_status_on_off(self._key_oscillate, oscillating) + + def update_state(self, status): + try: + self.schedule_update_ha_state() + except Exception as e: + pass diff --git a/custom_components/midea_meiju_codec/midea_entities.py b/custom_components/midea_meiju_codec/midea_entities.py index 4213265..825f41d 100644 --- a/custom_components/midea_meiju_codec/midea_entities.py +++ b/custom_components/midea_meiju_codec/midea_entities.py @@ -13,20 +13,25 @@ class Rationale(IntEnum): GREATER = 1 LESS = 2 + class MideaEntity(Entity): - def __init__(self, device, manufacturer: str | None, entity_key: str, config: dict): + def __init__(self, device, manufacturer: str | None, rationale: list | None, entity_key: str, config: dict): self._device = device self._device.register_update(self.update_state) self._entity_key = entity_key self._config = config self._device_name = self._device.device_name - self._attr_native_unit_of_measurement = self._config.get("unit") + self._rationale = rationale + rationale = config.get("rationale") + if rationale: + self._rationale = rationale + if self._rationale is None: + self._rationale = ["off", "on"] + self._attr_native_unit_of_measurement = self._config.get("unit_of_measurement") 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}", @@ -43,16 +48,47 @@ class MideaEntity(Entity): def should_poll(self): return False - @property - def state(self): - 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(): + def _get_status_on_off(self, status_key: str): + result = False + status = self._device.get_attribute(status_key) + if status is not None: + try: + result = bool(self._rationale.index(status)) + except ValueError: + MideaLogger.error(f"The value of attribute {status_key} ('{status}') " + f"is not in rationale {self._rationale}") + return result + + def _set_status_on_off(self, status_key: str, turn_on: bool): + self._device.set_attribute(status_key, self._rationale[int(turn_on)]) + + def _list_get_selected(self, key_of_list: list, rationale: Rationale = Rationale.EQUALLY): + for index in range(0, len(key_of_list)): + match = True + for attr, value in key_of_list[index].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 index + return None + + def _dict_get_selected(self, key_of_dict: dict, rationale: Rationale = Rationale.EQUALLY): + for mode, status in key_of_dict.items(): match = True for attr, value in status.items(): state_value = self._device.get_attribute(attr) @@ -74,7 +110,6 @@ class MideaEntity(Entity): 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: @@ -82,10 +117,6 @@ class MideaEntity(Entity): 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): @@ -93,4 +124,4 @@ class MideaBinaryBaseEntity(MideaEntity): @property def is_on(self): - return self._device.get_attribute(self._entity_key) == self._binary_rationale[1] \ No newline at end of file + return self._get_status_on_off(self._entity_key) \ No newline at end of file diff --git a/custom_components/midea_meiju_codec/select.py b/custom_components/midea_meiju_codec/select.py new file mode 100644 index 0000000..abfc1e7 --- /dev/null +++ b/custom_components/midea_meiju_codec/select.py @@ -0,0 +1,50 @@ +from homeassistant.components.select import SelectEntity +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") + rationale = hass.data[DOMAIN][DEVICES][device_id].get("rationale") + entities = hass.data[DOMAIN][DEVICES][device_id].get(CONF_ENTITIES).get(Platform.SELECT) + devs = [] + if entities is not None: + for entity_key, config in entities.items(): + devs.append(MideaSelectEntity(device, manufacturer, rationale, entity_key, config)) + async_add_entities(devs) + + +class MideaSelectEntity(MideaEntity, SelectEntity): + def __init__(self, device, manufacturer, rationale, entity_key, config): + super().__init__(device, manufacturer, rationale, entity_key, config) + self._key_options = self._config.get("options") + + @property + def options(self): + return list(self._key_options.keys()) + + @property + def current_option(self): + return self._dict_get_selected(self._key_options) + + def select_option(self, option: str): + new_status = self._key_options.get(option) + self._device.set_attributes(new_status) + + def update_state(self, status): + try: + self.schedule_update_ha_state() + except Exception as e: + pass + diff --git a/custom_components/midea_meiju_codec/sensor.py b/custom_components/midea_meiju_codec/sensor.py index 484e996..9d77204 100644 --- a/custom_components/midea_meiju_codec/sensor.py +++ b/custom_components/midea_meiju_codec/sensor.py @@ -16,16 +16,17 @@ 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") + rationale = hass.data[DOMAIN][DEVICES][device_id].get("rationale") 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)) + devs.append(MideaSensorEntity(device, manufacturer, rationale, entity_key, config)) async_add_entities(devs) class MideaSensorEntity(MideaEntity, SensorEntity): @property - def state(self): + def native_value(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 index c97f593..850a222 100644 --- a/custom_components/midea_meiju_codec/switch.py +++ b/custom_components/midea_meiju_codec/switch.py @@ -4,8 +4,6 @@ from homeassistant.const import ( CONF_DEVICE_ID, CONF_DEVICE, CONF_ENTITIES, - STATE_ON, - STATE_OFF ) from .const import ( DOMAIN, @@ -18,18 +16,19 @@ 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") + rationale = hass.data[DOMAIN][DEVICES][device_id].get("rationale") 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)) + devs.append(MideaSwitchEntity(device, manufacturer, rationale, 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]) + self._set_status_on_off(self._entity_key, True) def turn_off(self): - self._device.set_attribute(attribute=self._entity_key, value=self._binary_rationale[0]) + self._set_status_on_off(self._entity_key, False) diff --git a/custom_components/midea_meiju_codec/water_heater.py b/custom_components/midea_meiju_codec/water_heater.py new file mode 100644 index 0000000..e614d06 --- /dev/null +++ b/custom_components/midea_meiju_codec/water_heater.py @@ -0,0 +1,128 @@ +from homeassistant.components.water_heater import WaterHeaterEntity, WaterHeaterEntityFeature +from homeassistant.const import ( + Platform, + CONF_DEVICE_ID, + CONF_DEVICE, + CONF_ENTITIES, + ATTR_TEMPERATURE +) +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") + rationale = hass.data[DOMAIN][DEVICES][device_id].get("rationale") + entities = hass.data[DOMAIN][DEVICES][device_id].get(CONF_ENTITIES).get(Platform.WATER_HEATER) + devs = [] + if entities is not None: + for entity_key, config in entities.items(): + devs.append(MideaWaterHeaterEntityEntity(device, manufacturer, rationale, entity_key, config)) + async_add_entities(devs) + + +class MideaWaterHeaterEntityEntity(MideaEntity, WaterHeaterEntity): + def __init__(self, device, manufacturer, rationale, entity_key, config): + super().__init__(device, manufacturer, rationale, entity_key, config) + self._key_power = self._config.get("power") + self._key_operation_list = self._config.get("operation_list") + self._key_min_temp = self._config.get("min_temp") + self._key_max_temp = self._config.get("max_temp") + self._key_current_temperature = self._config.get("current_temperature") + 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 supported_features(self): + features = 0 + if self._key_target_temperature is not None: + features |= WaterHeaterEntityFeature.TARGET_TEMPERATURE + if self._key_operation_list is not None: + features |= WaterHeaterEntityFeature.OPERATION_MODE + return features + + @property + def operation_list(self): + return list(self._key_operation_list.keys()) + + @property + def current_operation(self): + return self._dict_get_selected(self._key_operation_list) + + @property + def current_temperature(self): + return self._device.get_attribute(self._key_current_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 is_on(self) -> bool: + return self._get_status_on_off(self._key_power) + + def turn_on(self): + self._set_status_on_off(self._key_power, True) + + def turn_off(self): + self._set_status_on_off(self._key_power, False) + + 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) + 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 + self._device.set_attributes(new_status) + + def set_operation_mode(self, operation_mode: str) -> None: + new_status = self._key_operation_list.get(operation_mode) + self._device.set_attributes(new_status) + + def update_state(self, status): + try: + self.schedule_update_ha_state() + except Exception as e: + pass +