diff --git a/custom_components/xiaomi_miot/binary_sensor.py b/custom_components/xiaomi_miot/binary_sensor.py index d1ed803fb..36c77635d 100644 --- a/custom_components/xiaomi_miot/binary_sensor.py +++ b/custom_components/xiaomi_miot/binary_sensor.py @@ -86,7 +86,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class BinarySensorEntity(XEntity, BaseEntity, RestoreEntity): def set_state(self, data: dict): - val = data.get(self.attr) + val = self.conv.value_from_dict(data) if val is None: return if self.custom_reverse: diff --git a/custom_components/xiaomi_miot/config_flow.py b/custom_components/xiaomi_miot/config_flow.py index 1192c4bad..64215798b 100644 --- a/custom_components/xiaomi_miot/config_flow.py +++ b/custom_components/xiaomi_miot/config_flow.py @@ -420,9 +420,10 @@ async def async_step_customizing(self, user_input=None): 'switch_properties': cv.string, 'number_properties': cv.string, 'select_properties': cv.string, + 'button_properties': cv.string, + 'target_position_properties': cv.string, 'sensor_attributes': cv.string, 'binary_sensor_attributes': cv.string, - 'button_properties': cv.string, 'button_actions': cv.string, 'select_actions': cv.string, 'text_actions': cv.string, diff --git a/custom_components/xiaomi_miot/core/converters.py b/custom_components/xiaomi_miot/core/converters.py index 2169d37e7..fe944dab0 100644 --- a/custom_components/xiaomi_miot/core/converters.py +++ b/custom_components/xiaomi_miot/core/converters.py @@ -1,6 +1,6 @@ from typing import TYPE_CHECKING, Any from dataclasses import dataclass -from homeassistant.util import color +from homeassistant.util import color, percentage from miio.utils import ( rgb_to_int, int_to_rgb, @@ -29,9 +29,18 @@ def with_option(self, **kwargs): self.option.update(kwargs) return self + @property + def full_name(self): + if not self.domain: + return self.attr + return f'{self.domain}.{self.attr}' + + def value_from_dict(self, data): + return data.get(self.full_name, data.get(self.attr)) + # to hass def decode(self, device: 'Device', payload: dict, value): - payload[self.attr] = value + payload[self.full_name] = value # from hass def encode(self, device: 'Device', payload: dict, value): @@ -68,7 +77,7 @@ def decode(self, device: 'Device', payload: dict, value): payload.update({ **infos, **device.props, - 'converters': [c.attr for c in device.converters], + 'converters': [c.full_name for c in device.converters], 'customizes': device.customizes, **infos, }) @@ -111,7 +120,10 @@ def decode(self, device: 'Device', payload: dict, value): def encode(self, device: 'Device', payload: dict, value): if self.prop: if self.desc: - value = self.prop.list_value(value) + if isinstance(value, list): + value = self.prop.list_first(value) + else: + value = self.prop.list_value(value) elif self.prop.is_integer: value = int(value) # bool to int super().encode(device, payload, value) @@ -239,3 +251,25 @@ def encode(self, device: 'Device', payload: dict, value: tuple): rgb = color.color_hs_to_RGB(*value) num = rgb_to_int(rgb) super().encode(device, payload, num) + +@dataclass +class PercentagePropConv(MiotPropConv): + ranged = None + + def __post_init__(self): + super().__post_init__() + if self.prop and self.prop.value_range: + self.ranged = (self.prop.range_min(), self.prop.range_max()) + + def decode(self, device: 'Device', payload: dict, value: int): + if self.ranged: + value = int(percentage.ranged_value_to_percentage(self.ranged, value)) + super().decode(device, payload, value) + + def encode(self, device: 'Device', payload: dict, value: int): + if self.ranged: + value = int(percentage.percentage_to_ranged_value(self.ranged, value)) + super().encode(device, payload, value) + +class MiotTargetPositionConv(PercentagePropConv): + pass diff --git a/custom_components/xiaomi_miot/core/device.py b/custom_components/xiaomi_miot/core/device.py index 981ffcba5..9f9e837ba 100644 --- a/custom_components/xiaomi_miot/core/device.py +++ b/custom_components/xiaomi_miot/core/device.py @@ -21,7 +21,11 @@ ) from .hass_entry import HassEntry from .hass_entity import XEntity, BasicEntity, convert_unique_id -from .converters import BaseConv, InfoConv, MiotPropConv, MiotPropValueConv, MiotActionConv, AttrConv +from .converters import ( + BaseConv, InfoConv, MiotPropConv, + MiotPropValueConv, MiotActionConv, + AttrConv, MiotTargetPositionConv, +) from .coordinator import DataCoordinator from .miot_spec import MiotSpec, MiotProperty, MiotResults, MiotResult from .miio2miot import Miio2MiotHelper @@ -334,12 +338,17 @@ def hass_device_disabled(self): def add_converter(self, conv: BaseConv): if conv in self.converters: return - for c in self.converters: - if c.attr == conv.attr: - self.log.info('Converter for %s already exists. Ignored.', c.attr) - return + if self.find_converter(conv.full_name): + self.log.info('Converter for %s already exists. Ignored.', c.full_name) + return self.converters.append(conv) + def find_converter(self, full_name): + for c in self.converters: + if c.full_name == full_name: + return c + return None + def init_converters(self): self.add_converter(InfoConverter) self.dispatch_info() @@ -380,11 +389,11 @@ def init_converters(self): ac = c(attr, domain=d, prop=prop, desc=pc.get('desc')) self.add_converter(ac) if conv and not d: - conv.attrs.add(attr) + conv.attrs.add(ac.full_name) for d in [ 'button', 'sensor', 'binary_sensor', 'switch', 'number', 'select', 'text', - 'number_select', 'scanner', + 'number_select', 'scanner', 'target_position', ]: pls = self.custom_config_list(f'{d}_properties') or [] if not pls: @@ -401,6 +410,7 @@ def init_converters(self): platform = { 'scanner': 'device_tracker', 'tracker': 'device_tracker', + 'target_position': 'cover', }.get(d) or d if platform == 'button': if prop.value_list: @@ -416,8 +426,14 @@ def init_converters(self): elif platform == 'number' and not prop.value_range: self.log.warning(f'Unsupported customize entity: %s for %s', platform, prop.full_name) continue + elif d == 'target_position' and not prop.value_range: + self.log.warning(f'Unsupported customize entity: %s for %s', d, prop.full_name) + continue else: - conv = MiotPropConv(prop.full_name, platform, prop=prop) + conv_cls = { + 'target_position': MiotTargetPositionConv, + }.get(d) or MiotPropConv + conv = conv_cls(prop.full_name, platform, prop=prop) conv.with_option( entity_type=None if platform == d else d, ) @@ -534,7 +550,7 @@ def add_entities(self, domain): for conv in self.converters: if conv.domain != domain: continue - unique = convert_unique_id(conv) + unique = f'{domain}.{convert_unique_id(conv)}' entity = self.entities.get(unique) if entity: continue @@ -571,7 +587,7 @@ def remove_listener(self, handler: Callable): def dispatch(self, data: dict, only_info=False, log=True): if log: - self.log.info('Device updated: %s', data) + self.log.info('Device updated: %s', {**data, 'only_info': only_info}) for handler in self.listeners: handler(data, only_info=only_info) @@ -619,7 +635,7 @@ def encode(self, value: dict) -> dict: payload = {} for k, v in value.items(): for conv in self.converters: - if conv.attr == k: + if conv.full_name == k: conv.encode(self, payload, v) return payload diff --git a/custom_components/xiaomi_miot/core/device_customizes.py b/custom_components/xiaomi_miot/core/device_customizes.py index f4a5b01de..98dd84273 100644 --- a/custom_components/xiaomi_miot/core/device_customizes.py +++ b/custom_components/xiaomi_miot/core/device_customizes.py @@ -1342,6 +1342,7 @@ 'switch_properties': 'ai_on', 'select_properties': 'mode,hardness,memory_one,memory_two,sleep_lock', 'number_properties': 'lumbar_angle,backrest_angle,leg_rest_angle', + 'target_position_properties': 'lumbar_angle,backrest_angle,leg_rest_angle', }, 'qushui.blanket.mj1': { 'chunk_properties': 1, @@ -1354,6 +1355,11 @@ 'rhj.sensor_occupy.l730a': { 'sensor_properties': 'illumination,no_one_duration,has_someone_duration', }, + 'rmt.bed.zhsbed': { + 'sensor_properties': 'fault', + 'select_properties': 'mode', + 'target_position_properties': 'backrest_angle,leg_rest_angle', + }, 'roborock.vacuum.*': { 'sensor_attributes': 'props:clean_area,props:clean_time', 'sensor_properties': 'vacuum.status', diff --git a/custom_components/xiaomi_miot/core/hass_entity.py b/custom_components/xiaomi_miot/core/hass_entity.py index dce6779e2..618d8ceea 100644 --- a/custom_components/xiaomi_miot/core/hass_entity.py +++ b/custom_components/xiaomi_miot/core/hass_entity.py @@ -66,7 +66,7 @@ def __init__(self, device: 'Device', conv: 'BaseConv'): self.device = device self.hass = device.hass self.conv = conv - self.attr = conv.attr + self.attr = conv.full_name self.log = device.log if isinstance(conv, MiotPropConv): @@ -95,10 +95,11 @@ def __init__(self, device: 'Device', conv: 'BaseConv'): self._miot_property = conv.prop else: - self.entity_id = device.spec.generate_entity_id(self, self.attr, conv.domain) - # self._attr_name = self.attr.replace('_', '').title() - self._attr_translation_key = self.attr + self.entity_id = device.spec.generate_entity_id(self, conv.attr, conv.domain) + # self._attr_name = conv.attr.replace('_', '').title() + self._attr_translation_key = conv.attr if isinstance(conv, InfoConv): + self.attr = conv.attr self._attr_available = True self.listen_attrs = {self.attr} | set(conv.attrs) @@ -161,18 +162,21 @@ def on_device_update(self, data: dict, only_info=False): state_change = False self._attr_available = self.device.available - if isinstance(self.conv, InfoConv): + if not only_info: + pass + elif isinstance(self.conv, InfoConv): self._attr_available = True self._attr_icon = data.get('icon', self._attr_icon) self._attr_extra_state_attributes.update(data) - elif only_info: + else: return if keys := self.listen_attrs & data.keys(): self.set_state(data) state_change = True for key in keys: - self._attr_extra_state_attributes[key] = data.get(key) + if conv := self.device.find_converter(key): + self._attr_extra_state_attributes[conv.attr] = self.device.props.get(conv.attr) if state_change and self.added: self._async_write_ha_state() @@ -218,7 +222,7 @@ def customize_keys(self): keys.append(f'{mod}:{prop.full_name}') keys.append(f'{mod}:{prop.name}') if self.attr and not (prop or action): - keys.append(f'{mod}:{self.attr}') + keys.append(f'{mod}:{self.conv.attr}') return keys diff --git a/custom_components/xiaomi_miot/cover.py b/custom_components/xiaomi_miot/cover.py index d5c6adec3..ca0ac2e12 100644 --- a/custom_components/xiaomi_miot/cover.py +++ b/custom_components/xiaomi_miot/cover.py @@ -4,7 +4,7 @@ from homeassistant.components.cover import ( DOMAIN as ENTITY_DOMAIN, - CoverEntity, + CoverEntity as BaseEntity, CoverEntityFeature, # v2022.5 CoverDeviceClass, ATTR_POSITION, @@ -15,6 +15,7 @@ CONF_MODEL, XIAOMI_CONFIG_SCHEMA as PLATFORM_SCHEMA, # noqa: F401 HassEntry, + XEntity, MiotEntity, async_setup_config_entry, bind_services_to_entries, @@ -22,7 +23,9 @@ from .core.miot_spec import ( MiotSpec, MiotService, + MiotProperty, ) +from .core.converters import MiotPropConv, MiotTargetPositionConv _LOGGER = logging.getLogger(__name__) DATA_KEY = f'{ENTITY_DOMAIN}.{DOMAIN}' @@ -54,7 +57,75 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= bind_services_to_entries(hass, SERVICE_TO_METHOD) -class MiotCoverEntity(MiotEntity, CoverEntity): +class CoverEntity(XEntity, BaseEntity): + _attr_is_closed = None + _attr_target_cover_position = None + _attr_supported_features = CoverEntityFeature(0) + _conv_status = None + _conv_motor: MiotPropConv = None + _conv_current_position = None + _conv_target_position = None + _current_range = None + _target_range = None + + def on_init(self): + for conv in self.device.converters: + prop = getattr(conv, 'prop', None) + if not isinstance(prop, MiotProperty): + continue + elif prop.in_list(['status']): + self._conv_status = conv + elif prop.in_list(['motor_control']): + self._conv_motor = conv + elif prop.in_list(['current_position']) and prop.value_range: + self._conv_current_position = conv + self._current_range = (prop.range_min, prop.range_max) + elif prop.value_range and isinstance(conv, MiotTargetPositionConv): + self._conv_target_position = conv + self._target_range = conv.ranged + self._attr_supported_features |= CoverEntityFeature.SET_POSITION + elif prop.value_range and prop.in_list(['target_position']): + self._conv_target_position = conv + self._target_range = (prop.range_min(), prop.range_max()) + self._attr_supported_features |= CoverEntityFeature.SET_POSITION + + def set_state(self, data: dict): + if self._conv_current_position: + val = self._conv_current_position.value_from_dict(data) + if val is not None: + self._attr_current_cover_position = int(val) + if self._conv_target_position: + val = self._conv_target_position.value_from_dict(data) + if val is not None: + self._attr_target_cover_position = int(val) + if not self._conv_current_position: + self._attr_current_cover_position = self._attr_target_cover_position + + async def async_open_cover(self, **kwargs): + if self._conv_motor: + val = self._conv_motor.prop.list_first('Open', 'Up') + if val is not None: + await self.device.async_write({self._conv_motor.full_name: val}) + return + await self.async_set_cover_position(100) + + async def async_close_cover(self, **kwargs): + if self._conv_motor: + val = self._conv_motor.prop.list_first('Close', 'Down') + if val is not None: + await self.device.async_write({self._conv_motor.full_name: val}) + return + await self.async_set_cover_position(0) + + async def async_set_cover_position(self, position, **kwargs): + if not self._conv_target_position: + return + await self.device.async_write({self._conv_target_position.full_name: position}) + +XEntity.CLS[ENTITY_DOMAIN] = CoverEntity + + +class MiotCoverEntity(MiotEntity, BaseEntity): def __init__(self, config: dict, miot_service: MiotService): super().__init__(miot_service, config=config, logger=_LOGGER) diff --git a/custom_components/xiaomi_miot/number.py b/custom_components/xiaomi_miot/number.py index 398b8d122..2c0da9fcd 100644 --- a/custom_components/xiaomi_miot/number.py +++ b/custom_components/xiaomi_miot/number.py @@ -69,8 +69,7 @@ def get_state(self) -> dict: return {self.attr: self._attr_native_value} def set_state(self, data: dict): - val = data.get(self.attr) - self._attr_native_value = val + self._attr_native_value = self.conv.value_from_dict(data) async def async_set_native_value(self, value: float): await self.device.async_write({self.attr: value}) diff --git a/custom_components/xiaomi_miot/text.py b/custom_components/xiaomi_miot/text.py index bd6a30d36..daea99236 100644 --- a/custom_components/xiaomi_miot/text.py +++ b/custom_components/xiaomi_miot/text.py @@ -40,7 +40,7 @@ def get_state(self) -> dict: return {self.attr: self._attr_native_value} def set_state(self, data: dict): - val = data.get(self.attr) + val = self.conv.value_from_dict(data) if isinstance(val, list): val = val[0] if val else None if val is None: diff --git a/custom_components/xiaomi_miot/translations/en.json b/custom_components/xiaomi_miot/translations/en.json index 25785cec1..d09e750b0 100644 --- a/custom_components/xiaomi_miot/translations/en.json +++ b/custom_components/xiaomi_miot/translations/en.json @@ -110,6 +110,7 @@ "state_class": "state_class", "state_property": "state_property", "switch_properties": "switch_properties", + "target_position_properties": "target_position_properties", "television_name": "television_name", "text_actions": "text_actions", "turn_on_hvac": "turn_on_hvac",