mirror of
				https://github.com/XiaoMi/ha_xiaomi_home.git
				synced 2025-10-30 17:01:51 +08:00 
			
		
		
		
	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
This commit is contained in:
		| @@ -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]: | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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') | ||||
|  | ||||
|   | ||||
| @@ -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() | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 ( | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
							
								
								
									
										44
									
								
								custom_components/xiaomi_home/miot/specs/spec_modify.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								custom_components/xiaomi_home/miot/specs/spec_modify.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| @@ -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] = { | ||||
|         '<property instance name>':{ | ||||
|             '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 | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user