diff --git a/custom_components/xiaomi_home/light.py b/custom_components/xiaomi_home/light.py index 26ed208..ec47607 100644 --- a/custom_components/xiaomi_home/light.py +++ b/custom_components/xiaomi_home/light.py @@ -47,11 +47,12 @@ Light entities for Xiaomi Home. """ from __future__ import annotations import logging -from typing import Any, Optional +from typing import Any, Optional, List, Dict from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, @@ -86,7 +87,7 @@ async def async_setup_entry( for miot_device in device_list: for data in miot_device.entity_list.get('light', []): new_entities.append( - Light(miot_device=miot_device, entity_data=data)) + Light(miot_device=miot_device, entity_data=data, hass=hass)) if new_entities: async_add_entities(new_entities) @@ -106,10 +107,13 @@ class Light(MIoTServiceEntity, LightEntity): _mode_map: Optional[dict[Any, Any]] def __init__( - self, miot_device: MIoTDevice, entity_data: MIoTEntityData + self, miot_device: MIoTDevice, entity_data: MIoTEntityData,hass: HomeAssistant ) -> None: """Initialize the Light.""" super().__init__(miot_device=miot_device, entity_data=entity_data) + self.hass = hass + self.miot_device = miot_device + self._command_send_mode_entity_id = None self._attr_color_mode = None self._attr_supported_color_modes = set() self._attr_supported_features = LightEntityFeature(0) @@ -252,42 +256,178 @@ class Light(MIoTServiceEntity, LightEntity): """ # on # Dirty logic for lumi.gateway.mgl03 indicator light - if self._prop_on: - value_on = True if self._prop_on.format_ == bool else 1 - await self.set_property_async( - prop=self._prop_on, value=value_on) - # brightness + # Determine whether the device sends the light-on properties in batches or one by one + # Search entityid through unique_id to avoid the user modifying entityid and causing command_send_mode to not match + # 获取开灯模式 + if self._command_send_mode_entity_id is None: + entity_registry = async_get_entity_registry(self.hass) + device_id = list( + self.miot_device.device_info.get("identifiers"))[0][1] + self._command_send_mode_entity_id = entity_registry.async_get_entity_id( + "select", DOMAIN, f"select.light_{device_id}_command_send_mode") + if self._command_send_mode_entity_id is None: + _LOGGER.error( + "light command_send_mode not found, %s", + self.entity_id, + ) + return + command_send_mode = self.hass.states.get( + self._command_send_mode_entity_id) + + # 判断是先发送亮度还是先发送色温 + send_brightness_first = False if ATTR_BRIGHTNESS in kwargs: - brightness = brightness_to_value( - self._brightness_scale, kwargs[ATTR_BRIGHTNESS]) - await self.set_property_async( - prop=self._prop_brightness, value=brightness, - write_ha_state=False) - # color-temperature - if ATTR_COLOR_TEMP_KELVIN in kwargs: - await self.set_property_async( - prop=self._prop_color_temp, - value=kwargs[ATTR_COLOR_TEMP_KELVIN], - write_ha_state=False) - self._attr_color_mode = ColorMode.COLOR_TEMP - # rgb color - if ATTR_RGB_COLOR in kwargs: - r = kwargs[ATTR_RGB_COLOR][0] - g = kwargs[ATTR_RGB_COLOR][1] - b = kwargs[ATTR_RGB_COLOR][2] - rgb = (r << 16) | (g << 8) | b - await self.set_property_async( - prop=self._prop_color, value=rgb, - write_ha_state=False) - self._attr_color_mode = ColorMode.RGB - # mode - if ATTR_EFFECT in kwargs: - await self.set_property_async( - prop=self._prop_mode, - value=self.get_map_key( - map_=self._mode_map, value=kwargs[ATTR_EFFECT]), - write_ha_state=False) - self.async_write_ha_state() + brightness_new = kwargs[ATTR_BRIGHTNESS] + brightness_old = self.brightness + if brightness_old and brightness_new <= brightness_old: + send_brightness_first = True + + # 开始发送开灯命令 + if command_send_mode and command_send_mode.state == "Send Together": + set_properties_list: List[Dict[str, Any]] = [] + # mode + if ATTR_EFFECT in kwargs: + set_properties_list.append({ + "prop":self._prop_mode, + "value":self.get_map_key( + map_=self._mode_map,value=kwargs[ATTR_EFFECT]), + }) + # brightness + if send_brightness_first and ATTR_BRIGHTNESS in kwargs: + brightness = brightness_to_value( + self._brightness_scale,kwargs[ATTR_BRIGHTNESS]) + set_properties_list.append({ + "prop": self._prop_brightness, + "value": brightness + }) + # color-temperature + if ATTR_COLOR_TEMP_KELVIN in kwargs: + set_properties_list.append({ + "prop": self._prop_color_temp, + "value": kwargs[ATTR_COLOR_TEMP_KELVIN], + }) + self._attr_color_mode = ColorMode.COLOR_TEMP + # rgb color + if ATTR_RGB_COLOR in kwargs: + r = kwargs[ATTR_RGB_COLOR][0] + g = kwargs[ATTR_RGB_COLOR][1] + b = kwargs[ATTR_RGB_COLOR][2] + rgb = (r << 16) | (g << 8) | b + set_properties_list.append({ + "prop": self._prop_color, + "value": rgb + }) + self._attr_color_mode = ColorMode.RGB + # brightness + if not send_brightness_first and ATTR_BRIGHTNESS in kwargs: + brightness = brightness_to_value( + self._brightness_scale,kwargs[ATTR_BRIGHTNESS]) + set_properties_list.append({ + "prop": self._prop_brightness, + "value": brightness + }) + + if self._prop_on: + value_on = True if self._prop_on.format_ == bool else 1 + set_properties_list.append({ + "prop": self._prop_on, + "value": value_on + }) + await self.set_properties_async(set_properties_list,write_ha_state=False) + self.async_write_ha_state() + + elif command_send_mode and command_send_mode.state == "Send Turn On First": + set_properties_list: List[Dict[str, Any]] = [] + if self._prop_on: + value_on = True if self._prop_on.format_ == bool else 1 + set_properties_list.append({ + "prop": self._prop_on, + "value": value_on + }) + # mode + if ATTR_EFFECT in kwargs: + set_properties_list.append({ + "prop": + self._prop_mode, + "value": + self.get_map_key( + map_=self._mode_map,value=kwargs[ATTR_EFFECT]), + }) + # brightness + if send_brightness_first and ATTR_BRIGHTNESS in kwargs: + brightness = brightness_to_value( + self._brightness_scale,kwargs[ATTR_BRIGHTNESS]) + set_properties_list.append({ + "prop": self._prop_brightness, + "value": brightness + }) + # color-temperature + if ATTR_COLOR_TEMP_KELVIN in kwargs: + set_properties_list.append({ + "prop": self._prop_color_temp, + "value": kwargs[ATTR_COLOR_TEMP_KELVIN], + }) + self._attr_color_mode = ColorMode.COLOR_TEMP + # rgb color + if ATTR_RGB_COLOR in kwargs: + r = kwargs[ATTR_RGB_COLOR][0] + g = kwargs[ATTR_RGB_COLOR][1] + b = kwargs[ATTR_RGB_COLOR][2] + rgb = (r << 16) | (g << 8) | b + set_properties_list.append({ + "prop": self._prop_color, + "value": rgb + }) + self._attr_color_mode = ColorMode.RGB + # brightness + if not send_brightness_first and ATTR_BRIGHTNESS in kwargs: + brightness = brightness_to_value( + self._brightness_scale,kwargs[ATTR_BRIGHTNESS]) + set_properties_list.append({ + "prop": self._prop_brightness, + "value": brightness + }) + + await self.set_properties_async(set_properties_list,write_ha_state=False) + self.async_write_ha_state() + + else: + if self._prop_on: + value_on = True if self._prop_on.format_ == bool else 1 + await self.set_property_async( + prop=self._prop_on, value=value_on) + # brightness + if ATTR_BRIGHTNESS in kwargs: + brightness = brightness_to_value( + self._brightness_scale, kwargs[ATTR_BRIGHTNESS]) + await self.set_property_async( + prop=self._prop_brightness, value=brightness, + write_ha_state=False) + # color-temperature + if ATTR_COLOR_TEMP_KELVIN in kwargs: + await self.set_property_async( + prop=self._prop_color_temp, + value=kwargs[ATTR_COLOR_TEMP_KELVIN], + write_ha_state=False) + self._attr_color_mode = ColorMode.COLOR_TEMP + # rgb color + if ATTR_RGB_COLOR in kwargs: + r = kwargs[ATTR_RGB_COLOR][0] + g = kwargs[ATTR_RGB_COLOR][1] + b = kwargs[ATTR_RGB_COLOR][2] + rgb = (r << 16) | (g << 8) | b + await self.set_property_async( + prop=self._prop_color, value=rgb, + write_ha_state=False) + self._attr_color_mode = ColorMode.RGB + # mode + if ATTR_EFFECT in kwargs: + await self.set_property_async( + prop=self._prop_mode, + value=self.get_map_key( + map_=self._mode_map, value=kwargs[ATTR_EFFECT]), + write_ha_state=False) + self.async_write_ha_state() async def async_turn_off(self, **kwargs) -> None: """Turn the light off.""" diff --git a/custom_components/xiaomi_home/miot/miot_client.py b/custom_components/xiaomi_home/miot/miot_client.py index cd25568..76d0ec9 100644 --- a/custom_components/xiaomi_home/miot/miot_client.py +++ b/custom_components/xiaomi_home/miot/miot_client.py @@ -46,7 +46,7 @@ off Xiaomi or its affiliates' products. MIoT client instance. """ from copy import deepcopy -from typing import Any, Callable, Optional, final +from typing import Any, Callable, Optional, final, Dict, List import asyncio import json import logging @@ -710,6 +710,84 @@ class MIoTClient: f'{self._i18n.translate("miot.client.device_exec_error")}, ' f'{self._i18n.translate("error.common.-10007")}') + async def set_props_async( + self, props_list: List[Dict[str, Any]], + ) -> bool: + # props_list = [{'did': str, 'siid': int, 'piid': int, 'value': Any}......] + # 判断是不是只有一个did + did_set = {prop["did"] for prop in props_list} + if len(did_set) != 1: + raise MIoTClientError(f"more than one or no did once, {did_set}") + did = did_set.pop() + + if did not in self._device_list_cache: + raise MIoTClientError(f"did not exist, {did}") + # Priority local control + if self._ctrl_mode == CtrlMode.AUTO: + # Gateway control + device_gw = self._device_list_gateway.get(did, None) + if ( + device_gw and device_gw.get("online", False) + and device_gw.get("specv2_access", False) and "group_id" in device_gw + ): + mips = self._mips_local.get(device_gw["group_id"], None) + if mips is None: + _LOGGER.error( + "no gateway route, %s, try control through cloud", + device_gw) + else: + result = await mips.set_props_async( + did=did,props_list=props_list) + _LOGGER.debug("gateway set props, %s -> %s", props_list, result) + rc = [(r or {}).get("code", + MIoTErrorCode.CODE_MIPS_INVALID_RESULT.value) + for r in result] + if all(t in [0, 1] for t in rc): + return True + else: + raise MIoTClientError( + self.__get_exec_error_with_rc(rc=next(x for x in rc if x not in (0, 1)))) + # Lan control + device_lan = self._device_list_lan.get(did, None) + if device_lan and device_lan.get("online", False): + result = await self._miot_lan.set_props_async( + did=did, props_list=props_list) + _LOGGER.debug("lan set props, %s -> %s", props_list, result) + rc = [(r or {}).get("code", + MIoTErrorCode.CODE_MIPS_INVALID_RESULT.value) + for r in result] + if all(t in [0, 1] for t in rc): + return True + else: + raise MIoTClientError( + self.__get_exec_error_with_rc(rc=next(x for x in rc if x not in (0, 1)))) + # Cloud control + device_cloud = self._device_list_cloud.get(did, None) + if device_cloud and device_cloud.get("online", False): + result = await self._http.set_props_async(params=props_list) + _LOGGER.debug( + "cloud set props, %s, result, %s", + props_list, result) + if result and len(result) == len(props_list): + rc = [(r or {}).get("code", + MIoTErrorCode.CODE_MIPS_INVALID_RESULT.value) + for r in result] + if all(t in [0, 1] for t in rc): + return True + if any(t in [-704010000, -704042011] for t in rc): + # Device remove or offline + _LOGGER.error("device may be removed or offline, %s", did) + self._main_loop.create_task( + await + self.__refresh_cloud_device_with_dids_async(dids=[did])) + raise MIoTClientError( + self.__get_exec_error_with_rc(rc=next(x for x in rc if x not in (0, 1)))) + + # Show error message + raise MIoTClientError( + f'{self._i18n.translate("miot.client.device_exec_error")}, ' + f'{self._i18n.translate("error.common.-10007")}') + def request_refresh_prop( self, did: str, siid: int, piid: int ) -> None: diff --git a/custom_components/xiaomi_home/miot/miot_cloud.py b/custom_components/xiaomi_home/miot/miot_cloud.py index ed672a0..d9acd5c 100644 --- a/custom_components/xiaomi_home/miot/miot_cloud.py +++ b/custom_components/xiaomi_home/miot/miot_cloud.py @@ -825,6 +825,22 @@ class MIoTHttpClient: return res_obj['result'] + async def set_props_async(self, params: list) -> list: + """ + params = [{"did": "xxxx", "siid": 2, "piid": 1, "value": False}] + """ + res_obj = await self.__mihome_api_post_async( + url_path='/app/v2/miotspec/prop/set', + data={ + 'params': params + }, + timeout=15 + ) + if 'result' not in res_obj: + raise MIoTHttpError('invalid response result') + + return res_obj['result'] + async def action_async( self, did: str, siid: int, aiid: int, in_list: list[dict] ) -> dict: diff --git a/custom_components/xiaomi_home/miot/miot_device.py b/custom_components/xiaomi_home/miot/miot_device.py index e2431c3..6c012a0 100644 --- a/custom_components/xiaomi_home/miot/miot_device.py +++ b/custom_components/xiaomi_home/miot/miot_device.py @@ -47,7 +47,7 @@ MIoT device instance. """ import asyncio from abc import abstractmethod -from typing import Any, Callable, Optional +from typing import Any, Callable, Optional, Dict, List import logging from homeassistant.helpers.entity import Entity @@ -1090,6 +1090,49 @@ class MIoTServiceEntity(Entity): self.async_write_ha_state() return True + async def set_properties_async( + self, set_properties_list: List[Dict[str, Any]], + update_value: bool = True, write_ha_state: bool = True, + ) -> bool: + # set_properties_list = [{'prop': Optional[MIoTSpecProperty], + # 'value': Any}....] + for set_property in set_properties_list: + prop = set_property.get("prop") + value = set_property.get("value") + if not prop: + raise RuntimeError( + f'set property failed, property is None, ' + f'{self.entity_id}, {self.name}') + value = prop.value_format(value) + value = prop.value_precision(value) + # 因为下面还有判断在这个循环里 所以这里要赋值回去 + set_property["value"] = value + if prop not in self.entity_data.props: + raise RuntimeError( + f'set property failed, unknown property, ' + f'{self.entity_id}, {self.name}, {prop.name}') + if not prop.writable: + raise RuntimeError( + f'set property failed, not writable, ' + f'{self.entity_id}, {self.name}, {prop.name}') + try: + await self.miot_device.miot_client.set_props_async([{ + "did": self.miot_device.did, + "siid": set_property["prop"].service.iid, + "piid": set_property["prop"].iid, + "value": set_property["value"], + } for set_property in set_properties_list]) + except MIoTClientError as e: + raise RuntimeError( + f"{e}, {self.entity_id}, {self.name}, {'&'.join([set_property['prop'].name for set_property in set_properties_list])}") from e + if update_value: + for set_property in set_properties_list: + self._prop_value_map[ + set_property["prop"]] = set_property["value"] + if write_ha_state: + self.async_write_ha_state() + return True + async def get_property_async(self, prop: MIoTSpecProperty) -> Any: if not prop: _LOGGER.error( diff --git a/custom_components/xiaomi_home/miot/miot_lan.py b/custom_components/xiaomi_home/miot/miot_lan.py index 5c56a55..8e95b32 100644 --- a/custom_components/xiaomi_home/miot/miot_lan.py +++ b/custom_components/xiaomi_home/miot/miot_lan.py @@ -58,7 +58,7 @@ import secrets import socket import struct import threading -from typing import Any, Callable, Coroutine, Optional, final +from typing import Any, Callable, Coroutine, Optional, final, Dict, List from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives import padding from cryptography.hazmat.backends import default_backend @@ -857,6 +857,29 @@ class MIoTLan: return result_obj raise MIoTError('Invalid result', MIoTErrorCode.CODE_INTERNAL_ERROR) + @final + async def set_props_async( + self,did: str,props_list: List[Dict[str, Any]], + timeout_ms: int = 10000) -> dict: + # props_list = [{'did': did, 'siid': siid, 'piid': piid, 'value': value}......] + self.__assert_service_ready() + result_obj = await self.__call_api_async( + did=did, msg={ + 'method': 'set_properties', + 'params': props_list, + }, timeout_ms=timeout_ms) + if result_obj: + if ( + 'result' in result_obj and + len(result_obj['result']) == len(props_list) + and result_obj['result'][0].get('did') == did + and all('code' in item for item in result_obj['result']) + ): + return result_obj['result'] + if 'code' in result_obj: + return result_obj + raise MIoTError('Invalid result', MIoTErrorCode.CODE_INTERNAL_ERROR) + @final async def action_async( self, did: str, siid: int, aiid: int, in_list: list, diff --git a/custom_components/xiaomi_home/miot/miot_mips.py b/custom_components/xiaomi_home/miot/miot_mips.py index f4305c7..a3c59ae 100644 --- a/custom_components/xiaomi_home/miot/miot_mips.py +++ b/custom_components/xiaomi_home/miot/miot_mips.py @@ -56,7 +56,7 @@ import threading from abc import ABC, abstractmethod from dataclasses import dataclass from enum import Enum, auto -from typing import Any, Callable, Optional, final, Coroutine +from typing import Any, Callable, Optional, final, Coroutine, Dict, List from paho.mqtt.client import ( MQTT_ERR_SUCCESS, @@ -1342,6 +1342,41 @@ class MipsLocalClient(_MipsClient): 'code': MIoTErrorCode.CODE_INTERNAL_ERROR.value, 'message': 'Invalid result'} + @final + async def set_props_async( + self, did: str, props_list: List[Dict[str, Any]], + timeout_ms: int = 10000 + ) -> dict: + # props_list= [{ + # 'did': did, + # 'siid': siid, + # 'piid': piid, + # 'value': value + # }] + payload_obj: dict = { + "did": did, + "rpc": { + "id": self.__gen_mips_id, + "method": "set_properties", + "params": props_list, + } + } + result_obj = await self.__request_async( + topic="proxy/rpcReq", + payload=json.dumps(payload_obj), + timeout_ms=timeout_ms) + if result_obj: + if ("result" in result_obj and + len(result_obj["result"]) == len(props_list) and + result_obj["result"][0].get("did") == did and + all("code" in item for item in result_obj["result"])): + return result_obj["result"] + if "error" in result_obj: + return result_obj["error"] + return { + 'code': MIoTErrorCode.CODE_INTERNAL_ERROR.value, + 'message': 'Invalid result'} + @final async def action_async( self, did: str, siid: int, aiid: int, in_list: list, diff --git a/custom_components/xiaomi_home/select.py b/custom_components/xiaomi_home/select.py index 21b5e78..3804d63 100644 --- a/custom_components/xiaomi_home/select.py +++ b/custom_components/xiaomi_home/select.py @@ -52,6 +52,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.components.select import SelectEntity +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.restore_state import RestoreEntity from .miot.const import DOMAIN from .miot.miot_device import MIoTDevice, MIoTPropertyEntity @@ -75,6 +77,17 @@ async def async_setup_entry( if new_entities: async_add_entities(new_entities) + # create select for light + new_light_select_entities = [] + for miot_device in device_list: + # Add it to all devices with light entities, because some bathroom heaters and clothes drying racks also have lights. + # if "device:light" in miot_device.spec_instance.urn: + if miot_device.entity_list.get("light", []): + device_id = list(miot_device.device_info.get("identifiers"))[0][1] + new_light_select_entities.append( + LightCommandSendMode(hass=hass, device_id=device_id)) + if new_light_select_entities: + async_add_entities(new_light_select_entities) class Select(MIoTPropertyEntity, SelectEntity): """Select entities for Xiaomi Home.""" @@ -94,3 +107,38 @@ class Select(MIoTPropertyEntity, SelectEntity): def current_option(self) -> Optional[str]: """Return the current selected option.""" return self.get_vlist_description(value=self._value) + + +class LightCommandSendMode(SelectEntity, RestoreEntity): + """To control whether to turn on the light, you need to send the light-on command first and + then send other color temperatures and brightness or send them all at the same time. + The default is to send one by one.""" + + def __init__(self, hass: HomeAssistant, device_id: str): + super().__init__() + self.hass = hass + self._device_id = device_id + self._attr_name = "Command Send Mode" + self.entity_id = f"select.light_{device_id}_command_send_mode" + self._attr_unique_id = self.entity_id + self._attr_options = [ + "Send One by One", "Send Turn On First", "Send Together" + ] + self._attr_device_info = {"identifiers": {(DOMAIN, device_id)}} + self._attr_current_option = self._attr_options[0] + self._attr_entity_category = EntityCategory.CONFIG + + async def async_select_option(self, option: str): + if option in self._attr_options: + self._attr_current_option = option + self.async_write_ha_state() + + async def async_added_to_hass(self): + await super().async_added_to_hass() + if (last_state := await self.async_get_last_state() + ) and last_state.state in self._attr_options: + self._attr_current_option = last_state.state + + @property + def current_option(self): + return self._attr_current_option