diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dfcdb65..ec0d459 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -98,6 +98,9 @@ footer: Optional. The footer is the place to reference GitHub issues and PRs tha When contributing to this project, you agree that your contributions will be licensed under the project's [LICENSE](../LICENSE.md). + +When you submit your first pull request, GitHub Action will prompt you to sign the Contributor License Agreement (CLA). Only after you sign the CLA, your pull request will be merged. + ## How to Get Help If you need help or have questions, feel free to ask in [discussions](https://github.com/XiaoMi/ha_xiaomi_home/discussions/) on GitHub. diff --git a/custom_components/xiaomi_home/climate.py b/custom_components/xiaomi_home/climate.py index fb3dc45..88140ab 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, write_ha_state=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) ): @@ -295,39 +296,37 @@ class AirConditioner(MIoTServiceEntity, ClimateEntity): async def async_set_swing_mode(self, swing_mode): """Set new target swing operation.""" if swing_mode == SWING_BOTH: - if await self.set_property_async( - prop=self._prop_horizontal_swing, value=True, update=False): - self.set_prop_value(self._prop_horizontal_swing, value=True) - if await self.set_property_async( - prop=self._prop_vertical_swing, value=True, update=False): - self.set_prop_value(self._prop_vertical_swing, value=True) + await self.set_property_async( + prop=self._prop_horizontal_swing, value=True, + write_ha_state=False) + await self.set_property_async( + prop=self._prop_vertical_swing, value=True) elif swing_mode == SWING_HORIZONTAL: - if await self.set_property_async( - prop=self._prop_horizontal_swing, value=True, update=False): - self.set_prop_value(self._prop_horizontal_swing, value=True) + await self.set_property_async( + prop=self._prop_horizontal_swing, value=True) elif swing_mode == SWING_VERTICAL: - if await self.set_property_async( - prop=self._prop_vertical_swing, value=True, update=False): - self.set_prop_value(self._prop_vertical_swing, value=True) + await self.set_property_async( + prop=self._prop_vertical_swing, value=True) elif swing_mode == SWING_ON: - if await self.set_property_async( - prop=self._prop_fan_on, value=True, update=False): - self.set_prop_value(self._prop_fan_on, value=True) + await self.set_property_async( + prop=self._prop_fan_on, value=True) elif swing_mode == SWING_OFF: - if self._prop_fan_on and await self.set_property_async( - prop=self._prop_fan_on, value=False, update=False): - self.set_prop_value(self._prop_fan_on, value=False) - if self._prop_horizontal_swing and await self.set_property_async( + if self._prop_fan_on: + await self.set_property_async( + prop=self._prop_fan_on, value=False, + write_ha_state=False) + if self._prop_horizontal_swing: + await self.set_property_async( prop=self._prop_horizontal_swing, value=False, - update=False): - self.set_prop_value(self._prop_horizontal_swing, value=False) - if self._prop_vertical_swing and await self.set_property_async( - prop=self._prop_vertical_swing, value=False, update=False): - self.set_prop_value(self._prop_vertical_swing, value=False) + write_ha_state=False) + if self._prop_vertical_swing: + await self.set_property_async( + prop=self._prop_vertical_swing, value=False, + write_ha_state=False) + self.async_write_ha_state() else: raise RuntimeError( f'unknown swing_mode, {swing_mode}, {self.entity_id}') - self.async_write_ha_state() async def async_set_fan_mode(self, fan_mode): """Set new target fan mode.""" @@ -368,9 +367,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]: @@ -388,12 +387,10 @@ class AirConditioner(MIoTServiceEntity, ClimateEntity): Requires ClimateEntityFeature.SWING_MODE. """ - horizontal: bool = ( - self.get_prop_value(prop=self._prop_horizontal_swing) - if self._prop_horizontal_swing else None) - vertical: bool = ( - self.get_prop_value(prop=self._prop_vertical_swing) - if self._prop_vertical_swing else None) + horizontal = ( + self.get_prop_value(prop=self._prop_horizontal_swing)) + vertical = ( + self.get_prop_value(prop=self._prop_vertical_swing)) if horizontal and vertical: return SWING_BOTH if horizontal: @@ -449,7 +446,11 @@ class AirConditioner(MIoTServiceEntity, ClimateEntity): self.set_prop_value(prop=self._prop_fan_level, value=v_ac_state['S']) # D: swing mode. 0: on, 1: off - if 'D' in v_ac_state and len(self._attr_swing_modes) == 2: + if ( + 'D' in v_ac_state + and self._attr_swing_modes + and len(self._attr_swing_modes) == 2 + ): if ( SWING_HORIZONTAL in self._attr_swing_modes and self._prop_horizontal_swing @@ -464,10 +465,10 @@ class AirConditioner(MIoTServiceEntity, ClimateEntity): self.set_prop_value( prop=self._prop_vertical_swing, value=v_ac_state['D'] == 0) - - self._value_ac_state.update(v_ac_state) - _LOGGER.debug( - 'ac_state update, %s', self._value_ac_state) + if self._value_ac_state: + self._value_ac_state.update(v_ac_state) + _LOGGER.debug( + 'ac_state update, %s', self._value_ac_state) class Heater(MIoTServiceEntity, ClimateEntity): diff --git a/custom_components/xiaomi_home/cover.py b/custom_components/xiaomi_home/cover.py index 78a6a02..f2ebaeb 100644 --- a/custom_components/xiaomi_home/cover.py +++ b/custom_components/xiaomi_home/cover.py @@ -200,7 +200,7 @@ class Cover(MIoTServiceEntity, CoverEntity): if pos is None: return None pos = round(pos*self._prop_position_value_range/100) - return await self.set_property_async( + await self.set_property_async( prop=self._prop_target_position, value=pos) @property diff --git a/custom_components/xiaomi_home/fan.py b/custom_components/xiaomi_home/fan.py index a28b989..92a41f4 100644 --- a/custom_components/xiaomi_home/fan.py +++ b/custom_components/xiaomi_home/fan.py @@ -303,7 +303,7 @@ class Fan(MIoTServiceEntity, FanEntity): fan_level = self.get_prop_value(prop=self._prop_fan_level) if fan_level is None: return None - if self._speed_names: + if self._speed_names and self._speed_name_map: return ordered_list_item_to_percentage( self._speed_names, self._speed_name_map[fan_level]) else: diff --git a/custom_components/xiaomi_home/light.py b/custom_components/xiaomi_home/light.py index 1667662..ef9fed2 100644 --- a/custom_components/xiaomi_home/light.py +++ b/custom_components/xiaomi_home/light.py @@ -96,7 +96,7 @@ class Light(MIoTServiceEntity, LightEntity): """Light entities for Xiaomi Home.""" # pylint: disable=unused-argument _VALUE_RANGE_MODE_COUNT_MAX = 30 - _prop_on: MIoTSpecProperty + _prop_on: Optional[MIoTSpecProperty] _prop_brightness: Optional[MIoTSpecProperty] _prop_color_temp: Optional[MIoTSpecProperty] _prop_color: Optional[MIoTSpecProperty] @@ -250,23 +250,25 @@ class Light(MIoTServiceEntity, LightEntity): Shall set attributes in kwargs if applicable. """ - result: bool = False # on # Dirty logic for lumi.gateway.mgl03 indicator light - value_on = True if self._prop_on.format_ == bool else 1 - result = await self.set_property_async( - prop=self._prop_on, value=value_on) + if self._prop_on: + value_on = True if self._prop_on.format_ == bool else 1 + await self.set_property_async( + prop=self._prop_on, value=value_on) # brightness if ATTR_BRIGHTNESS in kwargs: brightness = brightness_to_value( self._brightness_scale, kwargs[ATTR_BRIGHTNESS]) - result = await self.set_property_async( - prop=self._prop_brightness, value=brightness) + await self.set_property_async( + prop=self._prop_brightness, value=brightness, + write_ha_state=False) # color-temperature if ATTR_COLOR_TEMP_KELVIN in kwargs: - result = await self.set_property_async( + await self.set_property_async( prop=self._prop_color_temp, - value=kwargs[ATTR_COLOR_TEMP_KELVIN]) + value=kwargs[ATTR_COLOR_TEMP_KELVIN], + write_ha_state=False) self._attr_color_mode = ColorMode.COLOR_TEMP # rgb color if ATTR_RGB_COLOR in kwargs: @@ -274,19 +276,23 @@ class Light(MIoTServiceEntity, LightEntity): g = kwargs[ATTR_RGB_COLOR][1] b = kwargs[ATTR_RGB_COLOR][2] rgb = (r << 16) | (g << 8) | b - result = await self.set_property_async( - prop=self._prop_color, value=rgb) + await self.set_property_async( + prop=self._prop_color, value=rgb, + write_ha_state=False) self._attr_color_mode = ColorMode.RGB # mode if ATTR_EFFECT in kwargs: - result = await self.set_property_async( + await self.set_property_async( prop=self._prop_mode, value=self.get_map_key( - map_=self._mode_map, value=kwargs[ATTR_EFFECT])) - return result + map_=self._mode_map, value=kwargs[ATTR_EFFECT]), + write_ha_state=False) + self.async_write_ha_state() async def async_turn_off(self, **kwargs) -> None: """Turn the light off.""" + if not self._prop_on: + return # Dirty logic for lumi.gateway.mgl03 indicator light value_on = False if self._prop_on.format_ == bool else 0 - return await self.set_property_async(prop=self._prop_on, value=value_on) + await self.set_property_async(prop=self._prop_on, value=value_on) 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..1f3f186 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: @@ -903,14 +992,14 @@ class MIoTServiceEntity(Entity): siid=event.service.iid, eiid=event.iid, sub_id=sub_id) def get_map_value( - self, map_: dict[int, Any], key: int + self, map_: Optional[dict[int, Any]], key: int ) -> Any: if map_ is None: return None return map_.get(key, None) def get_map_key( - self, map_: dict[int, Any], value: Any + self, map_: Optional[dict[int, Any]], value: Any ) -> Optional[int]: if map_ is None: return None @@ -919,7 +1008,7 @@ class MIoTServiceEntity(Entity): return key return None - def get_prop_value(self, prop: MIoTSpecProperty) -> Any: + def get_prop_value(self, prop: Optional[MIoTSpecProperty]) -> Any: if not prop: _LOGGER.error( 'get_prop_value error, property is None, %s, %s', @@ -927,7 +1016,9 @@ class MIoTServiceEntity(Entity): return None return self._prop_value_map.get(prop, None) - def set_prop_value(self, prop: MIoTSpecProperty, value: Any) -> None: + def set_prop_value( + self, prop: Optional[MIoTSpecProperty], value: Any + ) -> None: if not prop: _LOGGER.error( 'set_prop_value error, property is None, %s, %s', @@ -936,13 +1027,14 @@ class MIoTServiceEntity(Entity): self._prop_value_map[prop] = value async def set_property_async( - self, prop: MIoTSpecProperty, value: Any, update: bool = True + self, prop: Optional[MIoTSpecProperty], value: Any, + update_value: bool = True, write_ha_state: bool = True ) -> bool: - value = prop.value_format(value) if not prop: raise RuntimeError( f'set property failed, property is None, ' f'{self.entity_id}, {self.name}') + value = prop.value_format(value) if prop not in self.entity_data.props: raise RuntimeError( f'set property failed, unknown property, ' @@ -958,8 +1050,9 @@ class MIoTServiceEntity(Entity): except MIoTClientError as e: raise RuntimeError( f'{e}, {self.entity_id}, {self.name}, {prop.name}') from e - if update: + if update_value: self._prop_value_map[prop] = value + if write_ha_state: self.async_write_ha_state() return True @@ -1184,6 +1277,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_network.py b/custom_components/xiaomi_home/miot/miot_network.py index 45b5f35..58751cc 100644 --- a/custom_components/xiaomi_home/miot/miot_network.py +++ b/custom_components/xiaomi_home/miot/miot_network.py @@ -94,7 +94,7 @@ class MIoTNetwork: _main_loop: asyncio.AbstractEventLoop _ip_addr_map: dict[str, float] - _url_addr_list: dict[str, float] + _http_addr_map: dict[str, float] _http_session: aiohttp.ClientSession _refresh_interval: int @@ -283,8 +283,8 @@ class MIoTNetwork: [ 'ping', '-c', '1', '-w', str(self._DETECT_TIMEOUT), address]), - stdout=subprocess.PIPE, - stderr=subprocess.PIPE + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL ) await process.communicate() if process.returncode == 0: diff --git a/custom_components/xiaomi_home/miot/miot_spec.py b/custom_components/xiaomi_home/miot/miot_spec.py index 8bb70f9..dbc7980 100644 --- a/custom_components/xiaomi_home/miot/miot_spec.py +++ b/custom_components/xiaomi_home/miot/miot_spec.py @@ -475,14 +475,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 @@ -495,13 +494,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}') @@ -516,6 +514,7 @@ class MIoTSpecProperty(_MIoTSpecBase): """MIoT SPEC property class.""" unit: Optional[str] precision: int + expr: Optional[str] _format_: Type _value_range: Optional[MIoTSpecValueRange] @@ -537,7 +536,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 @@ -547,6 +547,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}') @@ -619,6 +620,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 @@ -645,7 +658,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 } @@ -738,7 +753,6 @@ class MIoTSpecService(_MIoTSpecBase): } -# @dataclass class MIoTSpecInstance: """MIoT SPEC instance class.""" urn: str @@ -780,7 +794,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( @@ -1125,6 +1140,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 @@ -1138,6 +1226,7 @@ class MIoTSpecParser: _multi_lang: _MIoTSpecMultiLang _bool_trans: _SpecBoolTranslation _spec_filter: _SpecFilter + _spec_modify: _SpecModify _init_done: bool @@ -1155,6 +1244,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 @@ -1163,6 +1253,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 ( @@ -1202,6 +1293,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, @@ -1281,6 +1373,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], @@ -1326,12 +1420,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 = ( @@ -1371,7 +1467,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 c5bdbea..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,99 +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', + '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 }, - 'electric-current': { - 'device_class': SensorDeviceClass.CURRENT, + 'surge-power': { + 'device_class': SensorDeviceClass.POWER, 'entity': 'sensor', - 'optional': { - 'state_class': SensorStateClass.MEASUREMENT, - 'unit_of_measurement': UnitOfElectricCurrent.AMPERE - } + '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/custom_components/xiaomi_home/water_heater.py b/custom_components/xiaomi_home/water_heater.py index aba6093..3c255ec 100644 --- a/custom_components/xiaomi_home/water_heater.py +++ b/custom_components/xiaomi_home/water_heater.py @@ -100,7 +100,7 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity): ) -> None: """Initialize the Water heater.""" super().__init__(miot_device=miot_device, entity_data=entity_data) - self._attr_temperature_unit = None + self._attr_temperature_unit = None # type: ignore self._attr_supported_features = WaterHeaterEntityFeature(0) self._prop_on = None self._prop_temp = None @@ -112,20 +112,20 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity): for prop in entity_data.props: # on if prop.name == 'on': + self._attr_supported_features |= WaterHeaterEntityFeature.ON_OFF self._prop_on = prop # temperature if prop.name == 'temperature': - if prop.value_range: - if ( - self._attr_temperature_unit is None - and prop.external_unit - ): - self._attr_temperature_unit = prop.external_unit - self._prop_temp = prop - else: + if not prop.value_range: _LOGGER.error( 'invalid temperature value_range format, %s', self.entity_id) + continue + if prop.external_unit: + self._attr_temperature_unit = prop.external_unit + self._attr_min_temp = prop.value_range.min_ + self._attr_max_temp = prop.value_range.max_ + self._prop_temp = prop # target-temperature if prop.name == 'target-temperature': if not prop.value_range: @@ -133,8 +133,8 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity): 'invalid target-temperature value_range format, %s', self.entity_id) continue - self._attr_min_temp = prop.value_range.min_ - self._attr_max_temp = prop.value_range.max_ + self._attr_target_temperature_low = prop.value_range.min_ + self._attr_target_temperature_high = prop.value_range.max_ self._attr_precision = prop.value_range.step if self._attr_temperature_unit is None and prop.external_unit: self._attr_temperature_unit = prop.external_unit @@ -166,6 +166,8 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity): async def async_set_temperature(self, **kwargs: Any) -> None: """Set the temperature the water heater should heat water to.""" + if not self._prop_target_temp: + return await self.set_property_async( prop=self._prop_target_temp, value=kwargs[ATTR_TEMPERATURE]) @@ -181,16 +183,11 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity): return if self.get_prop_value(prop=self._prop_on) is False: await self.set_property_async( - prop=self._prop_on, value=True, update=False) + prop=self._prop_on, value=True, write_ha_state=False) await self.set_property_async( prop=self._prop_mode, value=self.get_map_key( - map_=self._mode_map, - value=operation_mode)) - - async def async_turn_away_mode_on(self) -> None: - """Set the water heater to away mode.""" - await self.hass.async_add_executor_job(self.turn_away_mode_on) + map_=self._mode_map, value=operation_mode)) @property def current_temperature(self) -> Optional[float]: @@ -200,6 +197,8 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity): @property def target_temperature(self) -> Optional[float]: """Return the target temperature.""" + if not self._prop_target_temp: + return None return self.get_prop_value(prop=self._prop_target_temp) @property diff --git a/doc/CONTRIBUTING_zh.md b/doc/CONTRIBUTING_zh.md index 2201fb8..f685bfe 100644 --- a/doc/CONTRIBUTING_zh.md +++ b/doc/CONTRIBUTING_zh.md @@ -98,6 +98,8 @@ footer :(可选)关联的 issue 或 pull request 编号。 在为本项目做出贡献时,您同意您的贡献遵循本项目的[许可证](../LICENSE.md) 。 +当您第一次提交拉取请求时,GitHub Action 会提示您签署贡献者许可协议(Contributor License Agreement,CLA)。只有签署了 CLA ,本项目才会合入您的拉取请求。 + ## 获取帮助 如果您需要帮助或有疑问,可在 GitHub 的[讨论区](https://github.com/XiaoMi/ha_xiaomi_home/discussions/)询问。 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) diff --git a/test/test_lan.py b/test/test_lan.py index a2861cc..42b0b49 100755 --- a/test/test_lan.py +++ b/test/test_lan.py @@ -56,7 +56,7 @@ async def test_lan_async(test_devices: dict): # Your central hub gateway did test_did = '111111' - # Your central hub gateway did + # Your central hub gateway token test_token = '11223344556677d9a03d43936fc384205' test_model = 'xiaomi.gateway.hub1' # Your computer interface list, such as enp3s0, wlp5s0 @@ -152,3 +152,5 @@ async def test_lan_async(test_devices: dict): await asyncio.sleep(0.2) await miot_lan.deinit_async() + await mips_service.deinit_async() + await miot_network.deinit_async()