6 Commits

Author SHA1 Message Date
sususweet
2af99c11da feat: update readme 2025-09-24 20:59:17 +08:00
sususweet
b1bf76f292 feat: refactor code to add more devices. 2025-09-24 20:56:04 +08:00
sususweet
51f0fcc8dc feat: refactor code to add more attributes. 2025-09-24 19:57:11 +08:00
sususweet
39f88365e5 feat: add more device support 2025-09-24 18:20:58 +08:00
sususweet
2f88658fda fix: cloud api loop error when device type not found. 2025-09-24 16:32:23 +08:00
sususweet
a9e2b784b5 fix: loop error when device type not found. 2025-09-24 11:41:11 +08:00
21 changed files with 1242 additions and 398 deletions

View File

@@ -14,6 +14,16 @@
- 所有设备默认可生成一个名为Status的二进制传感器其属性中列出了设备可访问的所有属性当然有些值不可设置
- Status实体前几项列出了该设备的分类信息供参考
## 目前支持的设备类型
- T0xAC 空调
- T0xB8 智能扫地机器人
- T0xE1 洗碗机
- T0xEA 电饭锅
- T0xED 软水机
欢迎合作开发添加更多设备支持。
## 实体映射
映射文件位于`device_mapping`下, 每个大的品类一个映射文件,目前支持映射的实体类型如下:

View File

@@ -1,6 +1,5 @@
import os
import base64
import voluptuous as vol
from importlib import import_module
from homeassistant.config_entries import ConfigEntry
from homeassistant.util.json import load_json
@@ -13,7 +12,6 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.core import (
HomeAssistant,
ServiceCall
)
from homeassistant.const import (
Platform,
@@ -50,8 +48,8 @@ from .const import CONF_PASSWORD as CONF_PASSWORD_KEY, CONF_SERVER as CONF_SERVE
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
# Platform.SENSOR,
# Platform.SWITCH,
Platform.SENSOR,
Platform.SWITCH,
Platform.CLIMATE,
Platform.SELECT,
Platform.WATER_HEATER,
@@ -85,16 +83,16 @@ async def load_device_config(hass: HomeAssistant, device_type, sn8):
config_file = hass.config.path(f"{CONFIG_PATH}/{sn8}.json")
raw = await hass.async_add_executor_job(_ensure_dir_and_load, config_dir, config_file)
json_data = {}
if isinstance(raw, dict) and len(raw) > 0:
# 兼容两种文件结构:
# 1) { "<sn8>": { ...mapping... } }
# 2) { ...mapping... }(直接就是映射体)
if sn8 in raw:
json_data = raw.get(sn8) or {}
else:
# 如果像映射体(包含 entities/centralized 等关键字段),直接使用
if any(k in raw for k in ["entities", "centralized", "queries", "manufacturer"]):
json_data = raw
# if isinstance(raw, dict) and len(raw) > 0:
# # 兼容两种文件结构:
# # 1) { "<sn8>": { ...mapping... } }
# # 2) { ...mapping... }(直接就是映射体)
# if sn8 in raw:
# json_data = raw.get(sn8) or {}
# else:
# # 如果像映射体(包含 entities/centralized 等关键字段),直接使用
# if any(k in raw for k in ["entities", "centralized", "queries", "manufacturer"]):
# json_data = raw
if not json_data:
device_path = f".device_mapping.{'T0x%02X' % device_type}"
try:
@@ -103,12 +101,12 @@ async def load_device_config(hass: HomeAssistant, device_type, sn8):
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}
# offload save_json as well
await hass.async_add_executor_job(save_json, config_file, save_data)
except ModuleNotFoundError:
MideaLogger.warning(f"Can't load mapping file for type {'T0x%02X' % device_type}")
save_data = {sn8: json_data}
# offload save_json as well
await hass.async_add_executor_job(save_json, config_file, save_data)
return json_data
async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry):
@@ -164,142 +162,137 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry):
return False
# 拉取家庭与设备列表
appliances = None
first_home_id = None
try:
homes = await cloud.list_home()
if homes and len(homes) > 0:
first_home_id = list(homes.keys())[0]
appliances = await cloud.list_appliances(first_home_id)
else:
appliances = await cloud.list_appliances(None)
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN].setdefault("accounts", {})
bucket = {"device_list": {}, "coordinator_map": {}}
home_ids = list(homes.keys())
for home_id in home_ids:
appliances = await cloud.list_appliances(home_id)
if appliances is None:
continue
# 为每台设备构建占位设备与协调器(不连接本地)
for appliance_code, info in appliances.items():
MideaLogger.debug(f"info={info} ")
try:
device = MiedaDevice(
name=info.get(CONF_NAME) or info.get("name"),
device_id=appliance_code,
device_type=info.get(CONF_TYPE) or info.get("type"),
ip_address=None,
port=None,
token=None,
key=None,
connected=info.get("online"),
protocol=info.get(CONF_PROTOCOL) or 2,
model=info.get(CONF_MODEL),
subtype=info.get(CONF_MODEL_NUMBER),
sn=info.get(CONF_SN) or info.get("sn"),
sn8=info.get(CONF_SN8) or info.get("sn8"),
)
# 加载并应用设备映射queries/centralized/calculate并预置 attributes 键
try:
mapping = await load_device_config(
hass,
info.get(CONF_TYPE) or info.get("type"),
info.get(CONF_SN8) or info.get("sn8"),
) or {}
except Exception:
mapping = {}
try:
device.set_queries(mapping.get("queries", []))
except Exception:
pass
try:
device.set_centralized(mapping.get("centralized", []))
except Exception:
pass
try:
device.set_calculate(mapping.get("calculate", {}))
except Exception:
pass
# 预置 attributes包含 centralized 里声明的所有键、entities 中使用到的所有属性键
try:
preset_keys = set(mapping.get("centralized", []))
entities_cfg = (mapping.get("entities") or {})
# 收集实体配置中直接引用的属性键
for platform_cfg in entities_cfg.values():
if not isinstance(platform_cfg, dict):
continue
for _, ecfg in platform_cfg.items():
if not isinstance(ecfg, dict):
continue
# 常见直接属性字段
for k in [
"power",
"aux_heat",
"current_temperature",
"target_temperature",
"oscillate",
"min_temp",
"max_temp",
]:
v = ecfg.get(k)
if isinstance(v, str):
preset_keys.add(v)
elif isinstance(v, list):
for vv in v:
if isinstance(vv, str):
preset_keys.add(vv)
# 模式映射里的条件字段
for map_key in [
"hvac_modes",
"preset_modes",
"swing_modes",
"fan_modes",
"operation_list",
"options",
]:
maps = ecfg.get(map_key) or {}
if isinstance(maps, dict):
for _, cond in maps.items():
if isinstance(cond, dict):
for attr_name in cond.keys():
preset_keys.add(attr_name)
# 传感器/开关等实体 key 本身也加入(其 key 即属性名)
for platform_name, platform_cfg in entities_cfg.items():
if not isinstance(platform_cfg, dict):
continue
platform_str = str(platform_name)
if platform_str in [
str(Platform.SENSOR),
str(Platform.BINARY_SENSOR),
str(Platform.SWITCH),
str(Platform.FAN),
str(Platform.SELECT),
]:
for entity_key in platform_cfg.keys():
preset_keys.add(entity_key)
# 写入默认空值
for k in preset_keys:
if k not in device.attributes:
device.attributes[k] = None
except Exception:
pass
coordinator = MideaDataUpdateCoordinator(hass, config_entry, device, cloud=cloud)
# 后台刷新,避免初始化阻塞
hass.async_create_task(coordinator.async_config_entry_first_refresh())
bucket["device_list"][appliance_code] = info
bucket["coordinator_map"][appliance_code] = coordinator
except Exception as e:
MideaLogger.error(f"Init device failed: {appliance_code}, error: {e}")
hass.data[DOMAIN]["accounts"][config_entry.entry_id] = bucket
except Exception as e:
MideaLogger.error(f"Fetch appliances failed: {e}")
appliances = None
if appliances is None:
appliances = {}
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN].setdefault("accounts", {})
bucket = {"device_list": {}, "coordinator_map": {}, "cloud": cloud, "home_id": first_home_id}
# 为每台设备构建占位设备与协调器(不连接本地)
for appliance_code, info in appliances.items():
MideaLogger.debug(f"info={info} ")
try:
device = MiedaDevice(
name=info.get(CONF_NAME) or info.get("name"),
device_id=appliance_code,
device_type=info.get(CONF_TYPE) or info.get("type"),
ip_address=None,
port=None,
token=None,
key=None,
connected=info.get("online"),
protocol=info.get(CONF_PROTOCOL) or 2,
model=info.get(CONF_MODEL),
subtype=info.get(CONF_MODEL_NUMBER),
sn=info.get(CONF_SN) or info.get("sn"),
sn8=info.get(CONF_SN8) or info.get("sn8"),
)
# 加载并应用设备映射queries/centralized/calculate并预置 attributes 键
try:
mapping = await load_device_config(
hass,
info.get(CONF_TYPE) or info.get("type"),
info.get(CONF_SN8) or info.get("sn8"),
) or {}
except Exception:
mapping = {}
try:
device.set_queries(mapping.get("queries", []))
except Exception:
pass
try:
device.set_centralized(mapping.get("centralized", []))
except Exception:
pass
try:
device.set_calculate(mapping.get("calculate", {}))
except Exception:
pass
# 预置 attributes包含 centralized 里声明的所有键、entities 中使用到的所有属性键
try:
preset_keys = set(mapping.get("centralized", []))
entities_cfg = (mapping.get("entities") or {})
# 收集实体配置中直接引用的属性键
for platform_cfg in entities_cfg.values():
if not isinstance(platform_cfg, dict):
continue
for _, ecfg in platform_cfg.items():
if not isinstance(ecfg, dict):
continue
# 常见直接属性字段
for k in [
"power",
"aux_heat",
"current_temperature",
"target_temperature",
"oscillate",
"min_temp",
"max_temp",
]:
v = ecfg.get(k)
if isinstance(v, str):
preset_keys.add(v)
elif isinstance(v, list):
for vv in v:
if isinstance(vv, str):
preset_keys.add(vv)
# 模式映射里的条件字段
for map_key in [
"hvac_modes",
"preset_modes",
"swing_modes",
"fan_modes",
"operation_list",
"options",
]:
maps = ecfg.get(map_key) or {}
if isinstance(maps, dict):
for _, cond in maps.items():
if isinstance(cond, dict):
for attr_name in cond.keys():
preset_keys.add(attr_name)
# 传感器/开关等实体 key 本身也加入(其 key 即属性名)
for platform_name, platform_cfg in entities_cfg.items():
if not isinstance(platform_cfg, dict):
continue
platform_str = str(platform_name)
if platform_str in [
str(Platform.SENSOR),
str(Platform.BINARY_SENSOR),
str(Platform.SWITCH),
str(Platform.FAN),
str(Platform.SELECT),
]:
for entity_key in platform_cfg.keys():
preset_keys.add(entity_key)
# 写入默认空值
for k in preset_keys:
if k not in device.attributes:
device.attributes[k] = None
except Exception:
pass
coordinator = MideaDataUpdateCoordinator(hass, config_entry, device)
await coordinator.async_config_entry_first_refresh()
bucket["device_list"][appliance_code] = info
bucket["coordinator_map"][appliance_code] = coordinator
except Exception as e:
MideaLogger.error(f"Init device failed: {appliance_code}, error: {e}")
hass.data[DOMAIN]["accounts"][config_entry.entry_id] = bucket
hass.async_create_task(hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS))
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True

View File

@@ -57,18 +57,17 @@ class MideaDeviceStatusSensorEntity(MideaEntity, BinarySensorEntity):
device.sn,
device.sn8,
device.model,
entity_key,
device=device,
manufacturer=manufacturer,
rationale=rationale,
config=config,
)
self._device = device
self._manufacturer = manufacturer
self._rationale = rationale
self._entity_key = entity_key
self._config = config
@property
def entity_id_suffix(self) -> str:
"""Return the suffix for entity ID."""
return "status"
@property
def device_class(self):
"""Return the device class."""
@@ -102,6 +101,11 @@ class MideaBinarySensorEntity(MideaEntity, BinarySensorEntity):
device.sn,
device.sn8,
device.model,
entity_key,
device=device,
manufacturer=manufacturer,
rationale=rationale,
config=config,
)
self._device = device
self._manufacturer = manufacturer
@@ -109,11 +113,6 @@ class MideaBinarySensorEntity(MideaEntity, BinarySensorEntity):
self._entity_key = entity_key
self._config = config
@property
def entity_id_suffix(self) -> str:
"""Return the suffix for entity ID."""
return f"binary_sensor_{self._entity_key}"
@property
def is_on(self):
"""Return if the binary sensor is on."""

View File

@@ -15,7 +15,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .core.logger import MideaLogger
from .midea_entity import MideaEntity
from .midea_entities import Rationale
from . import load_device_config
@@ -44,9 +43,6 @@ async def async_setup_entry(
coordinator = coordinator_map.get(device_id)
device = coordinator.device if coordinator else None
MideaLogger.debug(f"entities_cfg={entities_cfg} ")
for entity_key, ecfg in entities_cfg.items():
devs.append(MideaClimateEntity(
coordinator, device, manufacturer, rationale, entity_key, ecfg
@@ -64,11 +60,15 @@ class MideaClimateEntity(MideaEntity, ClimateEntity):
device.sn,
device.sn8,
device.model,
entity_key,
device=device,
manufacturer=manufacturer,
rationale=rationale,
config=config,
)
self._device = device
self._manufacturer = manufacturer
self._rationale = rationale
self._entity_key = entity_key
self._config = config
self._key_power = self._config.get("power")
self._key_hvac_modes = self._config.get("hvac_modes")
@@ -149,7 +149,7 @@ class MideaClimateEntity(MideaEntity, ClimateEntity):
@property
def fan_mode(self):
return self._dict_get_selected(self._key_fan_modes, Rationale.EQUALLY)
return self._dict_get_selected(self._key_fan_modes, "EQUALLY")
@property
def swing_modes(self):
@@ -157,7 +157,7 @@ class MideaClimateEntity(MideaEntity, ClimateEntity):
@property
def swing_mode(self):
return self._dict_get_selected(self._key_swing_modes)
return self._dict_get_selected(self._key_swing_modes, "EQUALLY")
@property
def is_on(self) -> bool:
@@ -165,7 +165,7 @@ class MideaClimateEntity(MideaEntity, ClimateEntity):
@property
def hvac_mode(self):
return self._dict_get_selected(self._key_hvac_modes)
return self._dict_get_selected(self._key_hvac_modes, "EQUALLY")
@property
def hvac_modes(self):
@@ -236,7 +236,7 @@ class MideaClimateEntity(MideaEntity, ClimateEntity):
return
await self.async_set_attribute(key, value)
def _dict_get_selected(self, dict_config, rationale=Rationale.EQUALLY):
def _dict_get_selected(self, dict_config, rationale="EQUALLY"):
"""Get selected value from dictionary configuration."""
if dict_config is None:
return None
@@ -251,15 +251,15 @@ class MideaClimateEntity(MideaEntity, ClimateEntity):
if device_value is None:
match = False
break
if rationale == Rationale.EQUALLY:
if rationale == "EQUALLY":
if device_value != attr_value:
match = False
break
elif rationale == Rationale.LESS:
elif rationale == "LESS":
if device_value >= attr_value:
match = False
break
elif rationale == Rationale.GREATER:
elif rationale == "GREATER":
if device_value <= attr_value:
match = False
break

View File

@@ -60,7 +60,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
data_schema=vol.Schema({
vol.Required(CONF_ACCOUNT): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(CONF_SERVER, default=1): vol.In(CONF_SERVERS)
vol.Required(CONF_SERVER, default=2): vol.In(CONF_SERVERS)
}),
errors=errors,
)

View File

@@ -3,9 +3,9 @@ import time
import datetime
import json
import base64
from threading import Lock
from aiohttp import ClientSession
from secrets import token_hex
from .logger import MideaLogger
from .security import CloudSecurity, MeijuCloudSecurity, MSmartCloudSecurity
_LOGGER = logging.getLogger(__name__)
@@ -50,7 +50,6 @@ class MideaCloud:
self._device_id = CloudSecurity.get_deviceid(account)
self._session = session
self._security = security
self._api_lock = Lock()
self._app_key = app_key
self._account = account
self._password = password
@@ -86,16 +85,15 @@ class MideaCloud:
"accesstoken": self._access_token
})
response:dict = {"code": -1}
for i in range(0, 3):
try:
with self._api_lock:
r = await self._session.request("POST", url, headers=header, data=dump_data, timeout=10)
raw = await r.read()
_LOGGER.debug(f"Midea cloud API url: {url}, data: {data}, response: {raw}")
response = json.loads(raw)
break
except Exception as e:
pass
_LOGGER.debug(f"Midea cloud API header: {header}")
_LOGGER.debug(f"Midea cloud API dump_data: {dump_data}")
try:
r = await self._session.request("POST", url, headers=header, data=dump_data, timeout=5)
raw = await r.read()
_LOGGER.debug(f"Midea cloud API url: {url}, data: {data}, response: {raw}")
response = json.loads(raw)
except Exception as e:
_LOGGER.debug(f"API request attempt failed: {e}")
if int(response["code"]) == 0 and "data" in response:
return response["data"]
@@ -251,7 +249,7 @@ class MeijuCloud(MideaCloud):
"sn": self._security.aes_decrypt(appliance.get("sn")) if appliance.get("sn") else "",
"sn8": appliance.get("sn8", "00000000"),
"model_number": appliance.get("modelNumber", "0"),
"manufacturer_code":appliance.get("enterpriseCode", "0000"),
"manufacturer_code": appliance.get("enterpriseCode", "0000"),
"model": appliance.get("productModel"),
"online": appliance.get("onlineStatus") == "1",
}
@@ -267,7 +265,7 @@ class MeijuCloud(MideaCloud):
data = {
"applianceCode": str(appliance_code),
"command": {
"query": {"query_type": "total_query"}
"query": {}
}
}
if response := await self._api_request(

View File

@@ -10,7 +10,6 @@ from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .core.device import MiedaDevice
from .const import DOMAIN
from .core.logger import MideaLogger
_LOGGER = logging.getLogger(__name__)
@@ -31,6 +30,7 @@ class MideaDataUpdateCoordinator(DataUpdateCoordinator[MideaDeviceData]):
hass: HomeAssistant,
config_entry: ConfigEntry,
device: MiedaDevice,
cloud=None,
) -> None:
"""Initialize the coordinator."""
super().__init__(
@@ -45,6 +45,7 @@ class MideaDataUpdateCoordinator(DataUpdateCoordinator[MideaDeviceData]):
self.device = device
self.state_update_muted: CALLBACK_TYPE | None = None
self._device_id = device.device_id
self._cloud = cloud
async def _async_setup(self) -> None:
"""Set up the coordinator."""
@@ -89,9 +90,8 @@ class MideaDataUpdateCoordinator(DataUpdateCoordinator[MideaDeviceData]):
return self.data
try:
# 尝试账号模式下的云端轮询(如果 cloud 存在且支持
account_bucket = self.hass.data.get(DOMAIN, {}).get("accounts", {}).get(self.config_entry.entry_id)
cloud = account_bucket.get("cloud") if account_bucket else None
# 使用传入的 cloud 实例(若可用
cloud = self._cloud
if cloud and hasattr(cloud, "get_device_status"):
try:
status = await cloud.get_device_status(self._device_id)
@@ -120,8 +120,7 @@ class MideaDataUpdateCoordinator(DataUpdateCoordinator[MideaDeviceData]):
async def async_set_attribute(self, attribute: str, value) -> None:
"""Set a device attribute."""
# 云端控制:构造 control 与 status携带当前状态作为上下文
account_bucket = self.hass.data.get(DOMAIN, {}).get("accounts", {}).get(self.config_entry.entry_id)
cloud = account_bucket.get("cloud") if account_bucket else None
cloud = self._cloud
control = {attribute: value}
status = dict(self.device.attributes)
if cloud and hasattr(cloud, "send_device_control"):
@@ -134,8 +133,7 @@ class MideaDataUpdateCoordinator(DataUpdateCoordinator[MideaDeviceData]):
async def async_set_attributes(self, attributes: dict) -> None:
"""Set multiple device attributes."""
account_bucket = self.hass.data.get(DOMAIN, {}).get("accounts", {}).get(self.config_entry.entry_id)
cloud = account_bucket.get("cloud") if account_bucket else None
cloud = self._cloud
control = dict(attributes)
status = dict(self.device.attributes)
if cloud and hasattr(cloud, "send_device_control"):

View File

@@ -15,7 +15,6 @@ DEVICE_MAPPING = {
"entities": {
Platform.CLIMATE: {
"thermostat": {
"name": "Thermostat",
"power": "power",
"hvac_modes": {
"off": {"power": "off"},
@@ -96,7 +95,6 @@ DEVICE_MAPPING = {
"entities": {
Platform.CLIMATE: {
"thermostat": {
"name": "Thermostat",
"power": "power",
"hvac_modes": {
"off": {"power": "off"},

View File

@@ -0,0 +1,159 @@
from homeassistant.const import Platform, UnitOfTemperature, PRECISION_HALVES, UnitOfTime, UnitOfArea, UnitOfVolume
from homeassistant.components.sensor import SensorStateClass, SensorDeviceClass
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.components.switch import SwitchDeviceClass
DEVICE_MAPPING = {
"default": {
"rationale": ["off", "on"],
"queries": [{}],
"centralized": [],
"entities": {
Platform.SELECT: {
"fan_level": {
"options": {
"low": {"fan_level": "low"},
"medium": {"fan_level": "medium"},
"high": {"fan_level": "high"},
"auto": {"fan_level": "auto"}
}
},
"work_mode": {
"options": {
"none": {"work_mode": "none"},
"auto": {"work_mode": "auto"},
"spot": {"work_mode": "spot"},
"edge": {"work_mode": "edge"},
"single_room": {"work_mode": "single_room"},
"custom": {"work_mode": "custom"}
}
},
"work_status": {
"options": {
"idle": {"work_status": "idle"},
"cleaning": {"work_status": "cleaning"},
"returning": {"work_status": "returning"},
"docked": {"work_status": "docked"},
"on_base": {"work_status": "on_base"},
"charging": {"work_status": "charging"},
"error": {"work_status": "error"}
}
},
"move_direction": {
"options": {
"none": {"move_direction": "none"},
"forward": {"move_direction": "forward"},
"backward": {"move_direction": "backward"},
"left": {"move_direction": "left"},
"right": {"move_direction": "right"}
}
},
"query_type": {
"options": {
"work": {"query_type": "work"},
"status": {"query_type": "status"},
"battery": {"query_type": "battery"},
"error": {"query_type": "error"}
}
},
"sub_work_status": {
"options": {
"idle": {"sub_work_status": "idle"},
"cleaning": {"sub_work_status": "cleaning"},
"charging": {"sub_work_status": "charging"},
"charge_finish": {"sub_work_status": "charge_finish"},
"error": {"sub_work_status": "error"}
}
},
"water_level": {
"options": {
"low": {"water_level": "low"},
"normal": {"water_level": "normal"},
"high": {"water_level": "high"}
}
},
"mop_status": {
"options": {
"normal": {"mop_status": "normal"},
"lack_water": {"mop_status": "lack_water"},
"full_water": {"mop_status": "full_water"}
}
},
"error_type": {
"options": {
"no_error": {"error_type": "no_error"},
"can_fix": {"error_type": "can_fix"},
"need_help": {"error_type": "need_help"}
}
},
"control_type": {
"options": {
"none": {"control_type": "none"},
"app": {"control_type": "app"},
"remote": {"control_type": "remote"},
"auto": {"control_type": "auto"}
}
}
},
Platform.BINARY_SENSOR: {
"carpet_switch": {
"device_class": BinarySensorDeviceClass.RUNNING,
},
"have_reserve_task": {
"device_class": BinarySensorDeviceClass.RUNNING,
}
},
Platform.SENSOR: {
"dust_count": {
"device_class": SensorDeviceClass.ENUM,
"state_class": SensorStateClass.MEASUREMENT
},
"area": {
"device_class": SensorDeviceClass.AREA,
"unit_of_measurement": UnitOfArea.SQUARE_METERS,
"state_class": SensorStateClass.MEASUREMENT
},
"voice_level": {
"device_class": SensorDeviceClass.BATTERY,
"unit_of_measurement": "%",
"state_class": SensorStateClass.MEASUREMENT
},
"switch_status": {
"device_class": SensorDeviceClass.ENUM,
"state_class": SensorStateClass.MEASUREMENT
},
"water_station_status": {
"device_class": SensorDeviceClass.ENUM,
"state_class": SensorStateClass.MEASUREMENT
},
"work_time": {
"device_class": SensorDeviceClass.DURATION,
"unit_of_measurement": UnitOfTime.MINUTES,
"state_class": SensorStateClass.MEASUREMENT
},
"battery_percent": {
"device_class": SensorDeviceClass.BATTERY,
"unit_of_measurement": "%",
"state_class": SensorStateClass.MEASUREMENT
},
"planner_status": {
"device_class": SensorDeviceClass.ENUM,
"state_class": SensorStateClass.MEASUREMENT
},
"sweep_then_mop_mode_progress": {
"device_class": SensorDeviceClass.BATTERY,
"unit_of_measurement": "%",
"state_class": SensorStateClass.MEASUREMENT
},
"error_desc": {
"device_class": SensorDeviceClass.ENUM,
"state_class": SensorStateClass.MEASUREMENT
},
"station_error_desc": {
"device_class": SensorDeviceClass.ENUM,
"state_class": SensorStateClass.MEASUREMENT
}
}
}
}
}

View File

@@ -0,0 +1,103 @@
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.const import Platform, UnitOfTemperature, PRECISION_HALVES, UnitOfTime
from homeassistant.components.sensor import SensorStateClass, SensorDeviceClass
from homeassistant.components.switch import SwitchDeviceClass
DEVICE_MAPPING = {
"default": {
"rationale": ["off", "on"],
"queries": [{}],
"centralized": [],
"entities": {
Platform.SWITCH: {
"airswitch": {
"device_class": SwitchDeviceClass.SWITCH,
},
"waterswitch": {
"device_class": SwitchDeviceClass.SWITCH,
},
"uvswitch": {
"device_class": SwitchDeviceClass.SWITCH,
},
"doorswitch": {
"device_class": SwitchDeviceClass.SWITCH,
},
"dryswitch": {
"device_class": SwitchDeviceClass.SWITCH,
},
"dry_step_switch": {
"device_class": SwitchDeviceClass.SWITCH,
}
},
Platform.BINARY_SENSOR: {
"air_status": {
"device_class": BinarySensorDeviceClass.RUNNING,
},
"water_lack": {
"device_class": BinarySensorDeviceClass.RUNNING,
},
"softwater_lack": {
"device_class": BinarySensorDeviceClass.RUNNING,
},
"wash_stage":{
"device_class": BinarySensorDeviceClass.RUNNING,
},
"bright_lack": {
"device_class": BinarySensorDeviceClass.RUNNING,
},
"diy_flag": {
"device_class": BinarySensorDeviceClass.RUNNING,
},
"diy_main_wash": {
"device_class": BinarySensorDeviceClass.RUNNING,
},
"diy_piao_wash": {
"device_class": BinarySensorDeviceClass.RUNNING,
},
"diy_times": {
"device_class": BinarySensorDeviceClass.RUNNING,
},
},
Platform.SELECT: {
"work_status": {
"options": {
"power_off": {"work_status": "power_off" },
"power_on": {"work_status": "power_on" },
}
},
},
Platform.SENSOR: {
"bright": {
"device_class": SensorDeviceClass.ILLUMINANCE,
"unit_of_measurement": "lx",
"state_class": SensorStateClass.MEASUREMENT
},
"temperature": {
"device_class": SensorDeviceClass.TEMPERATURE,
"unit_of_measurement": UnitOfTemperature.CELSIUS,
"state_class": SensorStateClass.MEASUREMENT
},
"softwater": {
"device_class": SensorDeviceClass.TEMPERATURE,
"unit_of_measurement": UnitOfTemperature.CELSIUS,
"state_class": SensorStateClass.MEASUREMENT
},
"left_time": {
"device_class": SensorDeviceClass.DURATION,
"unit_of_measurement": UnitOfTime.HOURS,
"state_class": SensorStateClass.MEASUREMENT
},
"air_set_hour": {
"device_class": SensorDeviceClass.DURATION,
"unit_of_measurement": UnitOfTime.HOURS,
"state_class": SensorStateClass.MEASUREMENT
},
"air_left_hour": {
"device_class": SensorDeviceClass.DURATION,
"unit_of_measurement": UnitOfTime.HOURS,
"state_class": SensorStateClass.MEASUREMENT
},
}
}
}
}

View File

@@ -0,0 +1,235 @@
from homeassistant.const import Platform, UnitOfTemperature, PRECISION_HALVES, UnitOfTime, UnitOfElectricPotential, UnitOfVolume, UnitOfMass
from homeassistant.components.sensor import SensorStateClass, SensorDeviceClass
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.components.switch import SwitchDeviceClass
DEVICE_MAPPING = {
"default": {
"rationale": ["off", "on"],
"queries": [{}],
"centralized": [],
"entities": {
Platform.SWITCH: {
"holiday_mode": {
"device_class": SwitchDeviceClass.SWITCH,
},
"water_way": {
"device_class": SwitchDeviceClass.SWITCH,
},
"soften": {
"device_class": SwitchDeviceClass.SWITCH,
},
"leak_water_protection": {
"device_class": SwitchDeviceClass.SWITCH,
},
"cl_sterilization": {
"device_class": SwitchDeviceClass.SWITCH,
},
"micro_leak": {
"device_class": SwitchDeviceClass.SWITCH,
},
"low_salt": {
"device_class": SwitchDeviceClass.SWITCH,
},
"no_salt": {
"device_class": SwitchDeviceClass.SWITCH,
},
"low_battery": {
"device_class": SwitchDeviceClass.SWITCH,
},
"salt_level_sensor_error": {
"device_class": SwitchDeviceClass.SWITCH,
},
"flowmeter_error": {
"device_class": SwitchDeviceClass.SWITCH,
},
"leak_water": {
"device_class": SwitchDeviceClass.SWITCH,
},
"micro_leak_protection": {
"device_class": SwitchDeviceClass.SWITCH,
},
"maintenance_reminder_switch": {
"device_class": SwitchDeviceClass.SWITCH,
},
"rsj_stand_by": {
"device_class": SwitchDeviceClass.SWITCH,
},
"regeneration": {
"device_class": SwitchDeviceClass.SWITCH,
},
"pre_regeneration": {
"device_class": SwitchDeviceClass.SWITCH,
}
},
Platform.BINARY_SENSOR: {
"maintenance_remind": {
"device_class": BinarySensorDeviceClass.PROBLEM,
},
"chlorine_sterilization_error": {
"device_class": BinarySensorDeviceClass.PROBLEM,
},
"rtc_error": {
"device_class": BinarySensorDeviceClass.PROBLEM,
}
},
Platform.SENSOR: {
"micro_leak_protection_value": {
"device_class": SensorDeviceClass.PRESSURE,
"state_class": SensorStateClass.MEASUREMENT
},
"regeneration_current_stages": {
"device_class": SensorDeviceClass.ENUM,
"state_class": SensorStateClass.MEASUREMENT
},
"water_hardness": {
"device_class": SensorDeviceClass.WATER,
"state_class": SensorStateClass.MEASUREMENT
},
"timing_regeneration_hour": {
"device_class": SensorDeviceClass.DURATION,
"unit_of_measurement": UnitOfTime.HOURS,
"state_class": SensorStateClass.MEASUREMENT
},
"real_time_setting_hour": {
"device_class": SensorDeviceClass.DURATION,
"unit_of_measurement": UnitOfTime.HOURS,
"state_class": SensorStateClass.MEASUREMENT
},
"timing_regeneration_min": {
"device_class": SensorDeviceClass.DURATION,
"unit_of_measurement": UnitOfTime.MINUTES,
"state_class": SensorStateClass.MEASUREMENT
},
"regeneration_left_seconds": {
"device_class": SensorDeviceClass.DURATION,
"unit_of_measurement": UnitOfTime.SECONDS,
"state_class": SensorStateClass.MEASUREMENT
},
"maintenance_reminder_setting": {
"device_class": SensorDeviceClass.ENUM,
"state_class": SensorStateClass.MEASUREMENT
},
"mixed_water_gear": {
"device_class": SensorDeviceClass.ENUM,
"state_class": SensorStateClass.MEASUREMENT
},
"use_days": {
"device_class": SensorDeviceClass.DURATION,
"unit_of_measurement": UnitOfTime.DAYS,
"state_class": SensorStateClass.MEASUREMENT
},
"days_since_last_regeneration": {
"device_class": SensorDeviceClass.DURATION,
"unit_of_measurement": UnitOfTime.DAYS,
"state_class": SensorStateClass.MEASUREMENT
},
"velocity": {
"device_class": SensorDeviceClass.SPEED,
"state_class": SensorStateClass.MEASUREMENT
},
"supply_voltage": {
"device_class": SensorDeviceClass.VOLTAGE,
"unit_of_measurement": UnitOfElectricPotential.VOLT,
"state_class": SensorStateClass.MEASUREMENT
},
"left_salt": {
"device_class": SensorDeviceClass.WEIGHT,
"unit_of_measurement": UnitOfMass.KILOGRAMS,
"state_class": SensorStateClass.MEASUREMENT
},
"pre_regeneration_days": {
"device_class": SensorDeviceClass.DURATION,
"unit_of_measurement": UnitOfTime.DAYS,
"state_class": SensorStateClass.MEASUREMENT
},
"flushing_days": {
"device_class": SensorDeviceClass.DURATION,
"unit_of_measurement": UnitOfTime.DAYS,
"state_class": SensorStateClass.MEASUREMENT
},
"salt_setting": {
"device_class": SensorDeviceClass.ENUM,
"state_class": SensorStateClass.MEASUREMENT
},
"regeneration_count": {
"device_class": SensorDeviceClass.ENUM,
"state_class": SensorStateClass.MEASUREMENT
},
"battery_voltage": {
"device_class": SensorDeviceClass.VOLTAGE,
"unit_of_measurement": UnitOfElectricPotential.VOLT,
"state_class": SensorStateClass.MEASUREMENT
},
"error": {
"device_class": SensorDeviceClass.ENUM,
"state_class": SensorStateClass.MEASUREMENT
},
"days_since_last_two_regeneration": {
"device_class": SensorDeviceClass.DURATION,
"unit_of_measurement": UnitOfTime.DAYS,
"state_class": SensorStateClass.MEASUREMENT
},
"remind_maintenance_days": {
"device_class": SensorDeviceClass.DURATION,
"unit_of_measurement": UnitOfTime.DAYS,
"state_class": SensorStateClass.MEASUREMENT
},
"real_date_setting_year": {
"device_class": SensorDeviceClass.ENUM,
"state_class": SensorStateClass.MEASUREMENT
},
"real_date_setting_month": {
"device_class": SensorDeviceClass.ENUM,
"state_class": SensorStateClass.MEASUREMENT
},
"real_date_setting_day": {
"device_class": SensorDeviceClass.ENUM,
"state_class": SensorStateClass.MEASUREMENT
},
"category": {
"device_class": SensorDeviceClass.ENUM,
"state_class": SensorStateClass.MEASUREMENT
},
"real_time_setting_min": {
"device_class": SensorDeviceClass.DURATION,
"unit_of_measurement": UnitOfTime.MINUTES,
"state_class": SensorStateClass.MEASUREMENT
},
"regeneration_stages": {
"device_class": SensorDeviceClass.ENUM,
"state_class": SensorStateClass.MEASUREMENT
},
"soft_available_big": {
"device_class": SensorDeviceClass.VOLUME,
"unit_of_measurement": UnitOfVolume.LITERS,
"state_class": SensorStateClass.MEASUREMENT
},
"water_consumption_big": {
"device_class": SensorDeviceClass.VOLUME,
"unit_of_measurement": UnitOfVolume.LITERS,
"state_class": SensorStateClass.MEASUREMENT
},
"water_consumption_today": {
"device_class": SensorDeviceClass.VOLUME,
"unit_of_measurement": UnitOfVolume.LITERS,
"state_class": SensorStateClass.MEASUREMENT
},
"water_consumption_average": {
"device_class": SensorDeviceClass.VOLUME,
"unit_of_measurement": UnitOfVolume.LITERS,
"state_class": SensorStateClass.MEASUREMENT
},
"salt_alarm_threshold": {
"device_class": SensorDeviceClass.WEIGHT,
"unit_of_measurement": UnitOfMass.KILOGRAMS,
"state_class": SensorStateClass.MEASUREMENT
},
"leak_water_protection_value": {
"device_class": SensorDeviceClass.PRESSURE,
"state_class": SensorStateClass.MEASUREMENT
}
}
}
}
}

View File

@@ -1,7 +1,7 @@
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.const import Platform
from .const import DOMAIN
from .midea_entities import MideaEntity
from .midea_entity import MideaEntity
from . import load_device_config
@@ -24,13 +24,30 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
coordinator = coordinator_map.get(device_id)
device = coordinator.device if coordinator else None
for entity_key, ecfg in entities_cfg.items():
devs.append(MideaFanEntity(device, manufacturer, rationale, entity_key, ecfg))
devs.append(MideaFanEntity(coordinator, device, manufacturer, rationale, entity_key, ecfg))
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)
def __init__(self, coordinator, device, manufacturer, rationale, entity_key, config):
super().__init__(
coordinator,
device.device_id,
device.device_name,
f"T0x{device.device_type:02X}",
device.sn,
device.sn8,
device.model,
entity_key,
device=device,
manufacturer=manufacturer,
rationale=rationale,
config=config,
)
self._device = device
self._manufacturer = manufacturer
self._rationale = rationale
self._config = config
self._key_power = self._config.get("power")
self._key_preset_modes = self._config.get("preset_modes")
self._key_speeds = self._config.get("speeds")
@@ -74,41 +91,48 @@ class MideaFanEntity(MideaEntity, FanEntity):
def oscillating(self):
return self._get_status_on_off(self._key_oscillate)
def turn_on(
async def async_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:
new_status = {}
if preset_mode is not None and self._key_preset_modes is not None:
new_status.update(self._key_preset_modes.get(preset_mode, {}))
if percentage is not None and self._key_speeds:
index = round(percentage * self._attr_speed_count / 100) - 1
index = max(0, min(index, len(self._key_speeds) - 1))
new_status.update(self._key_speeds[index])
new_status[self._key_power] = self._rationale[1]
self._device.set_attributes(new_status)
if self._key_power is not None:
new_status[self._key_power] = True
if new_status:
await self.async_set_attributes(new_status)
def turn_off(self):
self._set_status_on_off(self._key_power, False)
async def async_turn_off(self):
await self._async_set_status_on_off(self._key_power, False)
def set_percentage(self, percentage: int):
async def async_set_percentage(self, percentage: int):
if not self._key_speeds:
return
index = round(percentage * self._attr_speed_count / 100)
if 0 < index < len(self._key_speeds):
if 0 < index <= len(self._key_speeds):
new_status = self._key_speeds[index - 1]
self._device.set_attributes(new_status)
await self.async_set_attributes(new_status)
def set_preset_mode(self, preset_mode: str):
async def async_set_preset_mode(self, preset_mode: str):
if not self._key_preset_modes:
return
new_status = self._key_preset_modes.get(preset_mode)
self._device.set_attributes(new_status)
if new_status:
await self.async_set_attributes(new_status)
def oscillate(self, oscillating: bool):
async def async_oscillate(self, oscillating: bool):
if self.oscillating != oscillating:
self._set_status_on_off(self._key_oscillate, oscillating)
await self._async_set_status_on_off(self._key_oscillate, oscillating)
def update_state(self, status):
try:
self.schedule_update_ha_state()
except Exception as e:
except Exception:
pass

View File

@@ -7,5 +7,5 @@
"iot_class": "cloud_push",
"issue_tracker": "https://github.com/sususweet/midea-meiju-codec/issues",
"requirements": [],
"version": "v0.0.1"
"version": "v0.0.3"
}

View File

@@ -1,130 +0,0 @@
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, 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._rationale = rationale
if rationale_local := config.get("rationale"):
self._rationale = rationale_local
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_icon = self._config.get("icon")
self._attr_unique_id = f"{DOMAIN}.{self._device.device_id}_{self._entity_key}"
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
}
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 device(self):
return self._device
@property
def should_poll(self):
return False
@property
def available(self):
return self._device.connected
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)
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
class MideaBinaryBaseEntity(MideaEntity):
@property
def state(self):
return STATE_ON if self.is_on else STATE_OFF
@property
def is_on(self):
return self._get_status_on_off(self._entity_key)

View File

@@ -29,27 +29,72 @@ class MideaEntity(CoordinatorEntity[MideaDataUpdateCoordinator], Entity):
sn: str,
sn8: str,
model: str,
entity_key: str,
*,
device: Any | None = None,
manufacturer: str | None = None,
rationale: list | None = None,
config: dict | None = None,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._device_id = device_id
self._device_name = device_name
self._device_type = device_type
self._entity_key = entity_key
self._sn = sn
self._sn8 = sn8
self._model = model
# Legacy/extended fields
self._device = device
self._config = config or {}
self._rationale = rationale
if (self._config.get("rationale")) is not None:
self._rationale = self._config.get("rationale")
if self._rationale is None:
self._rationale = ["off", "on"]
# Display and identification
self._attr_has_entity_name = True
self._attr_unique_id = f"{sn8}_{self.entity_id_suffix}"
self.entity_id_base = f"midea_{sn8.lower()}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, sn8)},
model=model,
serial_number=sn,
manufacturer="Midea",
name=device_name,
)
# Prefer legacy unique_id scheme if device object is available (device_id based)
if self._device is not None:
self._attr_unique_id = f"{DOMAIN}.{self._device_id}_{self._entity_key}"
self.entity_id_base = f"midea_{self._device_id}"
manu = "Midea" if manufacturer is None else manufacturer
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, str(self._device_id))},
model=self._model,
serial_number=sn,
manufacturer=manu,
name=device_name,
)
# Presentation attributes from config
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_icon = self._config.get("icon")
# Prefer translated name; allow explicit override via config.name
self._attr_translation_key = self._config.get("translation_key") or self._entity_key
name_cfg = self._config.get("name")
if name_cfg is not None:
self._attr_name = f"{name_cfg}"
self.entity_id = self._attr_unique_id
# Register device updates for HA state refresh
try:
self._device.register_update(self.update_state) # type: ignore[attr-defined]
except Exception:
pass
else:
# Fallback to sn8-based unique id/device info
self._attr_unique_id = f"{sn8}_{self.entity_id_suffix}"
self.entity_id_base = f"midea_{sn8.lower()}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, sn8)},
model=model,
serial_number=sn,
manufacturer="Midea",
name=device_name,
)
# Debounced command publishing
self._debounced_publish_command = Debouncer(
@@ -87,6 +132,86 @@ class MideaEntity(CoordinatorEntity[MideaDataUpdateCoordinator], Entity):
# This will be implemented by subclasses
pass
# ===== Unified helpers migrated from legacy entity base =====
def _get_status_on_off(self, attribute_key: str | None) -> bool:
"""Return boolean value from device attributes for given key.
Accepts common truthy representations: True/1/"on"/"true".
"""
if attribute_key is None:
return False
value = self.device_attributes.get(attribute_key)
if isinstance(value, bool):
return value
return value in (1, "1", "on", "ON", "true", "TRUE")
async def _async_set_status_on_off(self, attribute_key: str | None, turn_on: bool) -> None:
"""Set boolean attribute via coordinator, no-op if key is None."""
if attribute_key is None:
return
await self.async_set_attribute(attribute_key, bool(turn_on))
def _list_get_selected(self, options: list[dict] | None, rationale: object = None) -> int | None:
"""Select index from a list of dict conditions matched against attributes.
The optional rationale supports equality/greater/less matching. It can be
a string name ("EQUALLY"/"GREATER"/"LESS") or an Enum with .name.
"""
if not options:
return None
rationale_name = getattr(rationale, "name", None) or rationale or "EQUALLY"
for index in range(0, len(options)):
match = True
for attr, expected in options[index].items():
current = self.device_attributes.get(attr)
if current is None:
match = False
break
if rationale_name == "EQUALLY" and current != expected:
match = False
break
if rationale_name == "GREATER" and current < expected:
match = False
break
if rationale_name == "LESS" and current > expected:
match = False
break
if match:
return index
return None
def _dict_get_selected(self, mapping: dict | None, rationale: object = None):
"""Return key from a dict whose value (a condition dict) matches attributes.
The optional rationale supports equality/greater/less matching. It can be
a string name ("EQUALLY"/"GREATER"/"LESS") or an Enum with .name.
"""
if not mapping:
return None
rationale_name = getattr(rationale, "name", None) or rationale or "EQUALLY"
for key, conditions in mapping.items():
if not isinstance(conditions, dict):
continue
match = True
for attr, expected in conditions.items():
current = self.device_attributes.get(attr)
if current is None:
match = False
break
if rationale_name == "EQUALLY" and current != expected:
match = False
break
if rationale_name == "GREATER" and current <= expected:
match = False
break
if rationale_name == "LESS" and current >= expected:
match = False
break
if match:
return key
return None
async def publish_command_from_current_state(self) -> None:
"""Publish commands to the device from current state."""
self.coordinator.mute_state_update_for_a_while()

View File

@@ -1,7 +1,7 @@
from homeassistant.components.select import SelectEntity
from homeassistant.const import Platform
from .const import DOMAIN
from .midea_entities import MideaEntity
from .midea_entity import MideaEntity
from . import load_device_config
@@ -24,13 +24,30 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
coordinator = coordinator_map.get(device_id)
device = coordinator.device if coordinator else None
for entity_key, ecfg in entities_cfg.items():
devs.append(MideaSelectEntity(device, manufacturer, rationale, entity_key, ecfg))
devs.append(MideaSelectEntity(coordinator, device, manufacturer, rationale, entity_key, ecfg))
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)
def __init__(self, coordinator, device, manufacturer, rationale, entity_key, config):
super().__init__(
coordinator,
device.device_id,
device.device_name,
f"T0x{device.device_type:02X}",
device.sn,
device.sn8,
device.model,
entity_key,
device=device,
manufacturer=manufacturer,
rationale=rationale,
config=config,
)
self._device = device
self._manufacturer = manufacturer
self._rationale = rationale
self._config = config
self._key_options = self._config.get("options")
@property
@@ -41,13 +58,14 @@ class MideaSelectEntity(MideaEntity, SelectEntity):
def current_option(self):
return self._dict_get_selected(self._key_options)
def select_option(self, option: str):
async def async_select_option(self, option: str):
new_status = self._key_options.get(option)
self._device.set_attributes(new_status)
if new_status:
await self.async_set_attributes(new_status)
def update_state(self, status):
try:
self.schedule_update_ha_state()
except Exception as e:
except Exception:
pass

View File

@@ -5,6 +5,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .core.logger import MideaLogger
from .midea_entity import MideaEntity
from . import load_device_config
@@ -51,18 +52,17 @@ class MideaSensorEntity(MideaEntity, SensorEntity):
device.sn,
device.sn8,
device.model,
entity_key,
device=device,
manufacturer=manufacturer,
rationale=rationale,
config=config,
)
self._device = device
self._manufacturer = manufacturer
self._rationale = rationale
self._entity_key = entity_key
self._config = config
@property
def entity_id_suffix(self) -> str:
"""Return the suffix for entity ID."""
return f"sensor_{self._entity_key}"
@property
def native_value(self):
"""Return the native value of the sensor."""

View File

@@ -27,7 +27,7 @@ async def async_setup_entry(
for device_id, info in device_list.items():
device_type = info.get("type")
sn8 = info.get("sn8")
config = load_device_config(hass, device_type, sn8) or {}
config = await load_device_config(hass, device_type, sn8) or {}
entities_cfg = (config.get("entities") or {}).get(Platform.SWITCH, {})
manufacturer = config.get("manufacturer")
rationale = config.get("rationale")
@@ -52,17 +52,17 @@ class MideaSwitchEntity(MideaEntity, SwitchEntity):
device.sn,
device.sn8,
device.model,
entity_key,
device=device,
manufacturer=manufacturer,
rationale=rationale,
config=config,
)
self._device = device
self._manufacturer = manufacturer
self._rationale = rationale
self._entity_key = entity_key
self._config = config
@property
def entity_id_suffix(self) -> str:
"""Return the suffix for entity ID."""
return f"switch_{self._entity_key}"
@property
def is_on(self) -> bool:

View File

@@ -102,5 +102,141 @@
}
}
}
},
"entity": {
"select": {
"work_status": {
"name": "Work Status"
},
"fan_level": { "name": "Fan Level" },
"work_mode": { "name": "Work Mode" },
"move_direction": { "name": "Move Direction" },
"query_type": { "name": "Query Type" },
"sub_work_status": { "name": "Sub Work Status" },
"water_level": { "name": "Water Level" },
"mop_status": { "name": "Mop Status" },
"error_type": { "name": "Error Type" },
"control_type": { "name": "Control Type" },
"mode": { "name": "Mode" },
"rice_type": { "name": "Rice Type" }
},
"sensor": {
"bright": { "name": "Brightness" },
"temperature": { "name": "Temperature" },
"softwater": { "name": "Soft Water Temp" },
"left_time": { "name": "Remaining Time" },
"air_set_hour": { "name": "AC Set Time" },
"air_left_hour": { "name": "AC Remaining Time" },
"micro_leak_protection_value": { "name": "Micro Leak Protection Value" },
"regeneration_current_stages": { "name": "Regeneration Current Stages" },
"water_hardness": { "name": "Water Hardness" },
"timing_regeneration_hour": { "name": "Timing Regeneration Hour" },
"real_time_setting_hour": { "name": "Real Time Setting Hour" },
"timing_regeneration_min": { "name": "Timing Regeneration Min" },
"regeneration_left_seconds": { "name": "Regeneration Left Seconds" },
"maintenance_reminder_setting": { "name": "Maintenance Reminder Setting" },
"mixed_water_gear": { "name": "Mixed Water Gear" },
"use_days": { "name": "Use Days" },
"days_since_last_regeneration": { "name": "Days Since Last Regeneration" },
"velocity": { "name": "Velocity" },
"supply_voltage": { "name": "Supply Voltage" },
"left_salt": { "name": "Left Salt" },
"pre_regeneration_days": { "name": "Pre Regeneration Days" },
"flushing_days": { "name": "Flushing Days" },
"salt_setting": { "name": "Salt Setting" },
"e_version": { "name": "E Version" },
"regeneration_count": { "name": "Regeneration Count" },
"battery_voltage": { "name": "Battery Voltage" },
"error": { "name": "Error" },
"days_since_last_two_regeneration": { "name": "Days Since Last Two Regeneration" },
"remind_maintenance_days": { "name": "Remind Maintenance Days" },
"real_date_setting_year": { "name": "Real Date Setting Year" },
"real_date_setting_month": { "name": "Real Date Setting Month" },
"real_date_setting_day": { "name": "Real Date Setting Day" },
"category": { "name": "Category" },
"real_time_setting_min": { "name": "Real Time Setting Min" },
"regeneration_stages": { "name": "Regeneration Stages" },
"v_version": { "name": "V Version" },
"k_version": { "name": "K Version" },
"w_version": { "name": "W Version" },
"soft_available_big": { "name": "Soft Available Big" },
"water_consumption_big": { "name": "Water Consumption Big" },
"water_consumption_today": { "name": "Water Consumption Today" },
"water_consumption_average": { "name": "Water Consumption Average" },
"salt_alarm_threshold": { "name": "Salt Alarm Threshold" },
"leak_water_protection_value": { "name": "Leak Water Protection Value" },
"sn8": { "name": "SN8" },
"version": { "name": "Version" },
"dust_count": { "name": "Dust Count" },
"area": { "name": "Area" },
"voice_level": { "name": "Voice Level" },
"switch_status": { "name": "Switch Status" },
"water_station_status": { "name": "Water Station Status" },
"work_time": { "name": "Work Time" },
"battery_percent": { "name": "Battery Percent" },
"planner_status": { "name": "Planner Status" },
"sweep_then_mop_mode_progress": { "name": "Sweep Then Mop Mode Progress" },
"error_desc": { "name": "Error Description" },
"station_error_desc": { "name": "Station Error Description" },
"work_stage": { "name": "Work Stage" },
"voltage": { "name": "Voltage" },
"top_temperature": { "name": "Top Temperature" },
"bottom_temperature": { "name": "Bottom Temperature" },
"remaining_time": { "name": "Remaining Time" },
"warming_time": { "name": "Warming Time" },
"delay_time": { "name": "Delay Time" },
"indoor_temperature": { "name": "Indoor Temperature" },
"outdoor_temperature": { "name": "Outdoor Temperature" }
},
"binary_sensor": {
"air_status": { "name": "Air Running" },
"water_lack": { "name": "Water Lack" },
"softwater_lack": { "name": "Soft Water Lack" },
"wash_stage": { "name": "Washing Stage" },
"bright_lack": { "name": "Brightness Lack" },
"diy_flag": { "name": "DIY Flag" },
"diy_main_wash": { "name": "DIY Main Wash" },
"diy_piao_wash": { "name": "DIY Rinse" },
"diy_times": { "name": "DIY Times" },
"maintenance_remind": { "name": "Maintenance Remind" },
"chlorine_sterilization_error": { "name": "Chlorine Sterilization Error" },
"rtc_error": { "name": "RTC Error" },
"carpet_switch": { "name": "Carpet Switch" },
"have_reserve_task": { "name": "Have Reserve Task" },
"top_hot": { "name": "Top Hot" },
"flank_hot": { "name": "Flank Hot" },
"bottom_hot": { "name": "Bottom Hot" }
},
"climate": {
"thermostat": { "name": "Thermostat" }
},
"switch": {
"airswitch": { "name": "AC Switch" },
"waterswitch": { "name": "Water Switch" },
"uvswitch": { "name": "UV Switch" },
"doorswitch": { "name": "Door Switch" },
"dryswitch": { "name": "Dry Switch" },
"dry_step_switch": { "name": "Dry Step Switch" },
"holiday_mode": { "name": "Holiday Mode" },
"water_way": { "name": "Water Way" },
"soften": { "name": "Soften" },
"leak_water_protection": { "name": "Leak Water Protection" },
"cl_sterilization": { "name": "Chlorine Sterilization" },
"micro_leak": { "name": "Micro Leak" },
"low_salt": { "name": "Low Salt" },
"no_salt": { "name": "No Salt" },
"low_battery": { "name": "Low Battery" },
"salt_level_sensor_error": { "name": "Salt Level Sensor Error" },
"flowmeter_error": { "name": "Flowmeter Error" },
"leak_water": { "name": "Leak Water" },
"micro_leak_protection": { "name": "Micro Leak Protection" },
"maintenance_reminder_switch": { "name": "Maintenance Reminder Switch" },
"rsj_stand_by": { "name": "RSJ Stand By" },
"regeneration": { "name": "Regeneration" },
"pre_regeneration": { "name": "Pre Regeneration" },
"dry": { "name": "Dry" },
"prevent_straight_wind": { "name": "Prevent Straight Wind" },
"aux_heat": { "name": "Aux Heat" }
}
}
}

View File

@@ -102,5 +102,141 @@
}
}
}
},
"entity": {
"select": {
"work_status": {
"name": "工作状态"
},
"fan_level": { "name": "风扇档位" },
"work_mode": { "name": "工作模式" },
"move_direction": { "name": "移动方向" },
"query_type": { "name": "查询类型" },
"sub_work_status": { "name": "子工作状态" },
"water_level": { "name": "水位" },
"mop_status": { "name": "拖地状态" },
"error_type": { "name": "错误类型" },
"control_type": { "name": "控制类型" },
"mode": { "name": "模式" },
"rice_type": { "name": "米种类型" }
},
"sensor": {
"bright": { "name": "亮度" },
"temperature": { "name": "温度" },
"softwater": { "name": "软水温度" },
"left_time": { "name": "剩余时间" },
"air_set_hour": { "name": "空调设置时间" },
"air_left_hour": { "name": "空调剩余时间" },
"micro_leak_protection_value": { "name": "微漏保护值" },
"regeneration_current_stages": { "name": "再生当前阶段" },
"water_hardness": { "name": "水质硬度" },
"timing_regeneration_hour": { "name": "定时再生小时" },
"real_time_setting_hour": { "name": "实时设置小时" },
"timing_regeneration_min": { "name": "定时再生分钟" },
"regeneration_left_seconds": { "name": "再生剩余秒数" },
"maintenance_reminder_setting": { "name": "维护提醒设置" },
"mixed_water_gear": { "name": "混合水档位" },
"use_days": { "name": "使用天数" },
"days_since_last_regeneration": { "name": "距离上次再生天数" },
"velocity": { "name": "流速" },
"supply_voltage": { "name": "供电电压" },
"left_salt": { "name": "剩余盐量" },
"pre_regeneration_days": { "name": "预再生天数" },
"flushing_days": { "name": "冲洗天数" },
"salt_setting": { "name": "盐设置" },
"e_version": { "name": "E 版本" },
"regeneration_count": { "name": "再生次数" },
"battery_voltage": { "name": "电池电压" },
"error": { "name": "错误" },
"days_since_last_two_regeneration": { "name": "距离上次两次再生天数" },
"remind_maintenance_days": { "name": "提醒维护天数" },
"real_date_setting_year": { "name": "实际日期设置年" },
"real_date_setting_month": { "name": "实际日期设置月" },
"real_date_setting_day": { "name": "实际日期设置日" },
"category": { "name": "类别" },
"real_time_setting_min": { "name": "实时设置分钟" },
"regeneration_stages": { "name": "再生阶段" },
"v_version": { "name": "V 版本" },
"k_version": { "name": "K 版本" },
"w_version": { "name": "W 版本" },
"soft_available_big": { "name": "软水可用量" },
"water_consumption_big": { "name": "用水量" },
"water_consumption_today": { "name": "今日用水量" },
"water_consumption_average": { "name": "平均用水量" },
"salt_alarm_threshold": { "name": "盐报警阈值" },
"leak_water_protection_value": { "name": "漏水保护值" },
"sn8": { "name": "SN8" },
"version": { "name": "版本" },
"dust_count": { "name": "灰尘计数" },
"area": { "name": "面积" },
"voice_level": { "name": "语音音量" },
"switch_status": { "name": "开关状态" },
"water_station_status": { "name": "水站状态" },
"work_time": { "name": "工作时间" },
"battery_percent": { "name": "电池百分比" },
"planner_status": { "name": "规划器状态" },
"sweep_then_mop_mode_progress": { "name": "先扫后拖模式进度" },
"error_desc": { "name": "错误描述" },
"station_error_desc": { "name": "基站错误描述" },
"work_stage": { "name": "工作阶段" },
"voltage": { "name": "电压" },
"top_temperature": { "name": "上盖温度" },
"bottom_temperature": { "name": "底部温度" },
"remaining_time": { "name": "剩余时间" },
"warming_time": { "name": "保温时间" },
"delay_time": { "name": "预约时间" },
"indoor_temperature": { "name": "室内温度" },
"outdoor_temperature": { "name": "室外温度" }
},
"binary_sensor": {
"air_status": { "name": "空气运行" },
"water_lack": { "name": "缺水" },
"softwater_lack": { "name": "软水不足" },
"wash_stage": { "name": "洗涤阶段" },
"bright_lack": { "name": "亮光不足" },
"diy_flag": { "name": "自定义标记" },
"diy_main_wash": { "name": "自定义主洗" },
"diy_piao_wash": { "name": "自定义漂洗" },
"diy_times": { "name": "自定义次数" },
"maintenance_remind": { "name": "维护提醒" },
"chlorine_sterilization_error": { "name": "氯气杀菌错误" },
"rtc_error": { "name": "RTC 错误" },
"carpet_switch": { "name": "地毯开关" },
"have_reserve_task": { "name": "有预约任务" },
"top_hot": { "name": "上盖加热" },
"flank_hot": { "name": "侧壁加热" },
"bottom_hot": { "name": "底部加热" }
},
"climate": {
"thermostat": { "name": "温控器" }
},
"switch": {
"airswitch": { "name": "空调开关" },
"waterswitch": { "name": "热水开关" },
"uvswitch": { "name": "UV 开关" },
"doorswitch": { "name": "门锁开关" },
"dryswitch": { "name": "烘干开关" },
"dry_step_switch": { "name": "烘干分步开关" },
"holiday_mode": { "name": "假日模式" },
"water_way": { "name": "水路" },
"soften": { "name": "软化" },
"leak_water_protection": { "name": "漏水保护" },
"cl_sterilization": { "name": "氯气杀菌" },
"micro_leak": { "name": "微漏" },
"low_salt": { "name": "低盐" },
"no_salt": { "name": "无盐" },
"low_battery": { "name": "低电量" },
"salt_level_sensor_error": { "name": "盐位传感器错误" },
"flowmeter_error": { "name": "流量计错误" },
"leak_water": { "name": "漏水" },
"micro_leak_protection": { "name": "微漏保护" },
"maintenance_reminder_switch": { "name": "维护提醒开关" },
"rsj_stand_by": { "name": "RSJ 待机" },
"regeneration": { "name": "再生" },
"pre_regeneration": { "name": "预再生" },
"dry": { "name": "干燥" },
"prevent_straight_wind": { "name": "防直吹" },
"aux_heat": { "name": "电辅热" }
}
}
}

View File

@@ -4,7 +4,7 @@ from homeassistant.const import (
ATTR_TEMPERATURE
)
from .const import DOMAIN
from .midea_entities import MideaEntity
from .midea_entity import MideaEntity
from . import load_device_config
@@ -27,13 +27,54 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
coordinator = coordinator_map.get(device_id)
device = coordinator.device if coordinator else None
for entity_key, ecfg in entities_cfg.items():
devs.append(MideaWaterHeaterEntityEntity(device, manufacturer, rationale, entity_key, ecfg))
devs.append(MideaWaterHeaterEntityEntity(coordinator, device, manufacturer, rationale, entity_key, ecfg))
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)
def __init__(self, coordinator, device, manufacturer, rationale, entity_key, config):
super().__init__(
coordinator,
device.device_id,
device.device_name,
f"T0x{device.device_type:02X}",
device.sn,
device.sn8,
device.model,
entity_key,
device=device,
manufacturer=manufacturer,
rationale=rationale,
config=config,
)
self._device = device
self._manufacturer = manufacturer
self._rationale = rationale
self._config = config
# Legacy compatibility: register update and restore display attributes
if self._device:
self._device.register_update(self.update_state)
if (rationale_local := self._config.get("rationale")) is not None:
self._rationale = rationale_local
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_icon = self._config.get("icon")
from .const import DOMAIN as _DOMAIN
self._attr_unique_id = f"{_DOMAIN}.{self._device.device_id}_{self._entity_key}"
self._attr_device_info = {
"manufacturer": "Midea" if self._manufacturer is None else self._manufacturer,
"model": f"{self._device.model}",
"identifiers": {( _DOMAIN, self._device.device_id)},
"name": self._device.device_name
}
name = self._config.get("name")
if name is None:
name = self._entity_key.replace("_", " ").title()
self._attr_name = f"{self._device.device_name} {name}"
self.entity_id = self._attr_unique_id
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")
@@ -62,30 +103,30 @@ class MideaWaterHeaterEntityEntity(MideaEntity, WaterHeaterEntity):
@property
def current_temperature(self):
return self._device.get_attribute(self._key_current_temperature)
return self.device_attributes.get(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])
temp_int = self.device_attributes.get(self._key_target_temperature[0])
tem_dec = self.device_attributes.get(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)
return self.device_attributes.get(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))
return float(self.device_attributes.get(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))
return float(self.device_attributes.get(self._key_max_temp))
else:
return float(self._key_max_temp)
@@ -101,13 +142,13 @@ class MideaWaterHeaterEntityEntity(MideaEntity, WaterHeaterEntity):
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)
async def async_turn_on(self):
await self._async_set_status_on_off(self._key_power, True)
def turn_off(self):
self._set_status_on_off(self._key_power, False)
async def async_turn_off(self):
await self._async_set_status_on_off(self._key_power, False)
def set_temperature(self, **kwargs):
async def async_set_temperature(self, **kwargs):
if ATTR_TEMPERATURE not in kwargs:
return
temperature = kwargs.get(ATTR_TEMPERATURE)
@@ -119,15 +160,16 @@ class MideaWaterHeaterEntityEntity(MideaEntity, WaterHeaterEntity):
new_status[self._key_target_temperature[1]] = temp_dec
else:
new_status[self._key_target_temperature] = temperature
self._device.set_attributes(new_status)
await self.async_set_attributes(new_status)
def set_operation_mode(self, operation_mode: str) -> None:
async def async_set_operation_mode(self, operation_mode: str) -> None:
new_status = self._key_operation_list.get(operation_mode)
self._device.set_attributes(new_status)
if new_status:
await self.async_set_attributes(new_status)
def update_state(self, status):
try:
self.schedule_update_ha_state()
except Exception as e:
except Exception:
pass