From 8778b00c3ac74ed704315dca56b32db5558addab Mon Sep 17 00:00:00 2001 From: Paul Shawn <32349595+topsworld@users.noreply.github.com> Date: Wed, 22 Jan 2025 19:21:02 +0800 Subject: [PATCH] feat: support modify spec and value conversion (#663) * fix: fix miot_device type error * fix: fix type error * feat: remove spec cache storage * feat: update std_lib and multi_lang logic * feat: update entity value-range * feat: update value-list logic * feat: update prop.format_ logic * fix: fix miot cloud log error * fix: fix fan entity * style: ignore type error * style: rename spec_filter func name * feat: move bool_trans from storage to spec * feat: move sepc_filter from storage to spec, use the YAML format file * feat: same prop supports multiple sub * feat: same event supports multiple sub * fix: fix device remove error * feat: add func slugify_did * fix: fix multi lang error * feat: update action debug logic * feat: ignore normal disconnect log * feat: support binary mode * feat: change miot spec name type define * style: ignore i18n tranlate type error * fix: fix pylint warning * fix: miot storage type error * feat: support binary display mode configure * feat: set default sensor state_class * fix: fix sensor entity type error * fix: fix __init__ type error * feat: update test case logic * fix: github actions add dependencies lib * fix: fix some type error * feat: update device list changed notify logic * feat: update prop expr logic * feat: add spec modify * feat: update device sub id logic * feat: update get miot client instance logic * fix: fix some type error * feat: update miot device unit and icon trans * perf: update spec trans entity logic * feat: update spec trans entity rule * feat: update spec_modify * feat: update sensor ENUM icon * fix: fix miot device error * fix: fix miot spec error * featL update format check and spec modify file * feat: update checkout rule format * feat: handle special property.unit * feat: add expr for cuco-cp1md * feat: fix climate hvac error * feat: set sensor suggested display precision * feat: update climate set hvac logic * feat: add expr for cuco-v3 * feat: update spec expr for chuangmi-212a01 --- custom_components/xiaomi_home/climate.py | 11 +- .../xiaomi_home/miot/miot_client.py | 202 ++++++------ .../xiaomi_home/miot/miot_cloud.py | 2 +- .../xiaomi_home/miot/miot_device.py | 300 ++++++++++++------ .../xiaomi_home/miot/miot_mips.py | 2 +- .../xiaomi_home/miot/miot_spec.py | 126 +++++++- .../xiaomi_home/miot/specs/bool_trans.yaml | 74 ++--- .../xiaomi_home/miot/specs/spec_modify.yaml | 44 +++ .../xiaomi_home/miot/specs/specv2entity.py | 103 +++--- custom_components/xiaomi_home/sensor.py | 5 +- test/check_rule_format.py | 38 ++- 11 files changed, 598 insertions(+), 309 deletions(-) create mode 100644 custom_components/xiaomi_home/miot/specs/spec_modify.yaml diff --git a/custom_components/xiaomi_home/climate.py b/custom_components/xiaomi_home/climate.py index fb3dc45..5a6b7b9 100644 --- a/custom_components/xiaomi_home/climate.py +++ b/custom_components/xiaomi_home/climate.py @@ -258,13 +258,14 @@ class AirConditioner(MIoTServiceEntity, ClimateEntity): f'{self.entity_id}') return # set air-conditioner on - elif self.get_prop_value(prop=self._prop_on) is False: - await self.set_property_async(prop=self._prop_on, value=True) + if self.get_prop_value(prop=self._prop_on) is False: + await self.set_property_async( + prop=self._prop_on, value=True, update=False) # set mode mode_value = self.get_map_key( map_=self._hvac_mode_map, value=hvac_mode) if ( - mode_value is None or + not mode_value or not await self.set_property_async( prop=self._prop_mode, value=mode_value) ): @@ -368,9 +369,9 @@ class AirConditioner(MIoTServiceEntity, ClimateEntity): """Return the hvac mode. e.g., heat, cool mode.""" if self.get_prop_value(prop=self._prop_on) is False: return HVACMode.OFF - return self.get_map_key( + return self.get_map_value( map_=self._hvac_mode_map, - value=self.get_prop_value(prop=self._prop_mode)) + key=self.get_prop_value(prop=self._prop_mode)) @property def fan_mode(self) -> Optional[str]: diff --git a/custom_components/xiaomi_home/miot/miot_client.py b/custom_components/xiaomi_home/miot/miot_client.py index 5f69062..8f89f8d 100644 --- a/custom_components/xiaomi_home/miot/miot_client.py +++ b/custom_components/xiaomi_home/miot/miot_client.py @@ -150,7 +150,7 @@ class MIoTClient: # Device list update timestamp _device_list_update_ts: int - _sub_source_list: dict[str, str] + _sub_source_list: dict[str, Optional[str]] _sub_tree: MIoTMatcher _sub_device_state: dict[str, MipsDeviceState] @@ -620,7 +620,7 @@ class MIoTClient: # Priority local control if self._ctrl_mode == CtrlMode.AUTO: # Gateway control - device_gw: dict = self._device_list_gateway.get(did, None) + device_gw = self._device_list_gateway.get(did, None) if ( device_gw and device_gw.get('online', False) and device_gw.get('specv2_access', False) @@ -641,7 +641,7 @@ class MIoTClient: raise MIoTClientError( self.__get_exec_error_with_rc(rc=rc)) # Lan control - device_lan: dict = self._device_list_lan.get(did, None) + device_lan = self._device_list_lan.get(did, None) if device_lan and device_lan.get('online', False): result = await self._miot_lan.set_prop_async( did=did, siid=siid, piid=piid, value=value) @@ -657,7 +657,7 @@ class MIoTClient: # Cloud control device_cloud = self._device_list_cloud.get(did, None) if device_cloud and device_cloud.get('online', False): - result: list = await self._http.set_prop_async( + result = await self._http.set_prop_async( params=[ {'did': did, 'siid': siid, 'piid': piid, 'value': value} ]) @@ -746,7 +746,7 @@ class MIoTClient: if did not in self._device_list_cache: raise MIoTClientError(f'did not exist, {did}') - device_gw: dict = self._device_list_gateway.get(did, None) + device_gw = self._device_list_gateway.get(did, None) # Priority local control if self._ctrl_mode == CtrlMode.AUTO: if ( @@ -782,7 +782,7 @@ class MIoTClient: self.__get_exec_error_with_rc(rc=rc)) # Cloud control device_cloud = self._device_list_cloud.get(did, None) - if device_cloud.get('online', False): + if device_cloud and device_cloud.get('online', False): result: dict = await self._http.action_async( did=did, siid=siid, aiid=aiid, in_list=in_list) if result: @@ -798,14 +798,15 @@ class MIoTClient: dids=[did])) raise MIoTClientError( self.__get_exec_error_with_rc(rc=rc)) - # Show error message + # TODO: Show error message _LOGGER.error( 'client action failed, %s.%d.%d', did, siid, aiid) - return None + return [] def sub_prop( self, did: str, handler: Callable[[dict, Any], None], - siid: int = None, piid: int = None, handler_ctx: Any = None + siid: Optional[int] = None, piid: Optional[int] = None, + handler_ctx: Any = None ) -> bool: if did not in self._device_list_cache: raise MIoTClientError(f'did not exist, {did}') @@ -818,7 +819,9 @@ class MIoTClient: _LOGGER.debug('client sub prop, %s', topic) return True - def unsub_prop(self, did: str, siid: int = None, piid: int = None) -> bool: + def unsub_prop( + self, did: str, siid: Optional[int] = None, piid: Optional[int] = None + ) -> bool: topic = ( f'{did}/p/' f'{"#" if siid is None or piid is None else f"{siid}/{piid}"}') @@ -829,7 +832,8 @@ class MIoTClient: def sub_event( self, did: str, handler: Callable[[dict, Any], None], - siid: int = None, eiid: int = None, handler_ctx: Any = None + siid: Optional[int] = None, eiid: Optional[int] = None, + handler_ctx: Any = None ) -> bool: if did not in self._device_list_cache: raise MIoTClientError(f'did not exist, {did}') @@ -841,7 +845,9 @@ class MIoTClient: _LOGGER.debug('client sub event, %s', topic) return True - def unsub_event(self, did: str, siid: int = None, eiid: int = None) -> bool: + def unsub_event( + self, did: str, siid: Optional[int] = None, eiid: Optional[int] = None + ) -> bool: topic = ( f'{did}/e/' f'{"#" if siid is None or eiid is None else f"{siid}/{eiid}"}') @@ -1081,7 +1087,7 @@ class MIoTClient: if state_old == state_new: continue self._device_list_cache[did]['online'] = state_new - sub: MipsDeviceState = self._sub_device_state.get(did, None) + sub = self._sub_device_state.get(did, None) if sub and sub.handler: sub.handler(did, MIoTDeviceState.OFFLINE, sub.handler_ctx) self.__request_show_devices_changed_notify() @@ -1091,8 +1097,8 @@ class MIoTClient: self, group_id: str, state: bool ) -> None: _LOGGER.info('local mips state changed, %s, %s', group_id, state) - mips: MipsLocalClient = self._mips_local.get(group_id, None) - if mips is None: + mips = self._mips_local.get(group_id, None) + if not mips: _LOGGER.error( 'local mips state changed, mips not exist, %s', group_id) return @@ -1124,7 +1130,7 @@ class MIoTClient: if state_old == state_new: continue self._device_list_cache[did]['online'] = state_new - sub: MipsDeviceState = self._sub_device_state.get(did, None) + sub = self._sub_device_state.get(did, None) if sub and sub.handler: sub.handler(did, MIoTDeviceState.OFFLINE, sub.handler_ctx) self.__request_show_devices_changed_notify() @@ -1171,7 +1177,7 @@ class MIoTClient: if state_old == state_new: continue self._device_list_cache[did]['online'] = state_new - sub: MipsDeviceState = self._sub_device_state.get(did, None) + sub = self._sub_device_state.get(did, None) if sub and sub.handler: sub.handler(did, MIoTDeviceState.OFFLINE, sub.handler_ctx) self._device_list_lan = {} @@ -1201,7 +1207,7 @@ class MIoTClient: if state_old == state_new: return self._device_list_cache[did]['online'] = state_new - sub: MipsDeviceState = self._sub_device_state.get(did, None) + sub = self._sub_device_state.get(did, None) if sub and sub.handler: sub.handler( did, MIoTDeviceState.ONLINE if state_new @@ -1257,7 +1263,7 @@ class MIoTClient: if state_old == state_new: return self._device_list_cache[did]['online'] = state_new - sub: MipsDeviceState = self._sub_device_state.get(did, None) + sub = self._sub_device_state.get(did, None) if sub and sub.handler: sub.handler( did, MIoTDeviceState.ONLINE if state_new @@ -1301,9 +1307,8 @@ class MIoTClient: async def __load_cache_device_async(self) -> None: """Load device list from cache.""" cache_list: Optional[dict[str, dict]] = await self._storage.load_async( - domain='miot_devices', - name=f'{self._uid}_{self._cloud_server}', - type_=dict) + domain='miot_devices', name=f'{self._uid}_{self._cloud_server}', + type_=dict) # type: ignore if not cache_list: self.__show_client_error_notify( message=self._i18n.translate( @@ -1346,7 +1351,7 @@ class MIoTClient: cloud_state_old: Optional[bool] = self._device_list_cloud.get( did, {}).get('online', None) cloud_state_new: Optional[bool] = None - device_new: dict = cloud_list.pop(did, None) + device_new = cloud_list.pop(did, None) if device_new: cloud_state_new = device_new.get('online', None) # Update cache device info @@ -1371,7 +1376,7 @@ class MIoTClient: continue info['online'] = state_new # Call device state changed callback - sub: MipsDeviceState = self._sub_device_state.get(did, None) + sub = self._sub_device_state.get(did, None) if sub and sub.handler: sub.handler( did, MIoTDeviceState.ONLINE if state_new @@ -1426,8 +1431,7 @@ class MIoTClient: self, dids: list[str] ) -> None: _LOGGER.debug('refresh cloud device with dids, %s', dids) - cloud_list: dict[str, dict] = ( - await self._http.get_devices_with_dids_async(dids=dids)) + cloud_list = await self._http.get_devices_with_dids_async(dids=dids) if cloud_list is None: _LOGGER.error('cloud http get_dev_list_async failed, %s', dids) return @@ -1466,11 +1470,11 @@ class MIoTClient: for did, info in self._device_list_cache.items(): if did not in filter_dids: continue - device_old: dict = self._device_list_gateway.get(did, None) + device_old = self._device_list_gateway.get(did, None) gw_state_old = device_old.get( 'online', False) if device_old else False gw_state_new: bool = False - device_new: dict = gw_list.pop(did, None) + device_new = gw_list.pop(did, None) if device_new: # Update gateway device info self._device_list_gateway[did] = { @@ -1493,7 +1497,7 @@ class MIoTClient: if state_old == state_new: continue info['online'] = state_new - sub: MipsDeviceState = self._sub_device_state.get(did, None) + sub = self._sub_device_state.get(did, None) if sub and sub.handler: sub.handler( did, MIoTDeviceState.ONLINE if state_new @@ -1518,7 +1522,7 @@ class MIoTClient: if state_old == state_new: continue self._device_list_cache[did]['online'] = state_new - sub: MipsDeviceState = self._sub_device_state.get(did, None) + sub = self._sub_device_state.get(did, None) if sub and sub.handler: sub.handler( did, MIoTDeviceState.ONLINE if state_new @@ -1533,7 +1537,7 @@ class MIoTClient: 'refresh gw devices with group_id, %s', group_id) # Remove timer self._mips_local_state_changed_timers.pop(group_id, None) - mips: MipsLocalClient = self._mips_local.get(group_id, None) + mips = self._mips_local.get(group_id, None) if not mips: _LOGGER.error('mips not exist, %s', group_id) return @@ -1900,77 +1904,73 @@ async def get_miot_instance_async( ) -> MIoTClient: if entry_id is None: raise MIoTClientError('invalid entry_id') - miot_client: MIoTClient = None - if a := hass.data[DOMAIN].get('miot_clients', {}).get(entry_id, None): + miot_client = hass.data[DOMAIN].get('miot_clients', {}).get(entry_id, None) + if miot_client: _LOGGER.info('instance exist, %s', entry_id) - miot_client = a - else: - if entry_data is None: - raise MIoTClientError('entry data is None') - # Get running loop - loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() - if loop is None: - raise MIoTClientError('loop is None') - # MIoT storage - storage: Optional[MIoTStorage] = hass.data[DOMAIN].get( - 'miot_storage', None) - if not storage: - storage = MIoTStorage( - root_path=entry_data['storage_path'], loop=loop) - hass.data[DOMAIN]['miot_storage'] = storage - _LOGGER.info('create miot_storage instance') - global_config: dict = await storage.load_user_config_async( - uid='global_config', cloud_server='all', - keys=['network_detect_addr', 'net_interfaces', 'enable_subscribe']) - # MIoT network - network_detect_addr: dict = global_config.get( - 'network_detect_addr', {}) - network: Optional[MIoTNetwork] = hass.data[DOMAIN].get( - 'miot_network', None) - if not network: - network = MIoTNetwork( - ip_addr_list=network_detect_addr.get('ip', []), - url_addr_list=network_detect_addr.get('url', []), - refresh_interval=NETWORK_REFRESH_INTERVAL, - loop=loop) - hass.data[DOMAIN]['miot_network'] = network - await network.init_async() - _LOGGER.info('create miot_network instance') - # MIoT service - mips_service: Optional[MipsService] = hass.data[DOMAIN].get( - 'mips_service', None) - if not mips_service: - aiozc = await zeroconf.async_get_async_instance(hass) - mips_service = MipsService(aiozc=aiozc, loop=loop) - hass.data[DOMAIN]['mips_service'] = mips_service - await mips_service.init_async() - _LOGGER.info('create mips_service instance') - # MIoT lan - miot_lan: Optional[MIoTLan] = hass.data[DOMAIN].get( - 'miot_lan', None) - if not miot_lan: - miot_lan = MIoTLan( - net_ifs=global_config.get('net_interfaces', []), - network=network, - mips_service=mips_service, - enable_subscribe=global_config.get('enable_subscribe', False), - loop=loop) - hass.data[DOMAIN]['miot_lan'] = miot_lan - _LOGGER.info('create miot_lan instance') - # MIoT client - miot_client = MIoTClient( - entry_id=entry_id, - entry_data=entry_data, + return miot_client + # Create new instance + if not entry_data: + raise MIoTClientError('entry data is None') + # Get running loop + loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + if not loop: + raise MIoTClientError('loop is None') + # MIoT storage + storage: Optional[MIoTStorage] = hass.data[DOMAIN].get( + 'miot_storage', None) + if not storage: + storage = MIoTStorage( + root_path=entry_data['storage_path'], loop=loop) + hass.data[DOMAIN]['miot_storage'] = storage + _LOGGER.info('create miot_storage instance') + global_config: dict = await storage.load_user_config_async( + uid='global_config', cloud_server='all', + keys=['network_detect_addr', 'net_interfaces', 'enable_subscribe']) + # MIoT network + network_detect_addr: dict = global_config.get('network_detect_addr', {}) + network: Optional[MIoTNetwork] = hass.data[DOMAIN].get( + 'miot_network', None) + if not network: + network = MIoTNetwork( + ip_addr_list=network_detect_addr.get('ip', []), + url_addr_list=network_detect_addr.get('url', []), + refresh_interval=NETWORK_REFRESH_INTERVAL, + loop=loop) + hass.data[DOMAIN]['miot_network'] = network + await network.init_async() + _LOGGER.info('create miot_network instance') + # MIoT service + mips_service: Optional[MipsService] = hass.data[DOMAIN].get( + 'mips_service', None) + if not mips_service: + aiozc = await zeroconf.async_get_async_instance(hass) + mips_service = MipsService(aiozc=aiozc, loop=loop) + hass.data[DOMAIN]['mips_service'] = mips_service + await mips_service.init_async() + _LOGGER.info('create mips_service instance') + # MIoT lan + miot_lan: Optional[MIoTLan] = hass.data[DOMAIN].get('miot_lan', None) + if not miot_lan: + miot_lan = MIoTLan( + net_ifs=global_config.get('net_interfaces', []), network=network, - storage=storage, mips_service=mips_service, - miot_lan=miot_lan, - loop=loop - ) - miot_client.persistent_notify = persistent_notify - hass.data[DOMAIN]['miot_clients'].setdefault(entry_id, miot_client) - _LOGGER.info( - 'new miot_client instance, %s, %s', entry_id, entry_data) - await miot_client.init_async() - + enable_subscribe=global_config.get('enable_subscribe', False), + loop=loop) + hass.data[DOMAIN]['miot_lan'] = miot_lan + _LOGGER.info('create miot_lan instance') + # MIoT client + miot_client = MIoTClient( + entry_id=entry_id, + entry_data=entry_data, + network=network, + storage=storage, + mips_service=mips_service, + miot_lan=miot_lan, + loop=loop + ) + miot_client.persistent_notify = persistent_notify + hass.data[DOMAIN]['miot_clients'].setdefault(entry_id, miot_client) + _LOGGER.info('new miot_client instance, %s, %s', entry_id, entry_data) + await miot_client.init_async() return miot_client diff --git a/custom_components/xiaomi_home/miot/miot_cloud.py b/custom_components/xiaomi_home/miot/miot_cloud.py index 59d0b50..1911d82 100644 --- a/custom_components/xiaomi_home/miot/miot_cloud.py +++ b/custom_components/xiaomi_home/miot/miot_cloud.py @@ -382,7 +382,7 @@ class MIoTHttpClient: return res_obj['data'] - async def get_central_cert_async(self, csr: str) -> Optional[str]: + async def get_central_cert_async(self, csr: str) -> str: if not isinstance(csr, str): raise MIoTHttpError('invalid params') diff --git a/custom_components/xiaomi_home/miot/miot_device.py b/custom_components/xiaomi_home/miot/miot_device.py index 991e2b1..dbc59e5 100644 --- a/custom_components/xiaomi_home/miot/miot_device.py +++ b/custom_components/xiaomi_home/miot/miot_device.py @@ -56,6 +56,7 @@ from homeassistant.const import ( CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, + DEGREE, LIGHT_LUX, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS, @@ -72,6 +73,7 @@ from homeassistant.const import ( UnitOfPower, UnitOfVolume, UnitOfVolumeFlowRate, + UnitOfDataRate ) from homeassistant.helpers.entity import DeviceInfo from homeassistant.components.switch import SwitchDeviceClass @@ -243,12 +245,12 @@ class MIoTDevice: def sub_device_state( self, key: str, handler: Callable[[str, MIoTDeviceState], None] ) -> int: - self._sub_id += 1 + sub_id = self.__gen_sub_id() if key in self._device_state_sub_list: - self._device_state_sub_list[key][str(self._sub_id)] = handler + self._device_state_sub_list[key][str(sub_id)] = handler else: - self._device_state_sub_list[key] = {str(self._sub_id): handler} - return self._sub_id + self._device_state_sub_list[key] = {str(sub_id): handler} + return sub_id def unsub_device_state(self, key: str, sub_id: int) -> None: sub_list = self._device_state_sub_list.get(key, None) @@ -266,14 +268,14 @@ class MIoTDevice: for handler in self._value_sub_list[key].values(): handler(params, ctx) - self._sub_id += 1 + sub_id = self.__gen_sub_id() if key in self._value_sub_list: - self._value_sub_list[key][str(self._sub_id)] = handler + self._value_sub_list[key][str(sub_id)] = handler else: - self._value_sub_list[key] = {str(self._sub_id): handler} + self._value_sub_list[key] = {str(sub_id): handler} self.miot_client.sub_prop( did=self._did, handler=_on_prop_changed, siid=siid, piid=piid) - return self._sub_id + return sub_id def unsub_property(self, siid: int, piid: int, sub_id: int) -> None: key: str = f'p.{siid}.{piid}' @@ -294,14 +296,14 @@ class MIoTDevice: for handler in self._value_sub_list[key].values(): handler(params, ctx) - self._sub_id += 1 + sub_id = self.__gen_sub_id() if key in self._value_sub_list: - self._value_sub_list[key][str(self._sub_id)] = handler + self._value_sub_list[key][str(sub_id)] = handler else: - self._value_sub_list[key] = {str(self._sub_id): handler} + self._value_sub_list[key] = {str(sub_id): handler} self.miot_client.sub_event( did=self._did, handler=_on_event_occurred, siid=siid, eiid=eiid) - return self._sub_id + return sub_id def unsub_event(self, siid: int, eiid: int, sub_id: int) -> None: key: str = f'e.{siid}.{eiid}' @@ -414,10 +416,12 @@ class MIoTDevice: spec_name: str = spec_instance.name if isinstance(SPEC_DEVICE_TRANS_MAP[spec_name], str): spec_name = SPEC_DEVICE_TRANS_MAP[spec_name] + if 'required' not in SPEC_DEVICE_TRANS_MAP[spec_name]: + return None # 1. The device shall have all required services. required_services = SPEC_DEVICE_TRANS_MAP[spec_name]['required'].keys() if not { - service.name for service in spec_instance.services + service.name for service in spec_instance.services }.issuperset(required_services): return None optional_services = SPEC_DEVICE_TRANS_MAP[spec_name]['optional'].keys() @@ -427,9 +431,13 @@ class MIoTDevice: for service in spec_instance.services: if service.platform: continue + required_properties: dict + optional_properties: dict + required_actions: set + optional_actions: set # 2. The service shall have all required properties, actions. if service.name in required_services: - required_properties: dict = SPEC_DEVICE_TRANS_MAP[spec_name][ + required_properties = SPEC_DEVICE_TRANS_MAP[spec_name][ 'required'].get( service.name, {} ).get('required', {}).get('properties', {}) @@ -446,7 +454,7 @@ class MIoTDevice: service.name, {} ).get('optional', {}).get('actions', set({})) elif service.name in optional_services: - required_properties: dict = SPEC_DEVICE_TRANS_MAP[spec_name][ + required_properties = SPEC_DEVICE_TRANS_MAP[spec_name][ 'optional'].get( service.name, {} ).get('required', {}).get('properties', {}) @@ -484,7 +492,7 @@ class MIoTDevice: set(required_properties.keys()), optional_properties): if prop.unit: prop.external_unit = self.unit_convert(prop.unit) - prop.icon = self.icon_convert(prop.unit) + # prop.icon = self.icon_convert(prop.unit) prop.platform = platform entity_data.props.add(prop) # action @@ -499,85 +507,95 @@ class MIoTDevice: return entity_data def parse_miot_service_entity( - self, service_instance: MIoTSpecService + self, miot_service: MIoTSpecService ) -> Optional[MIoTEntityData]: - service = service_instance - if service.platform or (service.name not in SPEC_SERVICE_TRANS_MAP): + if ( + miot_service.platform + or miot_service.name not in SPEC_SERVICE_TRANS_MAP + ): return None - - service_name = service.name + service_name = miot_service.name if isinstance(SPEC_SERVICE_TRANS_MAP[service_name], str): service_name = SPEC_SERVICE_TRANS_MAP[service_name] - # 1. The service shall have all required properties. + if 'required' not in SPEC_SERVICE_TRANS_MAP[service_name]: + return None + # Required properties, required access mode required_properties: dict = SPEC_SERVICE_TRANS_MAP[service_name][ 'required'].get('properties', {}) if not { - prop.name for prop in service.properties if prop.access + prop.name for prop in miot_service.properties if prop.access }.issuperset(set(required_properties.keys())): return None - # 2. The required property shall have all required access mode. - for prop in service.properties: + for prop in miot_service.properties: if prop.name in required_properties: if not set(prop.access).issuperset( required_properties[prop.name]): return None + # Required actions + # Required events platform = SPEC_SERVICE_TRANS_MAP[service_name]['entity'] - entity_data = MIoTEntityData(platform=platform, spec=service_instance) + entity_data = MIoTEntityData(platform=platform, spec=miot_service) + # Optional properties optional_properties = SPEC_SERVICE_TRANS_MAP[service_name][ 'optional'].get('properties', set({})) - for prop in service.properties: + for prop in miot_service.properties: if prop.name in set.union( set(required_properties.keys()), optional_properties): if prop.unit: prop.external_unit = self.unit_convert(prop.unit) - prop.icon = self.icon_convert(prop.unit) + # prop.icon = self.icon_convert(prop.unit) prop.platform = platform entity_data.props.add(prop) - # action - # event - # No actions or events is in SPEC_SERVICE_TRANS_MAP now. - service.platform = platform + # Optional actions + # Optional events + miot_service.platform = platform return entity_data - def parse_miot_property_entity( - self, property_instance: MIoTSpecProperty - ) -> Optional[dict[str, str]]: - prop = property_instance + def parse_miot_property_entity(self, miot_prop: MIoTSpecProperty) -> bool: if ( - prop.platform - or (prop.name not in SPEC_PROP_TRANS_MAP['properties']) + miot_prop.platform + or miot_prop.name not in SPEC_PROP_TRANS_MAP['properties'] ): - return None - - prop_name = prop.name + return False + prop_name = miot_prop.name if isinstance(SPEC_PROP_TRANS_MAP['properties'][prop_name], str): prop_name = SPEC_PROP_TRANS_MAP['properties'][prop_name] platform = SPEC_PROP_TRANS_MAP['properties'][prop_name]['entity'] + # Check prop_access: set = set({}) - if prop.readable: + if miot_prop.readable: prop_access.add('read') - if prop.writable: + if miot_prop.writable: prop_access.add('write') if prop_access != (SPEC_PROP_TRANS_MAP[ 'entities'][platform]['access']): - return None - if prop.format_.__name__ not in SPEC_PROP_TRANS_MAP[ + return False + if miot_prop.format_.__name__ not in SPEC_PROP_TRANS_MAP[ 'entities'][platform]['format']: - return None - if prop.unit: - prop.external_unit = self.unit_convert(prop.unit) - prop.icon = self.icon_convert(prop.unit) - device_class = SPEC_PROP_TRANS_MAP['properties'][prop_name][ + return False + miot_prop.device_class = SPEC_PROP_TRANS_MAP['properties'][prop_name][ 'device_class'] - result = {'platform': platform, 'device_class': device_class} - # optional: - if 'optional' in SPEC_PROP_TRANS_MAP['properties'][prop_name]: - optional = SPEC_PROP_TRANS_MAP['properties'][prop_name]['optional'] - if 'state_class' in optional: - result['state_class'] = optional['state_class'] - if not prop.unit and 'unit_of_measurement' in optional: - result['unit_of_measurement'] = optional['unit_of_measurement'] - return result + # Optional params + if 'state_class' in SPEC_PROP_TRANS_MAP['properties'][prop_name]: + miot_prop.state_class = SPEC_PROP_TRANS_MAP['properties'][ + prop_name]['state_class'] + if ( + not miot_prop.external_unit + and 'unit_of_measurement' in SPEC_PROP_TRANS_MAP['properties'][ + prop_name] + ): + # Priority: spec_modify.unit > unit_convert > specv2entity.unit + miot_prop.external_unit = SPEC_PROP_TRANS_MAP['properties'][ + prop_name]['unit_of_measurement'] + if ( + not miot_prop.icon + and 'icon' in SPEC_PROP_TRANS_MAP['properties'][prop_name] + ): + # Priority: spec_modify.icon > icon_convert > specv2entity.icon + miot_prop.icon = SPEC_PROP_TRANS_MAP['properties'][prop_name][ + 'icon'] + miot_prop.platform = platform + return True def spec_transform(self) -> None: """Parse service, property, event, action from device spec.""" @@ -589,7 +607,7 @@ class MIoTDevice: # STEP 2: service conversion for service in self.spec_instance.services: service_entity = self.parse_miot_service_entity( - service_instance=service) + miot_service=service) if service_entity: self.append_entity(entity_data=service_entity) # STEP 3.1: property conversion @@ -598,20 +616,11 @@ class MIoTDevice: continue if prop.unit: prop.external_unit = self.unit_convert(prop.unit) - prop.icon = self.icon_convert(prop.unit) - prop_entity = self.parse_miot_property_entity( - property_instance=prop) - if prop_entity: - prop.platform = prop_entity['platform'] - prop.device_class = prop_entity['device_class'] - if 'state_class' in prop_entity: - prop.state_class = prop_entity['state_class'] - if 'unit_of_measurement' in prop_entity: - prop.external_unit = self.unit_convert( - prop_entity['unit_of_measurement']) - prop.icon = self.icon_convert( - prop_entity['unit_of_measurement']) - # general conversion + if not prop.icon: + prop.icon = self.icon_convert(prop.unit) + # Special conversion + self.parse_miot_property_entity(miot_prop=prop) + # General conversion if not prop.platform: if prop.writable: if prop.format_ == str: @@ -625,7 +634,7 @@ class MIoTDevice: prop.platform = 'number' else: # Irregular property will not be transformed. - pass + continue elif prop.readable or prop.notifiable: if prop.format_ == bool: prop.platform = 'binary_sensor' @@ -653,11 +662,66 @@ class MIoTDevice: self.append_action(action=action) def unit_convert(self, spec_unit: str) -> Optional[str]: - """Convert MIoT unit to Home Assistant unit.""" + """Convert MIoT unit to Home Assistant unit. + 25/01/20: All online prop unit statistical tables: unit, quantity. + { + "no_unit": 148499, + "percentage": 10042, + "kelvin": 1895, + "rgb": 772, // color + "celsius": 5762, + "none": 16106, + "hours": 1540, + "minutes": 5061, + "ms": 27, + "watt": 216, + "arcdegrees": 159, + "ppm": 177, + "μg/m3": 106, + "days": 571, + "seconds": 2749, + "B/s": 21, + "pascal": 110, + "mg/m3": 339, + "lux": 125, + "kWh": 124, + "mv": 2, + "V": 38, + "A": 29, + "mV": 4, + "L": 352, + "m": 37, + "毫摩尔每升": 2, // blood-sugar, cholesterol + "mmol/L": 1, // urea + "weeks": 26, + "meter": 3, + "dB": 26, + "hour": 14, + "calorie": 19, // 1 cal = 4.184 J + "ppb": 3, + "arcdegress": 30, + "bpm": 4, // realtime-heartrate + "gram": 7, + "km/h": 9, + "W": 1, + "m3/h": 2, + "kilopascal": 1, + "mL": 4, + "mmHg": 4, + "w": 1, + "liter": 1, + "cm": 3, + "mA": 2, + "kilogram": 2, + "kcal/d": 2, // basal-metabolism + "times": 1 // exercise-count + } + """ unit_map = { 'percentage': PERCENTAGE, 'weeks': UnitOfTime.WEEKS, 'days': UnitOfTime.DAYS, + 'hour': UnitOfTime.HOURS, 'hours': UnitOfTime.HOURS, 'minutes': UnitOfTime.MINUTES, 'seconds': UnitOfTime.SECONDS, @@ -672,30 +736,48 @@ class MIoTDevice: 'ppb': CONCENTRATION_PARTS_PER_BILLION, 'lux': LIGHT_LUX, 'pascal': UnitOfPressure.PA, + 'kilopascal': UnitOfPressure.KPA, + 'mmHg': UnitOfPressure.MMHG, 'bar': UnitOfPressure.BAR, - 'watt': UnitOfPower.WATT, 'L': UnitOfVolume.LITERS, + 'liter': UnitOfVolume.LITERS, 'mL': UnitOfVolume.MILLILITERS, 'km/h': UnitOfSpeed.KILOMETERS_PER_HOUR, 'm/s': UnitOfSpeed.METERS_PER_SECOND, + 'watt': UnitOfPower.WATT, + 'w': UnitOfPower.WATT, + 'W': UnitOfPower.WATT, 'kWh': UnitOfEnergy.KILO_WATT_HOUR, 'A': UnitOfElectricCurrent.AMPERE, 'mA': UnitOfElectricCurrent.MILLIAMPERE, 'V': UnitOfElectricPotential.VOLT, + 'mv': UnitOfElectricPotential.MILLIVOLT, 'mV': UnitOfElectricPotential.MILLIVOLT, + 'cm': UnitOfLength.CENTIMETERS, 'm': UnitOfLength.METERS, + 'meter': UnitOfLength.METERS, 'km': UnitOfLength.KILOMETERS, 'm3/h': UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, 'gram': UnitOfMass.GRAMS, + 'kilogram': UnitOfMass.KILOGRAMS, 'dB': SIGNAL_STRENGTH_DECIBELS, + 'arcdegrees': DEGREE, + 'arcdegress': DEGREE, 'kB': UnitOfInformation.KILOBYTES, + 'MB': UnitOfInformation.MEGABYTES, + 'GB': UnitOfInformation.GIGABYTES, + 'TB': UnitOfInformation.TERABYTES, + 'B/s': UnitOfDataRate.BYTES_PER_SECOND, + 'KB/s': UnitOfDataRate.KILOBYTES_PER_SECOND, + 'MB/s': UnitOfDataRate.MEGABYTES_PER_SECOND, + 'GB/s': UnitOfDataRate.GIGABYTES_PER_SECOND } # Handle UnitOfConductivity separately since # it might not be available in all HA versions try: # pylint: disable=import-outside-toplevel - from homeassistant.const import UnitOfConductivity + from homeassistant.const import UnitOfConductivity # type: ignore unit_map['μS/cm'] = UnitOfConductivity.MICROSIEMENS_PER_CM except Exception: # pylint: disable=broad-except unit_map['μS/cm'] = 'μS/cm' @@ -703,59 +785,66 @@ class MIoTDevice: return unit_map.get(spec_unit, None) def icon_convert(self, spec_unit: str) -> Optional[str]: - if spec_unit in ['percentage']: + if spec_unit in {'percentage'}: return 'mdi:percent' - if spec_unit in [ - 'weeks', 'days', 'hours', 'minutes', 'seconds', 'ms', 'μs']: + if spec_unit in { + 'weeks', 'days', 'hour', 'hours', 'minutes', 'seconds', 'ms', 'μs' + }: return 'mdi:clock' - if spec_unit in ['celsius']: + if spec_unit in {'celsius'}: return 'mdi:temperature-celsius' - if spec_unit in ['fahrenheit']: + if spec_unit in {'fahrenheit'}: return 'mdi:temperature-fahrenheit' - if spec_unit in ['kelvin']: + if spec_unit in {'kelvin'}: return 'mdi:temperature-kelvin' - if spec_unit in ['μg/m3', 'mg/m3', 'ppm', 'ppb']: + if spec_unit in {'μg/m3', 'mg/m3', 'ppm', 'ppb'}: return 'mdi:blur' - if spec_unit in ['lux']: + if spec_unit in {'lux'}: return 'mdi:brightness-6' - if spec_unit in ['pascal', 'megapascal', 'bar']: + if spec_unit in {'pascal', 'kilopascal', 'megapascal', 'mmHg', 'bar'}: return 'mdi:gauge' - if spec_unit in ['watt']: + if spec_unit in {'watt', 'w', 'W'}: return 'mdi:flash-triangle' - if spec_unit in ['L', 'mL']: + if spec_unit in {'L', 'mL'}: return 'mdi:gas-cylinder' - if spec_unit in ['km/h', 'm/s']: + if spec_unit in {'km/h', 'm/s'}: return 'mdi:speedometer' - if spec_unit in ['kWh']: + if spec_unit in {'kWh'}: return 'mdi:transmission-tower' - if spec_unit in ['A', 'mA']: + if spec_unit in {'A', 'mA'}: return 'mdi:current-ac' - if spec_unit in ['V', 'mV']: + if spec_unit in {'V', 'mv', 'mV'}: return 'mdi:current-dc' - if spec_unit in ['m', 'km']: + if spec_unit in {'cm', 'm', 'meter', 'km'}: return 'mdi:ruler' - if spec_unit in ['rgb']: + if spec_unit in {'rgb'}: return 'mdi:palette' - if spec_unit in ['m3/h', 'L/s']: + if spec_unit in {'m3/h', 'L/s'}: return 'mdi:pipe-leak' - if spec_unit in ['μS/cm']: + if spec_unit in {'μS/cm'}: return 'mdi:resistor-nodes' - if spec_unit in ['gram']: + if spec_unit in {'gram', 'kilogram'}: return 'mdi:weight' - if spec_unit in ['dB']: + if spec_unit in {'dB'}: return 'mdi:signal-distance-variant' - if spec_unit in ['times']: + if spec_unit in {'times'}: return 'mdi:counter' - if spec_unit in ['mmol/L']: + if spec_unit in {'mmol/L'}: return 'mdi:dots-hexagon' - if spec_unit in ['arcdegress']: - return 'mdi:angle-obtuse' - if spec_unit in ['kB']: + if spec_unit in {'kB', 'MB', 'GB'}: return 'mdi:network-pos' - if spec_unit in ['calorie', 'kCal']: + if spec_unit in {'arcdegress', 'arcdegrees'}: + return 'mdi:angle-obtuse' + if spec_unit in {'B/s', 'KB/s', 'MB/s', 'GB/s'}: + return 'mdi:network' + if spec_unit in {'calorie', 'kCal'}: return 'mdi:food' return None + def __gen_sub_id(self) -> int: + self._sub_id += 1 + return self._sub_id + def __on_device_state_changed( self, did: str, state: MIoTDeviceState, ctx: Any ) -> None: @@ -1184,6 +1273,7 @@ class MIoTPropertyEntity(Entity): def __on_value_changed(self, params: dict, ctx: Any) -> None: _LOGGER.debug('property changed, %s', params) self._value = self.spec.value_format(params['value']) + self._value = self.spec.eval_expr(self._value) if not self._pending_write_ha_state_timer: self.async_write_ha_state() diff --git a/custom_components/xiaomi_home/miot/miot_mips.py b/custom_components/xiaomi_home/miot/miot_mips.py index b1bb65b..2187488 100644 --- a/custom_components/xiaomi_home/miot/miot_mips.py +++ b/custom_components/xiaomi_home/miot/miot_mips.py @@ -1414,7 +1414,7 @@ class MipsLocalClient(_MipsClient): @final @on_dev_list_changed.setter def on_dev_list_changed( - self, func: Callable[[Any, list[str]], Coroutine] + self, func: Optional[Callable[[Any, list[str]], Coroutine]] ) -> None: """run in main loop.""" self._on_dev_list_changed = func diff --git a/custom_components/xiaomi_home/miot/miot_spec.py b/custom_components/xiaomi_home/miot/miot_spec.py index 67fde7b..eaede61 100644 --- a/custom_components/xiaomi_home/miot/miot_spec.py +++ b/custom_components/xiaomi_home/miot/miot_spec.py @@ -469,14 +469,13 @@ class _MIoTSpecBase: proprietary: bool need_filter: bool name: str + icon: Optional[str] # External params platform: Optional[str] device_class: Any state_class: Any - icon: Optional[str] external_unit: Any - expression: Optional[str] spec_id: int @@ -489,13 +488,12 @@ class _MIoTSpecBase: self.proprietary = spec.get('proprietary', False) self.need_filter = spec.get('need_filter', False) self.name = spec.get('name', 'xiaomi') + self.icon = spec.get('icon', None) self.platform = None self.device_class = None self.state_class = None - self.icon = None self.external_unit = None - self.expression = None self.spec_id = hash(f'{self.type_}.{self.iid}') @@ -510,6 +508,7 @@ class MIoTSpecProperty(_MIoTSpecBase): """MIoT SPEC property class.""" unit: Optional[str] precision: int + expr: Optional[str] _format_: Type _value_range: Optional[MIoTSpecValueRange] @@ -531,7 +530,8 @@ class MIoTSpecProperty(_MIoTSpecBase): unit: Optional[str] = None, value_range: Optional[dict] = None, value_list: Optional[list[dict]] = None, - precision: Optional[int] = None + precision: Optional[int] = None, + expr: Optional[str] = None ) -> None: super().__init__(spec=spec) self.service = service @@ -541,6 +541,7 @@ class MIoTSpecProperty(_MIoTSpecBase): self.value_range = value_range self.value_list = value_list self.precision = precision or 1 + self.expr = expr self.spec_id = hash( f'p.{self.name}.{self.service.iid}.{self.iid}') @@ -613,6 +614,18 @@ class MIoTSpecProperty(_MIoTSpecBase): elif isinstance(value, MIoTSpecValueList): self._value_list = value + def eval_expr(self, src_value: Any) -> Any: + if not self.expr: + return src_value + try: + # pylint: disable=eval-used + return eval(self.expr, {'src_value': src_value}) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.error( + 'eval expression error, %s, %s, %s, %s', + self.iid, src_value, self.expr, err) + return src_value + def value_format(self, value: Any) -> Any: if value is None: return None @@ -639,7 +652,9 @@ class MIoTSpecProperty(_MIoTSpecBase): 'value_range': ( self._value_range.dump() if self._value_range else None), 'value_list': self._value_list.dump() if self._value_list else None, - 'precision': self.precision + 'precision': self.precision, + 'expr': self.expr, + 'icon': self.icon } @@ -732,7 +747,6 @@ class MIoTSpecService(_MIoTSpecBase): } -# @dataclass class MIoTSpecInstance: """MIoT SPEC instance class.""" urn: str @@ -774,7 +788,8 @@ class MIoTSpecInstance: unit=prop['unit'], value_range=prop['value_range'], value_list=prop['value_list'], - precision=prop.get('precision', None)) + precision=prop.get('precision', None), + expr=prop.get('expr', None)) spec_service.properties.append(spec_prop) for event in service['events']: spec_event = MIoTSpecEvent( @@ -1119,6 +1134,79 @@ class _SpecFilter: return False +class _SpecModify: + """MIoT-Spec-V2 modify for entity conversion.""" + _SPEC_MODIFY_FILE = 'specs/spec_modify.yaml' + _main_loop: asyncio.AbstractEventLoop + _data: Optional[dict] + _selected: Optional[dict] + + def __init__( + self, loop: Optional[asyncio.AbstractEventLoop] = None + ) -> None: + self._main_loop = loop or asyncio.get_running_loop() + self._data = None + + async def init_async(self) -> None: + if isinstance(self._data, dict): + return + modify_data = None + self._data = {} + self._selected = None + try: + modify_data = await self._main_loop.run_in_executor( + None, load_yaml_file, + os.path.join( + os.path.dirname(os.path.abspath(__file__)), + self._SPEC_MODIFY_FILE)) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.error('spec modify, load file error, %s', err) + return + if not isinstance(modify_data, dict): + _LOGGER.error('spec modify, invalid spec modify content') + return + for key, value in modify_data.items(): + if not isinstance(key, str) or not isinstance(value, (dict, str)): + _LOGGER.error('spec modify, invalid spec modify data') + return + + self._data = modify_data + + async def deinit_async(self) -> None: + self._data = None + self._selected = None + + async def set_spec_async(self, urn: str) -> None: + if not self._data: + return + self._selected = self._data.get(urn, None) + if isinstance(self._selected, str): + return await self.set_spec_async(urn=self._selected) + + def get_prop_unit(self, siid: int, piid: int) -> Optional[str]: + return self.__get_prop_item(siid=siid, piid=piid, key='unit') + + def get_prop_expr(self, siid: int, piid: int) -> Optional[str]: + return self.__get_prop_item(siid=siid, piid=piid, key='expr') + + def get_prop_icon(self, siid: int, piid: int) -> Optional[str]: + return self.__get_prop_item(siid=siid, piid=piid, key='icon') + + def get_prop_access(self, siid: int, piid: int) -> Optional[list]: + access = self.__get_prop_item(siid=siid, piid=piid, key='access') + if not isinstance(access, list): + return None + return access + + def __get_prop_item(self, siid: int, piid: int, key: str) -> Optional[str]: + if not self._selected: + return None + prop = self._selected.get(f'prop.{siid}.{piid}', None) + if not prop: + return None + return prop.get(key, None) + + class MIoTSpecParser: """MIoT SPEC parser.""" # pylint: disable=inconsistent-quotes @@ -1132,6 +1220,7 @@ class MIoTSpecParser: _multi_lang: _MIoTSpecMultiLang _bool_trans: _SpecBoolTranslation _spec_filter: _SpecFilter + _spec_modify: _SpecModify _init_done: bool @@ -1149,6 +1238,7 @@ class MIoTSpecParser: self._bool_trans = _SpecBoolTranslation( lang=self._lang, loop=self._main_loop) self._spec_filter = _SpecFilter(loop=self._main_loop) + self._spec_modify = _SpecModify(loop=self._main_loop) self._init_done = False @@ -1157,6 +1247,7 @@ class MIoTSpecParser: return await self._bool_trans.init_async() await self._spec_filter.init_async() + await self._spec_modify.init_async() std_lib_cache = await self._storage.load_async( domain=self._DOMAIN, name='spec_std_lib', type_=dict) if ( @@ -1196,6 +1287,7 @@ class MIoTSpecParser: # self._std_lib.deinit() await self._bool_trans.deinit_async() await self._spec_filter.deinit_async() + await self._spec_modify.deinit_async() async def parse( self, urn: str, skip_cache: bool = False, @@ -1275,6 +1367,8 @@ class MIoTSpecParser: await self._multi_lang.set_spec_async(urn=urn) # Set spec filter await self._spec_filter.set_spec_spec(urn_key=urn_key) + # Set spec modify + await self._spec_modify.set_spec_async(urn=urn) # Parse device type spec_instance: MIoTSpecInstance = MIoTSpecInstance( urn=urn, name=urn_strs[3], @@ -1320,12 +1414,14 @@ class MIoTSpecParser: ): continue p_type_strs: list[str] = property_['type'].split(':') + # Handle special property.unit + unit = property_.get('unit', None) spec_prop: MIoTSpecProperty = MIoTSpecProperty( spec=property_, service=spec_service, format_=property_['format'], access=property_['access'], - unit=property_.get('unit', None)) + unit=unit if unit != 'none' else None) spec_prop.name = p_type_strs[3] # Filter spec property spec_prop.need_filter = ( @@ -1365,7 +1461,19 @@ class MIoTSpecParser: if v_descriptions: # bool without value-list.name spec_prop.value_list = v_descriptions + # Prop modify + spec_prop.unit = self._spec_modify.get_prop_unit( + siid=service['iid'], piid=property_['iid'] + ) or spec_prop.unit + spec_prop.expr = self._spec_modify.get_prop_expr( + siid=service['iid'], piid=property_['iid']) + spec_prop.icon = self._spec_modify.get_prop_icon( + siid=service['iid'], piid=property_['iid']) spec_service.properties.append(spec_prop) + custom_access = self._spec_modify.get_prop_access( + siid=service['iid'], piid=property_['iid']) + if custom_access: + spec_prop.access = custom_access # Parse service event for event in service.get('events', []): if ( diff --git a/custom_components/xiaomi_home/miot/specs/bool_trans.yaml b/custom_components/xiaomi_home/miot/specs/bool_trans.yaml index 7bb3483..e248d68 100644 --- a/custom_components/xiaomi_home/miot/specs/bool_trans.yaml +++ b/custom_components/xiaomi_home/miot/specs/bool_trans.yaml @@ -59,43 +59,6 @@ data: urn:miot-spec-v2:property:wifi-ssid-hidden:000000E3: yes_no urn:miot-spec-v2:property:wind-reverse:00000117: yes_no translate: - contact_state: - de: - 'false': Kein Kontakt - 'true': Kontakt - en: - 'false': No Contact - 'true': Contact - es: - 'false': Sin contacto - 'true': Contacto - fr: - 'false': Pas de contact - 'true': Contact - it: - 'false': Nessun contatto - 'true': Contatto - ja: - 'false': 非接触 - 'true': 接触 - nl: - 'false': Geen contact - 'true': Contact - pt: - 'false': Sem contato - 'true': Contato - pt-BR: - 'false': Sem contato - 'true': Contato - ru: - 'false': Нет контакта - 'true': Контакт - zh-Hans: - 'false': 分离 - 'true': 接触 - zh-Hant: - 'false': 分離 - 'true': 接觸 default: de: 'false': Falsch @@ -133,6 +96,43 @@ translate: zh-Hant: 'false': 假 'true': 真 + contact_state: + de: + 'false': Kein Kontakt + 'true': Kontakt + en: + 'false': No Contact + 'true': Contact + es: + 'false': Sin contacto + 'true': Contacto + fr: + 'false': Pas de contact + 'true': Contact + it: + 'false': Nessun contatto + 'true': Contatto + ja: + 'false': 非接触 + 'true': 接触 + nl: + 'false': Geen contact + 'true': Contact + pt: + 'false': Sem contato + 'true': Contato + pt-BR: + 'false': Sem contato + 'true': Contato + ru: + 'false': Нет контакта + 'true': Контакт + zh-Hans: + 'false': 分离 + 'true': 接触 + zh-Hant: + 'false': 分離 + 'true': 接觸 motion_state: de: 'false': Keine Bewegung erkannt diff --git a/custom_components/xiaomi_home/miot/specs/spec_modify.yaml b/custom_components/xiaomi_home/miot/specs/spec_modify.yaml new file mode 100644 index 0000000..0a0950d --- /dev/null +++ b/custom_components/xiaomi_home/miot/specs/spec_modify.yaml @@ -0,0 +1,44 @@ +urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1:1: + prop.2.1: + name: access-mode + access: + - read + - notify + prop.2.2: + name: ip-address + icon: mdi:ip + prop.2.3: + name: wifi-ssid + access: + - read + - notify +urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1:2: urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1:1 +urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1:3: urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1:1 +urn:miot-spec-v2:device:outlet:0000A002:chuangmi-212a01:1: + prop.5.1: + name: power-consumption + expr: round(src_value/1000, 3) +urn:miot-spec-v2:device:outlet:0000A002:chuangmi-212a01:2: urn:miot-spec-v2:device:outlet:0000A002:chuangmi-212a01:1 +urn:miot-spec-v2:device:outlet:0000A002:chuangmi-212a01:3: urn:miot-spec-v2:device:outlet:0000A002:chuangmi-212a01:1 +urn:miot-spec-v2:device:outlet:0000A002:cuco-cp1md:1: + prop.2.2: + name: power-consumption + expr: round(src_value/1000, 3) +urn:miot-spec-v2:device:outlet:0000A002:cuco-v3:1: + prop.11.1: + name: power-consumption + expr: round(src_value/100, 2) +urn:miot-spec-v2:device:outlet:0000A002:cuco-v3:2: urn:miot-spec-v2:device:outlet:0000A002:cuco-v3:1 +urn:miot-spec-v2:device:outlet:0000A002:zimi-zncz01:2:0000C816: + prop.3.1: + name: electric-power + expr: round(src_value/100, 2) +urn:miot-spec-v2:device:router:0000A036:xiaomi-rd08:1: + prop.2.1: + name: download-speed + icon: mdi:download + unit: B/s + prop.2.2: + name: upload-speed + icon: mdi:upload + unit: B/s diff --git a/custom_components/xiaomi_home/miot/specs/specv2entity.py b/custom_components/xiaomi_home/miot/specs/specv2entity.py index 19023bb..0061f79 100644 --- a/custom_components/xiaomi_home/miot/specs/specv2entity.py +++ b/custom_components/xiaomi_home/miot/specs/specv2entity.py @@ -50,10 +50,15 @@ from homeassistant.components.sensor import SensorStateClass from homeassistant.components.event import EventDeviceClass from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + LIGHT_LUX, UnitOfEnergy, UnitOfPower, UnitOfElectricCurrent, UnitOfElectricPotential, + UnitOfTemperature, + UnitOfPressure, + PERCENTAGE ) # pylint: disable=pointless-string-statement @@ -96,7 +101,7 @@ from homeassistant.const import ( } } """ -SPEC_DEVICE_TRANS_MAP: dict[str, dict | str] = { +SPEC_DEVICE_TRANS_MAP: dict = { 'humidifier': { 'required': { 'humidifier': { @@ -263,7 +268,7 @@ SPEC_DEVICE_TRANS_MAP: dict[str, dict | str] = { } } """ -SPEC_SERVICE_TRANS_MAP: dict[str, dict | str] = { +SPEC_SERVICE_TRANS_MAP: dict = { 'light': { 'required': { 'properties': { @@ -334,15 +339,13 @@ SPEC_SERVICE_TRANS_MAP: dict[str, dict | str] = { '':{ 'device_class': str, 'entity': str, - 'optional':{ - 'state_class': str, - 'unit_of_measurement': str - } + 'state_class'?: str, + 'unit_of_measurement'?: str } } } """ -SPEC_PROP_TRANS_MAP: dict[str, dict | str] = { +SPEC_PROP_TRANS_MAP: dict = { 'entities': { 'sensor': { 'format': {'int', 'float'}, @@ -356,107 +359,111 @@ SPEC_PROP_TRANS_MAP: dict[str, dict | str] = { 'properties': { 'temperature': { 'device_class': SensorDeviceClass.TEMPERATURE, - 'entity': 'sensor' + 'entity': 'sensor', + 'state_class': SensorStateClass.MEASUREMENT, + 'unit_of_measurement': UnitOfTemperature.CELSIUS }, 'relative-humidity': { 'device_class': SensorDeviceClass.HUMIDITY, - 'entity': 'sensor' + 'entity': 'sensor', + 'state_class': SensorStateClass.MEASUREMENT, + 'unit_of_measurement': PERCENTAGE }, 'air-quality-index': { 'device_class': SensorDeviceClass.AQI, - 'entity': 'sensor' + 'entity': 'sensor', + 'state_class': SensorStateClass.MEASUREMENT, }, 'pm2.5-density': { 'device_class': SensorDeviceClass.PM25, - 'entity': 'sensor' + 'entity': 'sensor', + 'state_class': SensorStateClass.MEASUREMENT, + 'unit_of_measurement': CONCENTRATION_MICROGRAMS_PER_CUBIC_METER }, 'pm10-density': { 'device_class': SensorDeviceClass.PM10, - 'entity': 'sensor' + 'entity': 'sensor', + 'state_class': SensorStateClass.MEASUREMENT, + 'unit_of_measurement': CONCENTRATION_MICROGRAMS_PER_CUBIC_METER }, 'pm1': { 'device_class': SensorDeviceClass.PM1, - 'entity': 'sensor' + 'entity': 'sensor', + 'state_class': SensorStateClass.MEASUREMENT, + 'unit_of_measurement': CONCENTRATION_MICROGRAMS_PER_CUBIC_METER }, 'atmospheric-pressure': { 'device_class': SensorDeviceClass.ATMOSPHERIC_PRESSURE, - 'entity': 'sensor' + 'entity': 'sensor', + 'state_class': SensorStateClass.MEASUREMENT, + 'unit_of_measurement': UnitOfPressure.PA }, 'tvoc-density': { 'device_class': SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, - 'entity': 'sensor' + 'entity': 'sensor', + 'state_class': SensorStateClass.MEASUREMENT }, 'voc-density': 'tvoc-density', 'battery-level': { 'device_class': SensorDeviceClass.BATTERY, - 'entity': 'sensor' + 'entity': 'sensor', + 'state_class': SensorStateClass.MEASUREMENT, + 'unit_of_measurement': PERCENTAGE }, 'voltage': { 'device_class': SensorDeviceClass.VOLTAGE, 'entity': 'sensor', - 'optional': { - 'state_class': SensorStateClass.MEASUREMENT, - 'unit_of_measurement': UnitOfElectricPotential.VOLT - } + 'state_class': SensorStateClass.MEASUREMENT, + 'unit_of_measurement': UnitOfElectricPotential.VOLT }, 'electric-current': { 'device_class': SensorDeviceClass.CURRENT, 'entity': 'sensor', - 'optional': { - 'state_class': SensorStateClass.MEASUREMENT, - 'unit_of_measurement': UnitOfElectricCurrent.AMPERE - } + 'state_class': SensorStateClass.MEASUREMENT, + 'unit_of_measurement': UnitOfElectricCurrent.AMPERE }, 'illumination': { 'device_class': SensorDeviceClass.ILLUMINANCE, - 'entity': 'sensor' + 'entity': 'sensor', + 'state_class': SensorStateClass.MEASUREMENT, + 'unit_of_measurement': LIGHT_LUX }, 'no-one-determine-time': { 'device_class': SensorDeviceClass.DURATION, 'entity': 'sensor' }, + 'has-someone-duration': 'no-one-determine-time', + 'no-one-duration': 'no-one-determine-time', 'electric-power': { 'device_class': SensorDeviceClass.POWER, 'entity': 'sensor', - 'optional': { - 'state_class': SensorStateClass.MEASUREMENT, - 'unit_of_measurement': UnitOfPower.WATT - } + 'state_class': SensorStateClass.MEASUREMENT, + 'unit_of_measurement': UnitOfPower.WATT }, 'surge-power': { 'device_class': SensorDeviceClass.POWER, 'entity': 'sensor', - 'optional': { - 'state_class': SensorStateClass.MEASUREMENT, - 'unit_of_measurement': UnitOfPower.WATT - } + 'state_class': SensorStateClass.MEASUREMENT, + 'unit_of_measurement': UnitOfPower.WATT }, 'power-consumption': { 'device_class': SensorDeviceClass.ENERGY, 'entity': 'sensor', - 'optional': { - 'state_class': SensorStateClass.TOTAL_INCREASING, - 'unit_of_measurement': UnitOfEnergy.KILO_WATT_HOUR - } + 'state_class': SensorStateClass.TOTAL_INCREASING, + 'unit_of_measurement': UnitOfEnergy.KILO_WATT_HOUR }, 'power': { 'device_class': SensorDeviceClass.POWER, 'entity': 'sensor', - 'optional': { - 'state_class': SensorStateClass.MEASUREMENT, - 'unit_of_measurement': UnitOfPower.WATT - } + 'state_class': SensorStateClass.MEASUREMENT, + 'unit_of_measurement': UnitOfPower.WATT }, 'total-battery': { 'device_class': SensorDeviceClass.ENERGY, 'entity': 'sensor', - 'optional': { - 'state_class': SensorStateClass.TOTAL_INCREASING, - 'unit_of_measurement': UnitOfEnergy.KILO_WATT_HOUR - } - }, - 'has-someone-duration': 'no-one-determine-time', - 'no-one-duration': 'no-one-determine-time' + 'state_class': SensorStateClass.TOTAL_INCREASING, + 'unit_of_measurement': UnitOfEnergy.KILO_WATT_HOUR + } } } diff --git a/custom_components/xiaomi_home/sensor.py b/custom_components/xiaomi_home/sensor.py index 88b4bac..68e2d2d 100644 --- a/custom_components/xiaomi_home/sensor.py +++ b/custom_components/xiaomi_home/sensor.py @@ -95,7 +95,7 @@ class Sensor(MIoTPropertyEntity, SensorEntity): # Set device_class if self._value_list: self._attr_device_class = SensorDeviceClass.ENUM - self._attr_icon = 'mdi:message-text' + self._attr_icon = 'mdi:format-text' self._attr_native_unit_of_measurement = None self._attr_options = self._value_list.descriptions else: @@ -109,6 +109,9 @@ class Sensor(MIoTPropertyEntity, SensorEntity): self._attr_device_class, None) # type: ignore self._attr_native_unit_of_measurement = list( unit_sets)[0] if unit_sets else None + # Set suggested precision + if spec.format_ in {int, float}: + self._attr_suggested_display_precision = spec.precision # Set state_class if spec.state_class: self._attr_state_class = spec.state_class diff --git a/test/check_rule_format.py b/test/check_rule_format.py index 18ce034..79fcc43 100644 --- a/test/check_rule_format.py +++ b/test/check_rule_format.py @@ -20,6 +20,9 @@ SPEC_BOOL_TRANS_FILE = path.join( SPEC_FILTER_FILE = path.join( ROOT_PATH, '../custom_components/xiaomi_home/miot/specs/spec_filter.yaml') +SPEC_MODIFY_FILE = path.join( + ROOT_PATH, + '../custom_components/xiaomi_home/miot/specs/spec_modify.yaml') def load_json_file(file_path: str) -> Optional[dict]: @@ -54,7 +57,8 @@ def load_yaml_file(file_path: str) -> Optional[dict]: def save_yaml_file(file_path: str, data: dict) -> None: with open(file_path, 'w', encoding='utf-8') as file: yaml.safe_dump( - data, file, default_flow_style=False, allow_unicode=True, indent=2) + data, file, default_flow_style=False, + allow_unicode=True, indent=2, sort_keys=False) def dict_str_str(d: dict) -> bool: @@ -135,6 +139,21 @@ def bool_trans(d: dict) -> bool: return True +def spec_modify(data: dict) -> bool: + """dict[str, str | dict[str, dict]]""" + if not isinstance(data, dict): + return False + for urn, content in data.items(): + if not isinstance(urn, str) or not isinstance(content, (dict, str)): + return False + if isinstance(content, str): + continue + for key, value in content.items(): + if not isinstance(key, str) or not isinstance(value, dict): + return False + return True + + def compare_dict_structure(dict1: dict, dict2: dict) -> bool: if not isinstance(dict1, dict) or not isinstance(dict2, dict): _LOGGER.info('invalid type') @@ -181,6 +200,12 @@ def sort_spec_filter(file_path: str): return filter_data +def sort_spec_modify(file_path: str): + filter_data = load_yaml_file(file_path=file_path) + assert isinstance(filter_data, dict), f'{file_path} format error' + return dict(sorted(filter_data.items())) + + @pytest.mark.github def test_bool_trans(): data = load_yaml_file(SPEC_BOOL_TRANS_FILE) @@ -197,6 +222,14 @@ def test_spec_filter(): assert spec_filter(data), f'{SPEC_FILTER_FILE} format error' +@pytest.mark.github +def test_spec_modify(): + data = load_yaml_file(SPEC_MODIFY_FILE) + assert isinstance(data, dict) + assert data, f'load {SPEC_MODIFY_FILE} failed' + assert spec_modify(data), f'{SPEC_MODIFY_FILE} format error' + + @pytest.mark.github def test_miot_i18n(): for file_name in listdir(MIOT_I18N_RELATIVE_PATH): @@ -286,3 +319,6 @@ def test_sort_spec_data(): sort_data = sort_spec_filter(file_path=SPEC_FILTER_FILE) save_yaml_file(file_path=SPEC_FILTER_FILE, data=sort_data) _LOGGER.info('%s formatted.', SPEC_FILTER_FILE) + sort_data = sort_spec_modify(file_path=SPEC_MODIFY_FILE) + save_yaml_file(file_path=SPEC_MODIFY_FILE, data=sort_data) + _LOGGER.info('%s formatted.', SPEC_MODIFY_FILE)