diff --git a/custom_components/xiaomi_miot/__init__.py b/custom_components/xiaomi_miot/__init__.py index 42e498c1e..c85fa4b88 100644 --- a/custom_components/xiaomi_miot/__init__.py +++ b/custom_components/xiaomi_miot/__init__.py @@ -1,9 +1,7 @@ """Support for Xiaomi Miot.""" import logging import asyncio -import socket import json -import time import os import re from datetime import timedelta @@ -18,6 +16,7 @@ ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, + CONF_DEVICE, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_TOKEN, @@ -27,14 +26,8 @@ STATE_UNKNOWN, SERVICE_RELOAD, ) -from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.entity import ( - Entity, - ToggleEntity, - EntityCategory, -) +from homeassistant.helpers.entity import ToggleEntity, EntityCategory from homeassistant.config_entries import ConfigEntry -from homeassistant.components import persistent_notification from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.reload import async_integration_yaml_config @@ -42,21 +35,19 @@ import homeassistant.helpers.device_registry as dr import homeassistant.helpers.config_validation as cv -from miio import ( - Device as MiioDevice, # noqa: F401 - DeviceException, -) -from miio.device import DeviceInfo as MiioInfoBase -from miio.miot_device import MiotDevice as MiotDeviceBase - from .core.const import * from .core.utils import ( wildcard_models, is_offline_exception, async_analytics_track_event, ) +from .core import HassEntry, BasicEntity, XEntity # noqa +from .core.device import ( + Device, + MiioDevice, + DeviceException, +) from .core.miot_spec import ( - MiotSpec, MiotService, MiotProperty, MiotAction, @@ -68,7 +59,6 @@ MiCloudException, MiCloudAccessDenied, ) -from .core.miio2miot import Miio2MiotHelper from .core.templates import CUSTOM_TEMPLATES _LOGGER = logging.getLogger(__name__) @@ -92,7 +82,7 @@ SERVICE_TO_METHOD_BASE = { 'send_command': { - 'method': 'async_command', + 'method': 'async_miio_command', 'schema': XIAOMI_MIIO_SERVICE_SCHEMA.extend( { vol.Required('method'): cv.string, @@ -134,7 +124,7 @@ ), }, 'call_action': { - 'method': 'async_miot_action', + 'method': 'async_call_action', 'schema': XIAOMI_MIIO_SERVICE_SCHEMA.extend( { vol.Required('siid'): int, @@ -245,31 +235,24 @@ def extend_miot_specs(): async def async_setup_entry(hass: hass_core.HomeAssistant, config_entry: config_entries.ConfigEntry): hass.data.setdefault(DOMAIN, {}) entry_id = config_entry.entry_id - unique_id = config_entry.unique_id if config_entry.data.get('customizing_entity') or config_entry.data.get('customizing_device'): await async_setup_customizes(hass, config_entry) elif config_entry.data.get(CONF_USERNAME): await async_setup_xiaomi_cloud(hass, config_entry) else: - config = dict(config_entry.data) - config.update(config_entry.options or {}) - info = config.get('miio_info') or {} - model = str(config.get(CONF_MODEL) or info.get(CONF_MODEL) or '') - config[CONF_MODEL] = model - - urn = DEVICE_CUSTOMIZES.get(model, {}).get('miot_type') or config.get('miot_type') - urn = urn or await MiotSpec.async_get_model_type(hass, model) - config['miot_type'] = urn - if urn and model: - hass.data[DOMAIN]['miot_specs'][model] = await MiotSpec.async_from_type(hass, urn) + entry = HassEntry.init(hass, config_entry) + config = {**entry.get_config()} + device = await entry.new_device(config) + config[CONF_DEVICE] = device + config[CONF_MODEL] = device.model + config['miot_type'] = await device.get_urn() config['config_entry'] = config_entry config['miot_local'] = True config[CONF_CONN_MODE] = 'local' hass.data[DOMAIN][entry_id] = config _LOGGER.debug('Xiaomi Miot setup config entry: %s', { 'entry_id': entry_id, - 'unique_id': unique_id, 'config': config, }) @@ -282,69 +265,56 @@ async def async_setup_entry(hass: hass_core.HomeAssistant, config_entry: config_ async def async_setup_xiaomi_cloud(hass: hass_core.HomeAssistant, config_entry: config_entries.ConfigEntry): entry_id = config_entry.entry_id - entry = {**config_entry.data, **config_entry.options} + entry = HassEntry.init(hass, config_entry) + entry_config = entry.get_config() + username = entry_config.get(CONF_USERNAME) config = { 'entry_id': entry_id, 'config_entry': config_entry, 'configs': [], } try: - mic = await MiotCloud.from_token(hass, entry, login=False) - await mic.async_check_auth(notify=True) - config[CONF_XIAOMI_CLOUD] = mic - config['devices_by_mac'] = await mic.async_get_devices_by_key('mac', filters=entry) or {} + cloud = await entry.get_cloud(check=True) + config[CONF_XIAOMI_CLOUD] = cloud + config['devices_by_mac'] = await cloud.async_get_devices_by_key('mac', filters=entry_config) or {} except (MiCloudException, MiCloudAccessDenied) as exc: - _LOGGER.error('Setup xiaomi cloud for user: %s failed: %s', entry.get(CONF_USERNAME), exc) + _LOGGER.error('Setup xiaomi cloud for user: %s failed: %s', username, exc) return False if not config.get('devices_by_mac'): - _LOGGER.warning('None device in xiaomi cloud: %s', entry.get(CONF_USERNAME)) + _LOGGER.warning('None device in xiaomi cloud: %s', username) else: cnt = len(config['devices_by_mac']) - _LOGGER.debug('Setup xiaomi cloud for user: %s, %s devices', entry.get(CONF_USERNAME), cnt) + _LOGGER.debug('Setup xiaomi cloud for user: %s, %s devices', username, cnt) for mac, d in config['devices_by_mac'].items(): - model = d.get(CONF_MODEL) - urn = None - if model: - urn = DEVICE_CUSTOMIZES.get(model, {}).get('miot_type') - urn = urn or await MiotSpec.async_get_model_type(hass, model) - if not urn: - _LOGGER.info('Xiaomi device: %s has no urn', [d.get('name'), model]) + device = await entry.new_device(d) + if not device.spec: + _LOGGER.warning('%s: Device has no spec %s', device.name_model, device.info.urn) continue - hass.data[DOMAIN]['miot_specs'][model] = await MiotSpec.async_from_type(hass, urn) - ext = d.get('extra') or {} - mif = { - 'ap': {'ssid': d.get('ssid'), 'bssid': d.get('bssid'), 'rssi': d.get('rssi')}, - 'netif': {'localIp': d.get('localip'), 'gw': '', 'mask': ''}, - 'fw_ver': ext.get('fw_version', ''), - 'hw_ver': ext.get('hw_version', ''), - 'mac': d.get('mac'), - 'model': model, - 'token': d.get(CONF_TOKEN), - } - conn = entry.get(CONF_CONN_MODE, DEFAULT_CONN_MODE) + conn = device.conn_mode cfg = { - CONF_NAME: d.get(CONF_NAME) or DEFAULT_NAME, - CONF_HOST: d.get('localip') or '', - CONF_TOKEN: d.get('token') or '', - CONF_MODEL: model, - 'miot_did': d.get('did') or '', - 'miot_type': urn, - 'miio_info': mif, + CONF_DEVICE: device, + CONF_NAME: device.name, + CONF_HOST: device.info.host, + CONF_TOKEN: device.info.token, + CONF_MODEL: device.info.model, + 'miot_did': device.info.did, + 'miot_type': await device.get_urn(), + 'miio_info': device.info.miio_info, CONF_CONN_MODE: conn, 'miot_local': conn == 'local', 'miot_cloud': conn != 'local', - 'home_name': d.get('home_name') or '', - 'room_name': d.get('room_name') or '', + 'home_name': device.info.home_name, + 'room_name': device.info.room_name, 'entry_id': entry_id, - CONF_CONFIG_VERSION: entry.get(CONF_CONFIG_VERSION) or 0, + CONF_CONFIG_VERSION: entry_config.get(CONF_CONFIG_VERSION) or 0, } - if conn == 'auto' and model in MIOT_LOCAL_MODELS: + if conn == 'auto' and device.info.model in MIOT_LOCAL_MODELS: cfg['miot_local'] = True cfg['miot_cloud'] = False config['configs'].append(cfg) _LOGGER.debug('Xiaomi cloud device: %s', {**cfg, CONF_TOKEN: '****'}) hass.data[DOMAIN][entry_id] = config - hass.data[DOMAIN]['accounts'].setdefault(mic.user_id, {CONF_XIAOMI_CLOUD: mic}) + hass.data[DOMAIN]['accounts'].setdefault(cloud.user_id, {CONF_XIAOMI_CLOUD: cloud}) return True @@ -374,14 +344,7 @@ async def async_update_options(hass: hass_core.HomeAssistant, config_entry: conf async def async_unload_entry(hass: hass_core.HomeAssistant, config_entry: config_entries.ConfigEntry): - unload_ok = all( - await asyncio.gather( - *[ - hass.config_entries.async_forward_entry_unload(config_entry, sd) - for sd in SUPPORTED_DOMAINS - ] - ) - ) + unload_ok = await HassEntry.init(hass, config_entry).async_unload() if unload_ok: hass.data[DOMAIN].pop(config_entry.entry_id, None) hass.data[DOMAIN]['sub_entities'] = {} @@ -390,6 +353,7 @@ async def async_unload_entry(hass: hass_core.HomeAssistant, config_entry: config def init_integration_data(hass): hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN].setdefault('entries', {}) hass.data[DOMAIN].setdefault('configs', {}) hass.data[DOMAIN].setdefault('entities', {}) hass.data[DOMAIN].setdefault('accounts', {}) @@ -432,8 +396,11 @@ async def async_service_handler(service) -> ServiceResponse: if not hasattr(ent, fun): _LOGGER.warning('Call service failed: Entity %s have no method: %s', ent.entity_id, fun) continue - result = await getattr(ent, fun)(**params) - update_tasks.append(ent.async_update_ha_state(True)) + try: + result = await getattr(ent, fun)(**params) + update_tasks.append(ent.async_update_ha_state(True)) + except Exception as exc: + result = {'error': str(exc)} if update_tasks: await asyncio.gather(*update_tasks) if isinstance(result, (MiotResult, MiotResults)): @@ -637,82 +604,8 @@ async def async_remove_config_entry_device(hass: hass_core.HomeAssistant, entry: return True -def get_customize_via_entity(entity, key=None, default=None): - if key is None: - default = {} - if not isinstance(entity, BaseEntity): - return default - cfg = {} - if entity.hass and entity.entity_id: - cfg = { - **(entity.hass.data[DATA_CUSTOMIZE].get(entity.entity_id) or {}), - **(entity.hass.data[DOMAIN].get(DATA_CUSTOMIZE, {}).get(entity.entity_id) or {}), - } - if key is not None and key in cfg: - return cfg.get(key) - mls = [] - if entity.model: - if hasattr(entity, 'customize_keys'): - mls.extend(entity.customize_keys) - mls.append(entity.model) - for mod in mls: - cus = get_customize_via_model(mod) - cfg = {**cus, **cfg} - return cfg if key is None else cfg.get(key, default) - - -def get_customize_via_model(model, key=None, default=None): - cfg = {} - for m in wildcard_models(model): - cus = DEVICE_CUSTOMIZES.get(m) or {} - if key is not None and key not in cus: - continue - if cus: - cfg = {**cus, **cfg} - return cfg if key is None else cfg.get(key, default) - - -class MiioInfo(MiioInfoBase): - @property - def firmware_version(self): - """Firmware version if available.""" - return self.data.get('fw_ver') - - @property - def hardware_version(self): - """Hardware version if available.""" - return self.data.get('hw_ver') - - -class MiotDevice(MiotDeviceBase): - hass = None - - def get_properties_for_mapping(self, *, max_properties=12, did=None, mapping=None) -> list: - if mapping is None: - mapping = self.mapping - properties = [ - {'did': f'prop.{v["siid"]}.{v["piid"]}' if did is None else str(did), **v} - for k, v in mapping.items() - ] - return self.get_properties( - properties, - property_getter='get_properties', - max_properties=max_properties, - ) - - async def async_get_properties_for_mapping(self, *args, **kwargs) -> list: - if not self.hass: - return self.get_properties_for_mapping(*args, **kwargs) - - return await self.hass.async_add_executor_job( - partial( - self.get_properties_for_mapping, - *args, **kwargs, - ) - ) - - -class BaseEntity(Entity): +class BaseEntity(BasicEntity): + device: Device = None _config = None _model = None _attr_device_class = None @@ -743,11 +636,13 @@ def get_device_class(self, enum): @property def model(self): + if self.device: + return self.device.info.model return self._model @property def name_model(self): - return f'{self.name}({self._model})' + return f'{self.name}({self.model})' def global_config(self, key=None, default=None): if not self.hass: @@ -755,9 +650,6 @@ def global_config(self, key=None, default=None): cfg = self.hass.data[DOMAIN]['config'] or {} return cfg if key is None else cfg.get(key, default) - def custom_config(self, key=None, default=None): - return get_customize_via_entity(self, key, default) - @property def conn_mode(self): return self._config.get(CONF_CONN_MODE) @@ -787,51 +679,6 @@ def entry_config(self, key=None, default=None): cfg = {**cfg, **(self.hass.data[DOMAIN].get(eid) or {})} return cfg if key is None else cfg.get(key, default) - def custom_config_bool(self, key=None, default=None): - val = self.custom_config(key, default) - try: - val = cv.boolean(val) - except vol.Invalid: - val = default - return val - - def custom_config_number(self, key=None, default=None): - num = default - val = self.custom_config(key) - if val is not None: - try: - num = float(f'{val}') - except (TypeError, ValueError): - num = default - return num - - def custom_config_integer(self, key=None, default=None): - num = self.custom_config_number(key, default) - if num is not None: - num = int(num) - return num - - def custom_config_list(self, key=None, default=None): - lst = self.custom_config(key) - if lst is None: - return default - if not isinstance(lst, list): - lst = f'{lst}'.split(',') - lst = list(map(lambda x: x.strip(), lst)) - return lst - - def custom_config_json(self, key=None, default=None): - dic = self.custom_config(key) - if dic: - if not isinstance(dic, (dict, list)): - try: - dic = json.loads(dic or '{}') - except (TypeError, ValueError): - dic = None - if isinstance(dic, (dict, list)): - return dic - return default - def update_custom_scan_interval(self, only_custom=False): if not self.platform: return @@ -888,33 +735,18 @@ class MiioEntity(BaseEntity): def __init__(self, name, device, **kwargs): self._device = device self._config = dict(kwargs.get('config') or {}) - self.hass = self._config.get('hass') self.logger = kwargs.get('logger') or _LOGGER - try: - miio_info = kwargs.get('miio_info', self._config.get('miio_info')) - if miio_info and isinstance(miio_info, dict): - miio_info = MiioInfo(miio_info) - self._miio_info = miio_info if isinstance(miio_info, MiioInfo) else device.info() - except DeviceException as exc: - self.logger.error('%s: Device unavailable or token incorrect: %s', name, exc) - raise PlatformNotReady from exc - except (socket.gaierror, OSError) as exc: - self.logger.error("%s: Device unavailable: %s", name, exc) - raise PlatformNotReady from exc + self.device = self._config.get(CONF_DEVICE) + self.hass = self.device.hass + self._miio_info = self.device.info.miio_info self._unique_did = self.unique_did self._unique_id = self._unique_did self._name = name - self._model = self._miio_info.model or '' + self._model = self.device.info.model self._state = None self._available = False - self._state_attrs = { - CONF_MODEL: self._model, - 'lan_ip': self._miio_info.network_interface.get('localIp'), - 'mac_address': self._miio_info.mac_address, - 'entity_class': self.__class__.__name__, - } - if hnm := self._config.get('home_name'): - self._state_attrs['home_room'] = f"{hnm} {self._config.get('room_name')}" + self._state_attrs = {} + self._attr_device_info = self.device.hass_device_info self._supported_features = 0 self._props = ['power'] self._success_result = ['ok'] @@ -922,24 +754,23 @@ def __init__(self, name, device, **kwargs): self._vars = {} self._subs = {} + self._vars['is_main_entity'] = not self.device.miot_entity + self.device.miot_entity = self # TODO + @property def unique_id(self): return self._unique_id @property def unique_mac(self): - mac = self._miio_info.mac_address - if not mac and self.entry_config_version >= 0.2: - mac = self._config.get('miot_did') + mac = self.device.info.mac + if not mac: + mac = self.device.info.did return mac @property def unique_did(self): - did = dr.format_mac(self.unique_mac) - eid = self._config.get('entry_id') - if eid and self.entry_config_version >= 0.1: - did = f'{did}-{eid}' - return did + return self.device.unique_id @property def name(self): @@ -947,7 +778,7 @@ def name(self): @property def name_model(self): - return f'{self.device_name}({self._model})' + return self.device.name_model @property def device_name(self): @@ -955,7 +786,7 @@ def device_name(self): @property def device_host(self): - return self._config.get(CONF_HOST) or '' + return self.device.info.host @property def available(self): @@ -979,24 +810,6 @@ def extra_state_attributes(self): def supported_features(self): return self._supported_features - @property - def device_info(self): - swv = self._miio_info.firmware_version - if self._miio_info.hardware_version: - swv = f'{swv}@{self._miio_info.hardware_version}' - updater = self._state_attrs.get('state_updater') - if updater and updater not in ['none']: - swv = f'{swv} ({updater})' - return { - 'identifiers': {(DOMAIN, self._unique_did)}, - 'name': self.device_name, - 'model': self._model, - 'manufacturer': (self.model or 'Xiaomi').split('.', 1)[0], - 'sw_version': swv, - 'suggested_area': self._config.get('room_name'), - 'configuration_url': f'https://home.miot-spec.com/s/{self._model}', - } - async def async_added_to_hass(self): await super().async_added_to_hass() if self.platform: @@ -1017,9 +830,8 @@ async def _try_command(self, mask_error, func, *args, **kwargs): return False def send_miio_command(self, method, params=None, **kwargs): - self.logger.debug('%s: Send miio command: %s(%s)', self.name_model, method, params) try: - result = self._device.send(method, params if params is not None else []) + result = self._device.send(method, params) except DeviceException as ex: self.logger.error('%s: Send miio command: %s(%s) failed: %s', self.name_model, method, params, ex) return False @@ -1030,14 +842,6 @@ def send_miio_command(self, method, params=None, **kwargs): self.logger.info('%s: Send miio command: %s(%s) failed, result: %s', self.name_model, method, params, result) return ret - def send_command(self, method, params=None, **kwargs): - return self.send_miio_command(method, params, **kwargs) - - async def async_command(self, method, params=None, **kwargs): - return await self.hass.async_add_executor_job( - partial(self.send_miio_command, method, params, **kwargs) - ) - async def async_update(self): try: attrs = await self.hass.async_add_executor_job( @@ -1132,10 +936,6 @@ async def async_update_attrs(self, attrs: dict, update_parent=False, update_subs self._state_attrs = adt else: self._state_attrs.update(adt) - if pls := self.custom_config_list('sensor_attributes'): - await self.async_update_attr_sensor_entities(pls, domain='sensor') - if pls := self.custom_config_list('binary_sensor_attributes'): - await self.async_update_attr_sensor_entities(pls, domain='binary_sensor') return self._state_attrs @@ -1158,22 +958,6 @@ def update_attrs(self, *args, **kwargs): raise NotImplementedError() -def update_attrs_add_suffix_on_duplicate(attrs, new_dict): - updated_attrs = {} - - for key, value in new_dict.items(): - if key in attrs: - suffix = 2 - while f"{key}_{suffix}" in attrs: - suffix += 1 - updated_key = f"{key}_{suffix}" - else: - updated_key = key - - updated_attrs[updated_key] = value - attrs.update(updated_attrs) - - class MiotEntity(MiioEntity): def __init__(self, miot_service=None, device=None, **kwargs): self._config = dict(kwargs.get('config') or {}) @@ -1185,55 +969,23 @@ def __init__(self, miot_service=None, device=None, **kwargs): super().__init__(name, device, **kwargs) self._local_state = None - self._miio2miot = None + self._miio2miot = self.device.miio2miot self._miot_mapping = dict(kwargs.get('mapping') or {}) if self._miot_service: - if not self.cloud_only: - # only for local mode - ext = self.custom_config('extend_miot_specs') - if ext and isinstance(ext, str): - ext = DEVICE_CUSTOMIZES.get(ext, {}).get('extend_miot_specs') - else: - ext = self.custom_config_list('extend_miot_specs') - if ext and isinstance(ext, list): - self._miot_service.spec.extend_specs(services=ext) - self._miio2miot = Miio2MiotHelper.from_model(self.hass, self._model, self._miot_service.spec) - if dic := self.custom_config_json('miot_mapping'): - self._miot_service.spec.set_custom_mapping(dic) if not self._miot_mapping: - if ems := self.custom_config_list('exclude_miot_services') or []: - self._state_attrs['exclude_miot_services'] = ems - if eps := self.custom_config_list('exclude_miot_properties') or []: - self._state_attrs['exclude_miot_properties'] = eps urp = self.custom_config_bool('unreadable_properties') self._miot_mapping = miot_service.mapping( - excludes=eps, + excludes=self.custom_config_list('exclude_miot_properties') or [], unreadable_properties=urp, ) or {} - ism = True - if mms := self.custom_config_list('main_miot_services') or []: - if self._miot_service.in_list(mms): - ism = True - elif self._miot_service.spec.get_services(*mms): - ism = False - if ism: - ems = [self._miot_service.name, *ems] - ext = self._miot_service.spec.services_mapping( - excludes=ems, - exclude_properties=eps, - unreadable_properties=urp, - ) or {} - self._miot_mapping = {**self._miot_mapping, **ext, **self._miot_mapping} - self._vars['is_main_entity'] = ism self._unique_id = f'{self._unique_id}-{self._miot_service.iid}' self.entity_id = self._miot_service.generate_entity_id(self) - self._state_attrs['miot_type'] = self._miot_service.spec.type self._attr_translation_key = self._miot_service.name - if not self.entity_id and self._model: - mls = f'{self._model}..'.split('.') + if not self.entity_id and self.model: + mls = f'{self.model}..'.split('.') mac = re.sub(r'[\W_]+', '', self.unique_mac) self.entity_id = f'{DOMAIN}.{mls[0]}_{mls[2]}_{mac[-4:]}_{mls[1]}' - if self._model in MIOT_LOCAL_MODELS: + if self.model in MIOT_LOCAL_MODELS: self._vars['track_miot_error'] = True self._success_code = 0 self.logger.info('%s: Initializing miot device with mapping: %s', self.name_model, self._miot_mapping) @@ -1247,39 +999,9 @@ async def async_added_to_hass(self): @property def miot_device(self): - host = self.device_host - token = self._config.get(CONF_TOKEN) or None - if self._device: - pass - elif not host or host in ['0.0.0.0']: - pass - elif not token: - pass - elif self.hass: - device = None - mapping = self.custom_config_json('miot_local_mapping') - if not mapping: - mapping = self.miot_mapping - elif self._miot_service: - self._miot_service.spec.set_custom_mapping(mapping) - self._vars['has_local_mapping'] = True - try: - device = MiotDevice(ip=host, token=token, mapping=mapping or {}) - except TypeError as exc: - err = f'{exc}' - if 'mapping' in err: - if 'unexpected keyword argument' in err: - # for python-miio <= v0.5.5.1 - device = MiotDevice(host, token) - device.mapping = mapping - elif 'required positional argument' in err: - # for python-miio <= v0.5.4 - # https://github.com/al-one/hass-xiaomi-miot/issues/44#issuecomment-815474650 - device = MiotDevice(mapping, host, token) # noqa - except ValueError as exc: - self.logger.warning('%s: Initializing with host %s failed: %s', host, self.name_model, exc) + if not self._device: + device = self.device.local if device: - device.hass = self.hass self._device = device return self._device @@ -1287,7 +1009,7 @@ def miot_device(self): def miot_did(self): did = self.custom_config('miot_did') or self._config.get('miot_did') if self.entity_id and not did: - mac = self._miio_info.mac_address + mac = self.device.info.mac dvs = self.entry_config('devices_by_mac') or {} if mac in dvs: return dvs[mac].get('did') @@ -1295,9 +1017,7 @@ def miot_did(self): @property def xiaomi_cloud(self): - if self.hass: - return self.entry_config(CONF_XIAOMI_CLOUD) - return None + return self.device.cloud @property def miot_cloud(self): @@ -1344,15 +1064,6 @@ def is_main_entity(self): def miot_config(self): return self._config or {} - @property - def miot_mapping(self): - mmp = None - if self._miot_mapping: - mmp = self._miot_mapping - elif self._device and hasattr(self._device, 'mapping'): - mmp = self._device.mapping or {} - return mmp - @property def entity_id_prefix(self): if not self._miot_service: @@ -1384,270 +1095,47 @@ async def async_update(self): if self._vars.get('delay_update'): await asyncio.sleep(self._vars.get('delay_update')) self._vars.pop('delay_update', 0) - updater = 'none' attrs = {} - results = None - errors = None - mapping = self.miot_mapping - local_mapping = mapping - if self._vars.get('has_local_mapping'): - local_mapping = self._device.mapping - - if pls := self.custom_config_list('miio_properties'): - self._vars['miio_properties'] = pls - if self._miio2miot: - self._miio2miot.extend_miio_props(pls) - - use_local = self.miot_local or self._miio2miot - if self.cloud_only: - use_local = False - elif self.custom_config_bool('miot_cloud'): - use_local = False - elif not self.miot_device: - use_local = False - use_cloud = not use_local and self.miot_cloud - if not self.miot_device: - use_cloud = self.xiaomi_cloud - if not (mapping or local_mapping): - use_local = False - use_cloud = False - results = [] - - if use_local: - updater = 'lan' - max_properties = 10 - try: - if self._miio2miot: - results = await self._miio2miot.async_get_miot_props(self.miot_device, local_mapping) - attrs.update(self._miio2miot.entity_attrs()) - else: - max_properties = self.custom_config_integer('chunk_properties') - if not max_properties: - idx = len(local_mapping) - if idx >= 10: - idx -= 10 - chunks = [ - # 10,11,12,13,14,15,16,17,18,19 - 10, 6, 6, 7, 7, 8, 8, 9, 9, 10, - # 20,21,22,23,24,25,26,27,28,29 - 10, 7, 8, 8, 8, 9, 9, 9, 10, 10, - # 30,31,32,33,34,35,36,37,38,39 - 10, 8, 8, 7, 7, 7, 9, 9, 10, 10, - # 40,41,42,43,44,45,46,47,48,49 - 10, 9, 9, 9, 9, 9, 10, 10, 10, 10, - ] - max_properties = 10 if idx >= len(chunks) else chunks[idx] - results = await self._device.async_get_properties_for_mapping( - max_properties=max_properties, - did=self.miot_did, - mapping=local_mapping, - ) - self._local_state = True - except (DeviceException, OSError) as exc: - log = self.logger.error - if self.custom_config_bool('auto_cloud'): - use_cloud = self.xiaomi_cloud - log = self.logger.warning if self._local_state else self.logger.info - else: - self._available = False - errors = f'{exc}' - self._local_state = False - log( - '%s: Got MiioException while fetching the state: %s, mapping: %s, max_properties: %s/%s', - self.name_model, exc, mapping, max_properties, len(mapping) - ) - - if use_cloud: - updater = 'cloud' - try: - mic = self.xiaomi_cloud - results = await mic.async_get_properties_for_mapping(self.miot_did, mapping) - if self.custom_config_bool('check_lan'): - if self.miot_device: - await self.hass.async_add_executor_job(self.miot_device.info) - else: - self._available = False - return - except MiCloudException as exc: - self._available = False - errors = f'{exc}' - self.logger.error( - '%s: Got MiCloudException while fetching the state: %s, mapping: %s', - self.name_model, exc, mapping, - ) + result = await self.device.update_miot_status( + use_local=self.custom_config_bool('miot_local'), + use_cloud=self.custom_config_bool('miot_cloud'), + auto_cloud=self.custom_config_bool('auto_cloud'), + check_lan=self.custom_config_bool('check_lan'), + max_properties=self.custom_config_integer('chunk_properties'), + ) + self._available = self.device.available - self._vars.setdefault('offline_times', 0) - self.hass.data[DOMAIN].setdefault('offline_devices', {}) - result = MiotResults(results, mapping) if not result.is_valid: - self._available = False - if errors and is_offline_exception(errors): - odd = self.hass.data[DOMAIN]['offline_devices'].get(self.unique_did) or {} - if not self._vars.get('ignore_offline'): - self._vars['offline_times'] += 1 - if odd: - odd.update({ - 'occurrences': self._vars['offline_times'], - }) - elif self._vars['offline_times'] >= 5: - odd = { - 'entity': self, - 'occurrences': self._vars['offline_times'], - } - self.hass.data[DOMAIN]['offline_devices'][self.unique_did] = odd - tip = f'Some devices cannot be connected in the LAN, please check their IP ' \ - f'and make sure they are in the same subnet as the HA.\n\n' \ - f'一些设备无法通过局域网连接,请检查它们的IP,并确保它们和HA在同一子网。\n' - for d in self.hass.data[DOMAIN]['offline_devices'].values(): - ent = d.get('entity') - if not ent: - continue - tip += f'\n - {ent.name_model}: {ent.device_host}' - tip += '\n\n' - url = 'https://github.com/al-one/hass-xiaomi-miot/search' \ - '?type=issues&q=%22Unable+to+discover+the+device%22' - tip += f'[Known issues]({url})' - url = 'https://github.com/al-one/hass-xiaomi-miot/issues/500#offline' - tip += f' | [了解更多]({url})' - persistent_notification.async_create( - self.hass, - tip, - 'Devices offline', - f'{DOMAIN}-devices-offline', - ) - elif self._vars.get('track_miot_error') and updater == 'lan': + if result.errors and is_offline_exception(result.errors): + """ Migrated to device """ + elif result.updater == 'local' and self._vars.pop('track_miot_error', None): await async_analytics_track_event( - self.hass, 'miot', 'error', self._model, - firmware=self.device_info.get('sw_version'), - results=results or errors, + self.hass, 'miot', 'error', self.model, + firmware=self.device.info.firmware_version, + results=result._results or result.errors, ) - self._vars.pop('track_miot_error', None) - if result.is_empty and results: + if result.is_empty and result._results: self.logger.warning( '%s: Got invalid miot result while fetching the state: %s, mapping: %s', - self.name_model, results, mapping, + self.name_model, result._results, self._miot_mapping, ) return False - self._vars['offline_times'] = 0 - if self.hass.data[DOMAIN]['offline_devices'].pop(self.unique_did, None): - if not self.hass.data[DOMAIN]['offline_devices']: - persistent_notification.async_dismiss( - self.hass, - f'{DOMAIN}-devices-offline', - ) - attrs.update(result.to_attributes(self._state_attrs)) - attrs['state_updater'] = updater - self._available = True - self._state = True if self._state_attrs.get('power') else False - await self.async_update_attrs(attrs, update_subs=True) if self.is_main_entity: + attrs.update(self.device.props) + attrs['state_updater'] = result.updater await self.async_update_for_main_entity() - if self._subs: - await self.async_update_attrs({ - 'sub_entities': list(self._subs.keys()), - }, update_subs=False) + else: + attrs.update(result.to_attributes(self._state_attrs, self._miot_mapping)) + await self.async_update_attrs(attrs, update_subs=True) self.logger.debug('%s: Got new state: %s', self.name_model, attrs) async def async_update_for_main_entity(self): if self._miot_service: - for d in [ - 'sensor', 'binary_sensor', 'switch', 'number', 'select', - 'fan', 'cover', 'button', 'text', 'scanner', 'number_select', - ]: - pls = self.custom_config_list(f'{d}_properties') or [] - if pls: - self._update_sub_entities(pls, '*', domain=d) - for d in ['button', 'text', 'select']: - als = self.custom_config_list(f'{d}_actions') or [] - if als: - self._update_sub_entities(None, '*', domain=d, actions=als) for d in ['light', 'fan']: pls = self.custom_config_list(f'{d}_services') or [] if pls: self._update_sub_entities(None, pls, domain=d) - self._update_sub_entities( - [ - 'temperature', 'indoor_temperature', 'relative_humidity', 'humidity', - 'pm2_5_density', 'pm10_density', 'co2_density', 'tvoc_density', 'hcho_density', - 'air_quality', 'air_quality_index', 'illumination', 'motion_state', - ], - ['environment', 'illumination_sensor'], - domain='sensor', - ) - self._update_sub_entities( - [ - 'filter_life', 'filter_life_level', 'filter_left_time', 'filter_used_time', - 'filter_left_flow', 'filter_used_flow', - ], - ['filter', 'filter_life'], - domain='sensor', - ) - self._update_sub_entities( - [ - 'battery_level', 'ble_battery_level', 'charging_state', 'voltage', 'electric_power', - 'electric_current', 'leakage_current', 'surge_power', 'elec_count', - ], - ['battery', 'power_consumption', 'electricity'], - domain='sensor', - ) - self._update_sub_entities( - ['tds_in'], - ['tds_sensor'], - domain='sensor', - ) - self._update_sub_entities( - ['brush_life_level', 'brush_left_time'], - ['brush_cleaner'], - domain='sensor', - ) - self._update_sub_entities( - 'physical_controls_locked', - ['physical_controls_locked', self._miot_service.name], - domain='switch', - option={ - 'entity_category': EntityCategory.CONFIG.value, - }, - ) - self._update_sub_entities( - None, - ['indicator_light', 'night_light', 'ambient_light', 'plant_light'], - domain='light', - option={ - 'entity_category': EntityCategory.CONFIG.value, - }, - ) - - # update miio prop/event in cloud - if cls := self.custom_config_list('miio_cloud_records'): - await self.async_update_miio_cloud_records(cls) - - if pls := self.custom_config_list('miio_cloud_props'): - await self.async_update_miio_cloud_props(pls) - - # update micloud statistics in cloud - cls = self.custom_config_list('micloud_statistics') or [] - if keys := self.custom_config_list('stat_power_cost_key'): - for k in keys: - dic = { - 'type': self.custom_config('stat_power_cost_type', 'stat_day_v3'), - 'key': k, - 'day': 32, - 'limit': 31, - 'attribute': None, - 'template': 'micloud_statistics_power_cost', - } - cls.append(dic) - if cls: - await self.async_update_micloud_statistics(cls) - - # update miio properties in lan - if pls := self._vars.get('miio_properties', []): - await self.async_update_miio_props(pls) - - # update miio commands in lan - if cls := self.custom_config_json('miio_commands'): - await self.async_update_miio_commands(cls) async def async_update_miio_props(self, props): if not self.miot_device: @@ -1702,295 +1190,21 @@ async def async_update_miio_commands(self, commands): self.logger.debug('%s: Got miio properties: %s', self.name_model, attrs) await self.async_update_attrs(attrs) - async def async_update_miio_cloud_props(self, keys): - did = str(self.miot_did) - mic = self.xiaomi_cloud - if not did or not mic: - return - kls = [] - for k in keys: - if '.' not in k: - k = f'prop.{k}' - kls.append(k) - pms = { - 'did': did, - 'props': kls, - } - rdt = await mic.async_request_api('device/batchdevicedatas', [pms]) or {} - self.logger.debug('%s: Got miio cloud props: %s', self.name_model, rdt) - props = (rdt.get('result') or {}).get(did, {}) - - tpl = self.custom_config('miio_cloud_props_template') - if tpl and props: - tpl = CUSTOM_TEMPLATES.get(tpl, tpl) - tpl = cv.template(tpl) - tpl.hass = self.hass - attrs = tpl.async_render({'props': props}) - else: - attrs = props - await self.async_update_attrs(attrs) - - async def async_update_miio_cloud_records(self, keys): - did = self.miot_did - mic = self.xiaomi_cloud - if not did or not mic: - return - attrs = {} - for c in keys: - mat = re.match(r'^\s*(?:(\w+)\.?)([\w.]+)(?::(\d+))?(?::(\w+))?\s*$', c) - if not mat: - continue - typ, key, lmt, gby = mat.groups() - stm = int(time.time()) - 86400 * 32 - kws = { - 'time_start': stm, - 'limit': int(lmt or 1), - } - if gby: - kws['group'] = gby - rdt = await mic.async_get_user_device_data(did, key, typ, **kws) or [] - tpl = self.custom_config(f'miio_{typ}_{key}_template') - if tpl: - tpl = CUSTOM_TEMPLATES.get(tpl, tpl) - tpl = cv.template(tpl) - tpl.hass = self.hass - rls = tpl.async_render({'result': rdt}) - else: - rls = [ - v.get('value') - for v in rdt - if 'value' in v - ] - if isinstance(rls, dict) and rls.pop('_entity_attrs', False): - attrs.update(rls) - else: - attrs[f'{typ}.{key}'] = rls - if attrs: - await self.async_update_attrs(attrs) - - async def async_update_micloud_statistics(self, lst): - did = self.miot_did - mic = self.xiaomi_cloud - if not did or not mic: - return - now = int(time.time()) - attrs = {} - for c in lst: - if not c.get('key'): - continue - day = c.get('day') or 7 - pms = { - 'did': did, - 'key': c.get('key'), - 'data_type': c.get('type', 'stat_day_v3'), - 'time_start': now - 86400 * day, - 'time_end': now + 60, - 'limit': int(c.get('limit') or 1), - } - rdt = await mic.async_request_api('v2/user/statistics', pms) or {} - self.logger.debug('%s: Got micloud statistics: %s', self.name_model, rdt) - tpl = c.get('template') - if tpl: - tpl = CUSTOM_TEMPLATES.get(tpl, tpl) - tpl = cv.template(tpl) - tpl.hass = self.hass - rls = tpl.async_render(rdt) - else: - rls = [ - v.get('value') - for v in rdt - if 'value' in v - ] - if anm := c.get('attribute'): - attrs[anm] = rls - elif isinstance(rls, dict): - update_attrs_add_suffix_on_duplicate(attrs, rls) - if attrs: - await self.async_update_attrs(attrs) - - async def async_get_properties(self, mapping, update_entity=False, throw=False, **kwargs): - results = [] - if isinstance(mapping, list): - new_mapping = {} - for p in mapping: - siid = p['siid'] - piid = p['piid'] - pkey = self._miot_service.spec.unique_prop(siid, piid=piid) - prop = self._miot_service.spec.specs.get(pkey) - if not isinstance(prop, MiotProperty): - continue - new_mapping[prop.full_name] = p - mapping = new_mapping - if not mapping or not isinstance(mapping, dict): - return - try: - if self._local_state: - results = await self.miot_device.async_get_properties_for_mapping(mapping=mapping) - elif self.miot_cloud: - results = await self.miot_cloud.async_get_properties_for_mapping(self.miot_did, mapping) - except (ValueError, DeviceException) as exc: - self.logger.error( - '%s: Got exception while get properties: %s, mapping: %s, miio: %s', - self.name_model, exc, mapping, self._miio_info.data, - ) - if throw: - raise exc - return - result = MiotResults(results, mapping) - attrs = result.to_attributes(self._state_attrs) - self.logger.info('%s: Get miot properties: %s', self.name_model, results) - - if attrs and update_entity: - await self.async_update_attrs(attrs, update_subs=True) - self.schedule_update_ha_state() - return attrs - def set_property(self, field, value): - if isinstance(field, MiotProperty): - siid = field.siid - piid = field.iid - field = field.full_name - else: - ext = self.miot_mapping.get(field) or {} - if not ext: - self.logger.warning( - '%s: Set miot property %s(%s) failed: property not found', - self.name_model, field, value, - ) - return False - siid = ext['siid'] - piid = ext['piid'] - try: - result = self.set_miot_property(siid, piid, value) - except (DeviceException, MiCloudException) as exc: - self.logger.error('%s: Set miot property %s(%s) failed: %s', self.name_model, field, value, exc) - return False - ret = result.is_success if result else False - if ret: - if field in self._state_attrs: - self.update_attrs({ - field: value, - }, update_parent=False) - self.logger.debug('%s: Set miot property %s(%s), result: %s', self.name_model, field, value, result) - else: - self.logger.info('%s: Set miot property %s(%s) failed, result: %s', self.name_model, field, value, result) - return ret - - async def async_set_property(self, *args, **kwargs): - if not self.hass: - self.logger.info('%s: Set miot property %s failed: hass not ready.', self.name_model, args) - return False - return await self.hass.async_add_executor_job(partial(self.set_property, *args, **kwargs)) - - def set_miot_property(self, siid, piid, value, did=None, **kwargs): - if did is None: - did = self.miot_did or f'prop.{siid}.{piid}' - pms = { - 'did': str(did), - 'siid': siid, - 'piid': piid, - 'value': value, - } - ret = None - dly = 1 - m2m = self._miio2miot and self.miot_device and not self.custom_config_bool('miot_cloud_write') - mcw = self.miot_cloud_write - if self.custom_config_bool('auto_cloud') and not self._local_state: - mcw = self.xiaomi_cloud - if not self.miot_device: - mcw = self.xiaomi_cloud - try: - if m2m and self._miio2miot.has_setter(siid, piid=piid): - results = [ - self._miio2miot.set_property(self.miot_device, siid, piid, value), - ] - elif isinstance(mcw, MiotCloud): - results = mcw.set_props([pms]) - dly = self.custom_config_integer('cloud_delay_update', 6) - else: - results = self.miot_device.send('set_properties', [pms]) - dly = self.custom_config_integer('local_delay_update', 1) - ret = MiotResults(results).first - except (DeviceException, MiCloudException) as exc: - self.logger.warning('%s: Set miot property %s failed: %s', self.name_model, pms, exc) - if ret: - self._vars['delay_update'] = dly - if not ret.is_success: - self.logger.warning('%s: Set miot property %s failed, result: %s', self.name_model, pms, ret) - else: - self.logger.debug('%s: Set miot property %s, result: %s', self.name_model, pms, ret) - if not self._miot_service: - pass - elif not (srv := self._miot_service.spec.services.get(siid)): - pass - elif prop := srv.properties.get(piid): - self._state_attrs[prop.full_name] = value - self.schedule_update_ha_state() - return ret + return self.device.set_property(field, value) - async def async_set_miot_property(self, siid, piid, value, did=None, **kwargs): - return await self.hass.async_add_executor_job( - partial(self.set_miot_property, siid, piid, value, did, **kwargs) - ) + def set_miot_property(self, siid, piid, value, **kwargs): + return self.device.set_miot_property(siid, piid, value, **kwargs) - def call_action(self, action: MiotAction, params=None, did=None, **kwargs): + def call_action(self, action: MiotAction, params=None, **kwargs): aiid = action.iid siid = action.service.iid pms = params or [] kwargs['action'] = action - return self.miot_action(siid, aiid, pms, did, **kwargs) + return self.miot_action(siid, aiid, pms, **kwargs) - def miot_action(self, siid, aiid, params=None, did=None, **kwargs): - if did is None: - did = self.miot_did or f'action-{siid}-{aiid}' - pms = { - 'did': str(did), - 'siid': siid, - 'aiid': aiid, - 'in': params or [], - } - dly = 1 - eno = 1 - result = None - action = kwargs.get('action') - if not action and self._miot_service: - action = self._miot_service.spec.services.get(siid, {}).actions.get(aiid) - m2m = self._miio2miot and self.miot_device and not self.custom_config_bool('miot_cloud_action') - mca = self.miot_cloud_action - if self.custom_config_bool('auto_cloud') and not self._local_state: - mca = self.xiaomi_cloud - elif not self.miot_device: - mca = self.xiaomi_cloud - try: - if m2m and self._miio2miot.has_setter(siid, aiid=aiid): - result = self._miio2miot.call_action(self.miot_device, siid, aiid, params) - elif isinstance(mca, MiotCloud): - result = mca.do_action(pms) - dly = self.custom_config_integer('cloud_delay_update', 5) - else: - if not kwargs.get('force_params'): - pms['in'] = action.in_params(params or []) - result = self.miot_device.send('action', pms) - dly = self.custom_config_integer('local_delay_update', 1) - eno = dict(result or {}).get('code', eno) - except (DeviceException, MiCloudException) as exc: - self.logger.warning('%s: Call miot action %s failed: %s', self.name_model, pms, exc) - except (TypeError, ValueError) as exc: - self.logger.warning('%s: Call miot action %s failed: %s, result: %s', self.name_model, pms, exc, result) - ret = eno == self._success_code - if ret: - self._vars['delay_update'] = dly - self.logger.debug('%s: Call miot action %s, result: %s', self.name_model, pms, result) - else: - self._state_attrs['miot_action_error'] = MiotSpec.spec_error(eno) - self.logger.info('%s: Call miot action %s failed: %s', self.name_model, pms, result) - self._state_attrs['miot_action_result'] = result - return result if ret else ret - - async def async_miot_action(self, siid, aiid, params=None, did=None, **kwargs): - return await self.hass.async_add_executor_job( - partial(self.miot_action, siid, aiid, params, did, **kwargs) - ) + def miot_action(self, siid, aiid, params=None, **kwargs): + return self.device.call_action(siid, aiid, params, **kwargs) def turn_on(self, **kwargs): ret = False @@ -2010,12 +1224,8 @@ def turn_off(self, **kwargs): def _update_sub_entities(self, properties, services=None, domain=None, option=None, **kwargs): actions = kwargs.get('actions', []) - from .sensor import MiotSensorSubEntity - from .binary_sensor import MiotBinarySensorSubEntity - from .switch import MiotSwitchSubEntity from .light import MiotLightSubEntity - from .fan import (MiotFanSubEntity, MiotModesSubEntity) - from .cover import MiotCoverSubEntity + from .fan import MiotFanSubEntity if isinstance(services, MiotService): sls = [services] elif services == '*': @@ -2026,17 +1236,8 @@ def _update_sub_entities(self, properties, services=None, domain=None, option=No sls = [properties.service] else: sls = [self._miot_service] - add_sensors = self._add_entities.get('sensor') - add_binary_sensors = self._add_entities.get('binary_sensor') - add_switches = self._add_entities.get('switch') add_lights = self._add_entities.get('light') add_fans = self._add_entities.get('fan') - add_covers = self._add_entities.get('cover') - add_numbers = self._add_entities.get('number') - add_selects = self._add_entities.get('select') - add_buttons = self._add_entities.get('button') - add_texts = self._add_entities.get('text') - add_device_trackers = self._add_entities.get('device_tracker') exclude_services = self._state_attrs.get('exclude_miot_services') or [] for s in sls: if s.name in exclude_services: @@ -2065,99 +1266,6 @@ def _update_sub_entities(self, properties, services=None, domain=None, option=No if new and fnm in self._subs: self._check_same_sub_entity(fnm, domain, add=1) self.logger.debug('%s: Added sub entity %s: %s', self.name_model, domain, fnm) - continue - pls = [] - if isinstance(properties, MiotProperty): - pls = [properties] - if properties: - pls.extend(s.get_properties(*cv.ensure_list(properties))) - if actions: - pls.extend(s.get_actions(*cv.ensure_list(actions))) - for p in pls: - fnm = p.unique_name - opt = { - 'unique_id': f'{self.unique_did}-{fnm}', - **(option or {}), - } - tms = self._check_same_sub_entity(fnm, domain) - new = True - if fnm in self._subs: - new = False - self._subs[fnm].update_from_parent() - self._check_same_sub_entity(fnm, domain, add=1) - elif tms > 0: - if tms <= 1: - self.logger.info('%s: Device sub entity %s: %s already exists.', self.name_model, domain, fnm) - elif isinstance(p, MiotAction): - if add_buttons and domain == 'button': - from .button import MiotButtonActionSubEntity - self._subs[fnm] = MiotButtonActionSubEntity(self, p, option=opt) - add_buttons([self._subs[fnm]]) - elif add_texts and domain == 'text': - from .text import MiotTextActionSubEntity - self._subs[fnm] = MiotTextActionSubEntity(self, p, option=opt) - add_texts([self._subs[fnm]]) - elif add_selects and domain == 'select' and p.ins: - from .select import MiotActionSelectSubEntity - self._subs[fnm] = MiotActionSelectSubEntity(self, p, option=opt) - add_selects([self._subs[fnm]]) - elif add_buttons and domain == 'button' and (p.value_list or p.is_bool): - from .button import MiotButtonSubEntity - nls = [] - f = fnm - for pv in p.value_list: - vk = pv.get('value') - f = f'{fnm}-{vk}' - if f in self._subs: - new = False - continue - self._subs[f] = MiotButtonSubEntity(self, p, vk, option=opt) - nls.append(self._subs[f]) - if p.is_bool: - self._subs[f] = MiotButtonSubEntity(self, p, True, option=opt) - nls.append(self._subs[f]) - if nls: - add_buttons(nls, update_before_add=True) - new = True - fnm = f - elif p.full_name not in self._state_attrs and not p.writeable and not kwargs.get('whatever'): - continue - elif add_switches and domain == 'switch' and (p.format in ['bool', 'uint8']) and p.writeable: - self._subs[fnm] = MiotSwitchSubEntity(self, p, option=opt) - add_switches([self._subs[fnm]], update_before_add=True) - elif add_binary_sensors and domain == 'binary_sensor' and (p.is_bool or p.is_integer): - self._subs[fnm] = MiotBinarySensorSubEntity(self, p, option=opt) - add_binary_sensors([self._subs[fnm]], update_before_add=True) - elif add_sensors and domain == 'sensor': - if p.full_name == self._state_attrs.get('state_property'): - continue - self._subs[fnm] = MiotSensorSubEntity(self, p, option=opt) - add_sensors([self._subs[fnm]], update_before_add=True) - elif add_fans and domain == 'fan': - self._subs[fnm] = MiotModesSubEntity(self, p, option=opt) - add_fans([self._subs[fnm]], update_before_add=True) - elif add_covers and domain == 'cover': - self._subs[fnm] = MiotCoverSubEntity(self, p, option=opt) - add_covers([self._subs[fnm]], update_before_add=True) - elif add_numbers and domain in ['number', 'number_select'] and p.value_range: - from .number import MiotNumberSubEntity - self._subs[fnm] = MiotNumberSubEntity(self, p, option=opt) - add_numbers([self._subs[fnm]], update_before_add=True) - elif add_selects and domain in ['select', 'number_select'] and (p.value_list or p.value_range): - from .select import MiotSelectSubEntity - self._subs[fnm] = MiotSelectSubEntity(self, p, option=opt) - add_selects([self._subs[fnm]], update_before_add=True) - elif add_texts and domain == 'text' and p.writeable: - from .text import MiotTextSubEntity - self._subs[fnm] = MiotTextSubEntity(self, p, option=opt) - add_texts([self._subs[fnm]], update_before_add=True) - elif add_device_trackers and domain == 'scanner' and (p.is_bool or p.is_integer): - from .device_tracker import MiotScannerSubEntity - self._subs[fnm] = MiotScannerSubEntity(self, p, option=opt) - add_device_trackers([self._subs[fnm]], update_before_add=True) - if new and fnm in self._subs: - self._check_same_sub_entity(fnm, domain, add=1) - self.logger.debug('%s: Added sub entity %s: %s', self.name_model, domain, fnm) async def async_get_device_data(self, key, did=None, throw=False, **kwargs): if did is None: @@ -2277,6 +1385,7 @@ def turn_off(self, **kwargs): class BaseSubEntity(BaseEntity): def __init__(self, parent, attr, option=None, **kwargs): self.hass = parent.hass + self.device = parent.device self._unique_id = f'{parent.unique_id}-{attr}' self._name = f'{parent.name} {attr}' self._state = STATE_UNKNOWN @@ -2347,7 +1456,7 @@ def device_name(self): @property def name_model(self): - return f'{self.device_name}({self._model})' + return f'{self.device_name}({self.model})' def format_name_by_property(self, prop: MiotProperty): return f'{self.device_name} {prop.friendly_desc}'.strip() @@ -2398,7 +1507,7 @@ def miot_cloud(self): @property def customize_keys(self): mar = [] - for mod in wildcard_models(self._model): + for mod in wildcard_models(self.model): if self._dict_key: mar.append(f'{mod}:{self._attr}:{self._dict_key}') elif self._attr: diff --git a/custom_components/xiaomi_miot/alarm_control_panel.py b/custom_components/xiaomi_miot/alarm_control_panel.py index 480674479..4b4177c1e 100644 --- a/custom_components/xiaomi_miot/alarm_control_panel.py +++ b/custom_components/xiaomi_miot/alarm_control_panel.py @@ -11,6 +11,7 @@ DOMAIN, CONF_MODEL, XIAOMI_CONFIG_SCHEMA as PLATFORM_SCHEMA, # noqa: F401 + HassEntry, MiotEntity, async_setup_config_entry, bind_services_to_entries, @@ -28,6 +29,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): + HassEntry.init(hass, config_entry).new_adder(ENTITY_DOMAIN, async_add_entities) await async_setup_config_entry(hass, config_entry, async_setup_platform, async_add_entities, ENTITY_DOMAIN) @@ -53,7 +55,7 @@ class MiotAlarmEntity(MiotEntity, AlarmControlPanelEntity): def __init__(self, config, miot_service: MiotService): super().__init__(miot_service, config=config, logger=_LOGGER) self._attr_code_arm_required = False - self._is_mgl03 = self._model == 'lumi.gateway.mgl03' + self._is_mgl03 = self.model == 'lumi.gateway.mgl03' self._prop_mode = miot_service.get_property('arming_mode') if self._prop_mode: if self._prop_mode.list_value('home_arming') is not None: diff --git a/custom_components/xiaomi_miot/binary_sensor.py b/custom_components/xiaomi_miot/binary_sensor.py index 2cba37df3..0d71c9011 100644 --- a/custom_components/xiaomi_miot/binary_sensor.py +++ b/custom_components/xiaomi_miot/binary_sensor.py @@ -3,32 +3,33 @@ import time import json from datetime import datetime +from functools import cached_property from homeassistant.const import ( - STATE_OFF, - STATE_ON, - STATE_UNKNOWN, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, ) from homeassistant.components.binary_sensor import ( DOMAIN as ENTITY_DOMAIN, - BinarySensorEntity, + BinarySensorEntity as BaseEntity, BinarySensorDeviceClass, ) +from homeassistant.helpers.restore_state import RestoreEntity from . import ( DOMAIN, CONF_MODEL, XIAOMI_CONFIG_SCHEMA as PLATFORM_SCHEMA, # noqa: F401 + HassEntry, + XEntity, MiotToggleEntity, - MiotPropertySubEntity, - ToggleSubEntity, async_setup_config_entry, bind_services_to_entries, ) from .core.miot_spec import ( MiotSpec, MiotService, - MiotProperty, ) from .core.xiaomi_cloud import MiotCloud from .core.utils import local_zone @@ -40,6 +41,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): + HassEntry.init(hass, config_entry).new_adder(ENTITY_DOMAIN, async_add_entities) await async_setup_config_entry(hass, config_entry, async_setup_platform, async_add_entities, ENTITY_DOMAIN) @@ -82,7 +84,27 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= bind_services_to_entries(hass, SERVICE_TO_METHOD) -class MiotBinarySensorEntity(MiotToggleEntity, BinarySensorEntity): +class BinarySensorEntity(XEntity, BaseEntity, RestoreEntity): + def set_state(self, data: dict): + val = data.get(self.attr) + if val is None: + return + if self.custom_reverse: + self._attr_extra_state_attributes['reverse_state'] = True + val = not val + self._attr_is_on = val + + def get_state(self) -> dict: + return {self.attr: not self._attr_is_on if self.custom_reverse else self._attr_is_on} + + @cached_property + def custom_reverse(self): + return self.custom_config_bool('reverse_state', False) + +XEntity.CLS[ENTITY_DOMAIN] = BinarySensorEntity + + +class MiotBinarySensorEntity(MiotToggleEntity, BaseEntity): def __init__(self, config, miot_service: MiotService, **kwargs): kwargs.setdefault('logger', _LOGGER) super().__init__(miot_service, config=config, **kwargs) @@ -127,10 +149,6 @@ async def async_added_to_hass(self): if rev is not None: self._vars['reverse_state'] = rev - async def async_update_for_main_entity(self): - await super().async_update_for_main_entity() - self._update_sub_entities(['illumination', 'no_motion_duration'], domain='sensor') - @property def is_on(self): ret = self._state @@ -196,7 +214,6 @@ async def async_update_for_main_entity(self): if self.custom_config_bool('use_ble_object', True): await self.async_update_ble_data() await super().async_update_for_main_entity() - self._update_sub_entities(['illumination', 'no_motion_duration'], domain='sensor') async def async_update_ble_data(self): did = self.miot_did @@ -287,43 +304,6 @@ def __init__(self, config, miot_service: MiotService): self._prop_state.name if self._prop_state else 'status', ) - async def async_update(self): - await super().async_update() - if not self._available: - return - from .fan import MiotModesSubEntity - add_fans = self._add_entities.get('fan') - pls = self._miot_service.get_properties( - 'mode', 'washing_strength', 'nozzle_position', 'heat_level', - ) - seat = self._miot_service.spec.get_service('seat') - if seat: - prop = seat.get_property('heat_level') - if prop: - pls.append(prop) - else: - self._update_sub_entities( - ['heating', 'deodorization'], - [seat], - domain='switch', - ) - for p in pls: - if not p.value_list and not p.value_range: - continue - if p.name in self._subs: - self._subs[p.name].update() - elif add_fans: - opt = None - if p.name in ['heat_level']: - opt = { - 'power_property': p.service.bool_property('heating'), - } - self._subs[p.name] = MiotModesSubEntity(self, p, opt) - add_fans([self._subs[p.name]], update_before_add=True) - - if self._prop_power: - self._update_sub_entities(self._prop_power, None, 'switch') - @property def icon(self): return 'mdi:toilet' @@ -372,9 +352,3 @@ async def async_update(self): adt[self._prop_state.full_name] = self._state if adt: await self.async_update_attrs(adt) - - -class MiotBinarySensorSubEntity(MiotPropertySubEntity, ToggleSubEntity, BinarySensorEntity): - def __init__(self, parent, miot_property: MiotProperty, option=None): - ToggleSubEntity.__init__(self, parent, miot_property.full_name, option) - super().__init__(parent, miot_property, option, domain=ENTITY_DOMAIN) diff --git a/custom_components/xiaomi_miot/button.py b/custom_components/xiaomi_miot/button.py index 80910f12e..0cd5abeed 100644 --- a/custom_components/xiaomi_miot/button.py +++ b/custom_components/xiaomi_miot/button.py @@ -3,22 +3,19 @@ from homeassistant.components.button import ( DOMAIN as ENTITY_DOMAIN, - ButtonEntity, + ButtonEntity as BaseEntity, ) from . import ( DOMAIN, - CONF_MODEL, XIAOMI_CONFIG_SCHEMA as PLATFORM_SCHEMA, # noqa: F401 - MiotEntity, + HassEntry, + XEntity, MiotPropertySubEntity, BaseSubEntity, async_setup_config_entry, - bind_services_to_entries, ) from .core.miot_spec import ( - MiotSpec, - MiotService, MiotProperty, MiotAction, ) @@ -30,37 +27,33 @@ async def async_setup_entry(hass, config_entry, async_add_entities): + HassEntry.init(hass, config_entry).new_adder(ENTITY_DOMAIN, async_add_entities) await async_setup_config_entry(hass, config_entry, async_setup_platform, async_add_entities, ENTITY_DOMAIN) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): hass.data.setdefault(DATA_KEY, {}) - hass.data[DOMAIN]['add_entities'][ENTITY_DOMAIN] = async_add_entities - config['hass'] = hass - model = str(config.get(CONF_MODEL) or '') - spec = hass.data[DOMAIN]['miot_specs'].get(model) - entities = [] - if isinstance(spec, MiotSpec): - for srv in spec.get_services('none_service'): - if not srv.get_property('none_property'): - continue - entities.append(MiotButtonEntity(config, srv)) - for entity in entities: - hass.data[DOMAIN]['entities'][entity.unique_id] = entity - async_add_entities(entities, update_before_add=True) - bind_services_to_entries(hass, SERVICE_TO_METHOD) - - -class MiotButtonEntity(MiotEntity, ButtonEntity): - def __init__(self, config, miot_service: MiotService): - super().__init__(miot_service, config=config, logger=_LOGGER) - - def press(self) -> None: - """Press the button.""" - raise NotImplementedError() -class MiotButtonSubEntity(MiotPropertySubEntity, ButtonEntity): +class ButtonEntity(XEntity, BaseEntity): + def on_init(self): + self._attr_available = True + if des := getattr(self.conv, 'description', None): + self._attr_name = f'{self._attr_name} {des}' + + def set_state(self, data: dict): + pass + + async def async_press(self): + pms = getattr(self.conv, 'value', None) + if self._miot_action and self._miot_action.ins: + pms = self.custom_config_list('action_params', pms) + await self.device.async_write({self.attr: pms}) + +XEntity.CLS[ENTITY_DOMAIN] = ButtonEntity + + +class MiotButtonSubEntity(MiotPropertySubEntity, BaseEntity): def __init__(self, parent, miot_property: MiotProperty, value, option=None): super().__init__(parent, miot_property, option, domain=ENTITY_DOMAIN) self._miot_property_value = value @@ -87,7 +80,7 @@ def press(self): return self.set_parent_property(self._miot_property_value) -class MiotButtonActionSubEntity(BaseSubEntity, ButtonEntity): +class MiotButtonActionSubEntity(BaseSubEntity, BaseEntity): def __init__(self, parent, miot_action: MiotAction, option=None): self._miot_action = miot_action super().__init__(parent, miot_action.full_name, option, domain=ENTITY_DOMAIN) @@ -116,7 +109,7 @@ def press(self): return self.call_parent('call_action', self._miot_action, pms) -class ButtonSubEntity(ButtonEntity, BaseSubEntity): +class ButtonSubEntity(BaseEntity, BaseSubEntity): def __init__(self, parent, attr, option=None): BaseSubEntity.__init__(self, parent, attr, option) self._available = True diff --git a/custom_components/xiaomi_miot/camera.py b/custom_components/xiaomi_miot/camera.py index fc3bc360c..b83a5e063 100644 --- a/custom_components/xiaomi_miot/camera.py +++ b/custom_components/xiaomi_miot/camera.py @@ -28,6 +28,7 @@ DOMAIN, CONF_MODEL, XIAOMI_CONFIG_SCHEMA as PLATFORM_SCHEMA, # noqa: F401 + HassEntry, MiotToggleEntity, BaseSubEntity, MiotCloud, @@ -49,6 +50,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): + HassEntry.init(hass, config_entry).new_adder(ENTITY_DOMAIN, async_add_entities) await async_setup_config_entry(hass, config_entry, async_setup_platform, async_add_entities, ENTITY_DOMAIN) @@ -181,7 +183,7 @@ def __init__(self, hass: HomeAssistant, config: dict, miot_service: MiotService) self._supported_features |= CameraEntityFeature.ON_OFF if miot_service: self._prop_motion_tracking = miot_service.bool_property('motion_detection', 'motion_tracking') - self._is_doorbell = miot_service.name in ['video_doorbell'] or '.lock.' in self._model + self._is_doorbell = miot_service.name in ['video_doorbell'] or '.lock.' in self.model async def async_added_to_hass(self): await super().async_added_to_hass() @@ -279,7 +281,7 @@ async def async_update(self): api = mic.get_api_by_host('business.smartcamera.api.io.mi.com', 'common/app/get/eventlist') rqd = { 'did': self.miot_did, - 'model': self._model, + 'model': self.model, 'doorBell': self._is_doorbell, 'eventType': 'Default', 'needMerge': True, diff --git a/custom_components/xiaomi_miot/climate.py b/custom_components/xiaomi_miot/climate.py index 9d51f9646..7dc311d4b 100644 --- a/custom_components/xiaomi_miot/climate.py +++ b/custom_components/xiaomi_miot/climate.py @@ -30,6 +30,7 @@ DOMAIN, CONF_MODEL, XIAOMI_CONFIG_SCHEMA as PLATFORM_SCHEMA, # noqa: F401 + HassEntry, MiotEntity, MiotToggleEntity, async_setup_config_entry, @@ -55,6 +56,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): + HassEntry.init(hass, config_entry).new_adder(ENTITY_DOMAIN, async_add_entities) await async_setup_config_entry(hass, config_entry, async_setup_platform, async_add_entities, ENTITY_DOMAIN) @@ -252,18 +254,6 @@ async def async_update(self): add_fans([self._subs[des]], update_before_add=True) add_switches = self._add_entities.get('switch') - for p in self._miot_service.properties.values(): - if not (p.format == 'bool' and p.readable and p.writeable): - continue - if p.name in self._power_modes: - continue - if self._prop_power and self._prop_power.name == p.name: - continue - self._update_sub_entities(p, None, 'switch') - - if self._miot_service.name in ['ptc_bath_heater']: - self._update_sub_entities(None, ['light', 'light_bath_heater'], domain='light') - if self._miot_service.get_action('start_wash'): pnm = 'action_wash' prop = self._miot_service.get_property('status') diff --git a/custom_components/xiaomi_miot/config_flow.py b/custom_components/xiaomi_miot/config_flow.py index 690fe84d8..a74a377f7 100644 --- a/custom_components/xiaomi_miot/config_flow.py +++ b/custom_components/xiaomi_miot/config_flow.py @@ -30,10 +30,13 @@ DEFAULT_NAME, DEFAULT_CONN_MODE, init_integration_data, +) +from .core.utils import ( get_customize_via_entity, get_customize_via_model, + in_china, + async_analytics_track_event, ) -from .core.utils import in_china, async_analytics_track_event from .core.const import SUPPORTED_DOMAINS, CLOUD_SERVERS, CONF_XIAOMI_CLOUD from .core.miot_spec import MiotSpec from .core.xiaomi_cloud import ( @@ -343,6 +346,7 @@ async def async_step_cloud(self, user_input=None): vol.In(CLOUD_SERVERS), vol.Required(CONF_CONN_MODE, default=user_input.get(CONF_CONN_MODE, 'auto')): vol.In(CONN_MODES), + vol.Optional('trans_options', default=user_input.get('trans_options', False)): bool, vol.Optional('filter_models', default=user_input.get('filter_models', False)): bool, }) return self.async_show_form( @@ -412,7 +416,6 @@ async def async_step_customizing(self, user_input=None): 'switch_properties': cv.string, 'number_properties': cv.string, 'select_properties': cv.string, - 'cover_properties': cv.string, 'sensor_attributes': cv.string, 'binary_sensor_attributes': cv.string, 'button_properties': cv.string, @@ -423,7 +426,8 @@ async def async_step_customizing(self, user_input=None): 'fan_services': cv.string, 'exclude_miot_services': cv.string, 'exclude_miot_properties': cv.string, - 'main_miot_services': cv.string, + 'configuration_entities': cv.string, + 'diagnostic_entities': cv.string, 'cloud_delay_update': cv.string, } options = { @@ -662,6 +666,7 @@ async def async_step_cloud(self, user_input=None): vol.Required(CONF_CONN_MODE, default=user_input.get(CONF_CONN_MODE, DEFAULT_CONN_MODE)): vol.In(CONN_MODES), vol.Optional('renew_devices', default=user_input.get('renew_devices', False)): bool, + vol.Optional('trans_options', default=user_input.get('trans_options', False)): bool, vol.Optional('disable_message', default=user_input.get('disable_message', False)): bool, vol.Optional('disable_scene_history', default=user_input.get('disable_scene_history', False)): bool, }) @@ -690,6 +695,7 @@ async def async_step_cloud_filter(self, user_input=None): cfg = self.cloud.to_config() or {} cfg.update({ CONF_CONN_MODE: prev_input.get(CONF_CONN_MODE), + 'trans_options': prev_input.get('trans_options'), 'filter_models': prev_input.get('filter_models'), 'disable_message': prev_input.get('disable_message'), 'disable_scene_history': prev_input.get('disable_scene_history'), diff --git a/custom_components/xiaomi_miot/core/__init__.py b/custom_components/xiaomi_miot/core/__init__.py new file mode 100644 index 000000000..73bd2e5e6 --- /dev/null +++ b/custom_components/xiaomi_miot/core/__init__.py @@ -0,0 +1,11 @@ +from .device import Device, DeviceInfo +from .hass_entry import HassEntry +from .hass_entity import BasicEntity, XEntity + +__all__ = [ + 'Device', + 'DeviceInfo', + 'HassEntry', + 'BasicEntity', + 'XEntity', +] \ No newline at end of file diff --git a/custom_components/xiaomi_miot/core/const.py b/custom_components/xiaomi_miot/core/const.py index b2f2037cf..c63ad8fe8 100644 --- a/custom_components/xiaomi_miot/core/const.py +++ b/custom_components/xiaomi_miot/core/const.py @@ -1,7 +1,7 @@ from enum import Enum from typing import Union -from .device_customizes import DEVICE_CUSTOMIZES # noqa +from .device_customizes import DEVICE_CUSTOMIZES, GLOBAL_CONVERTERS # noqa from .miot_local_devices import MIOT_LOCAL_MODELS # noqa from .translation_languages import TRANSLATION_LANGUAGES # noqa diff --git a/custom_components/xiaomi_miot/core/converters.py b/custom_components/xiaomi_miot/core/converters.py new file mode 100644 index 000000000..1aff17d55 --- /dev/null +++ b/custom_components/xiaomi_miot/core/converters.py @@ -0,0 +1,235 @@ +from typing import TYPE_CHECKING, Any +from dataclasses import dataclass +from homeassistant.util import color +from miio.utils import ( + rgb_to_int, + int_to_rgb, +) + +if TYPE_CHECKING: + from .device import Device + from .miot_spec import MiotService, MiotProperty, MiotAction + + +@dataclass +class BaseConv: + attr: str + domain: str = None + mi: str | int = None + attrs: set = None + option: dict = None + + def __post_init__(self): + if self.attrs is None: + self.attrs = set() + if self.option is None: + self.option = {} + + def with_option(self, **kwargs): + self.option.update(kwargs) + return self + + # to hass + def decode(self, device: 'Device', payload: dict, value): + payload[self.attr] = value + + # from hass + def encode(self, device: 'Device', payload: dict, value): + params = None + if self.mi and 'prop.' in self.mi: + _, s, p = self.mi.split('.') + payload['method'] = 'set_properties' + params = {'siid': int(s), 'piid': int(p)} + if params: + params.update({'did': device.did, 'value': value}) + payload.setdefault('params', []).append(params) + +@dataclass +class InfoConv(BaseConv): + attr: str = 'info' + domain: str = 'button' + + def decode(self, device: 'Device', payload: dict, value): + updater = device.data.get('updater') + infos = { + self.attr: device.name, + 'model': device.model, + 'did': device.info.did, + 'mac': device.info.mac, + 'lan_ip': device.info.host, + 'app_link': device.app_link, + 'miot_type': device.info.urn, + 'available': device.available, + 'home_room': device.info.home_room, + 'icon': self.option.get('icon') if device.available else 'mdi:information-off', + 'updater': updater or 'none', + 'updated_at': str(device.data.get('updated', '')), + } + payload.update({ + **infos, + **device.props, + 'converters': [c.attr for c in device.converters], + 'customizes': device.customizes, + **infos, + }) + if device.available: + payload.pop('miot_error', None) + if device.miot_results: + if err := device.miot_results.errors: + payload['miot_error'] = str(err) + + def encode(self, device: 'Device', payload: dict, value): + payload.update({ + 'method': 'update_status', + }) + +@dataclass +class AttrConv(BaseConv): + pass + +@dataclass +class MiotPropConv(BaseConv): + prop: 'MiotProperty' = None + desc: bool = False + + def __post_init__(self): + super().__post_init__() + if not self.mi and self.prop: + from .miot_spec import MiotSpec + self.mi = MiotSpec.unique_prop(self.prop.siid, piid=self.prop.iid) + + def decode(self, device: 'Device', payload: dict, value): + if self.desc and self.prop: + value = self.prop.list_description(value) + if self.domain == 'sensor' and isinstance(value, str): + value = value.lower() + super().decode(device, payload, value) + + def encode(self, device: 'Device', payload: dict, value): + if self.prop: + if self.desc and self.prop.value_list: + value = self.prop.list_value(value) + if self.prop.is_integer: + value = int(value) # bool to int + super().encode(device, payload, value) + +@dataclass +class MiotPropValueConv(MiotPropConv): + value: Any = None + description: str = None + + def decode(self, device: 'Device', payload: dict, value): + pass + +@dataclass +class MiotActionConv(BaseConv): + action: 'MiotAction' = None + prop: 'MiotProperty' = None + + def __post_init__(self): + super().__post_init__() + if not self.mi: + from .miot_spec import MiotSpec + self.mi = MiotSpec.unique_prop(self.action.siid, aiid=self.action.iid) + if not self.prop: + self.prop = self.action.in_properties()[0] if self.action.ins else None + + def decode(self, device: 'Device', payload: dict, value): + super().decode(device, payload, value) + + def encode(self, device: 'Device', payload: dict, value): + if self.prop and self.prop.value_list and isinstance(value, str): + value = self.prop.list_value(value) + ins = value if isinstance(value, list) else [] if value is None else [value] + _, s, p = self.mi.split('.') + payload['method'] = 'action' + payload['param'] = { + 'did': device.did, + 'siid': int(s), + 'aiid': int(p), + 'in': ins, + } + +@dataclass +class MiotServiceConv(MiotPropConv): + attr: str = None + service: 'MiotService' = None + prop: 'MiotProperty' = None + main_props: list = None + + def __post_init__(self): + if not self.prop and self.service and self.main_props: + self.prop = self.service.get_property(*self.main_props) + super().__post_init__() + if not self.attr and self.prop: + self.attr = self.prop.full_name + +@dataclass +class MiotSensorConv(MiotServiceConv): + domain: str = 'sensor' + +@dataclass +class MiotSwitchConv(MiotServiceConv): + domain: str = 'switch' + + def __post_init__(self): + if not self.main_props: + self.main_props = ['on', 'switch'] + super().__post_init__() + +@dataclass +class MiotLightConv(MiotSwitchConv): + domain: str = 'light' + +@dataclass +class MiotBrightnessConv(MiotPropConv): + def decode(self, device: 'Device', payload: dict, value: int): + max = self.prop.range_max() + if max != None: + super().encode(device, payload, value / max * 255.0) + + def encode(self, device: 'Device', payload: dict, value: float): + max = self.prop.range_max() + if max != None: + value = round(value / 255.0 * max) + super().encode(device, payload, int(value)) + +@dataclass +class MiotColorTempConv(MiotPropConv): + def decode(self, device: 'Device', payload: dict, value: int): + if self.prop.unit not in ['kelvin']: + if not value: + return + value = round(1000000.0 / value) + super().decode(device, payload, value) + + def encode(self, device: 'Device', payload: dict, value: int): + if self.prop.unit not in ['kelvin']: + if not value: + return + value = round(1000000.0 / value) + if value < self.prop.range_min(): + value = self.prop.range_min() + if value > self.prop.range_max(): + value = self.prop.range_max() + super().encode(device, payload, value) + +@dataclass +class MiotRgbColorConv(MiotPropConv): + def decode(self, device: 'Device', payload: dict, value: int): + super().decode(device, payload, int_to_rgb(value)) + + def encode(self, device: 'Device', payload: dict, value: tuple[int, int, int]): + super().encode(device, payload, rgb_to_int(value)) + +@dataclass +class MiotHsColorConv(MiotPropConv): + def decode(self, device: 'Device', payload: dict, value: int): + rgb = int_to_rgb(value) + hsc = color.color_RGB_to_hs(*rgb) + super().decode(device, payload, hsc) + + 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) diff --git a/custom_components/xiaomi_miot/core/coordinator.py b/custom_components/xiaomi_miot/core/coordinator.py new file mode 100644 index 000000000..263ee3724 --- /dev/null +++ b/custom_components/xiaomi_miot/core/coordinator.py @@ -0,0 +1,38 @@ +import logging +from typing import TYPE_CHECKING + +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +if TYPE_CHECKING: + from .device import Device + +_LOGGER = logging.getLogger(__name__) + +class DataCoordinator(DataUpdateCoordinator): + def __init__(self, device: 'Device', update_method, **kwargs): + kwargs.setdefault('always_update', True) + + if callable(update_method): + name = update_method.__name__ + elif isinstance(update_method, str): + name = update_method + update_method = getattr(device, name, None) + else: + raise ValueError('Invalid update method') + name = kwargs.pop('name', name) + + super().__init__( + device.hass, + logger=device.log, + name=f'{device.unique_id}-{name}', + update_method=update_method, + **kwargs, + ) + self.device = device + + async def _async_setup(self): + """Set up coordinator.""" + self.async_add_listener(self.coordinator_updated) + + def coordinator_updated(self): + _LOGGER.debug('%s: Coordinator updated: %s', self.device.name_model, [self.name, self.data]) diff --git a/custom_components/xiaomi_miot/core/device.py b/custom_components/xiaomi_miot/core/device.py new file mode 100644 index 000000000..5198b0a3f --- /dev/null +++ b/custom_components/xiaomi_miot/core/device.py @@ -0,0 +1,1218 @@ +import logging +import copy +import re +from typing import TYPE_CHECKING, Optional, Callable +from datetime import timedelta +from functools import partial, cached_property +from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_HOST, CONF_TOKEN, CONF_MODEL, CONF_USERNAME, EntityCategory +from homeassistant.util import dt +from homeassistant.components import persistent_notification +from homeassistant.helpers.event import async_call_later +import homeassistant.helpers.device_registry as dr + +from .const import ( + DOMAIN, + DEVICE_CUSTOMIZES, + GLOBAL_CONVERTERS, + MIOT_LOCAL_MODELS, + DEFAULT_NAME, + CONF_CONN_MODE, + DEFAULT_CONN_MODE, +) +from .hass_entry import HassEntry +from .hass_entity import XEntity, convert_unique_id +from .converters import BaseConv, InfoConv, MiotPropConv, MiotPropValueConv, MiotActionConv, AttrConv +from .coordinator import DataCoordinator +from .miot_spec import MiotSpec, MiotProperty, MiotResults, MiotResult +from .miio2miot import Miio2MiotHelper +from .xiaomi_cloud import MiotCloud, MiCloudException +from .utils import ( + CustomConfigHelper, + get_customize_via_model, + get_value, + is_offline_exception, + update_attrs_with_suffix, +) +from .templates import template + + +from miio import ( # noqa: F401 + Device as MiioDevice, + DeviceException, +) +from miio.device import DeviceInfo as MiioInfoBase +from miio.miot_device import MiotDevice as MiotDeviceBase + +if TYPE_CHECKING: + from . import BasicEntity + +InfoConverter = InfoConv().with_option( + icon='mdi:information', + device_class='update', + entity_category=EntityCategory.DIAGNOSTIC, +) + + +class DeviceInfo: + def __init__(self, data: dict): + self.data = data + + def get(self, key, default=None): + return self.data.get(key, default) + + @property + def did(self): + return self.data.get('did', '') + + @cached_property + def unique_id(self): + if mac := self.mac: + return dr.format_mac(mac).lower() + return self.did + + @property + def name(self): + return self.data.get('name') or DEFAULT_NAME + + @cached_property + def model(self): + return self.miio_info.model or '' + + @cached_property + def mac(self): + return self.data.get('mac') or self.miio_info.mac_address or '' + + @property + def host(self): + return self.data.get('localip') or self.data.get(CONF_HOST) or '' + + @property + def token(self): + return self.data.get(CONF_TOKEN) or self.miio_info.token or '' + + @cached_property + def pid(self): + pid = self.data.get('pid') + if pid is not None: + try: + pid = int(pid) + except Exception: + pid = None + return pid + + @property + def urn(self): + return self.data.get('urn') or self.data.get('spec_type') or '' + + @property + def extra(self): + return self.data.get('extra') or {} + + @cached_property + def firmware_version(self): + return self.miio_info.firmware_version + + @cached_property + def hardware_version(self): + return self.miio_info.hardware_version + + @cached_property + def home_name(self): + return self.data.get('home_name', '') + + @cached_property + def room_name(self): + return self.data.get('room_name', '') + + @cached_property + def home_room(self): + return f'{self.home_name} {self.room_name}'.strip() + + @cached_property + def miio_info(self): + info = self.data + data = info.get('miio_info') or { + 'ap': {'ssid': info.get('ssid'), 'bssid': info.get('bssid'), 'rssi': info.get('rssi')}, + 'netif': {'localIp': self.host, 'gw': '', 'mask': ''}, + 'fw_ver': self.extra.get('fw_version', ''), + 'hw_ver': info.get('hw_ver', ''), + 'mac': info.get('mac', ''), + 'model': info.get(CONF_MODEL, ''), + 'token': info.get(CONF_TOKEN, ''), + } + return MiioInfo(data) + + +class Device(CustomConfigHelper): + spec: Optional['MiotSpec'] = None + cloud: Optional['MiotCloud'] = None + local: Optional['MiotDevice'] = None + miio2miot: Optional['Miio2MiotHelper'] = None + available = True + miot_entity = None + miot_results = None + _local_state = None + _miot_mapping = None + _exclude_miot_services = None + _exclude_miot_properties = None + _unreadable_properties = None + + def __init__(self, info: DeviceInfo, entry: HassEntry): + self.data = {} + self.info = info + self.hass = entry.hass + self.entry = entry + self.cloud = entry.cloud + self.props: dict = {} + self.entities: dict[str, 'BasicEntity'] = {} + self.listeners: list[Callable] = [] + self.converters: list[BaseConv] = [] + self.coordinators: list[DataCoordinator] = [] + self.log = logging.getLogger(f'{__name__}.{self.model}') + + async def async_init(self): + if not self.cloud_only: + self.local = MiotDevice.from_device(self) + spec = await self.get_spec() + if spec and self.local and not self.cloud_only: + self.miio2miot = Miio2MiotHelper.from_model(self.hass, self.model, spec) + mps = self.custom_config_list('miio_properties') + if mps and self.miio2miot: + self.miio2miot.extend_miio_props(mps) + + self._exclude_miot_services = self.custom_config_list('exclude_miot_services', []) + self._exclude_miot_properties = self.custom_config_list('exclude_miot_properties', []) + self._unreadable_properties = self.custom_config_bool('unreadable_properties') + + async def async_unload(self): + for coo in self.coordinators: + await coo.async_shutdown() + + self.spec = None + self.hass.data[DOMAIN].setdefault('miot_specs', {}).pop(self.model, None) + + @cached_property + def did(self): + return self.info.did + + @property + def name(self): + return self.info.name + + @cached_property + def model(self): + return self.info.model + + @cached_property + def name_model(self): + return f'{self.name}({self.model})' + + @cached_property + def unique_id(self): + return f'{self.info.unique_id}-{self.entry.id}' + + @cached_property + def app_link(self): + uid = self.cloud.user_id if self.cloud else '' + if not self.did: + return '' + return f'mihome://device?uid={uid}&did={self.did}' + + @property + def conn_mode(self): + if not self.entry.get_config(CONF_USERNAME): + return 'local' + return self.entry.get_config(CONF_CONN_MODE) or DEFAULT_CONN_MODE + + @property + def local_only(self): + return self.conn_mode == 'local' + + @property + def cloud_only(self): + return self.conn_mode == 'cloud' + + @property + def sw_version(self): + swv = self.info.firmware_version + if self.info.hardware_version: + swv = f'{swv}@{self.info.hardware_version}' + updater = self.data.get('updater') + emoji = { + 'local': '🛜', + 'cloud': '☁️', + }.get(updater) + if emoji: + swv = f'{swv} {emoji}' + elif updater: + swv = f'{swv} ({updater})' + return swv + + @property + def identifiers(self): + return {(DOMAIN, self.unique_id)} + + @property + def hass_device_info(self): + return { + 'identifiers': self.identifiers, + 'name': self.name, + 'model': self.model, + 'manufacturer': (self.model or 'Xiaomi').split('.', 1)[0], + 'sw_version': self.sw_version, + 'suggested_area': self.info.room_name, + 'configuration_url': f'https://home.miot-spec.com/s/{self.model}', + } + + @property + def customizes(self): + return get_customize_via_model(self.model) + + def custom_config(self, key=None, default=None): + cfg = self.customizes + return cfg if key is None else cfg.get(key, default) + + @cached_property + def extend_miot_specs(self): + if self.cloud_only: + # only for local mode + return None + ext = self.custom_config('extend_miot_specs') + if ext and isinstance(ext, str): + ext = DEVICE_CUSTOMIZES.get(ext, {}).get('extend_miot_specs') + else: + ext = self.custom_config_list('extend_miot_specs') + if ext and isinstance(ext, list): + return ext + return None + + async def get_spec(self) -> Optional[MiotSpec]: + if self.spec: + return self.spec + + dat = self.hass.data[DOMAIN].setdefault('miot_specs', {}) + obj = dat.get(self.model) + if not obj: + trans_options = self.custom_config_bool('trans_options', self.entry.get_config('trans_options')) + urn = await self.get_urn() + obj = await MiotSpec.async_from_type(self.hass, urn, trans_options=trans_options) + dat[self.model] = obj + if obj: + self.spec = copy.copy(obj) + if not self.cloud_only: + if ext := self.extend_miot_specs: + self.spec.extend_specs(services=ext) + self.init_converters() + return self.spec + + async def get_urn(self): + urn = self.custom_config('miot_type') + if not urn: + urn = self.info.urn + if not urn: + urn = await MiotSpec.async_get_model_type(self.hass, self.model) + self.info.data['urn'] = urn + return urn + + 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 + self.converters.append(conv) + + def init_converters(self): + self.add_converter(InfoConverter) + self.dispatch_info() + + if not self.spec: + return + + for cfg in GLOBAL_CONVERTERS: + cls = cfg.get('class') + kwargs = cfg.get('kwargs', {}) + if services := cfg.get('services'): + for service in self.spec.get_services(*services, excludes=self._exclude_miot_services): + conv = None + if cls and hasattr(cls, 'service'): + conv = cls(service=service, **kwargs) + if getattr(conv, 'prop', None): + self.add_converter(conv) + else: + self.log.info('Converter has no main props: %s', conv) + conv = None + + for p in cfg.get('converters') or []: + if not (props := p.get('props')): + continue + for prop in service.get_properties(*props, excludes=self._exclude_miot_properties): + attr = p.get('attr', prop.full_name) + c = p.get('class', MiotPropConv) + d = p.get('domain', None) + ac = c(attr, domain=d, prop=prop, desc=p.get('desc', False)) + self.add_converter(ac) + if conv and not d: + conv.attrs.add(attr) + + for d in [ + 'button', 'sensor', 'binary_sensor', 'switch', 'number', 'select', 'text', + 'number_select', 'scanner', + ]: + pls = self.custom_config_list(f'{d}_properties') or [] + if not pls: + continue + for prop in self.spec.get_properties(*pls): + if d == 'number_select': + if prop.value_range: + d = 'number' + elif prop.value_list: + d = 'select' + else: + self.log.warning(f'Unsupported customize entity: %s for %s', d, prop.full_name) + continue + platform = { + 'scanner': 'device_tracker', + 'tracker': 'device_tracker', + }.get(d) or d + if platform == 'button': + if prop.value_list: + for pv in prop.value_list: + val = pv.get('value') + des = pv.get('description') or val + attr = f'{prop.full_name}-{val}' + conv = MiotPropValueConv(attr, platform, prop=prop, value=val, description=des) + self.add_converter(conv) + elif prop.is_bool: + conv = MiotPropValueConv(prop.full_name, platform, prop=prop, value=True) + self.add_converter(conv) + elif platform == 'number' and not prop.value_range: + self.log.warning(f'Unsupported customize entity: %s for %s', platform, prop.full_name) + continue + else: + desc = bool(prop.value_list and platform in ['sensor', 'select']) + conv = MiotPropConv(prop.full_name, platform, prop=prop, desc=desc) + conv.with_option( + entity_type=None if platform == d else d, + ) + self.add_converter(conv) + + for d in ['button', 'text', 'select']: + als = self.custom_config_list(f'{d}_actions') or [] + if not als: + continue + for srv in self.spec.services.values(): + for action in srv.get_actions(*als): + self.add_converter(MiotActionConv(action.full_name, d, action=action)) + + for d in ['sensor', 'binary_sensor']: + for attr in self.custom_config_list(f'{d}_attributes') or []: + self.add_converter(AttrConv(attr, d)) + + async def init_coordinators(self, _): + interval = 30 + interval = self.entry.get_config('scan_interval') or interval + interval = self.custom_config_integer('interval_seconds') or interval + lst = [] + if not self.miot_entity: + lst.append( + DataCoordinator(self, self.update_miot_status, update_interval=timedelta(seconds=interval)), + ) + if self.cloud_statistics_commands: + lst.append( + DataCoordinator(self, self.update_cloud_statistics, update_interval=timedelta(seconds=interval*20)), + ) + if self.miio_cloud_records: + lst.append( + DataCoordinator(self, self.update_miio_cloud_records, update_interval=timedelta(seconds=interval*20)), + ) + if self.miio_cloud_props: + lst.append( + DataCoordinator(self, self.update_miio_cloud_props, update_interval=timedelta(seconds=interval*2)), + ) + if self.custom_miio_properties: + lst.append( + DataCoordinator(self, self.update_miio_props, update_interval=timedelta(seconds=interval)), + ) + if self.custom_miio_commands: + lst.append( + DataCoordinator(self, self.update_miio_commands, update_interval=timedelta(seconds=interval)), + ) + self.coordinators.extend(lst) + for coo in lst: + await coo.async_config_entry_first_refresh() + + async def update_status(self): + for coo in self.coordinators: + await coo.async_refresh() + + def add_entities(self, domain): + for conv in self.converters: + if conv.domain != domain: + continue + unique = convert_unique_id(conv) + entity = self.entities.get(unique) + if entity: + continue + cls = XEntity.CLS.get(domain) + if entity_type := conv.option.get('entity_type'): + cls = XEntity.CLS.get(entity_type) or cls + adder = self.entry.adders.get(domain) + if not (cls and adder): + self.log.warning('Entity class/adder not found: %s', [domain, conv.attr, cls, adder]) + continue + entity = cls(self, conv) + self.add_entity(entity, unique) + adder([entity], update_before_add=False) + self.log.info('New entity: %s', entity) + + if domain == 'button': + self.dispatch_info() + if not self.coordinators: + async_call_later(self.hass, 0.1, self.init_coordinators) + + def add_entity(self, entity: 'BasicEntity', unique=None): + if unique == None: + unique = entity.unique_id + if unique in self.entities: + return None + self.entities[unique] = entity + return entity + + def add_listener(self, handler: Callable): + if handler not in self.listeners: + self.listeners.append(handler) + + def remove_listener(self, handler: Callable): + if handler in self.listeners: + self.listeners.remove(handler) + + def dispatch(self, data: dict, only_info=False, log=True): + if log: + self.log.info('Device updated: %s', data) + for handler in self.listeners: + handler(data, only_info=only_info) + + def dispatch_info(self): + info = {} + InfoConverter.decode(self, info, None) + self.dispatch(info, only_info=True, log=False) + + def decode(self, data: dict | list) -> dict: + """Decode data from device.""" + payload = {} + if not isinstance(data, list): + data = [data] + for value in data: + self.decode_one(payload, value) + return payload + + def decode_one(self, payload: dict, value: dict): + if not isinstance(value, dict): + self.log.warning('Value is not dict: %s', value) + return + if value.get('code', 0): + return + siid = value.get('siid') + piid = value.get('piid') + if siid and piid: + mi = MiotSpec.unique_prop(siid, piid=piid) + for conv in self.converters: + if conv.mi == mi: + conv.decode(self, payload, value.get('value')) + + def decode_attrs(self, value: dict): + if not isinstance(value, dict): + self.log.warning('Value is not dict: %s', value) + return + payload = {} + for conv in self.converters: + val = get_value(value, conv.attr, None, ':') + if val is not None: + conv.decode(self, payload, val) + return payload + + def encode(self, value: dict) -> dict: + """Encode data from hass to device.""" + payload = {} + for k, v in value.items(): + for conv in self.converters: + if conv.attr == k: + conv.encode(self, payload, v) + return payload + + async def async_write(self, payload: dict): + """Send command to device.""" + data = self.encode(payload) + result = None + method = data.get('method') + + if method == 'update_status': + result = await self.update_miot_status() + + if method == 'set_properties': + params = data.get('params', []) + cloud_params = [] + if not self._local_state or self.cloud_only: + cloud_params = params + elif self.miio2miot: + result = [] + for param in params: + siid = param['siid'] + piid = param['piid'] + if not self.miio2miot.has_setter(siid, piid=piid): + cloud_params.append(param) + continue + cmd = partial(self.miio2miot.set_property, self.local, siid, piid, param['value']) + result.append(await self.hass.async_add_executor_job(cmd)) + elif self.local: + result = await self.local.async_send(method, params) + if self.cloud and cloud_params: + result = await self.cloud.async_set_props(cloud_params) + + if method == 'action': + param = data.get('param', {}) + cloud_param = None + siid = param['siid'] + aiid = param['aiid'] + if not self._local_state or self.cloud_only: + cloud_param = param + elif self.miio2miot: + if self.miio2miot.has_setter(siid, aiid=aiid): + cmd = partial(self.miio2miot.call_action, self.local, siid, aiid, param.get('in', [])) + result = await self.hass.async_add_executor_job(cmd) + else: + cloud_param = param + elif self.local: + result = await self.local.async_send(method, param) + if self.cloud and cloud_param: + result = await self.cloud.async_do_action(cloud_param) + + self.log.info('Device write: %s', [payload, data, result]) + if result: + self.dispatch(payload) + return result + + @property + def use_local(self): + if self.cloud_only: + return False + if not self.local: + return False + if self.miio2miot: + return True + if self.model in MIOT_LOCAL_MODELS: + return True + if self.custom_config_bool('miot_local'): + return True + return False + + @property + def use_cloud(self): + if self.local_only: + return False + if self.use_local: + return False + if not self.cloud: + return False + if self.custom_config_bool('miot_cloud'): + return True + return True + + @property + def auto_cloud(self): + if not self.cloud: + return False + return self.custom_config_bool('auto_cloud') + + def miot_mapping(self): + if self._miot_mapping: + return self._miot_mapping + + if not self.spec: + return None + + if dic := self.custom_config_json('miot_mapping'): + self.spec.set_custom_mapping(dic) + self._miot_mapping = dic + return dic + + mapping = self.spec.services_mapping( + excludes=self._exclude_miot_services, + exclude_properties=self._exclude_miot_properties, + unreadable_properties=self._unreadable_properties, + ) or {} + self._miot_mapping = mapping + return mapping + + async def update_miot_status( + self, + mapping=None, + use_local=None, + use_cloud=None, + auto_cloud=None, + check_lan=None, + max_properties=None, + ) -> MiotResults: + results = [] + self.miot_results = MiotResults() + + if use_local is None: + use_local = False if use_cloud else self.use_local + if use_cloud is None: + use_cloud = False if use_local else self.use_cloud + if auto_cloud is None: + auto_cloud = self.auto_cloud + + if mapping is None: + mapping = self.miot_mapping() + if not mapping: + use_local = False + use_cloud = False + + if use_local: + try: + if self.miio2miot: + results = await self.miio2miot.async_get_miot_props(self.local, mapping) + self.props.update(self.miio2miot.entity_attrs()) + else: + if not max_properties: + max_properties = self.custom_config_integer('chunk_properties') + if not max_properties: + max_properties = self.local.get_max_properties(mapping) + maps = [] + if self.custom_config_integer('chunk_services'): + for service in self.spec.get_services(excludes=self._exclude_miot_services): + mapp = service.mapping( + excludes=self._exclude_miot_properties, + unreadable_properties=self._unreadable_properties, + ) or {} + if mapp: + maps.append(mapp) + else: + maps.append(mapping) + for mapp in maps: + res = await self.local.async_get_properties_for_mapping( + max_properties=max_properties, + did=self.did, + mapping=mapp, + ) + results.extend(res) + self.available = True + self._local_state = True + self.miot_results.updater = 'local' + self.miot_results.set_results(results, mapping) + except (DeviceException, OSError) as exc: + log = self.log.error + if auto_cloud: + use_cloud = self.cloud + log = self.log.warning if self._local_state else self.log.info + else: + self.miot_results.errors = exc + self.available = False + self._local_state = False + log( + 'Got MiioException while fetching the state: %s, mapping: %s, max_properties: %s/%s', + exc, mapping, max_properties, len(mapping) + ) + + if use_cloud: + try: + self.miot_results.updater = 'cloud' + results = await self.cloud.async_get_properties_for_mapping(self.did, mapping) + if check_lan and self.local: + await self.hass.async_add_executor_job(partial(self.local.info, skip_cache=True)) + self.available = True + self.miot_results.set_results(results, mapping) + except MiCloudException as exc: + self.available = False + self.miot_results.errors = exc + self.log.error( + 'Got MiCloudException while fetching the state: %s, mapping: %s', + exc, mapping, + ) + + if self.miot_results.updater != self.data.get('updater'): + dev_reg = dr.async_get(self.hass) + if dev := dev_reg.async_get_device(self.identifiers): + self.data['updater'] = self.miot_results.updater + dev_reg.async_update_device(dev.id, sw_version=self.sw_version) + self.log.info('State updater: %s', self.sw_version) + if results: + self.miot_results.to_attributes(self.props) + self.data['updated'] = dt.now() + self.dispatch(self.decode(results)) + self.dispatch_info() + await self.offline_notify() + return self.miot_results + + async def offline_notify(self): + result = self.miot_results + is_offline = not result.is_valid and result.errors and is_offline_exception(result.errors) + offline_devices = self.hass.data[DOMAIN].setdefault('offline_devices', {}) + notification_id = f'{DOMAIN}-devices-offline' + if not is_offline: + self.data.pop('offline_times', None) + if offline_devices.pop(self.info.unique_id, None) and not offline_devices: + persistent_notification.async_dismiss(self.hass, notification_id) + return + offline_times = self.data.setdefault('offline_times', 0) + if not self.custom_config_bool('ignore_offline'): + offline_times += 1 + odd = offline_devices.get(self.info.unique_id) or {} + if odd: + odd.update({ + 'occurrences': offline_times, + }) + elif offline_times >= 5: + odd = { + 'device': self, + 'occurrences': offline_times, + } + offline_devices[self.info.unique_id] = odd + tip = f'Some devices cannot be connected in the LAN, please check their IP ' \ + f'and make sure they are in the same subnet as the HA.\n\n' \ + f'一些设备无法通过局域网连接,请检查它们的IP,并确保它们和HA在同一子网。\n' + for d in offline_devices.values(): + device = d.get('device') + if not device: + continue + tip += f'\n - {device.name_model}: {device.info.host}' + tip += '\n\n' + url = 'https://github.com/al-one/hass-xiaomi-miot/search' \ + '?type=issues&q=%22Unable+to+discover+the+device%22' + tip += f'[Known issues]({url})' + url = 'https://github.com/al-one/hass-xiaomi-miot/issues/500#offline' + tip += f' | [了解更多]({url})' + persistent_notification.async_create( + self.hass, + tip, + 'Devices offline', + notification_id, + ) + self.data['offline_times'] = offline_times + + async def async_get_properties(self, mapping, update_entity=True, throw=False, **kwargs): + if not self.spec: + return {'error': 'No spec'} + if isinstance(mapping, list): + new_mapping = {} + for p in mapping: + siid = p['siid'] + piid = p['piid'] + pkey = self.spec.unique_prop(siid, piid=piid) + prop = self.spec.specs.get(pkey) + if not isinstance(prop, MiotProperty): + continue + new_mapping[prop.full_name] = p + mapping = new_mapping + if not mapping or not isinstance(mapping, dict): + return {'error': 'Mapping error'} + try: + results = [] + if self.use_local and self._local_state: + results = await self.local.async_get_properties_for_mapping(did=self.did, mapping=mapping) + elif self.cloud: + results = await self.cloud.async_get_properties_for_mapping(self.did, mapping) + except (DeviceException, MiCloudException) as exc: + self.log.error( + 'Got exception while get properties: %s, mapping: %s, miio: %s', + exc, mapping, self.info.miio_info, + ) + if throw: + raise exc + return {'error': str(exc)} + self.log.info('Get miot properties: %s', results) + if results and update_entity: + self.dispatch(self.decode(results)) + result = MiotResults(results, mapping) + return result.to_attributes() + + def set_property(self, field, value): + if isinstance(field, MiotProperty): + siid = field.siid + piid = field.iid + field = field.full_name + else: + ext = (self.miot_mapping() or {}).get(field) or {} + if not ext: + return MiotResult({}, code=-1, error='Field not found') + siid = ext['siid'] + piid = ext['piid'] + try: + result = self.set_miot_property(siid, piid, value) + except (DeviceException, MiCloudException) as exc: + self.log.error('Set miot property %s(%s) failed: %s', field, value, exc) + return MiotResult({}, code=-1, error=str(exc)) + ret = result.is_success if result else False + if ret: + self.log.debug('Set miot property %s(%s), result: %s', field, value, result) + else: + self.log.info('Set miot property %s(%s) failed, result: %s', field, value, result) + return ret + + def set_miot_property(self, siid, piid, value, **kwargs): + iid = MiotSpec.unique_prop(siid, piid) + did = self.did or iid + pms = { + 'did': str(did), + 'siid': siid, + 'piid': piid, + 'value': value, + } + cloud_pms = None + m2m = None if self.custom_config_bool('miot_cloud_write') else self.miio2miot + try: + results = [] + if not self._local_state or self.cloud_only: + cloud_pms = pms + elif m2m: + if m2m.has_setter(siid, piid=piid): + results = [m2m.set_property(self.local, siid, piid, value)] + else: + cloud_pms = pms + elif self.local: + results = self.local.send('set_properties', [pms]) + if self.cloud and cloud_pms: + results = self.cloud.set_props([pms]) + result = MiotResults(results).first + except (DeviceException, MiCloudException) as exc: + self.log.warning('Set miot property %s failed: %s', pms, exc) + return MiotResult({}, code=-1, error=str(exc)) + if not result.is_success: + self.log.warning('Set miot property %s failed, result: %s', pms, result) + else: + self.log.info('Set miot property %s, result: %s', pms, result) + result.value = value + self.dispatch(self.decode(result.to_json())) + return result + + def call_action(self, siid, aiid, params=None, **kwargs): + did = self.did or MiotSpec.unique_prop(siid, aiid=aiid) + pms = { + 'did': str(did), + 'siid': siid, + 'aiid': aiid, + 'in': params or [], + } + action = kwargs.get('action') + if not action and self.spec: + action = self.spec.services.get(siid, {}).actions.get(aiid) + m2m = None if self.custom_config_bool('miot_cloud_action') else self.miio2miot + mca = self.cloud if not self.use_local else None + if self.auto_cloud and not self._local_state: + mca = self.cloud + try: + if m2m and m2m.has_setter(siid, aiid=aiid): + result = m2m.call_action(self.local, siid, aiid, params) + elif isinstance(mca, MiotCloud): + result = mca.do_action(pms) + else: + if not kwargs.get('force_params'): + pms['in'] = action.in_params(params or []) + result = self.local.send('action', pms) + result = MiotResult(result) + except (DeviceException, MiCloudException) as exc: + self.log.warning('Call miot action %s failed: %s', pms, exc) + return MiotResult({}, code=-1, error=str(exc)) + except (TypeError, ValueError) as exc: + self.log.warning('Call miot action %s failed: %s, result: %s', pms, exc, result) + return MiotResult({}, code=-1, error=str(exc)) + if result.is_success: + self.log.debug('Call miot action %s, result: %s', pms, result) + else: + self.log.info('Call miot action %s failed: %s', pms, result) + return result + + @cached_property + def cloud_statistics_commands(self): + commands = self.custom_config_list('micloud_statistics') or [] + if keys := self.custom_config_list('stat_power_cost_key'): + for k in keys: + commands.append({ + 'type': self.custom_config('stat_power_cost_type', 'stat_day_v3'), + 'key': k, + 'day': 32, + 'limit': 31, + 'attribute': None, + 'template': 'micloud_statistics_power_cost', + }) + return commands + + + async def update_cloud_statistics(self, commands=None): + if not self.did or not self.cloud: + return + if commands is None: + commands = self.cloud_statistics_commands + + now = int(dt.now().timestamp()) + attrs = {} + for c in commands: + if not c.get('key'): + continue + pms = { + 'did': self.did, + 'key': c.get('key'), + 'data_type': c.get('type', 'stat_day_v3'), + 'time_start': now - 86400 * (c.get('day') or 7), + 'time_end': now + 60, + 'limit': int(c.get('limit') or 1), + } + rdt = await self.cloud.async_request_api('v2/user/statistics', pms) or {} + self.log.info('Got micloud statistics: %s', rdt) + if tpl := c.get('template'): + tpl = template(tpl, self.hass) + rls = tpl.async_render(rdt) + else: + rls = [ + v.get('value') + for v in rdt + if 'value' in v + ] + if anm := c.get('attribute'): + attrs[anm] = rls + elif isinstance(rls, dict): + update_attrs_with_suffix(attrs, rls) + if attrs: + self.available = True + self.props.update(attrs) + self.data['updated'] = dt.now() + self.dispatch(self.decode_attrs(attrs)) + return attrs + + @cached_property + def miio_cloud_records(self): + return self.custom_config_list('miio_cloud_records') or [] + + async def update_miio_cloud_records(self, keys=None): + if not self.did or not self.cloud: + return + if keys is None: + keys = self.miio_cloud_records + if not keys: + return + + attrs = {} + for c in keys: + mat = re.match(r'^\s*(?:(\w+)\.?)([\w.]+)(?::(\d+))?(?::(\w+))?\s*$', c) + if not mat: + continue + typ, key, lmt, gby = mat.groups() + kws = { + 'time_start': int(dt.now().timestamp()) - 86400 * 32, + 'limit': int(lmt or 1), + } + if gby: + kws['group'] = gby + rdt = await self.cloud.async_get_user_device_data(self.did, key, typ, **kws) or [] + tpl = self.custom_config(f'miio_{typ}_{key}_template') + if tpl: + tpl = template(tpl, self.hass) + rls = tpl.async_render({'result': rdt}) + else: + rls = [ + v.get('value') + for v in rdt + if 'value' in v + ] + if isinstance(rls, dict) and rls.pop('_entity_attrs', False): + attrs.update(rls) + else: + attrs[f'{typ}.{key}'] = rls + if attrs: + self.available = True + self.props.update(attrs) + self.data['updated'] = dt.now() + self.dispatch(self.decode_attrs(attrs)) + return attrs + + @cached_property + def miio_cloud_props(self): + return self.custom_config_list('miio_cloud_props') or [] + + async def update_miio_cloud_props(self, keys=None): + did = str(self.did) + if not did or not self.cloud: + return + if keys is None: + keys = self.miio_cloud_props + if not keys: + return + + pms = { + 'did': did, + 'props': [ + k if '.' in k else f'prop.{k}' + for k in keys + ], + } + rdt = await self.cloud.async_request_api('device/batchdevicedatas', [pms]) or {} + self.log.debug('Got miio cloud props: %s', rdt) + props = (rdt.get('result') or {}).get(did, {}) + + tpl = self.custom_config('miio_cloud_props_template') + if tpl and props: + tpl = template(tpl, self.hass) + attrs = tpl.async_render({'props': props}) + else: + attrs = props + if attrs: + self.available = True + self.props.update(attrs) + self.data['updated'] = dt.now() + self.dispatch(self.decode_attrs(attrs)) + return attrs + + @cached_property + def custom_miio_properties(self): + return self.custom_config_list('miio_properties') or [] + + async def update_miio_props(self, props=None): + if not self.local: + return + if props == None: + props = self.custom_miio_properties + if self.miio2miot: + attrs = self.miio2miot.only_miio_props(props) + else: + try: + num = self.custom_config_integer('chunk_properties') or 15 + attrs = await self.hass.async_add_executor_job( + partial(self.local.get_properties, props, max_properties=num) + ) + except DeviceException as exc: + self.log.warning('%s: Got miio properties %s failed: %s', self.name_model, props, exc) + return + if len(props) != len(attrs): + self.props.update({ + 'miio.props': attrs, + }) + return + attrs = dict(zip(map(lambda x: f'miio.{x}', props), attrs)) + self.props.update(attrs) + self.log.info('%s: Got miio properties: %s', self.name_model, attrs) + + @cached_property + def custom_miio_commands(self): + return self.custom_config_json('miio_commands') or {} + + async def update_miio_commands(self, commands=None): + if not self.local: + return + if commands == None: + commands = self.custom_miio_commands + if isinstance(commands, dict): + commands = [ + {'method': cmd, **(cfg if isinstance(cfg, dict) else {'values': cfg})} + for cmd, cfg in commands.items() + ] + elif not isinstance(commands, list): + commands = [] + for cfg in commands: + cmd = cfg.get('method') + pms = cfg.get('params') or [] + try: + attrs = await self.local.async_send(cmd, pms) + except DeviceException as exc: + self.log.warning('%s: Send miio command %s(%s) failed: %s', self.name_model, cmd, cfg, exc) + continue + props = cfg.get('values', pms) or [] + if len(props) != len(attrs): + attrs = { + f'miio.{cmd}': attrs, + } + else: + attrs = dict(zip(props, attrs)) + self.props.update(attrs) + self.log.info('%s: Got miio properties: %s', self.name_model, attrs) + + +class MiotDevice(MiotDeviceBase): + hass: HomeAssistant = None + + @staticmethod + def from_device(device: Device): + host = device.info.host + token = device.info.token + if not host or host in ['0.0.0.0']: + return None + elif not token: + return None + elif device.info.pid in [6, 15, 16, 17]: + return None + mapping = {} + miot_device = None + try: + miot_device = MiotDevice(ip=host, token=token, model=device.model, mapping=mapping or {}) + except TypeError as exc: + err = f'{exc}' + if 'mapping' in err: + if 'unexpected keyword argument' in err: + # for python-miio <= v0.5.5.1 + miot_device = MiotDevice(host, token) + miot_device.mapping = mapping + elif 'required positional argument' in err: + # for python-miio <= v0.5.4 + # https://github.com/al-one/hass-xiaomi-miot/issues/44#issuecomment-815474650 + miot_device = MiotDevice(mapping, host, token) # noqa + except ValueError as exc: + device.log.warning('Initializing with host %s failed: %s', host, exc) + if miot_device: + miot_device.hass = device.hass + return miot_device + + def get_properties_for_mapping(self, *, max_properties=12, did=None, mapping=None) -> list: + if mapping is None: + mapping = self.mapping + properties = [ + {'did': f'prop.{v["siid"]}.{v["piid"]}' if did is None else str(did), **v} + for k, v in mapping.items() + ] + return self.get_properties( + properties, + property_getter='get_properties', + max_properties=max_properties, + ) + + async def async_get_properties_for_mapping(self, *, max_properties=None, did=None, mapping=None) -> list: + return await self.hass.async_add_executor_job( + partial( + self.get_properties_for_mapping, + max_properties=max_properties, + did=did, + mapping=mapping, + ) + ) + + def get_max_properties(self, mapping): + idx = len(mapping) + if idx < 10: + return idx + idx -= 10 + chunks = [ + # 10,11,12,13,14,15,16,17,18,19 + 10, 6, 6, 7, 7, 8, 8, 9, 9, 10, + # 20,21,22,23,24,25,26,27,28,29 + 10, 7, 8, 8, 8, 9, 9, 9, 10, 10, + # 30,31,32,33,34,35,36,37,38,39 + 10, 8, 8, 7, 7, 7, 9, 9, 10, 10, + # 40,41,42,43,44,45,46,47,48,49 + 10, 9, 9, 9, 9, 9, 10, 10, 10, 10, + ] + return 10 if idx >= len(chunks) else chunks[idx] + + async def async_send(self, *args, **kwargs): + return await self.hass.async_add_executor_job(partial(self.send,*args, **kwargs)) + + +class MiioInfo(MiioInfoBase): + @property + def firmware_version(self): + return self.data.get('fw_ver') + + @property + def hardware_version(self): + return self.data.get('hw_ver') diff --git a/custom_components/xiaomi_miot/core/device_customizes.py b/custom_components/xiaomi_miot/core/device_customizes.py index e806fe23e..4f16184df 100644 --- a/custom_components/xiaomi_miot/core/device_customizes.py +++ b/custom_components/xiaomi_miot/core/device_customizes.py @@ -1,3 +1,5 @@ +from .converters import * + CHUNK_1 = { 'chunk_properties': 1, } @@ -9,7 +11,6 @@ } DEVICE_CUSTOMIZES = { - '090615.aircondition.ktf': { 'current_temp_property': 'setmode.roomtemp', }, @@ -49,7 +50,6 @@ 'offline_duration,ble_in_threshold,ble_out_threshold,ble_far_timeout', }, 'ainice.sensor_occupy.3b': { - 'main_miot_services': 'occupancy_sensor', 'state_property': 'occupancy_sensor.current_occupied', 'interval_seconds': 30, 'chunk_properties': 7, @@ -87,7 +87,6 @@ 'device_class': 'occupancy', }, 'ainice.sensor_occupy.bt': { - 'main_miot_services': 'occupancy_sensor', 'interval_seconds': 10, 'parallel_updates': 1, 'switch_properties': 'indicator_switch,bt_pair_switch', @@ -99,7 +98,6 @@ 'with_properties': 'online_duration,offline_duration,offline_interval,online_mode,bt_capture_mode,binding_info', }, 'ainice.sensor_occupy.pr': { - 'main_miot_services': 'occupancy_sensor', 'state_property': 'occupancy_sensor.occupancy_status', 'interval_seconds': 10, 'sensor_properties': 'no_one_duration,has_someone_duration,zone_param', @@ -118,7 +116,7 @@ }, 'aupu.bhf_light.s368m': { 'ignore_fan_switch': True, - 'switch_properties': 'fan_control.on,onoff.on', + 'switch_properties': 'fan_control.on,onoff.on,blow,ventilation,dryer', 'select_properties': 'mode', }, @@ -219,6 +217,22 @@ **ENERGY_KWH, 'value_ratio': 0.001, }, + 'chunmi.cooker.normalcd2': { + 'button_actions': 'pause,cancel_cooking', + 'select_actions': 'start_cook', + }, + 'chunmi.health_pot.a1': { + 'select_actions': 'start_cook', + }, + 'chunmi.health_pot.cmpa1': { + 'select_actions': 'start_cook', + }, + 'chunmi.health_pot.zwza1': { + 'select_actions': 'start_cook', + }, + 'chunmi.microwave.n23l01': { + 'button_actions': 'pause', + }, 'chunmi.ysj.*': { 'sensor_properties': 'water_dispenser.status,filter_life_level,home_temp,clean_precent', 'switch_properties': 'winter_mode,cold_keep,cup_check', @@ -302,7 +316,8 @@ 'value_ratio': 0.01, }, 'cuco.plug.cp2a': { - 'main_miot_services': 'switch-2', + 'chunk_services': 1, + 'miot_type': 'urn:miot-spec-v2:device:outlet:0000A002:cuco-cp2a:2', }, 'cuco.plug.cp2d': { 'chunk_properties': 1, @@ -335,11 +350,9 @@ 'stat_power_cost_key': '2.2', }, 'cuco.plug.cp5d': { - 'main_miot_services': 'switch-2', 'exclude_miot_services': 'indicator_light', # issues/836 }, 'cuco.plug.cp5prd': { - 'main_miot_services': 'switch-2', 'exclude_miot_services': 'device_setting,use_ele_alert', 'exclude_miot_properties': 'power_consumption,electric_current,voltage,temperature_high_ai,temperature_high_ci,' 'indicator_light.mode,start_time,end_time,data_values', @@ -353,7 +366,6 @@ 'value_ratio': 1, }, 'cuco.plug.cp5pro': { - 'main_miot_services': 'switch-2', 'exclude_miot_services': 'power_consumption,device_setting,use_ele_alert', # issues/763 'sensor_attributes': 'power_cost_today,power_cost_month', 'stat_power_cost_key': '10.1', @@ -365,12 +377,10 @@ 'value_ratio': 1, }, 'cuco.plug.p8amd': { - 'main_miot_services': 'switch-2', 'switch_properties': 'usb_switch.on,light,light.mode', 'select_properties': 'default_power_on_state', }, 'cuco.plug.sp5': { - 'main_miot_services': 'switch-2', 'exclude_miot_services': 'custome,physical_controls_locked,indicator_light', }, 'cuco.plug.v2eur': { @@ -389,7 +399,6 @@ 'value_ratio': 0.01, }, 'cuco.plug.v3': { - 'main_miot_services': 'switch-2', 'sensor_attributes': 'power_cost_today,power_cost_month', 'stat_power_cost_key': '11.1', }, @@ -405,7 +414,6 @@ 'value_ratio': 0.01, }, 'cuco.plug.wp5m': { - 'main_miot_services': 'switch-2', 'sensor_attributes': 'power_cost_today,power_cost_month', 'stat_power_cost_key': '3.1', 'chunk_properties': 1, @@ -422,7 +430,6 @@ 'value_ratio': 0.01, }, 'cuco.plug.wp12': { - 'main_miot_services': 'switch-2', 'sensor_attributes': 'power_cost_today,power_cost_month', 'stat_power_cost_key': '11.1' }, @@ -435,7 +442,6 @@ 'value_ratio': 1, }, 'cuco.plug.*': { - 'main_miot_services': 'switch-2', 'parallel_updates': 3, }, 'cuco.plug.*:electric_current': { @@ -464,7 +470,6 @@ 'unit_of_measurement': 'V', }, 'cuco.switch.*': { - 'main_miot_services': 'switch-2', 'exclude_miot_services': 'setting,wireless_switch', }, 'cykj.hood.jyj22': { @@ -621,7 +626,6 @@ 'number_properties': 'worktime,sleeptime', }, 'era.ysj.b65': { - 'main_miot_services': 'water_dispenser', 'sensor_properties': 'status,current_water,filter_life_level', 'select_properties': 'mode,filter_reset', 'number_properties': 'out_water_volume,feidian', @@ -705,7 +709,6 @@ 'cover_position_mapping': {0: 50, 1: 0, 2: 100}, }, 'hyd.airer.*': { - 'main_miot_services': 'airer', 'switch_properties': 'uv', 'select_properties': 'mode,dryer', 'number_properties': 'drying_time', @@ -852,7 +855,6 @@ 'number_properties': 'screen_brightness,tsms_turn_off', }, 'linp.switch.s2dw3': { - 'main_miot_services': 'switch-2', 'button_actions': 'reboot', 'switch_properties': 'screen.on,auto_screen_off,auto_screen_brightness,night_mode', 'select_properties': 'mode,default_power_on_state,auto_screen_off_time,screen_switch,sensitivity', @@ -861,6 +863,7 @@ 'light_services': 'vd_light_a,vd_light_b,vd_light_c', }, 'lumi.acpartner.mcn02': { + 'sensor_attributes': 'power_cost_today,power_cost_month', 'miio_cloud_props': [], }, 'lumi.acpartner.mcn02:electric_power': { @@ -869,13 +872,12 @@ 'lumi.acpartner.mcn04': { 'auto_cloud': True, 'chunk_properties': 7, - 'main_miot_services': 'air_conditioner', - 'switch_properties': 'quick_cool_enable,indicator_light', - 'select_properties': 'ac_mode', + 'switch_properties': 'on,quick_cool_enable,indicator_light', + 'select_properties': 'fan_level,ac_mode', 'miio_cloud_props': [], 'stat_power_cost_type': 'stat_day_v3', 'stat_power_cost_key': '7.1,7.3', - 'sensor_attributes': 'power_cost_today,power_cost_month,power_cost_today_2,power_cost_month_2' + 'sensor_attributes': 'power_cost_today,power_cost_month,power_cost_today_2,power_cost_month_2', }, 'lumi.acpartner.mcn04:power_consumption': ENERGY_KWH, 'lumi.acpartner.mcn04:power_cost_today': { @@ -896,6 +898,7 @@ }, 'lumi.acpartner.*': { 'sensor_attributes': 'electric_power,power_cost_today,power_cost_month', + 'select_properties': 'fan_level', 'miio_cloud_props': 'ac_power,load_power', 'miio_cloud_props_template': 'lumi_acpartner_electric_power', 'stat_power_cost_type': 'stat_day', @@ -1013,7 +1016,12 @@ }, 'midjd8.washer.*': { 'select_properties': 'shake_time,soak_time', - 'switch_properties': 'high_water_switch,steam_sterilization,sleep_mode' + 'switch_properties': 'high_water_switch,steam_sterilization,sleep_mode', + }, + 'miir.aircondition.*': { + 'select_properties': 'ir_mode', + 'number_properties': 'ir_temperature', + 'button_actions': 'turn_on,turn_off,fan_speed_up,fan_speed_down,temperature_up,temperature_down', }, 'mijia.light.*': { 'cloud_delay_update': 7, @@ -1025,6 +1033,7 @@ 'exclude_miot_services': 'battery', }, 'midr.rv_mirror.*': { + 'binary_sensor_properties': 'driving_status', 'miio_cloud_props': 'Status,Position', 'miio_cloud_props_template': 'midr_rv_mirror_cloud_props', }, @@ -1099,11 +1108,9 @@ 'sensor_properties': 'fault,left_time', 'select_properties': 'dryer,drying_level', 'switch_properties': '', - 'fan_properties': '', 'chunk_properties': 1, }, 'mrbond.airer.*': { - 'main_miot_services': 'airer', 'parallel_updates': 1, }, 'msj.f_washer.m2': { @@ -1202,7 +1209,6 @@ 'exclude_miot_properties': 'fault', }, 'qmi.plug.psv3': { - 'main_miot_services': 'switch-2', 'sensor_attributes': 'power_cost_today,power_cost_month', 'sensor_properties': 'switch.temperature', 'stat_power_cost_key': '3.1', @@ -1220,7 +1226,6 @@ 'qmi.plug.psv3:power_cost_today': ENERGY_KWH, 'qmi.plug.psv3:power_cost_month': ENERGY_KWH, 'qmi.plug.tw02': { - 'main_miot_services': 'switch-2', 'sensor_attributes': 'power_cost_today,power_cost_month', 'sensor_properties': 'switch.temperature', 'stat_power_cost_key': '4.1', @@ -1241,7 +1246,6 @@ 'qmi.plug.tw02:power_cost_today': ENERGY_KWH, 'qmi.plug.tw02:power_cost_month': ENERGY_KWH, 'qmi.plug.2a1c1': { - 'main_miot_services': 'switch-2', 'sensor_attributes': 'power_cost_today,power_cost_month', 'sensor_properties': 'switch.temperature', 'stat_power_cost_key': '3.1', @@ -1339,7 +1343,6 @@ }, 'topwit.bhf_light.rz01': { - 'main_miot_services': 'light-2', 'sensor_properties': 'temperature', 'switch_properties': 'heating,blow,ventilation', 'number_properties': 'ventilation_cnt_down', @@ -1364,7 +1367,6 @@ 'sensor_properties': 'fridge.temperature', }, 'viomi.hood.v1': { - 'main_miot_services': 'hood-2', 'number_properties': 'off_delay_time', 'miio_properties': [ 'cruise', 'link', 'holiday', 'leftBtn', 'rightBtn', 'batLife', @@ -1450,6 +1452,8 @@ 'exclude_miot_properties': 'enhance.timer,humidity_range,filter_core_rest,sleep_diy_sign', }, 'xiaomi.aircondition.mt6': { + 'select_properties': 'fan_level,horizontal_angle,vertical_angle', + 'number_properties': 'target_humidity,fan_percent', 'exclude_miot_services': 'iot_linkage,machine_state,screen_show', 'exclude_miot_properties': 'enhance.timer,humidity_range,filter_core_rest,sleep_diy_sign', }, @@ -1529,6 +1533,9 @@ 'sensor_properties': 'pet_food_left_level,fault,desiccant_left_level,desiccant_left_time', 'switch_properties': 'compensate_switch,prevent_accumulation', }, + 'xiaomi.health_pot.p1': { + 'select_actions': 'start_cook', + }, 'xiaomi.heater.ma8': { 'button_actions': 'toggle', }, @@ -1600,6 +1607,13 @@ 'reset_mop_life,reset_brush_life,reset_filter_life,reset_detergent_management_level,' 'reset_dust_bag_life', 'select_actions': 'remote_control', + 'configuration_entities': 'ai_cleaning,ai_managed_cleaning,use_detergent,defecation_detection,cut_hair_config,' + 'solid_dirt_detection,floor_material_detection,room_detection,liquid_dirt_detection,' + 'mop_auto_lift,carpet_boost,carpet_avoidance,carpet_cleaning_method,object_detection,' + 'dirt_detection,sweep_ai_detection,hot_water_mop_wash,physical_control_locked,volume,' + 'auto_water_change,auto_mop_dry,auto_dust_arrest,dust_arrest_frequency,' + 'vacuum.detergent_self_delivery,detergent_self_delivery_level', + 'diagnostic_entities': 'voltage,water_check_status', }, 'xiaomi.watch.*': { 'sensor_properties': 'current_step_count,current_distance', @@ -1613,10 +1627,15 @@ 'number_properties': 'target_temperature,boost_value', }, 'xiaomi.wifispeaker.*': { - 'switch_properties': 'sleep_mode,no_disturb', + 'switch_properties': 'on,sleep_mode,no_disturb', 'button_actions': 'wake_up,play_music,tv_switchon,stop_alarm', 'text_actions': 'play_text,execute_text_directive', }, + 'xiaomi.wifispeaker.l04m:wake_up': {'action_params': ''}, + 'xiaomi.wifispeaker.l06a:wake_up': {'action_params': ''}, + 'xiaomi.wifispeaker.l09a:wake_up': {'action_params': ''}, + 'xiaomi.wifispeaker.lx04:wake_up': {'action_params': ''}, + 'xiaomi.wifispeaker.x08a:wake_up': {'action_params': ''}, 'xjx.toilet.relax': { 'button_actions': 'flush_on', }, @@ -1651,6 +1670,9 @@ 'yeelink.bhf_light.v13': { 'miot_type': 'urn:miot-spec-v2:device:bath-heater:0000A028:yeelink-v13:1', }, + 'yeelink.bhf_light.*': { + 'switch_properties': 'heating,blow,ventilation', + }, 'yeelink.light.dn2grp': { 'cloud_delay_update': 7, }, @@ -1670,7 +1692,6 @@ 'cloud_delay_update': 7, }, 'yeelink.light.*': { - 'main_miot_services': 'light-2', 'switch_properties': 'bg_on', }, 'yeelink.switch.sw1': { @@ -1899,7 +1920,6 @@ 'kitchen_mode_temp,pet_mode_temp,custom_time,boost_value', }, 'zinguo.switch.b5m': { - 'main_miot_services': 'switch-2', 'sensor_properties': 'temperature', 'switch_properties': 'heating,blow,ventilation', 'select_properties': 'link', @@ -1908,26 +1928,30 @@ '*.aircondition.*': { 'sensor_properties': 'electricity.electricity', - 'switch_properties': 'air_conditioner.on,alarm.alarm,heater', + 'switch_properties': 'air_conditioner.on,uv,heater,eco,dryer,sleep_mode,soft_wind,' + 'horizontal_swing,vertical_swing,alarm.alarm', + 'select_properties': 'fan_level', + 'number_properties': 'target_humidity', 'fan_services': 'air_fresh', }, '*.airer.*': { 'position_reverse': True, 'sensor_properties': 'left_time', 'switch_properties': 'dryer,uv', - 'fan_properties': 'drying_level', + 'select_properties': 'drying_level', }, '*.airrtc.*': { 'switch_properties': 'air_conditioner.on', }, '*.airpurifier.*': { - 'main_miot_services': 'air_purifier', 'switch_properties': 'air_purifier.on,alarm.alarm,anion,uv', 'sensor_properties': 'relative_humidity,pm2_5_density,temperature,filter_life_level', }, '*.bhf_light.*': { - 'main_miot_services': 'ptc_bath_heater', - 'number_properties': 'off_delay_time', + 'sensor_properties': 'temperature', + 'switch_properties': 'heating,blow,ventilation,dryer', + 'select_properties': 'mode', + 'number_properties': 'target_temperature,off_delay_time', }, '*.blanket.*': { 'sensor_properties': 'temperature', @@ -1951,7 +1975,7 @@ }, '*.cooker.*': { 'sensor_properties': 'temperature,left_time', - 'switch_properties': 'cooker.on', + 'switch_properties': 'on,auto_keep_warm', 'button_actions': 'start_cook,pause,cancel_cooking', }, '*.curtain.*': { @@ -2005,12 +2029,17 @@ 'switch_properties': 'on', 'number_properties': 'target_temperature', }, + '*.health_pot.*': { + 'button_actions': 'cancel_cooking', + }, '*.heater.*': { 'switch_properties': 'heater.on,horizontal_swing,alarm.alarm,delay.delay', 'number_properties': 'countdown_time,delay_time', }, '*.ihcooker.*': { - 'sensor_properties': 'temperature,left_time', + 'sensor_properties': 'left_time,working_time', + 'switch_properties': 'induction_cooker.on', + 'number_properties': 'heat_level', 'button_actions': 'start_cook,pause,cancel_cooking', }, '*.light.*': { @@ -2101,7 +2130,11 @@ 'number_select_properties': 'speed_level', }, '*.washer.*': { - 'button_actions': 'start_wash,pause', + 'button_actions': 'start_wash,pause,stop_washing', + 'sensor_properties': 'fault,run_status,left_time,door_state', + 'switch_properties': 'on,sleep_mode,steam_sterilization,ai_mode,high_water_switch,one_click_wash', + 'select_properties': 'mode,drying_level,rinsh_times,drying_degree', + 'number_select_properties': 'target_temperature,spin_speed,soak_time,wash_time,drying_time', }, '*.waterheater.*': { 'sensor_properties': 'water_velocity,tds_in,tds_out', @@ -2117,4 +2150,193 @@ '*.airp.*': DEVICE_CUSTOMIZES.get('*.airpurifier.*') or {}, '*.door.*': DEVICE_CUSTOMIZES.get('*.lock.*') or {}, '*.dryer.*': DEVICE_CUSTOMIZES.get('*.dry.*') or {}, + '*.pre_cooker.*': DEVICE_CUSTOMIZES.get('*.cooker.*') or {}, }) + + +GLOBAL_CONVERTERS = [ + { + 'class': MiotSwitchConv, + 'services': [ + 'switch', 'outlet', 'massager', 'towel_rack', 'diffuser', 'fish_tank', + 'pet_drinking_fountain', 'mosquito_dispeller', 'electric_blanket', 'foot_bath', + ], + }, + { + 'class': MiotSensorConv, + 'services': [ + 'cooker', 'induction_cooker', 'pressure_cooker', 'oven', 'microwave_oven', + 'health_pot', 'coffee_machine', 'multifunction_cooking_pot', + 'air_fryer', 'juicer', 'electric_steamer', + ], + 'kwargs': {'main_props': ['status'], 'desc': True}, + 'converters' : [ + {'props': ['fault'], 'desc': True}, + ], + }, + { + 'class': MiotSensorConv, + 'services': ['water_purifier', 'dishwasher', 'fruit_vegetable_purifier'], + 'kwargs': {'main_props': ['status', 'fault'], 'desc': True}, + }, + { + 'class': MiotSensorConv, + 'services': ['printer', 'vibration_sensor', 'router'], + 'kwargs': {'main_props': ['status'], 'desc': True}, + }, + { + 'class': MiotSensorConv, + 'services': ['pet_feeder', 'cat_toilet', 'cat_litter'], + 'kwargs': {'main_props': ['status'], 'desc': True}, + }, + { + 'class': MiotSensorConv, + 'services': ['washer'], + 'kwargs': {'main_props': ['status', 'run_status'], 'desc': True}, + }, + { + 'class': MiotSensorConv, + 'services': ['plant_monitor'], + 'kwargs': {'main_props': ['status'], 'desc': True}, + 'converters' : [ + {'props': ['fault'], 'desc': True}, + {'props': ['soil_ec', 'illumination'], 'domain': 'sensor'}, + ], + }, + { + 'class': MiotSensorConv, + 'services': ['gas_sensor'], + 'kwargs': {'main_props': ['gas_concentration', 'status'], 'desc': True}, + }, + { + 'class': MiotSensorConv, + 'services': ['occupancy_sensor'], + 'kwargs': {'main_props': ['occupancy_status'], 'desc': True}, + }, + { + 'class': MiotSensorConv, + 'services': ['pressure_sensor'], + 'kwargs': {'main_props': ['pressure_present_duration', 'status'], 'desc': True}, + }, + { + 'class': MiotSensorConv, + 'services': ['sleep_monitor'], + 'kwargs': {'main_props': ['sleep_state', 'status'], 'desc': True}, + }, + { + 'class': MiotSensorConv, + 'services': ['smoke_sensor'], + 'kwargs': {'main_props': ['smoke_concentration', 'status'], 'desc': True}, + }, + { + 'class': MiotLightConv, + 'services': ['light'], + 'converters' : [ + {'props': ['brightness'], 'class': MiotBrightnessConv}, + {'props': ['color_temperature', 'color_temp'], 'class': MiotColorTempConv}, + {'props': ['color'], 'class': MiotRgbColorConv}, + {'props': ['mode'], 'desc': True}, + ], + }, + { + 'class': MiotLightConv, + 'services': ['night_light', 'ambient_light', 'plant_light', 'light_bath_heater'], + 'converters' : [ + {'props': ['brightness'], 'class': MiotBrightnessConv}, + {'props': ['color_temperature', 'color_temp'], 'class': MiotColorTempConv}, + {'props': ['color'], 'class': MiotRgbColorConv}, + {'props': ['mode'], 'desc': True}, + ], + }, + { + 'class': MiotLightConv, + 'services': ['indicator_light'], + 'kwargs': { + 'main_props': ['on', 'switch', 'brightness'], + 'option': {'entity_category': 'config'}, + }, + 'converters' : [ + {'props': ['brightness'], 'class': MiotBrightnessConv}, + {'props': ['mode'], 'desc': True}, + ], + }, + { + 'services': ['physical_control_locked', 'physical_controls_locked'], + 'converters' : [ + { + 'props': ['physical_control_locked', 'physical_controls_locked'], + 'domain': 'switch', + 'option': {'entity_category': 'config'}, + }, + ], + }, + { + 'services': ['tds_sensor'], + 'converters' : [ + {'props': ['tds_in', 'tds_out'], 'domain': 'sensor'}, + ], + }, + { + 'services': ['filter', 'filter_life'], + 'converters' : [ + { + 'props': [ + 'filter_life', 'filter_life_level', + 'filter_left_time', 'filter_used_time', + 'filter_left_flow', 'filter_used_flow', + ], + 'domain': 'sensor', + }, + ], + }, + { + 'services': ['brush_cleaner'], + 'converters' : [ + {'props': ['brush_life_level', 'brush_left_time'], 'domain': 'sensor'}, + ], + }, + { + 'services': ['environment', 'temperature_humidity_sensor'], + 'converters' : [ + { + 'props': [ + 'temperature', 'indoor_temperature', 'relative_humidity', 'humidity', + 'pm2_5_density', 'pm10_density', 'co2_density', 'tvoc_density', 'hcho_density', + 'air_quality', 'air_quality_index', 'illumination', 'atmospheric_pressure', + ], + 'domain': 'sensor', + }, + ], + }, + { + 'services': ['illumination_sensor'], + 'converters' : [ + {'props': ['illumination'], 'domain': 'sensor'}, + ], + }, + { + 'services': ['battery', 'power_consumption', 'electricity'], + 'converters' : [ + { + 'props': [ + 'battery_level', 'electric_power', 'electric_current', + 'voltage', 'leakage_current', 'surge_power', + ], + 'domain': 'sensor', + }, + ], + }, + { + 'services': ['router', 'wifi', 'guest_wifi'], + 'converters' : [ + {'props': ['on'], 'domain': 'switch'}, + { + 'props': [ + 'download_speed', 'upload_speed', 'connected_device_number', 'network_connection_type', + 'ip_address', 'online_time', 'wifi_ssid', 'wifi_bandwidth', + ], + 'domain': 'sensor', + }, + ], + }, +] diff --git a/custom_components/xiaomi_miot/core/hass_entity.py b/custom_components/xiaomi_miot/core/hass_entity.py new file mode 100644 index 000000000..60877c297 --- /dev/null +++ b/custom_components/xiaomi_miot/core/hass_entity.py @@ -0,0 +1,227 @@ +import logging +from typing import TYPE_CHECKING, Optional, Callable +from functools import partial, cached_property + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity, EntityCategory +from homeassistant.helpers.restore_state import ExtraStoredData, RestoredExtraData + +from .const import DOMAIN +from .utils import get_customize_via_entity, wildcard_models, CustomConfigHelper +from .miot_spec import MiotService, MiotProperty, MiotAction +from .converters import BaseConv, InfoConv, MiotServiceConv, MiotPropConv, MiotActionConv + +if TYPE_CHECKING: + from .device import Device + +_LOGGER = logging.getLogger(__name__) + + +class BasicEntity(Entity, CustomConfigHelper): + hass: HomeAssistant = None + device: 'Device' = None + conv: 'BaseConv' = None + + def custom_config(self, key=None, default=None): + return get_customize_via_entity(self, key, default) + + async def async_get_properties(self, mapping, update_entity=False, **kwargs): + return await self.device.async_get_properties(mapping, update_entity, **kwargs) + + async def async_set_property(self, field, value): + return await self.hass.async_add_executor_job(self.device.set_property, field, value) + + async def async_set_miot_property(self, siid, piid, value, **kwargs): + return await self.hass.async_add_executor_job( + partial(self.device.set_miot_property, siid, piid, value, **kwargs) + ) + + async def async_call_action(self, siid, aiid, params=None, **kwargs): + return await self.hass.async_add_executor_job( + partial(self.device.call_action, siid, aiid, params, **kwargs) + ) + + async def async_miio_command(self, method, params=None, **kwargs): + if not self.device.local: + return {'error': 'Unsupported'} + if params is None: + params = [] + _LOGGER.debug('%s: Send miio command: %s(%s)', self.device.name_model, method, params) + return await self.device.local.async_send(method, params) + + +class XEntity(BasicEntity): + CLS: dict[str, Callable] = {} + + log = _LOGGER + added = False + _attr_available = False + _attr_should_poll = False + _attr_has_entity_name = True + _miot_service: Optional[MiotService] = None + _miot_property: Optional[MiotProperty] = None + _miot_action: Optional[MiotAction] = None + + def __init__(self, device: 'Device', conv: 'BaseConv'): + self.device = device + self.hass = device.hass + self.conv = conv + self.attr = conv.attr + + if isinstance(conv, MiotPropConv): + self.entity_id = conv.prop.generate_entity_id(self, conv.domain) + self._attr_name = str(conv.prop.friendly_desc) + self._attr_translation_key = conv.prop.friendly_name + self._miot_service = conv.prop.service + self._miot_property = conv.prop + + elif isinstance(conv, MiotActionConv): + self.entity_id = device.spec.generate_entity_id(self, conv.action.name, conv.domain) + self._attr_name = str(conv.action.friendly_desc) + self._attr_translation_key = conv.action.friendly_name + self._miot_service = conv.action.service + self._miot_action = conv.action + self._miot_property = conv.prop + self._attr_available = True + + elif isinstance(conv, MiotServiceConv): + self.entity_id = conv.service.generate_entity_id(self, conv.domain) + self._attr_name = str(conv.service.friendly_desc) + self._attr_translation_key = conv.service.name + self._miot_service = conv.service + 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 + if isinstance(conv, InfoConv): + self._attr_available = True + + self.listen_attrs = {self.attr} | set(conv.attrs) + if getattr(self, '_attr_name', None): + self._attr_name = self._attr_name.replace(device.name, '').strip() + self._attr_unique_id = f'{device.unique_id}-{convert_unique_id(conv)}' + self._attr_device_info = self.device.hass_device_info + self._attr_extra_state_attributes = {} + + self._attr_icon = conv.option.get('icon') + self._attr_device_class = self.custom_config('device_class') or conv.option.get('device_class') + + cate = self.custom_config('entity_category') or conv.option.get('entity_category') + if isinstance(cate, str): + cate = EntityCategory(cate) if cate in EntityCategory else None + if cate: + self._attr_entity_category = EntityCategory(cate) if cate in EntityCategory else None + elif not cate: + cats = { + 'configuration_entities': EntityCategory.CONFIG, + 'diagnostic_entities': EntityCategory.DIAGNOSTIC, + } + for k, v in cats.items(): + names = self.custom_config_list(k) or [] + if self._miot_property and self._miot_property.in_list(names): + self._attr_entity_category = v + break + if self._miot_action and self._miot_action.in_list(names): + self._attr_entity_category = v + break + + self.on_init() + self.device.add_listener(self.on_device_update) + + @cached_property + def model(self): + return self.device.model + + @cached_property + def unique_mac(self): + return self.device.info.unique_id + + def on_init(self): + """Run on class init.""" + + async def async_update(self): + await self.device.update_status() + + def on_device_update(self, data: dict, only_info=False): + state_change = False + self._attr_available = self.device.available + + if 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: + return + + if keys := self.listen_attrs & data.keys(): + self.set_state(data) + state_change = True + for key in keys: + if key == self.attr: + continue + self._attr_extra_state_attributes[key] = data.get(key) + + if state_change and self.added: + self._async_write_ha_state() + _LOGGER.debug('%s: Entity state updated: %s', self.entity_id, data.get(self.attr)) + + def get_state(self) -> dict: + """Run before entity remove if entity is subclass from RestoreEntity.""" + return {} + + def set_state(self, data: dict): + """Run on data from device.""" + self._attr_state = data.get(self.attr) + + @property + def extra_restore_state_data(self) -> ExtraStoredData | None: + # filter None values + if state := {k: v for k, v in self.get_state().items() if v is not None}: + return RestoredExtraData(state) + return None + + async def async_added_to_hass(self) -> None: + self.added = True + self.hass.data[DOMAIN]['entities'][self.entity_id] = self + + if call := getattr(self, 'async_get_last_extra_data', None): + data: RestoredExtraData = await call() + if data and self.listen_attrs & data.as_dict().keys(): + self.set_state(data.as_dict()) + + async def async_will_remove_from_hass(self) -> None: + self.device.remove_listener(self.on_device_update) + + @cached_property + def customize_keys(self): + keys = [] + prop = getattr(self.conv, 'prop', None) + action = getattr(self.conv, 'action', None) + for mod in wildcard_models(self.device.model): + if isinstance(action, MiotAction): + keys.append(f'{mod}:{action.full_name}') + keys.append(f'{mod}:{action.name}') + if isinstance(prop, MiotProperty): + 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}') + return keys + + +def convert_unique_id(conv: 'BaseConv'): + service = getattr(conv, 'service', None) + if isinstance(conv, MiotServiceConv) and isinstance(service, MiotService): + return service.iid + + action = getattr(conv, 'action', None) + if isinstance(action, MiotAction): + return action.unique_name + + prop = getattr(conv, 'prop', None) + if isinstance(prop, MiotProperty): + return prop.unique_name + + return conv.attr diff --git a/custom_components/xiaomi_miot/core/hass_entry.py b/custom_components/xiaomi_miot/core/hass_entry.py new file mode 100644 index 000000000..64e457825 --- /dev/null +++ b/custom_components/xiaomi_miot/core/hass_entry.py @@ -0,0 +1,86 @@ +import logging +import asyncio +from typing import TYPE_CHECKING +from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_USERNAME +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .const import SUPPORTED_DOMAINS +from .xiaomi_cloud import MiotCloud + +if TYPE_CHECKING: + from .device import Device + +_LOGGER = logging.getLogger(__name__) + +class HassEntry: + ALL: dict[str, 'HassEntry'] = {} + cloud: MiotCloud = None + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry): + self.id = entry.entry_id + self.hass = hass + self.entry = entry + self.adders: dict[str, AddEntitiesCallback] = {} + self.devices: dict[str, 'Device'] = {} + + @staticmethod + def init(hass: HomeAssistant, entry: ConfigEntry): + this = HassEntry.ALL.get(entry.entry_id) + if not this: + this = HassEntry(hass, entry) + HassEntry.ALL[entry.entry_id] = this + return this + + async def async_unload(self): + ret = all( + await asyncio.gather( + *[ + self.hass.config_entries.async_forward_entry_unload(self.entry, domain) + for domain in SUPPORTED_DOMAINS + ] + ) + ) + if ret: + for device in self.devices.values(): + await device.async_unload() + HassEntry.ALL.pop(self.entry.entry_id, None) + return ret + + def __getattr__(self, item): + return getattr(self.entry, item) + + def get_config(self, key=None, default=None): + dat = { + **self.entry.data, + **self.entry.options, + } + if key: + return dat.get(key, default) + return dat + + async def new_device(self, device_info: dict): + from .device import Device, DeviceInfo + info = DeviceInfo(device_info) + device = Device(info, self) + await device.async_init() + self.devices[info.unique_id] = device + return device + + def new_adder(self, domain, adder: AddEntitiesCallback): + self.adders[domain] = adder + _LOGGER.info('New adder: %s', [domain, adder]) + + for device in self.devices.values(): + device.add_entities(domain) + + return self + + async def get_cloud(self, check=False, login=False): + if not self.cloud: + if not self.get_config(CONF_USERNAME): + return None + self.cloud = await MiotCloud.from_token(self.hass, self.get_config(), login=login) + if check: + await self.cloud.async_check_auth(notify=True) + return self.cloud diff --git a/custom_components/xiaomi_miot/core/miot_spec.py b/custom_components/xiaomi_miot/core/miot_spec.py index 555719cd6..422b53e65 100644 --- a/custom_components/xiaomi_miot/core/miot_spec.py +++ b/custom_components/xiaomi_miot/core/miot_spec.py @@ -4,7 +4,9 @@ import random import time import re +from functools import cached_property +from homeassistant.core import HomeAssistant from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, @@ -20,6 +22,7 @@ UnitOfTemperature, ) from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.util.dt import now from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.storage import Store from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -29,10 +32,11 @@ DOMAIN, TRANSLATION_LANGUAGES, ) +from .utils import get_translation_langs _LOGGER = logging.getLogger(__name__) -# https://iot.mi.com/new/doc/guidelines-for-access/other-platform-access/control-api#MIOT%E7%8A%B6%E6%80%81%E7%A0%81 +# https://iot.mi.com/new/doc/accesses/direct-access/other-platform-access/control-api#MIOT%E7%8A%B6%E6%80%81%E7%A0%81 SPEC_ERRORS = { '000': 'Unknown', '001': 'Device does not exist', @@ -103,7 +107,7 @@ def name_by_type(typ): def translation_keys(self): return ['_globals'] - @property + @cached_property def translations(self): dic = TRANSLATION_LANGUAGES kls = self.translation_keys @@ -114,7 +118,7 @@ def translations(self): dic = {**dic, **d} return dic - def get_translation(self, des): + def get_translation(self, des, viid=None, spec=True): dls = [ des.lower(), des, @@ -131,6 +135,11 @@ def get_translation(self, des): continue ret = ret[d] return ret + + fun = getattr(self, 'get_spec_translation', None) + ret = fun(viid=viid) if fun and spec else None + if ret: + return ret return des @staticmethod @@ -143,10 +152,16 @@ def spec_error(errno): err += f' {SPEC_ERRORS.get(cod)}' return err + def __repr__(self): + return f'{self.__class__.__name__}({self.type})' + # https://iot.mi.com/new/doc/design/spec/xiaoai class MiotSpec(MiotSpecInstance): - def __init__(self, dat: dict): + def __init__(self, hass: HomeAssistant, dat: dict, translations=None, trans_options=None): + self.hass = hass + self.trans_options = trans_options + self.spec_translations = translations or {} super().__init__(dat) self.services = {} self.services_count = {} @@ -204,7 +219,7 @@ def set_custom_mapping(self, mapping: dict): p.full_name = n def get_services(self, *args, **kwargs): - excludes = kwargs.get('excludes', []) + excludes = kwargs.get('excludes') or [] excludes.append('device_information') return [ s @@ -231,6 +246,12 @@ def get_property(self, *args, only_format=None): return p return None + def get_properties(self, *args): + lst = [] + for srv in self.services.values(): + lst.extend(srv.get_properties(*args)) + return lst + def generate_entity_id(self, entity, suffix=None, domain=None): mod = f'{self.type}::::'.split(':')[5] if not mod: @@ -244,6 +265,18 @@ def generate_entity_id(self, entity, suffix=None, domain=None): domain = DOMAIN return f'{domain}.{eid}' + def get_spec_translation(self, siid, piid=None, aiid=None, viid=None): + if viid != None and not self.trans_options: + return None + key = MiotSpec.spec_lang_key(siid, piid=piid, aiid=aiid, viid=viid) + langs = get_translation_langs(self.hass, list(self.spec_translations.keys())) + for lang in langs: + dic = self.spec_translations.get(lang) or {} + val = dic.get(key) + if val != None: + return val + return None + @staticmethod async def async_from_model(hass, model, use_remote=False): typ = await MiotSpec.async_get_model_type(hass, model, use_remote) @@ -307,7 +340,9 @@ async def async_get_model_type(hass, model, use_remote=False): return typ @staticmethod - async def async_from_type(hass, typ): + async def async_from_type(hass, typ, trans_options=False): + if not typ: + return None fnm = f'{DOMAIN}/{typ}.json' if platform.system() == 'Windows': fnm = fnm.replace(':', '_') @@ -342,7 +377,9 @@ async def async_from_type(hass, typ): } await store.async_save(dat) _LOGGER.warning('Get miot-spec for %s failed: %s', typ, exc) - return MiotSpec(dat) + + translations = await MiotSpec.async_get_langs(hass, typ) + return MiotSpec(hass, dat, translations, trans_options=trans_options) @staticmethod def unique_prop(siid, piid=None, aiid=None, eiid=None, valid=False): @@ -363,6 +400,57 @@ def unique_prop(siid, piid=None, aiid=None, eiid=None, valid=False): return None return f'{typ}.{siid}.{iid}' + @staticmethod + def spec_lang_key(siid, piid=None, aiid=None, viid=None): + key = f'service:{siid:03}' + if aiid != None: + return f'{key}:action:{aiid:03}' + if piid != None: + key = f'{key}:property:{piid:03}' + if viid != None: + key = f'{key}:valuelist:{viid:03}' + return key + + @staticmethod + async def async_get_langs(hass, typ): + if not typ: + return None + fnm = f'{DOMAIN}/spec-langs/{typ}.json' + if platform.system() == 'Windows': + fnm = fnm.replace(':', '_') + store = Store(hass, 1, fnm) + try: + cached = await store.async_load() or {} + except (ValueError, HomeAssistantError): + await store.async_remove() + cached = {} + dat = cached + ptm = dat.pop('_updated_time', 0) + now = int(time.time()) + ttl = 60 + if dat.get('data'): + ttl = 86400 * random.randint(30, 50) + if dat and now - ptm > ttl: + dat = {} + if not dat.get('type'): + try: + url = f'/instance/v2/multiLanguage?urn={typ}' + dat = await MiotSpec.async_download_miot_spec(hass, url, tries=3) + dat['_updated_time'] = now + await store.async_save(dat) + except (TypeError, ValueError, BaseException) as exc: + if cached: + dat = cached + else: + dat = { + 'type': typ or 'unknown', + 'exception': f'{exc}', + '_updated_time': now, + } + await store.async_save(dat) + _LOGGER.warning('Get miot-spec langs for %s failed: %s', typ, exc) + return dat.get('data') or {} + @staticmethod async def async_download_miot_spec(hass, path, tries=1, timeout=30): session = async_get_clientsession(hass) @@ -459,11 +547,12 @@ def mapping(self, excludes=None, **kwargs): } return dat - def get_properties(self, *args): + def get_properties(self, *args, **kwargs): + excludes = kwargs.get('excludes') or [] return [ p for p in self.properties.values() - if p.in_list(args) or not args + if not p.in_list(excludes) and (not args or p.in_list(args)) ] def get_property(self, *args, only_format=None): @@ -524,6 +613,9 @@ def unique_prop(self, **kwargs): def generate_entity_id(self, entity, domain=None): return self.spec.generate_entity_id(entity, self.desc_name, domain) + def get_spec_translation(self, piid=None, aiid=None, viid=None): + return self.spec.get_spec_translation(self.iid, piid=piid, aiid=aiid, viid=viid) + @property def translation_keys(self): return ['_globals', self.name] @@ -551,7 +643,6 @@ def __init__(self, dat: dict, service: MiotService): self.unique_prop = self.service.unique_prop(piid=self.iid) self.desc_name = self.format_desc_name(self.description, self.name) self.friendly_name = f'{service.name}.{self.name}' - self.friendly_desc = self.short_desc self.format = dat.get('format') or '' self.access = dat.get('access') or [] self.unit = dat.get('unit') or '' @@ -582,6 +673,7 @@ def __init__(self, dat: dict, service: MiotService): 'siid': self.siid, 'piid': self.iid, } + self.friendly_desc = self.short_desc def in_list(self, lst): return self.name in lst \ @@ -593,17 +685,28 @@ def in_list(self, lst): @property def short_desc(self): - sde = (self.service.description or self.service.name).strip() - pde = (self.description or self.name).strip() - des = pde - if sde != pde: - des = f'{sde} {pde}'.strip() - ret = self.get_translation(des) - if ret != des: - return ret - ret = self.get_translation(pde) - if ret != pde: - return f'{sde} {ret}'.strip() + serv = self.service + sde = '' + if self.in_list(['on', 'switch']): + sde = serv.get_spec_translation() or '' + pde = self.get_spec_translation() or '' + if sde and pde: + pde = pde.replace(sde, '') + des = f'{sde} {pde}'.strip() + if not des: + sde = (serv.description or serv.name).strip() + pde = (self.description or self.name).strip() + if sde == pde: + des = pde + else: + des = f'{sde} {pde}'.strip() + ret = self.get_translation(des, spec=False) + if ret != des: + return ret + sde = serv.get_translation(sde, spec=False) + pde = self.get_translation(pde, spec=False) + if sde != pde: + return f'{sde} {pde}'.strip() arr = des.split(' ') return ' '.join(dict(zip(arr, arr)).keys()) @@ -620,6 +723,16 @@ def generate_entity_id(self, entity, domain=None): eid = re.sub(r'_(\d(?:_|$))', r'\1', eid) # issue#153 return eid + def get_spec_translation(self, viid=None): + if viid == None: + return self.service.get_spec_translation(piid=self.iid) + idx = 0 + for v in self.value_list: + if viid == v.get('value'): + return self.service.get_spec_translation(piid=self.iid, viid=idx) + idx += 1 + return None + @property def translation_keys(self): return [ @@ -659,19 +772,20 @@ def list_value(self, des): vde = v.get('description') if des is None: rls.append(val) - elif des in [vde, f'{vde}'.lower(), self.get_translation(vde)]: + elif des in [vde, f'{vde}'.lower(), self.get_translation(vde, viid=val)]: return val return rls if des is None else None def list_description(self, val): rls = [] for v in self.value_list: - des = self.get_translation(v.get('description')) + vid = v.get('value') + des = self.get_translation(v.get('description'), viid=vid) if val is None: if des == '': - des = v.get('value') + des = vid rls.append(str(des)) - elif val == v.get('value'): + elif val == vid: return des if rls and val is None: return rls @@ -711,7 +825,7 @@ def list_search(self, *args, **kwargs): des, des.lower(), self.format_name(des), - self.get_translation(des), + self.get_translation(des, viid=v.get('value', -1)), ] for d in dls: if d not in args: @@ -975,6 +1089,9 @@ def out_results(self, out=None): return dict(zip(kls, out)) return None + def get_spec_translation(self, viid=None): + return self.service.get_spec_translation(aiid=self.iid) + @property def translation_keys(self): return [ @@ -984,15 +1101,27 @@ def translation_keys(self): class MiotResults: - def __init__(self, results, mapping=None): - self._results = results + _results: list = None + updater: str = None + updated = None + errors = None + + def __init__(self, results=None, mapping=None): self.mapping = mapping or {} - self.results = [] + self.results: list[MiotResult] = [] + if results: + self.set_results(results) + + def set_results(self, results, mapping=None): + if mapping: + self.mapping = mapping + self._results = results for v in results or []: if not isinstance(v, dict): continue r = MiotResult(v) self.results.append(r) + self.updated = now() @property def is_empty(self): @@ -1008,30 +1137,28 @@ def first(self): return None return self.results[0] - def to_attributes(self, attrs=None): + def to_attributes(self, attrs=None, mapping=None): rmp = {} - for k, v in self.mapping.items(): - s = v.get('siid') - p = v.get('piid') - rmp[f'prop.{s}.{p}'] = k + if not mapping: + mapping = self.mapping + for k, v in mapping.items(): + u = MiotSpec.unique_prop(v.get('siid'), piid=v.get('piid')) + rmp[u] = k if attrs is None: attrs = {} - adt = {} for prop in self.results: - s = prop.siid - p = prop.piid - k = rmp.get(f'prop.{s}.{p}', prop.did) - if k is None: + k = rmp.get(prop.unique_prop) + if k == None: continue e = prop.code ek = f'{k}.error' if e == 0: - adt[k] = prop.value + attrs[k] = prop.value if ek in attrs: attrs.pop(ek, None) else: - adt[ek] = prop.spec_error - return adt + attrs[ek] = prop.spec_error + return attrs def to_json(self): return [r.to_json() for r in self.results] @@ -1041,13 +1168,16 @@ def __str__(self): class MiotResult: - def __init__(self, result: dict): + def __init__(self, result: dict, **kwargs): + result.update(kwargs) self.result = result self.code = result.get('code') self.value = result.get('value') self.did = result.get('did') self.siid = result.get('siid') self.piid = result.get('piid') + self.unique_prop = MiotSpec.unique_prop(self.siid, piid=self.piid) + self.error = result.get('error') def get(self, key, default=None): return self.result.get(key, default) diff --git a/custom_components/xiaomi_miot/core/utils.py b/custom_components/xiaomi_miot/core/utils.py index 75f2734ff..88ceedbc1 100644 --- a/custom_components/xiaomi_miot/core/utils.py +++ b/custom_components/xiaomi_miot/core/utils.py @@ -3,13 +3,116 @@ import json import locale import tzlocal +import voluptuous as vol from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity +from homeassistant.util import language as language_util from homeassistant.util.dt import DEFAULT_TIME_ZONE, get_time_zone from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from .const import DOMAIN, DEVICE_CUSTOMIZES, DATA_CUSTOMIZE from .translation_languages import TRANSLATION_LANGUAGES +def get_value(obj, key, def_value=None, sep='.'): + keys = f'{key}'.split(sep) + result = obj + for k in keys: + if result is None: + return None + if isinstance(result, dict): + result = result.get(k, def_value) + elif isinstance(result, (list, tuple)): + try: + result = result[int(k)] + except Exception: + result = def_value + return result + +def get_customize_via_model(model, key=None, default=None): + cfg = {} + for m in wildcard_models(model): + cus = DEVICE_CUSTOMIZES.get(m) or {} + if key is not None and key not in cus: + continue + if cus: + cfg = {**cus, **cfg} + return cfg if key is None else cfg.get(key, default) + +def get_customize_via_entity(entity, key=None, default=None): + if key is None: + default = {} + if not isinstance(entity, Entity): + return default + cfg = {} + if entity.hass and entity.entity_id: + cfg = { + **(entity.hass.data[DATA_CUSTOMIZE].get(entity.entity_id) or {}), + **(entity.hass.data[DOMAIN].get(DATA_CUSTOMIZE, {}).get(entity.entity_id) or {}), + } + if key is not None and key in cfg: + return cfg.get(key) + mls = [] + if model := getattr(entity, 'model', None): + if hasattr(entity, 'customize_keys'): + mls.extend(entity.customize_keys) + mls.append(model) + for mod in mls: + cus = get_customize_via_model(mod) + cfg = {**cus, **cfg} + return cfg if key is None else cfg.get(key, default) + +class CustomConfigHelper: + def custom_config(self, key=None, default=None): + raise NotImplementedError + + def custom_config_bool(self, key=None, default=None): + val = self.custom_config(key, default) + try: + val = cv.boolean(val) + except vol.Invalid: + val = default + return val + + def custom_config_number(self, key=None, default=None): + num = default + val = self.custom_config(key) + if val is not None: + try: + num = float(f'{val}') + except (TypeError, ValueError): + num = default + return num + + def custom_config_integer(self, key=None, default=None): + num = self.custom_config_number(key, default) + if num is not None: + num = int(num) + return num + + def custom_config_list(self, key=None, default=None): + lst = self.custom_config(key) + if lst is None: + return default + if not isinstance(lst, list): + lst = f'{lst}'.split(',') + lst = list(map(lambda x: x.strip(), lst)) + return lst + + def custom_config_json(self, key=None, default=None): + dic = self.custom_config(key) + if dic: + if not isinstance(dic, (dict, list)): + try: + dic = json.loads(dic or '{}') + except (TypeError, ValueError): + dic = None + if isinstance(dic, (dict, list)): + return dic + return default + + def get_manifest(field=None, default=None): manifest = {} with open(f'{os.path.dirname(__file__)}/../manifest.json') as fil: @@ -50,6 +153,7 @@ def wildcard_models(model): model, wil, re.sub(r'^[^.]+\.', '*.', wil), + '*', ] @@ -73,6 +177,14 @@ def get_translations(*keys): dic.update(tls) return dic +def get_translation_langs(hass: HomeAssistant, langs=None): + lang = hass.config.language + if not langs: + return [lang] + if 'en' not in langs: + langs.append('en') + return language_util.matches(lang, langs) + def is_offline_exception(exc): err = f'{exc}' @@ -86,6 +198,21 @@ def is_offline_exception(exc): return ret +def update_attrs_with_suffix(attrs, new_dict): + updated_attrs = {} + for key, value in new_dict.items(): + if key in attrs: + suffix = 2 + while f"{key}_{suffix}" in attrs: + suffix += 1 + updated_key = f"{key}_{suffix}" + else: + updated_key = key + + updated_attrs[updated_key] = value + attrs.update(updated_attrs) + + async def async_analytics_track_event(hass: HomeAssistant, event, action, label, value=0, **kwargs): pms = { 'model': label, diff --git a/custom_components/xiaomi_miot/core/xiaomi_cloud.py b/custom_components/xiaomi_miot/core/xiaomi_cloud.py index 57b1bdaff..8347dfa16 100644 --- a/custom_components/xiaomi_miot/core/xiaomi_cloud.py +++ b/custom_components/xiaomi_miot/core/xiaomi_cloud.py @@ -180,6 +180,7 @@ async def async_check_auth(self, notify=False): except requests.exceptions.Timeout: return None # auth err + _LOGGER.info('Xiaomi auth failed, try relogin. %s', rdt) nid = f'xiaomi-miot-auth-warning-{self.user_id}' if await self.async_relogin(): persistent_notification.dismiss(self.hass, nid) diff --git a/custom_components/xiaomi_miot/cover.py b/custom_components/xiaomi_miot/cover.py index 2880ad05b..2c4ed595e 100644 --- a/custom_components/xiaomi_miot/cover.py +++ b/custom_components/xiaomi_miot/cover.py @@ -14,15 +14,14 @@ DOMAIN, CONF_MODEL, XIAOMI_CONFIG_SCHEMA as PLATFORM_SCHEMA, # noqa: F401 + HassEntry, MiotEntity, - MiotPropertySubEntity, async_setup_config_entry, bind_services_to_entries, ) from .core.miot_spec import ( MiotSpec, MiotService, - MiotProperty, ) _LOGGER = logging.getLogger(__name__) @@ -33,6 +32,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): + HassEntry.init(hass, config_entry).new_adder(ENTITY_DOMAIN, async_add_entities) await async_setup_config_entry(hass, config_entry, async_setup_platform, async_add_entities, ENTITY_DOMAIN) @@ -100,7 +100,7 @@ async def async_added_to_hass(self): def device_class(self): if cls := self.get_device_class(CoverDeviceClass): return cls - typ = f'{self._model} {self._miot_service.spec.type}' + typ = f'{self.model} {self._miot_service.spec.type}' if 'curtain' in typ: return CoverDeviceClass.CURTAIN if 'window_opener' in typ: @@ -244,99 +244,3 @@ def stop_cover(self, **kwargs): val = self._prop_motor_control.list_first('Pause', 'Stop') val = self.custom_config_integer('stop_cover_value', val) return self.set_property(self._prop_motor_control, val) - - -class MiotCoverSubEntity(MiotPropertySubEntity, CoverEntity): - def __init__(self, parent, miot_property: MiotProperty, option=None): - super().__init__(parent, miot_property, option, domain=ENTITY_DOMAIN) - self._prop_status = self._option.get('status_property') - if self._prop_status: - self._option['keys'] = [*(self._option.get('keys') or []), self._prop_status.full_name] - self._prop_target_position = self._miot_service.get_property('target_position') - self._value_open = self._miot_property.list_first('Open', 'Up', 'All-up', 'Rise') - self._value_close = self._miot_property.list_first('Close', 'Down', 'All-down') - self._value_stop = self._miot_property.list_first('Pause', 'Stop') - if self._value_open is not None: - self._supported_features |= CoverEntityFeature.OPEN - if self._value_close is not None: - self._supported_features |= CoverEntityFeature.CLOSE - if self._value_stop is not None: - self._supported_features |= CoverEntityFeature.STOP - if self._prop_target_position: - self._supported_features |= CoverEntityFeature.SET_POSITION - if self._miot_property.value_range: - self._supported_features |= CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - self._supported_features |= CoverEntityFeature.SET_POSITION - - @property - def current_cover_position(self): - """Return current position of cover. - None is unknown, 0 is closed, 100 is fully open. - """ - if self._miot_property.value_range: - val = round(self._miot_property.from_dict(self._state_attrs) or -1, 2) - top = self._miot_property.range_max() - return round(val / top * 100) - - prop = self._miot_service.get_property('current_position') - if self.custom_config_bool('target2current_position'): - prop = self._miot_service.get_property('target_position') or prop - if prop: - return round(prop.from_dict(self._state_attrs) or -1) - return None - - def set_cover_position(self, **kwargs): - """Move the cover to a specific position.""" - pos = round(kwargs.get(ATTR_POSITION) or 0) - if self._prop_target_position: - return self.set_parent_property(pos, self._prop_target_position) - if self._miot_property.value_range: - stp = self._miot_property.range_step() - top = self._miot_property.range_max() - pos = round(top * (pos / 100) / stp) * stp - return self.set_parent_property(pos) - raise NotImplementedError() - - @property - def is_closed(self): - """Return if the cover is closed or not.""" - if self._prop_status: - val = self._prop_status.from_dict(self._state_attrs) - vls = self._prop_status.list_search('Closed', 'Down') - if vls and val is not None: - return val in vls - pos = self.current_cover_position - if pos is not None and pos >= 0: - return pos <= 0 - return None - - def open_cover(self, **kwargs): - """Open the cover.""" - val = None - if self._miot_property.value_list: - val = self._value_open - elif self._miot_property.value_range: - val = self._miot_property.range_max() - if val is not None: - return self.set_parent_property(val) - raise NotImplementedError() - - def close_cover(self, **kwargs): - """Close cover.""" - val = None - if self._miot_property.value_list: - val = self._value_close - elif self._miot_property.value_range: - val = self._miot_property.range_min() - if val is not None: - return self.set_parent_property(val) - raise NotImplementedError() - - def stop_cover(self, **kwargs): - """Stop the cover.""" - val = None - if self._miot_property.value_list: - val = self._value_stop - if val is not None: - return self.set_parent_property(val) - raise NotImplementedError() diff --git a/custom_components/xiaomi_miot/device_tracker.py b/custom_components/xiaomi_miot/device_tracker.py index 07b7eda1c..47bc64aa7 100644 --- a/custom_components/xiaomi_miot/device_tracker.py +++ b/custom_components/xiaomi_miot/device_tracker.py @@ -7,12 +7,18 @@ DOMAIN as ENTITY_DOMAIN, ) from homeassistant.components.device_tracker.const import SourceType -from homeassistant.components.device_tracker.config_entry import TrackerEntity, ScannerEntity +from homeassistant.components.device_tracker.config_entry import ( + TrackerEntity as BaseTrackerEntity, + ScannerEntity as BaseScannerEntity, +) +from homeassistant.helpers.restore_state import RestoreEntity from . import ( DOMAIN, CONF_MODEL, XIAOMI_CONFIG_SCHEMA as PLATFORM_SCHEMA, # noqa: F401 + HassEntry, + XEntity, MiotEntity, MiotPropertySubEntity, async_setup_config_entry, @@ -33,6 +39,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): + HassEntry.init(hass, config_entry).new_adder(ENTITY_DOMAIN, async_add_entities) await async_setup_config_entry(hass, config_entry, async_setup_platform, async_add_entities, ENTITY_DOMAIN) @@ -59,7 +66,33 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= bind_services_to_entries(hass, SERVICE_TO_METHOD) -class MiotTrackerEntity(MiotEntity, TrackerEntity): +class ScannerEntity(XEntity, BaseScannerEntity, RestoreEntity): + @property + def device_info(self): + return self._attr_device_info + + @property + def unique_id(self): + return self._attr_unique_id + + @property + def is_connected(self): + """Return true if the device is connected to the network.""" + return True if self._attr_state else False + + @property + def source_type(self): + """Return the source type, eg gps or router, of the device.""" + return SourceType.ROUTER + + def get_state(self) -> dict: + return {self.attr: self._attr_state} + +XEntity.CLS[ENTITY_DOMAIN] = ScannerEntity +XEntity.CLS['scanner'] = ScannerEntity + + +class MiotTrackerEntity(MiotEntity, BaseTrackerEntity): _attr_latitude = None _attr_longitude = None _attr_location_name = None @@ -86,9 +119,6 @@ async def async_update(self): self._attr_location_name = prop.from_dict(self._state_attrs) await self.transform_coord() - for p in self._miot_service.get_properties('driving_status'): - self._update_sub_entities(p, None, 'binary_sensor') - async def transform_coord(self, default=None): if not (self._attr_latitude or self._attr_longitude): return @@ -172,7 +202,7 @@ async def update_location(self): 'dids': [did], 'params': { 'CID': 50031, - 'model': self._model, + 'model': self.model, 'SN': int(time.time() / 1000), 'PL': { 'Size': 1, @@ -219,7 +249,7 @@ async def update_location(self): }) -class MiotScannerSubEntity(MiotPropertySubEntity, ScannerEntity): +class MiotScannerSubEntity(MiotPropertySubEntity, BaseScannerEntity): def __init__(self, parent, miot_property: MiotProperty, option=None): super().__init__(parent, miot_property, option, domain=ENTITY_DOMAIN) diff --git a/custom_components/xiaomi_miot/fan.py b/custom_components/xiaomi_miot/fan.py index 0e281b139..32196e9e9 100644 --- a/custom_components/xiaomi_miot/fan.py +++ b/custom_components/xiaomi_miot/fan.py @@ -13,6 +13,7 @@ DOMAIN, CONF_MODEL, XIAOMI_CONFIG_SCHEMA as PLATFORM_SCHEMA, # noqa: F401 + HassEntry, MiotToggleEntity, MiirToggleEntity, MiotPropertySubEntity, @@ -39,6 +40,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): + HassEntry.init(hass, config_entry).new_adder(ENTITY_DOMAIN, async_add_entities) await async_setup_config_entry(hass, config_entry, async_setup_platform, async_add_entities, ENTITY_DOMAIN) @@ -611,37 +613,6 @@ def modes_count(self): return 0 -class MiotCookerSubEntity(MiotModesSubEntity): - def __init__(self, parent, miot_property: MiotProperty, prop_status: MiotProperty, option=None): - super().__init__(parent, miot_property, option) - if not miot_property.readable: - self._attr = prop_status.full_name - self._prop_status = prop_status - self._option['keys'] = [prop_status.full_name, *(self._option.get('keys') or [])] - self._values_on = self._option.get('values_on') or [] - self._values_off = self._option.get('values_off') or [] - - @property - def is_on(self): - return self._parent.is_on - - def set_preset_mode(self, preset_mode: str): - if not self._miot_property.writeable: - ret = False - act = self._miot_service.get_action('start_cook') - val = self._miot_property.list_first(preset_mode) - if act and val is not None: - ret = self.call_parent('miot_action', self._miot_service.iid, act.iid, [val]) - sta = self._values_on[0] if self._values_on else None - if ret and sta is not None: - self.update_attrs({ - self._prop_status.full_name: sta, - self._attr: val, - }) - return ret - return super().set_preset_mode(preset_mode) - - class MiotWasherSubEntity(MiotModesSubEntity): @property diff --git a/custom_components/xiaomi_miot/humidifier.py b/custom_components/xiaomi_miot/humidifier.py index d09b1456c..25eeec272 100644 --- a/custom_components/xiaomi_miot/humidifier.py +++ b/custom_components/xiaomi_miot/humidifier.py @@ -16,6 +16,7 @@ DOMAIN, CONF_MODEL, XIAOMI_CONFIG_SCHEMA as PLATFORM_SCHEMA, # noqa: F401 + HassEntry, MiotToggleEntity, async_setup_config_entry, bind_services_to_entries, @@ -34,6 +35,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): + HassEntry.init(hass, config_entry).new_adder(ENTITY_DOMAIN, async_add_entities) await async_setup_config_entry(hass, config_entry, async_setup_platform, async_add_entities, ENTITY_DOMAIN) @@ -98,11 +100,6 @@ async def async_update(self): num = round(num * fac) self._attr_target_humidity = num - if self._prop_water_level and self._prop_water_level.writeable: - self._update_sub_entities( - [self._prop_water_level.name], - domain='number_select', - ) add_fans = self._add_entities.get('fan') for p in self._mode_props: pnm = p.full_name @@ -118,7 +115,7 @@ async def async_update(self): def device_class(self): if cls := self.get_device_class(HumidifierDeviceClass): return cls - typ = f'{self._model} {self._miot_service.spec.type}' + typ = f'{self.model} {self._miot_service.spec.type}' if HumidifierDeviceClass.DEHUMIDIFIER.value in typ or '.derh.' in typ: return HumidifierDeviceClass.DEHUMIDIFIER return HumidifierDeviceClass.HUMIDIFIER diff --git a/custom_components/xiaomi_miot/light.py b/custom_components/xiaomi_miot/light.py index 628c12855..39053f3a3 100644 --- a/custom_components/xiaomi_miot/light.py +++ b/custom_components/xiaomi_miot/light.py @@ -4,21 +4,26 @@ from homeassistant.components.light import ( DOMAIN as ENTITY_DOMAIN, - LightEntity, + LightEntity as BaseEntity, LightEntityFeature, # v2022.5 ColorMode, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, + ATTR_RGB_COLOR, ATTR_HS_COLOR, ATTR_EFFECT, ATTR_TRANSITION, ) +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util import color from . import ( DOMAIN, CONF_MODEL, XIAOMI_CONFIG_SCHEMA as PLATFORM_SCHEMA, # noqa: F401 + HassEntry, + XEntity, MiotToggleEntity, MiirToggleEntity, ToggleSubEntity, @@ -28,6 +33,7 @@ from .core.miot_spec import ( MiotSpec, MiotService, + MiotProperty, ) from miio.utils import ( rgb_to_int, @@ -41,6 +47,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): + HassEntry.init(hass, config_entry).new_adder(ENTITY_DOMAIN, async_add_entities) await async_setup_config_entry(hass, config_entry, async_setup_platform, async_add_entities, ENTITY_DOMAIN) @@ -52,7 +59,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= spec = hass.data[DOMAIN]['miot_specs'].get(model) entities = [] if isinstance(spec, MiotSpec): - for srv in spec.get_services(ENTITY_DOMAIN, 'ir_light_control', 'light_bath_heater'): + for srv in spec.get_services('ir_light_control'): if srv.name in ['ir_light_control']: entities.append(MiirLightEntity(config, srv)) continue @@ -69,7 +76,102 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= bind_services_to_entries(hass, SERVICE_TO_METHOD) -class MiotLightEntity(MiotToggleEntity, LightEntity): +class LightEntity(XEntity, BaseEntity, RestoreEntity): + _attr_names = None + _brightness_for_on = None + _brightness_for_off = None + + def on_init(self): + self._attr_names = {} + self._brightness_for_on = self.custom_config_number('brightness_for_on') + self._brightness_for_off = self.custom_config_number('brightness_for_off') + self._attr_color_mode = ColorMode.ONOFF + modes = set() + + for conv in self.device.converters: + if not self._miot_service: + break + prop = getattr(conv, 'prop', None) + if isinstance(prop, MiotProperty): + if conv.attr == ATTR_BRIGHTNESS or prop.in_list(['brightness']): + self.listen_attrs.add(conv.attr) + self._attr_names[ATTR_BRIGHTNESS] = conv.attr + self._attr_color_mode = ColorMode.BRIGHTNESS + elif conv.attr == ATTR_COLOR_TEMP or prop.in_list(['color_temperature', 'color_temp']): + self.listen_attrs.add(conv.attr) + self._attr_color_mode = ColorMode.COLOR_TEMP + modes.add(ColorMode.COLOR_TEMP) + if prop.unit in ['kelvin']: + self._attr_min_color_temp_kelvin = prop.range_min() + self._attr_max_color_temp_kelvin = prop.range_max() + self._attr_names[ATTR_COLOR_TEMP_KELVIN] = conv.attr + else: + self._attr_min_mireds = prop.range_min() + self._attr_max_mireds = prop.range_max() + self._attr_names[ATTR_COLOR_TEMP] = conv.attr + elif conv.attr == ATTR_RGB_COLOR or prop.in_list(['color']): + self.listen_attrs.add(conv.attr) + self._attr_names[ATTR_RGB_COLOR] = conv.attr + modes.add(ColorMode.RGB) + elif conv.attr == ATTR_EFFECT or prop.in_list(['mode']): + self.listen_attrs.add(conv.attr) + self._attr_names[ATTR_EFFECT] = conv.attr + self._attr_effect_list = prop.list_descriptions() + self._attr_supported_features |= LightEntityFeature.EFFECT + + self._attr_supported_color_modes = modes if modes else {self._attr_color_mode} + + def get_state(self) -> dict: + return { + self.attr: self._attr_is_on, + ATTR_BRIGHTNESS: self._attr_brightness, + ATTR_COLOR_TEMP: self._attr_color_temp, + } + + def set_state(self, data: dict): + val = data.get(self.attr) + if val != None: + val = bool(val) + self._attr_is_on = val + + if (val := data.get(self._attr_names.get(ATTR_BRIGHTNESS))) != None: + self._attr_brightness = val + if self._brightness_for_on != None: + self._attr_is_on = val >= self._brightness_for_on + if (val := data.get(self._attr_names.get(ATTR_COLOR_TEMP_KELVIN))) != None: + if val != self._attr_color_temp_kelvin: + self._attr_color_temp_kelvin = val + self._attr_color_mode = ColorMode.COLOR_TEMP + elif (val := data.get(self._attr_names.get(ATTR_COLOR_TEMP))) != None: + if val != self._attr_color_temp: + self._attr_color_temp = val + self._attr_color_mode = ColorMode.COLOR_TEMP + if (val := data.get(self._attr_names.get(ATTR_RGB_COLOR))) != None: + if val != self._attr_rgb_color: + self._attr_rgb_color = val + self._attr_color_mode = ColorMode.RGB + if (val := data.get(self._attr_names.get(ATTR_EFFECT))) != None: + self._attr_effect = val + + async def async_turn_on(self, **kwargs): + dat = {self.attr: True} + if self._brightness_for_on != None: + dat[self.attr] = self._brightness_for_on + for k, v in kwargs.items(): + if attr := self._attr_names.get(k): + dat[attr] = v + await self.device.async_write(dat) + + async def async_turn_off(self, **kwargs): + dat = {self.attr: False} + if self._brightness_for_off != None: + dat[self.attr] = self._brightness_for_off + await self.device.async_write(dat) + +XEntity.CLS[ENTITY_DOMAIN] = LightEntity + + +class MiotLightEntity(MiotToggleEntity, BaseEntity): def __init__(self, config: dict, miot_service: MiotService, **kwargs): kwargs.setdefault('logger', _LOGGER) super().__init__(miot_service, config=config, **kwargs) @@ -314,7 +416,7 @@ def effect(self): return None -class MiirLightEntity(MiirToggleEntity, LightEntity): +class MiirLightEntity(MiirToggleEntity, BaseEntity): def __init__(self, config: dict, miot_service: MiotService): super().__init__(miot_service, config=config, logger=_LOGGER) @@ -394,29 +496,3 @@ async def async_update(self): def set_property(self, field, value): return self.set_parent_property(value, field) - - -class LightSubEntity(ToggleSubEntity, LightEntity): - _brightness = None - _color_temp = None - - def update(self, data=None): - super().update(data) - if self._available: - attrs = self._state_attrs - self._brightness = attrs.get('brightness', 0) - self._color_temp = attrs.get('color_temp', 0) - - def turn_on(self, **kwargs): - self.call_parent(['turn_on_light', 'turn_on'], **kwargs) - - def turn_off(self, **kwargs): - self.call_parent(['turn_off_light', 'turn_off'], **kwargs) - - @property - def brightness(self): - return self._brightness - - @property - def color_temp(self): - return self._color_temp diff --git a/custom_components/xiaomi_miot/manifest.json b/custom_components/xiaomi_miot/manifest.json index 2c608c528..a9f9533f7 100644 --- a/custom_components/xiaomi_miot/manifest.json +++ b/custom_components/xiaomi_miot/manifest.json @@ -17,5 +17,5 @@ "python-miio>=0.5.12", "micloud>=0.5" ], - "version": "0.7.25" + "version": "1.0.0b3" } diff --git a/custom_components/xiaomi_miot/media_player.py b/custom_components/xiaomi_miot/media_player.py index d5c85c9ea..8f217ddfc 100644 --- a/custom_components/xiaomi_miot/media_player.py +++ b/custom_components/xiaomi_miot/media_player.py @@ -12,7 +12,6 @@ from urllib.parse import urlencode, urlparse, parse_qsl from homeassistant.const import ( - ATTR_ATTRIBUTION, ATTR_FRIENDLY_NAME, CONF_HOST, ) @@ -38,6 +37,7 @@ CONF_MODEL, XIAOMI_CONFIG_SCHEMA as PLATFORM_SCHEMA, # noqa: F401 XIAOMI_MIIO_SERVICE_SCHEMA, + HassEntry, BaseEntity, MiotEntityInterface, MiotEntity, @@ -80,6 +80,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): + HassEntry.init(hass, config_entry).new_adder(ENTITY_DOMAIN, async_add_entities) await async_setup_config_entry(hass, config_entry, async_setup_platform, async_add_entities, ENTITY_DOMAIN) @@ -170,7 +171,7 @@ def supported_features(self): def device_class(self): if cls := self.get_device_class(MediaPlayerDeviceClass): return cls - typ = f'{self._model} {self._miot_service.spec.type}' + typ = f'{self.model} {self._miot_service.spec.type}' if 'speaker' in typ: return MediaPlayerDeviceClass.SPEAKER if 'receiver' in typ: @@ -337,8 +338,6 @@ def __init__(self, config: dict, miot_service: MiotService): self._message_router = miot_service.spec.get_service('message_router') self.xiaoai_cloud = None self.xiaoai_device = None - if self._intelligent_speaker: - self._state_attrs[ATTR_ATTRIBUTION] = 'Support TTS through service' self._supported_features |= MediaPlayerEntityFeature.PLAY_MEDIA @property @@ -358,7 +357,6 @@ async def async_update(self): await super().async_update() if not self._available: return - self._update_sub_entities('on', domain='switch') if self._prop_state and not self._prop_state.readable: if self.is_volume_muted is False: diff --git a/custom_components/xiaomi_miot/number.py b/custom_components/xiaomi_miot/number.py index f7e860e00..81f301654 100644 --- a/custom_components/xiaomi_miot/number.py +++ b/custom_components/xiaomi_miot/number.py @@ -3,14 +3,16 @@ from homeassistant.components.number import ( DOMAIN as ENTITY_DOMAIN, - NumberEntity, RestoreNumber, + NumberMode, ) from . import ( DOMAIN, CONF_MODEL, XIAOMI_CONFIG_SCHEMA as PLATFORM_SCHEMA, # noqa: F401 + HassEntry, + XEntity, MiotEntity, MiotPropertySubEntity, async_setup_config_entry, @@ -30,6 +32,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): + HassEntry.init(hass, config_entry).new_adder(ENTITY_DOMAIN, async_add_entities) await async_setup_config_entry(hass, config_entry, async_setup_platform, async_add_entities, ENTITY_DOMAIN) @@ -51,7 +54,30 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= bind_services_to_entries(hass, SERVICE_TO_METHOD) -class MiotNumberEntity(MiotEntity, NumberEntity): +class NumberEntity(XEntity, RestoreNumber): + _attr_mode = NumberMode.AUTO + + def on_init(self): + if self._miot_property: + self._attr_native_step = self._miot_property.range_step() + self._attr_native_max_value = self._miot_property.range_max() + self._attr_native_min_value = self._miot_property.range_min() + self._attr_native_unit_of_measurement = self._miot_property.unit_of_measurement + + 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 + + async def async_set_native_value(self, value: float): + await self.device.async_write({self.attr: value}) + +XEntity.CLS[ENTITY_DOMAIN] = NumberEntity + + +class MiotNumberEntity(MiotEntity, RestoreNumber): def __init__(self, config, miot_service: MiotService): super().__init__(miot_service, config=config, logger=_LOGGER) diff --git a/custom_components/xiaomi_miot/remote.py b/custom_components/xiaomi_miot/remote.py index 9e6e6e7b5..b46dd9225 100644 --- a/custom_components/xiaomi_miot/remote.py +++ b/custom_components/xiaomi_miot/remote.py @@ -19,6 +19,7 @@ DOMAIN, CONF_MODEL, XIAOMI_CONFIG_SCHEMA as PLATFORM_SCHEMA, # noqa: F401 + HassEntry, MiotEntity, DeviceException, async_setup_config_entry, @@ -45,6 +46,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): + HassEntry.init(hass, config_entry).new_adder(ENTITY_DOMAIN, async_add_entities) await async_setup_config_entry(hass, config_entry, async_setup_platform, async_add_entities, ENTITY_DOMAIN) diff --git a/custom_components/xiaomi_miot/select.py b/custom_components/xiaomi_miot/select.py index 8ce893a17..b61648663 100644 --- a/custom_components/xiaomi_miot/select.py +++ b/custom_components/xiaomi_miot/select.py @@ -3,13 +3,16 @@ from homeassistant.components.select import ( DOMAIN as ENTITY_DOMAIN, - SelectEntity, + SelectEntity as BaseEntity, ) +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.event import async_call_later from . import ( DOMAIN, - CONF_MODEL, XIAOMI_CONFIG_SCHEMA as PLATFORM_SCHEMA, # noqa: F401 + HassEntry, + XEntity, MiotEntity, BaseSubEntity, MiotPropertySubEntity, @@ -17,10 +20,8 @@ bind_services_to_entries, ) from .core.miot_spec import ( - MiotSpec, MiotService, MiotProperty, - MiotAction, ) _LOGGER = logging.getLogger(__name__) @@ -30,6 +31,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): + HassEntry.init(hass, config_entry).new_adder(ENTITY_DOMAIN, async_add_entities) await async_setup_config_entry(hass, config_entry, async_setup_platform, async_add_entities, ENTITY_DOMAIN) @@ -37,61 +39,52 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= hass.data.setdefault(DATA_KEY, {}) hass.data[DOMAIN]['add_entities'][ENTITY_DOMAIN] = async_add_entities config['hass'] = hass - model = str(config.get(CONF_MODEL) or '') - spec = hass.data[DOMAIN]['miot_specs'].get(model) - entities = [] - if isinstance(spec, MiotSpec): - for srv in spec.get_services('ir_aircondition_control'): - if not srv.actions: - continue - entities.append(MiotActionsEntity(config, srv)) - for entity in entities: - hass.data[DOMAIN]['entities'][entity.unique_id] = entity - async_add_entities(entities, update_before_add=True) bind_services_to_entries(hass, SERVICE_TO_METHOD) -class MiotSelectEntity(MiotEntity, SelectEntity): - def __init__(self, config, miot_service: MiotService): - super().__init__(miot_service, config=config, logger=_LOGGER) - self._attr_current_option = None +class SelectEntity(XEntity, BaseEntity, RestoreEntity): + _attr_current_option = None + _attr_options = [] - def select_option(self, option): - """Change the selected option.""" - raise NotImplementedError() + def on_init(self): + if self._miot_property: + self._attr_options = self._miot_property.list_descriptions() + if self._miot_action: + self._attr_options.insert(0, '') + if lst := getattr(self.conv, 'options', None): + self._attr_options = lst + def get_state(self) -> dict: + return {self.attr: self._attr_current_option} -class MiotActionsEntity(MiotSelectEntity): + def set_state(self, data: dict): + val = data.get(self.attr) + self._attr_current_option = val + + async def async_select_option(self, option: str): + if self._miot_action and option == '': + return + + await self.device.async_write({self.attr: option}) + + if self._miot_action: + self._attr_current_option = '' + async_call_later(self.hass, 0.5, self.schedule_update_ha_state) + +XEntity.CLS[ENTITY_DOMAIN] = SelectEntity + + +class MiotSelectEntity(MiotEntity, BaseEntity): def __init__(self, config, miot_service: MiotService): - super().__init__(config, miot_service) - als = [] - for a in miot_service.actions.values(): - anm = a.friendly_desc or a.name - als.append(anm) - self._attr_options = als - - async def async_added_to_hass(self): - await super().async_added_to_hass() - if p := self._miot_service.get_property('ir_mode'): - self._state_attrs[p.full_name] = None - self._update_sub_entities([p.name], domain='select', whatever=True) - if p := self._miot_service.get_property('ir_temperature'): - self._state_attrs[p.full_name] = None - self._update_sub_entities([p.name], domain='number', whatever=True) + super().__init__(miot_service, config=config, logger=_LOGGER) + self._attr_current_option = None def select_option(self, option): """Change the selected option.""" - ret = False - act = self._miot_service.search_action(option) - if act: - if ret := self.call_action(act): - self._attr_current_option = option - self.schedule_update_ha_state() - self._attr_current_option = None - return ret + raise NotImplementedError() -class MiotSelectSubEntity(SelectEntity, MiotPropertySubEntity): +class MiotSelectSubEntity(BaseEntity, MiotPropertySubEntity): def __init__(self, parent, miot_property: MiotProperty, option=None): MiotPropertySubEntity.__init__(self, parent, miot_property, option, domain=ENTITY_DOMAIN) self._attr_options = miot_property.list_descriptions() @@ -120,46 +113,7 @@ def select_option(self, option): return False -class MiotActionSelectSubEntity(MiotSelectSubEntity): - def __init__(self, parent, miot_action: MiotAction, miot_property: MiotProperty = None, option=None): - if not miot_property: - miot_property = miot_action.in_properties()[0] if miot_action.ins else None - super().__init__(parent, miot_property, option) - self._miot_action = miot_action - self._attr_current_option = None - self._attr_options = miot_property.list_descriptions() - self._extra_actions = self._option.get('extra_actions') or {} - if self._extra_actions: - self._attr_options.extend(self._extra_actions.keys()) - - self._state_attrs.update({ - 'miot_action': miot_action.full_name, - }) - - def update(self, data=None): - self._available = True - self._attr_current_option = None - - def select_option(self, option): - """Change the selected option.""" - ret = None - val = self._miot_property.list_value(option) - if val is None: - act = self._extra_actions.get(option) - if isinstance(act, MiotAction): - ret = self.call_parent('call_action', act) - else: - return False - if ret is None: - pms = [val] if self._miot_action.ins else [] - ret = self.call_parent('call_action', self._miot_action, pms) - if ret: - self._attr_current_option = option - self.schedule_update_ha_state() - return ret - - -class SelectSubEntity(SelectEntity, BaseSubEntity): +class SelectSubEntity(BaseEntity, BaseSubEntity): def __init__(self, parent, attr, option=None): BaseSubEntity.__init__(self, parent, attr, option) self._available = True diff --git a/custom_components/xiaomi_miot/sensor.py b/custom_components/xiaomi_miot/sensor.py index 04d8eac4b..f3c2b5998 100644 --- a/custom_components/xiaomi_miot/sensor.py +++ b/custom_components/xiaomi_miot/sensor.py @@ -4,13 +4,14 @@ import json from typing import cast from datetime import datetime, timedelta -from functools import cmp_to_key +from functools import cmp_to_key, cached_property from homeassistant.const import STATE_UNKNOWN -from homeassistant.helpers.entity import Entity from homeassistant.components.sensor import ( DOMAIN as ENTITY_DOMAIN, + SensorEntity as BaseEntity, SensorDeviceClass, + STATE_CLASSES, ) from homeassistant.helpers.restore_state import RestoreEntity, RestoredExtraData from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -18,8 +19,9 @@ from . import ( DOMAIN, CONF_MODEL, - CONF_XIAOMI_CLOUD, XIAOMI_CONFIG_SCHEMA as PLATFORM_SCHEMA, # noqa: F401 + HassEntry, + XEntity, MiotEntity, BaseSubEntity, MiCoordinatorEntity, @@ -35,19 +37,6 @@ ) from .core.utils import local_zone, get_translation -try: - # hass 2021.4.0b0+ - from homeassistant.components.sensor import SensorEntity -except ImportError: - class SensorEntity(Entity): - """Base class for sensor entities.""" - -try: - # hass 2021.6.0b0+ - from homeassistant.components.sensor import STATE_CLASSES -except ImportError: - STATE_CLASSES = [] - _LOGGER = logging.getLogger(__name__) DATA_KEY = f'{ENTITY_DOMAIN}.{DOMAIN}' @@ -55,28 +44,27 @@ class SensorEntity(Entity): async def async_setup_entry(hass, config_entry, async_add_entities): - cfg = hass.data[DOMAIN].get(config_entry.entry_id) or {} - mic = cfg.get(CONF_XIAOMI_CLOUD) - config_data = config_entry.data or {} + entry = HassEntry.init(hass, config_entry).new_adder(ENTITY_DOMAIN, async_add_entities) + cloud = await entry.get_cloud() - if isinstance(mic, MiotCloud) and mic.user_id: - if not config_data.get('disable_message'): - hass.data[DOMAIN]['accounts'].setdefault(mic.user_id, {}) + if cloud: + if not entry.get_config('disable_message'): + hass.data[DOMAIN]['accounts'].setdefault(cloud.user_id, {}) - if not hass.data[DOMAIN]['accounts'][mic.user_id].get('messenger'): - entity = MihomeMessageSensor(hass, mic) - hass.data[DOMAIN]['accounts'][mic.user_id]['messenger'] = entity + if not hass.data[DOMAIN]['accounts'][cloud.user_id].get('messenger'): + entity = MihomeMessageSensor(hass, cloud) + hass.data[DOMAIN]['accounts'][cloud.user_id]['messenger'] = entity async_add_entities([entity], update_before_add=False) - if not config_data.get('disable_scene_history'): - homes = await mic.async_get_homerooms() + if not entry.get_config('disable_scene_history'): + homes = await cloud.async_get_homerooms() for home in homes: home_id = home.get('id') - if hass.data[DOMAIN]['accounts'][mic.user_id].get(f'scene_history_{home_id}'): + if hass.data[DOMAIN]['accounts'][cloud.user_id].get(f'scene_history_{home_id}'): continue - entity = MihomeSceneHistorySensor(hass, mic, home_id, home.get('uid')) - hass.data[DOMAIN]['accounts'][mic.user_id][f'scene_history_{home_id}'] = entity + entity = MihomeSceneHistorySensor(hass, cloud, home_id, home.get('uid')) + hass.data[DOMAIN]['accounts'][cloud.user_id][f'scene_history_{home_id}'] = entity async_add_entities([entity], update_before_add=False) await async_setup_config_entry(hass, config_entry, async_setup_platform, async_add_entities, ENTITY_DOMAIN) @@ -91,14 +79,10 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= entities = [] if isinstance(spec, MiotSpec): for srv in spec.get_services( - 'battery', 'environment', 'tds_sensor', 'switch_sensor', 'vibration_sensor', 'occupancy_sensor', - 'temperature_humidity_sensor', 'illumination_sensor', 'gas_sensor', 'smoke_sensor', 'pressure_sensor', - 'router', 'lock', 'door', 'washer', 'printer', 'sleep_monitor', 'bed', 'walking_pad', 'treadmill', - 'oven', 'microwave_oven', 'health_pot', 'coffee_machine', 'multifunction_cooking_pot', - 'cooker', 'induction_cooker', 'pressure_cooker', 'air_fryer', 'juicer', 'electric_steamer', - 'water_purifier', 'dishwasher', 'fruit_vegetable_purifier', - 'pet_feeder', 'cat_toilet', 'fridge_chamber', 'plant_monitor', 'germicidal_lamp', 'vital_signs', - 'sterilizer', 'steriliser', 'table', 'chair', 'dryer', 'clothes_dryer', + 'switch_sensor', + 'lock', 'door', 'bed', 'walking_pad', 'treadmill', + 'fridge_chamber', 'germicidal_lamp', 'vital_signs', + 'sterilizer', 'steriliser', 'chair', 'dryer', 'clothes_dryer', ): if srv.name in ['lock']: if not srv.get_property('operation_method', 'operation_id'): @@ -106,33 +90,9 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= elif srv.name in ['door']: if spec.get_service('lock'): continue - elif srv.name in ['battery']: - if spec.name not in ['switch_sensor', 'toothbrush']: - continue - elif srv.name in ['environment']: - if spec.name not in ['air_monitor']: - continue - elif srv.name in ['tds_sensor']: - if spec.get_service('water_purifier', 'fish_tank'): - continue - elif srv.name in ['temperature_humidity_sensor']: - if spec.name not in ['temperature_humidity_sensor']: - continue - elif srv.name in ['illumination_sensor']: - if spec.name not in ['illumination_sensor']: - continue - elif srv.name in ['pet_feeder', 'table']: - # no readable properties in mmgg.feeder.petfeeder - # nineam.desk.hoo01 - pass elif not srv.mapping(): continue - if srv.get_property('cook_mode') or srv.get_action('start_cook', 'cancel_cooking'): - entities.append(MiotCookerEntity(config, srv)) - elif srv.name in ['oven', 'microwave_oven']: - entities.append(MiotCookerEntity(config, srv)) - else: - entities.append(MiotSensorEntity(config, srv)) + entities.append(MiotSensorEntity(config, srv)) for entity in entities: hass.data[DOMAIN]['entities'][entity.unique_id] = entity async_add_entities(entities, update_before_add=False) @@ -153,7 +113,49 @@ def datetime_with_tzinfo(value): return value -class MiotSensorEntity(MiotEntity, SensorEntity): +class SensorEntity(XEntity, BaseEntity, RestoreEntity): + def on_init(self): + self._attr_state_class = self.custom_config('state_class') + self._attr_native_unit_of_measurement = self.custom_config('unit_of_measurement') + if self._miot_property: + if not self._attr_icon: + self._attr_icon = self._miot_property.entity_icon + if not self._attr_device_class: + self._attr_device_class = self._miot_property.device_class + if not self._attr_state_class: + self._attr_state_class = self._miot_property.state_class + if not self._attr_native_unit_of_measurement: + self._attr_native_unit_of_measurement = self._miot_property.unit_of_measurement + + def get_state(self) -> dict: + return {self.attr: self._attr_native_value} + + def set_state(self, data: dict): + value = data.get(self.attr) + prop = self._miot_property + if prop and prop.value_range: + if not prop.range_min() <= value <= prop.range_max(): + value = None + if value is not None: + try: + if ratio := self.custom_value_ratio: + value = round(float(value) * ratio, 3) + elif self.state_class: + value = round(float(value), 3) + except (TypeError, ValueError): + value = None + if self.device_class == SensorDeviceClass.TIMESTAMP: + value = datetime_with_tzinfo(value) + self._attr_native_value = value + + @cached_property + def custom_value_ratio(self): + return self.custom_config_number('value_ratio') or 0 + +XEntity.CLS[ENTITY_DOMAIN] = SensorEntity + + +class MiotSensorEntity(MiotEntity, BaseEntity): def __init__(self, config, miot_service: MiotService): super().__init__(miot_service, config=config, logger=_LOGGER) @@ -166,22 +168,6 @@ def __init__(self, config, miot_service: MiotService): ) if miot_service.name in ['lock']: self._prop_state = miot_service.get_property('operation_method') or self._prop_state - elif miot_service.name in ['tds_sensor']: - self._prop_state = miot_service.get_property('tds_out') or self._prop_state - elif miot_service.name in ['temperature_humidity_sensor']: - self._prop_state = miot_service.get_property( - 'temperature', 'indoor_temperature', 'relative_humidity', - ) or self._prop_state - elif miot_service.name in ['sleep_monitor']: - self._prop_state = miot_service.get_property('sleep_state') or self._prop_state - elif miot_service.name in ['gas_sensor']: - self._prop_state = miot_service.get_property('gas_concentration') or self._prop_state - elif miot_service.name in ['smoke_sensor']: - self._prop_state = miot_service.get_property('smoke_concentration') or self._prop_state - elif miot_service.name in ['occupancy_sensor']: - self._prop_state = miot_service.get_property('occupancy_status') or self._prop_state - elif miot_service.name in ['pressure_sensor']: - self._prop_state = miot_service.get_property('pressure_present_duration') or self._prop_state self._attr_icon = self._miot_service.entity_icon self._attr_state_class = None @@ -237,73 +223,6 @@ async def async_update(self): }) self._prop_state.description_to_dict(self._state_attrs) - async def async_update_for_main_entity(self): - await super().async_update_for_main_entity() - - if self._miot_service.name in ['washer']: - pls = self._miot_service.get_properties( - 'mode', 'spin_speed', 'rinsh_times', - 'target_temperature', 'target_water_level', - 'drying_level', 'drying_time', - ) - for p in pls: - if not p.value_list and not p.value_range: - continue - if self.entry_config_version >= 0.3: - opt = { - 'before_select': self.before_select_modes, - } - self._update_sub_entities(p, None, 'select', option=opt) - else: - self._update_sub_entities(p, None, 'fan') - add_switches = self._add_entities.get('switch') - if self._miot_service.get_action('start_wash', 'pause'): - pnm = 'action' - prop = self._miot_service.get_property('status') - if pnm in self._subs: - self._subs[pnm].update_from_parent() - elif add_switches and prop: - from .switch import MiotWasherActionSubEntity - self._subs[pnm] = MiotWasherActionSubEntity(self, prop) - add_switches([self._subs[pnm]], update_before_add=True) - - self._update_sub_entities( - [ - 'download_speed', 'upload_speed', 'connected_device_number', 'network_connection_type', - 'ip_address', 'online_time', 'wifi_ssid', 'wifi_bandwidth', - ], - ['router', 'wifi', 'guest_wifi'], - domain='sensor', - ) - self._update_sub_entities( - ['on'], - [self._miot_service.name, 'router', 'wifi', 'guest_wifi'], - domain='switch', - ) - self._update_sub_entities( - [ - 'temperature', 'relative_humidity', 'humidity', 'pm2_5_density', - 'battery_level', 'soil_ec', 'illumination', 'atmospheric_pressure', - ], - ['temperature_humidity_sensor', 'illumination_sensor', 'plant_monitor'], - domain='sensor', - ) - self._update_sub_entities( - [ - 'mode_time', 'start_pause', 'leg_pillow', 'rl_control', - 'heat_level', 'heat_time', 'heat_zone', 'intensity_mode', 'massage_strength', - ], - [ - 'bed', 'backrest_control', 'leg_rest_control', 'massage_mattress', 'fridge', - ], - domain='fan', - ) - self._update_sub_entities( - ['motor_control', 'backrest_angle', 'leg_rest_angle'], - ['bed', 'backrest_control', 'leg_rest_control'], - domain='cover', - ) - @property def device_class(self): """Return the class of this entity.""" @@ -346,64 +265,6 @@ def __init__(self, config, miot_service: MiotService): 'Idle', 'Completed', 'Shutdown', 'CookFinish', 'Pause', 'Paused', 'Fault', 'Error', 'Stop', 'Off', ) - async def async_update(self): - await super().async_update() - if not self._available: - return - if self._prop_state: - self._update_sub_entities( - ['target_temperature'], - domain='number', - ) - add_fans = self._add_entities.get('fan') - add_selects = self._add_entities.get('select') - add_switches = self._add_entities.get('switch') - pls = self._miot_service.get_properties( - 'mode', 'cook_mode', 'heat_level', 'target_time', 'target_temperature', - ) - for p in pls: - opt = None - if p.name in self._subs: - self._subs[p.name].update_from_parent() - elif not (p.value_list or p.value_range): - continue - elif add_selects: - from .select import ( - MiotSelectSubEntity, - MiotActionSelectSubEntity, - ) - if p.writeable: - self._subs[p.name] = MiotSelectSubEntity(self, p) - elif not self._action_start: - continue - elif p.iid in self._action_start.ins: - if self._action_cancel: - opt = { - 'extra_actions': { - p.get_translation('Off'): self._action_cancel, - }, - } - self._subs[p.name] = MiotActionSelectSubEntity(self, self._action_start, p, opt) - if p.name in self._subs: - add_selects([self._subs[p.name]], update_before_add=True) - elif add_fans: - if p.value_list: - opt = { - 'values_on': self._values_on, - 'values_off': self._values_off, - } - from .fan import MiotCookerSubEntity - self._subs[p.name] = MiotCookerSubEntity(self, p, self._prop_state, opt) - add_fans([self._subs[p.name]], update_before_add=True) - if self._action_start or self._action_cancel: - pnm = 'cook_switch' - if pnm in self._subs: - self._subs[pnm].update_from_parent() - elif add_switches: - from .switch import MiotCookerSwitchSubEntity - self._subs[pnm] = MiotCookerSwitchSubEntity(self, self._prop_state) - add_switches([self._subs[pnm]], update_before_add=True) - @property def is_on(self): val = self._prop_state.from_dict(self._state_attrs) @@ -434,7 +295,7 @@ def turn_action(self, on): return ret -class BaseSensorSubEntity(BaseSubEntity, SensorEntity): +class BaseSensorSubEntity(BaseSubEntity, BaseEntity): def __init__(self, parent, attr, option=None, **kwargs): kwargs.setdefault('domain', ENTITY_DOMAIN) self._attr_state_class = None @@ -485,49 +346,7 @@ def update(self, data=None): self._extra_attrs['updated_time'] = now -class MiotSensorSubEntity(MiotPropertySubEntity, BaseSensorSubEntity): - def __init__(self, parent, miot_property: MiotProperty, option=None): - super().__init__(parent, miot_property, option, domain=ENTITY_DOMAIN) - self._attr_state_class = miot_property.state_class - - self._prop_battery = None - for s in self._miot_service.spec.get_services('battery', self._miot_service.name): - p = s.get_property('battery_level') - if p: - self._prop_battery = p - if self._prop_battery: - self._option['keys'] = [*(self._option.get('keys') or []), self._prop_battery.full_name] - - async def async_added_to_hass(self): - await BaseSensorSubEntity.async_added_to_hass(self) - - def update(self, data=None): - super().update(data) - if not self._available: - return - self.update_with_properties() - self._miot_property.description_to_dict(self._state_attrs) - - @property - def native_value(self): - prop = self._miot_property - if not self._attr_native_unit_of_measurement: - key = f'{prop.full_name}_desc' - if key in self._state_attrs: - return f'{self._state_attrs[key]}'.lower() - val = prop.from_dict(self._state_attrs) - if not prop.range_valid(val): - val = None - if val is not None: - svd = self.custom_config_number('value_ratio') or 0 - if svd: - val = round(float(val) * svd, 3) - elif self.device_class in [SensorDeviceClass.HUMIDITY, SensorDeviceClass.TEMPERATURE]: - val = round(float(val), 3) - return val - - -class MihomeMessageSensor(MiCoordinatorEntity, SensorEntity, RestoreEntity): +class MihomeMessageSensor(MiCoordinatorEntity, BaseEntity, RestoreEntity): _filter_homes = None _exclude_types = None _has_none_message = False @@ -660,7 +479,7 @@ async def fetch_latest_message(self): return msg -class MihomeSceneHistorySensor(MiCoordinatorEntity, SensorEntity, RestoreEntity): +class MihomeSceneHistorySensor(MiCoordinatorEntity, BaseEntity, RestoreEntity): MESSAGE_TIMEOUT = 60 UPDATE_INTERVAL = 15 diff --git a/custom_components/xiaomi_miot/switch.py b/custom_components/xiaomi_miot/switch.py index bcce1d099..6588a39aa 100644 --- a/custom_components/xiaomi_miot/switch.py +++ b/custom_components/xiaomi_miot/switch.py @@ -11,14 +11,17 @@ ) from homeassistant.components.switch import ( DOMAIN as ENTITY_DOMAIN, - SwitchEntity, + SwitchEntity as BaseEntity, SwitchDeviceClass, ) +from homeassistant.helpers.restore_state import RestoreEntity from . import ( DOMAIN, CONF_MODEL, XIAOMI_CONFIG_SCHEMA as PLATFORM_SCHEMA, # noqa: F401 + HassEntry, + XEntity, MiioDevice, MiioEntity, MiotToggleEntity, @@ -41,6 +44,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): + HassEntry.init(hass, config_entry).new_adder(ENTITY_DOMAIN, async_add_entities) await async_setup_config_entry(hass, config_entry, async_setup_platform, async_add_entities, ENTITY_DOMAIN) @@ -58,21 +62,32 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= srv = spec.get_service('relays') if srv: entities.append(MiotPwznRelaySwitchEntity(config, srv)) - else: - for srv in spec.get_services( - ENTITY_DOMAIN, 'outlet', 'massager', 'towel_rack', 'diffuser', 'fish_tank', - 'pet_drinking_fountain', 'mosquito_dispeller', 'electric_blanket', 'foot_bath', - ): - if not srv.get_property('on'): - continue - entities.append(MiotSwitchEntity(config, srv)) for entity in entities: hass.data[DOMAIN]['entities'][entity.unique_id] = entity async_add_entities(entities, update_before_add=True) bind_services_to_entries(hass, SERVICE_TO_METHOD) -class MiotSwitchEntity(MiotToggleEntity, SwitchEntity): +class SwitchEntity(XEntity, BaseEntity, RestoreEntity): + def get_state(self) -> dict: + return {self.attr: self._attr_is_on} + + def set_state(self, data: dict): + val = data.get(self.attr) + if val is not None: + val = bool(val) + self._attr_is_on = val + + async def async_turn_on(self): + await self.device.async_write({self.attr: True}) + + async def async_turn_off(self): + await self.device.async_write({self.attr: False}) + +XEntity.CLS[ENTITY_DOMAIN] = SwitchEntity + + +class MiotSwitchEntity(MiotToggleEntity, BaseEntity): def __init__(self, config: dict, miot_service: MiotService): super().__init__(miot_service, config=config, logger=_LOGGER) self._attr_icon = self._miot_service.entity_icon @@ -81,7 +96,7 @@ def __init__(self, config: dict, miot_service: MiotService): def device_class(self): if cls := self.get_device_class(SwitchDeviceClass): return cls - typ = f'{self._model} {self._miot_service.spec.type}' + typ = f'{self.model} {self._miot_service.spec.type}' if 'outlet' in typ or '.plug.' in typ: return SwitchDeviceClass.OUTLET return SwitchDeviceClass.SWITCH @@ -96,26 +111,8 @@ async def async_added_to_hass(self): self._subs[fnm] = MiotSwitchActionSubEntity(self, prop, act) add_switches([self._subs[fnm]], update_before_add=True) - async def async_update(self): - await super().async_update() - if not self._available: - return - self._update_sub_entities( - ['heat_level'], - ['massager'], - domain='fan', - option={ - 'power_property': self._miot_service.get_property('heating'), - }, - ) - self._update_sub_entities( - ['mode', 'massage_strength', 'massage_part', 'massage_manipulation'], - ['massager'], - domain='number_select' if self.entry_config_version >= 0.3 else 'fan', - ) - - -class SwitchSubEntity(ToggleSubEntity, SwitchEntity): + +class SwitchSubEntity(ToggleSubEntity, BaseEntity): def __init__(self, parent, attr='switch', option=None, **kwargs): kwargs.setdefault('domain', ENTITY_DOMAIN) super().__init__(parent, attr, option, **kwargs) @@ -124,67 +121,6 @@ def update(self, data=None): super().update(data) -class MiotSwitchSubEntity(MiotPropertySubEntity, SwitchSubEntity): - def __init__(self, parent, miot_property: MiotProperty, option=None): - super().__init__(parent, miot_property, option, domain=ENTITY_DOMAIN) - self._name = self.format_name_by_property(miot_property) - self._prop_power = self._miot_service.get_property('on', 'power') - if self._prop_power: - self._option['keys'] = [*(self._option.get('keys') or []), self._prop_power.full_name] - self._option['icon'] = self._prop_power.entity_icon or self._option.get('icon') - self._on_descriptions = ['On', 'Open', 'Enable', 'Enabled', 'Yes', '开', '打开'] - if des := self.custom_config_list('descriptions_for_on'): - self._on_descriptions = des - - @property - def is_on(self): - val = self._miot_property.from_dict(self._state_attrs) - if self._miot_property.value_list: - if val is not None: - self._state = val in self._miot_property.list_search(*self._on_descriptions) - elif self._miot_property.value_range: - if self._miot_property.range_min() == 0 and self._miot_property.range_max() == 1: - self._state = val == self._miot_property.range_max() - elif self._miot_property.format in ['bool']: - self._state = val - - if self._miot_service.name in ['air_conditioner']: - if self._prop_power: - self._state = self._state and self._prop_power.from_dict(self._state_attrs) - - if self._reverse_state and self._state is not None: - return not self._state - return self._state - - def turn_on(self, **kwargs): - val = True - if self._miot_property.value_range: - val = self._miot_property.range_max() - if self._miot_property.value_list: - ret = self._miot_property.list_first(*self._on_descriptions) - val = 1 if ret is None else ret - elif self._miot_property.value_range: - val = self._miot_property.range_max() - if self._reverse_state: - val = not val - return self.set_parent_property(val) - - def turn_off(self, **kwargs): - val = False - if self._miot_property.value_range: - val = self._miot_property.range_min() - if self._miot_property.value_list: - if not (des := self.custom_config_list('descriptions_for_off')): - des = ['Off', 'Close', 'Closed', '关', '关闭'] - ret = self._miot_property.list_first(*des) - val = 0 if ret is None else ret - elif self._miot_property.value_range: - val = self._miot_property.range_min() - if self._reverse_state: - val = not val - return self.set_parent_property(val) - - class MiotSwitchActionSubEntity(MiotPropertySubEntity, SwitchSubEntity): def __init__(self, parent, miot_property: MiotProperty, miot_action: MiotAction, option=None): SwitchSubEntity.__init__(self, parent, miot_action.full_name, option) @@ -278,17 +214,7 @@ def icon(self): return 'mdi:play-box' -class MiotCookerSwitchSubEntity(SwitchSubEntity): - def __init__(self, parent, miot_property: MiotProperty, option=None): - super().__init__(parent, miot_property.full_name, option) - self._name = self.format_name_by_property(miot_property) - - @property - def is_on(self): - return self._parent.is_on - - -class MiotPwznRelaySwitchEntity(MiotToggleEntity, SwitchEntity): +class MiotPwznRelaySwitchEntity(MiotToggleEntity, BaseEntity): def __init__(self, config: dict, miot_service: MiotService): super().__init__(miot_service, config=config, logger=_LOGGER) self._prop_status = miot_service.get_property('all_status') @@ -368,7 +294,7 @@ def turn_off(self, **kwargs): return ret -class PwznRelaySwitchEntity(MiioEntity, SwitchEntity): +class PwznRelaySwitchEntity(MiioEntity, BaseEntity): def __init__(self, config: dict): name = config[CONF_NAME] host = config[CONF_HOST] diff --git a/custom_components/xiaomi_miot/text.py b/custom_components/xiaomi_miot/text.py index 4890b70f4..f6ba5817e 100644 --- a/custom_components/xiaomi_miot/text.py +++ b/custom_components/xiaomi_miot/text.py @@ -4,25 +4,19 @@ from homeassistant.components.text import ( DOMAIN as ENTITY_DOMAIN, - TextEntity, + TextEntity as BaseEntity, ) from . import ( DOMAIN, - CONF_MODEL, XIAOMI_CONFIG_SCHEMA as PLATFORM_SCHEMA, # noqa: F401 - MiotEntity, + HassEntry, + XEntity, MiotPropertySubEntity, BaseSubEntity, async_setup_config_entry, - bind_services_to_entries, -) -from .core.miot_spec import ( - MiotSpec, - MiotService, - MiotProperty, - MiotAction, ) +from .core.miot_spec import MiotAction _LOGGER = logging.getLogger(__name__) DATA_KEY = f'{ENTITY_DOMAIN}.{DOMAIN}' @@ -31,40 +25,43 @@ async def async_setup_entry(hass, config_entry, async_add_entities): + HassEntry.init(hass, config_entry).new_adder(ENTITY_DOMAIN, async_add_entities) await async_setup_config_entry(hass, config_entry, async_setup_platform, async_add_entities, ENTITY_DOMAIN) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): hass.data.setdefault(DATA_KEY, {}) - hass.data[DOMAIN]['add_entities'][ENTITY_DOMAIN] = async_add_entities - config['hass'] = hass - model = str(config.get(CONF_MODEL) or '') - spec = hass.data[DOMAIN]['miot_specs'].get(model) - entities = [] - if isinstance(spec, MiotSpec): - for srv in spec.get_services('none_service'): - if not srv.get_property('none_property'): - continue - entities.append(MiotTextEntity(config, srv)) - for entity in entities: - hass.data[DOMAIN]['entities'][entity.unique_id] = entity - async_add_entities(entities, update_before_add=True) - bind_services_to_entries(hass, SERVICE_TO_METHOD) - - -class MiotTextEntity(MiotEntity, TextEntity): + + +class TextEntity(XEntity, BaseEntity): _attr_native_value = '' - def __init__(self, config, miot_service: MiotService): - super().__init__(miot_service, config=config, logger=_LOGGER) + def get_state(self) -> dict: + return {self.attr: self._attr_native_value} - def set_value(self, value): - """Change the value.""" - self._attr_native_value = value - raise NotImplementedError() + def set_state(self, data: dict): + val = data.get(self.attr) + if isinstance(val, list): + val = val[0] if val else None + if val is None: + val = '' + self._attr_native_value = val + + async def async_set_value(self, value: str): + if self._miot_action and self._miot_action.name == 'execute_text_directive': + silent = self.custom_config_integer('silent_execution', 0) + value = [value, silent] + + await self.device.async_write({self.attr: value}) + + if self._miot_action: + self._attr_native_value = '' + self.schedule_update_ha_state() + +XEntity.CLS[ENTITY_DOMAIN] = TextEntity -class MiotTextSubEntity(MiotPropertySubEntity, TextEntity): +class MiotTextSubEntity(MiotPropertySubEntity, BaseEntity): _attr_native_value = '' def update(self, data=None): @@ -79,7 +76,7 @@ def set_value(self, value): return self.set_parent_property(value) -class MiotTextActionSubEntity(BaseSubEntity, TextEntity): +class MiotTextActionSubEntity(BaseSubEntity, BaseEntity): _attr_native_value = '' def __init__(self, parent, miot_action: MiotAction, option=None): diff --git a/custom_components/xiaomi_miot/translations/en.json b/custom_components/xiaomi_miot/translations/en.json index b7c88f681..dcc09f41c 100644 --- a/custom_components/xiaomi_miot/translations/en.json +++ b/custom_components/xiaomi_miot/translations/en.json @@ -25,6 +25,7 @@ "captcha": "Captcha", "server_country": "Server location of MiCloud", "conn_mode": "Connection mode for device", + "trans_options": "Translation property value description", "filter_models": "Filter devices via model/home/WiFi (Advanced)" } }, @@ -68,13 +69,14 @@ "close_texts": "close_texts", "closed_position": "closed_position", "cloud_delay_update": "cloud_delay_update", + "configuration_entities": "configuration_entities", "coord_type": "coord_type", - "cover_properties": "cover_properties", "current_temp_property": "current_temp_property", "descriptions_for_on": "descriptions_for_on", "descriptions_for_off": "descriptions_for_off", "deviated_position": "deviated_position", "device_class": "device_class", + "diagnostic_entities": "diagnostic_entities", "disable_location_name": "disable_location_name", "disable_preset_modes": "disable_preset_modes", "entity_category": "entity_category", @@ -155,6 +157,7 @@ "server_country": "Server location of MiCloud", "conn_mode": "Connection mode for device", "renew_devices": "Force renew devices", + "trans_options": "Translation property value description", "disable_message": "Disable Mihome notification sensor", "disable_scene_history": "Disable Mihome scene history sensor" } @@ -198,7 +201,14 @@ } }, "entity": { + "button": { + "info": {"name": "Info"} + }, "sensor": { + "power_cost_today": {"name": "Energy Today"}, + "power_cost_month": {"name": "Energy Month"}, + "power_cost_today_2": {"name": "Energy Today"}, + "power_cost_month_2": {"name": "Energy Month"}, "lock": { "state": { "bluetooth": "Bluetooth", diff --git a/custom_components/xiaomi_miot/translations/zh-Hans.json b/custom_components/xiaomi_miot/translations/zh-Hans.json index f1ff07e01..44fdab283 100644 --- a/custom_components/xiaomi_miot/translations/zh-Hans.json +++ b/custom_components/xiaomi_miot/translations/zh-Hans.json @@ -25,6 +25,7 @@ "captcha": "验证码", "server_country": "小米服务器", "conn_mode": "设备连接模式", + "trans_options": "翻译属性值描述", "filter_models": "通过型号/家庭/WiFi筛选设备 (高级模式,新手勿选)" } }, @@ -93,6 +94,7 @@ "server_country": "小米服务器", "conn_mode": "设备连接模式", "renew_devices": "更新设备列表", + "trans_options": "翻译属性值描述", "disable_message": "禁用米家APP通知消息实体", "disable_scene_history": "禁用米家场景历史实体" } @@ -136,7 +138,16 @@ } }, "entity": { + "button": { + "info": {"name": "信息"} + }, "sensor": { + "power_cost_today": {"name": "日电量"}, + "power_cost_month": {"name": "月电量"}, + "power_cost_today_2": {"name": "日电量"}, + "power_cost_month_2": {"name": "月电量"}, + "prop_cal_day.power_cost:today": {"name": "日电量"}, + "prop_cal_day.power_cost:month": {"name": "月电量"}, "lock": { "state": { "bluetooth": "蓝牙", diff --git a/custom_components/xiaomi_miot/vacuum.py b/custom_components/xiaomi_miot/vacuum.py index 29d24ef53..d1c8ac646 100644 --- a/custom_components/xiaomi_miot/vacuum.py +++ b/custom_components/xiaomi_miot/vacuum.py @@ -22,6 +22,7 @@ DOMAIN, CONF_MODEL, XIAOMI_CONFIG_SCHEMA as PLATFORM_SCHEMA, # noqa: F401 + HassEntry, MiotEntity, DeviceException, MIOT_LOCAL_MODELS, @@ -41,6 +42,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): + HassEntry.init(hass, config_entry).new_adder(ENTITY_DOMAIN, async_add_entities) await async_setup_config_entry(hass, config_entry, async_setup_platform, async_add_entities, ENTITY_DOMAIN) @@ -286,12 +288,13 @@ async def async_update(self): adt['clean_time'] = round(props['clean_time'] / 60, 1) if adt: await self.async_update_attrs(adt) + await self.device.dispatch(self.device.encode({'props': props})) async def get_room_mapping(self): if not self.miot_device: return None try: - rooms = self.miot_device.send('get_room_mapping') + rooms = await self.miot_device.async_send('get_room_mapping') if rooms and rooms != 'unknown_method': homes = await self.xiaomi_cloud.async_get_homerooms() if self.xiaomi_cloud else [] cloud_rooms = {} @@ -329,7 +332,7 @@ def pause(self): return super().pause() def return_to_base(self, **kwargs): - if self._model in ['rockrobo.vacuum.v1']: + if self.model in ['rockrobo.vacuum.v1']: self.stop() return super().return_to_base() diff --git a/custom_components/xiaomi_miot/water_heater.py b/custom_components/xiaomi_miot/water_heater.py index e01be2f41..38bd76105 100644 --- a/custom_components/xiaomi_miot/water_heater.py +++ b/custom_components/xiaomi_miot/water_heater.py @@ -18,6 +18,7 @@ DOMAIN, CONF_MODEL, XIAOMI_CONFIG_SCHEMA as PLATFORM_SCHEMA, # noqa: F401 + HassEntry, MiotToggleEntity, async_setup_config_entry, bind_services_to_entries, @@ -37,6 +38,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): + HassEntry.init(hass, config_entry).new_adder(ENTITY_DOMAIN, async_add_entities) await async_setup_config_entry(hass, config_entry, async_setup_platform, async_add_entities, ENTITY_DOMAIN) diff --git a/hacs.json b/hacs.json index b7aeeddf7..adc2be7e7 100644 --- a/hacs.json +++ b/hacs.json @@ -5,5 +5,5 @@ "filename": "xiaomi_miot.zip", "render_readme": true, "hide_default_branch": true, - "homeassistant": "2023.1.0" + "homeassistant": "2023.7.0" }