diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 481762c..34f8734 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -19,7 +19,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install pytest pytest-asyncio pytest-dependency zeroconf paho.mqtt psutil cryptography + pip install pytest pytest-asyncio pytest-dependency zeroconf paho.mqtt psutil cryptography slugify - name: Check rule format with pytest run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 22fdb9a..899e3b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # CHANGELOG +## v0.1.5b2 +### Added +- Support binary sensors to be displayed as text sensor entities and binary sensor entities. [#592](https://github.com/XiaoMi/ha_xiaomi_home/pull/592) +- Add miot cloud test case. [#620](https://github.com/XiaoMi/ha_xiaomi_home/pull/620) +- Add test case for user cert. [#638](https://github.com/XiaoMi/ha_xiaomi_home/pull/638) +- Add mips test case & Change mips reconnect logic. [#641](https://github.com/XiaoMi/ha_xiaomi_home/pull/641) +- Support remove device. [#622](https://github.com/XiaoMi/ha_xiaomi_home/pull/622) +- Support italian translation. [#183](https://github.com/XiaoMi/ha_xiaomi_home/pull/183) +### Changed +- Refactor miot spec. [#592](https://github.com/XiaoMi/ha_xiaomi_home/pull/592) +- Refactor miot mips & fix type errors. [#365](https://github.com/XiaoMi/ha_xiaomi_home/pull/365) +- Using logging for test case log print. [#636](https://github.com/XiaoMi/ha_xiaomi_home/pull/636) +- Add power properties trans. [#571](https://github.com/XiaoMi/ha_xiaomi_home/pull/571) +- Move web page to html. [#627](https://github.com/XiaoMi/ha_xiaomi_home/pull/627) +### Fixed +- Fix miot cloud and mdns error. [#637](https://github.com/XiaoMi/ha_xiaomi_home/pull/637) +- Fix type error + +## v0.1.5b1 +This version will cause some Xiaomi routers that do not support access (#564) to become unavailable. You can update the device list in the configuration or delete it manually. +### Added +- Fan entity support direction ctrl [#556](https://github.com/XiaoMi/ha_xiaomi_home/pull/556) +### Changed +- Filter miwifi.* devices and xiaomi.router.rd03 [#564](https://github.com/XiaoMi/ha_xiaomi_home/pull/564) +### Fixed +- Fix multi ha instance login [#560](https://github.com/XiaoMi/ha_xiaomi_home/pull/560) +- Fix fan speed [#464](https://github.com/XiaoMi/ha_xiaomi_home/pull/464) +- The number of profile models updated from 660 to 823. [#583](https://github.com/XiaoMi/ha_xiaomi_home/pull/583) + ## v0.1.5b0 ### Added - Add missing parameter state_class [#101](https://github.com/XiaoMi/ha_xiaomi_home/pull/101) diff --git a/custom_components/xiaomi_home/__init__.py b/custom_components/xiaomi_home/__init__.py index 3b534e3..fb1b5c7 100644 --- a/custom_components/xiaomi_home/__init__.py +++ b/custom_components/xiaomi_home/__init__.py @@ -54,6 +54,7 @@ from homeassistant.core import HomeAssistant from homeassistant.components import persistent_notification from homeassistant.helpers import device_registry, entity_registry +from .miot.common import slugify_did from .miot.miot_storage import ( DeviceManufacturer, MIoTStorage, MIoTCert) from .miot.miot_spec import ( @@ -92,7 +93,7 @@ async def async_setup_entry( """Send messages in Notifications dialog box.""" if title: persistent_notification.async_create( - hass=hass, message=message, + hass=hass, message=message or '', title=title, notification_id=notify_id) else: persistent_notification.async_dismiss( @@ -125,9 +126,8 @@ async def async_setup_entry( miot_devices: list[MIoTDevice] = [] er = entity_registry.async_get(hass=hass) for did, info in miot_client.device_list.items(): - spec_instance: MIoTSpecInstance = await spec_parser.parse( - urn=info['urn']) - if spec_instance is None: + spec_instance = await spec_parser.parse(urn=info['urn']) + if not isinstance(spec_instance, MIoTSpecInstance): _LOGGER.error('spec content is None, %s, %s', did, info) continue device: MIoTDevice = MIoTDevice( @@ -155,7 +155,8 @@ async def async_setup_entry( for entity in filter_entities: device.entity_list[platform].remove(entity) entity_id = device.gen_service_entity_id( - ha_domain=platform, siid=entity.spec.iid) + ha_domain=platform, + siid=entity.spec.iid) # type: ignore if er.async_get(entity_id_or_uuid=entity_id): er.async_remove(entity_id=entity_id) if platform in device.prop_list: @@ -208,12 +209,7 @@ async def async_setup_entry( if er.async_get(entity_id_or_uuid=entity_id): er.async_remove(entity_id=entity_id) # Action debug - if miot_client.action_debug: - if 'notify' in device.action_list: - # Add text entity for debug action - device.action_list['action_text'] = ( - device.action_list['notify']) - else: + if not miot_client.action_debug: # Remove text entity for debug action for action in device.action_list.get('notify', []): entity_id = device.gen_action_entity_id( @@ -221,6 +217,21 @@ async def async_setup_entry( siid=action.service.iid, aiid=action.iid) if er.async_get(entity_id_or_uuid=entity_id): er.async_remove(entity_id=entity_id) + # Binary sensor display + if not miot_client.display_binary_bool: + for prop in device.prop_list.get('binary_sensor', []): + entity_id = device.gen_prop_entity_id( + ha_domain='binary_sensor', spec_name=prop.name, + siid=prop.service.iid, piid=prop.iid) + if er.async_get(entity_id_or_uuid=entity_id): + er.async_remove(entity_id=entity_id) + if not miot_client.display_binary_text: + for prop in device.prop_list.get('binary_sensor', []): + entity_id = device.gen_prop_entity_id( + ha_domain='sensor', spec_name=prop.name, + siid=prop.service.iid, piid=prop.iid) + if er.async_get(entity_id_or_uuid=entity_id): + er.async_remove(entity_id=entity_id) hass.data[DOMAIN]['devices'][config_entry.entry_id] = miot_devices await hass.config_entries.async_forward_entry_setups( @@ -237,7 +248,7 @@ async def async_setup_entry( device_entry = dr.async_get_device( identifiers={( DOMAIN, - MIoTDevice.gen_did_tag( + slugify_did( cloud_server=config_entry.data['cloud_server'], did=did))}, connections=None) @@ -308,3 +319,32 @@ async def async_remove_entry( await miot_cert.remove_user_cert_async() await miot_cert.remove_user_key_async() return True + + +async def async_remove_config_entry_device( + hass: HomeAssistant, + config_entry: ConfigEntry, + device_entry: device_registry.DeviceEntry +) -> bool: + """Remove the device.""" + miot_client: MIoTClient = await get_miot_instance_async( + hass=hass, entry_id=config_entry.entry_id) + + if len(device_entry.identifiers) != 1: + _LOGGER.error( + 'remove device failed, invalid identifiers, %s, %s', + device_entry.id, device_entry.identifiers) + return False + identifiers = list(device_entry.identifiers)[0] + if identifiers[0] != DOMAIN: + _LOGGER.error( + 'remove device failed, invalid domain, %s, %s', + device_entry.id, device_entry.identifiers) + return False + + # Remove device + await miot_client.remove_device2_async(did_tag=identifiers[1]) + device_registry.async_get(hass).async_remove_device(device_entry.id) + _LOGGER.info( + 'remove device, %s, %s', identifiers[1], device_entry.id) + return True diff --git a/custom_components/xiaomi_home/binary_sensor.py b/custom_components/xiaomi_home/binary_sensor.py index 9ec6e83..aca45d8 100644 --- a/custom_components/xiaomi_home/binary_sensor.py +++ b/custom_components/xiaomi_home/binary_sensor.py @@ -68,9 +68,10 @@ async def async_setup_entry( new_entities = [] for miot_device in device_list: - for prop in miot_device.prop_list.get('binary_sensor', []): - new_entities.append(BinarySensor( - miot_device=miot_device, spec=prop)) + if miot_device.miot_client.display_binary_bool: + for prop in miot_device.prop_list.get('binary_sensor', []): + new_entities.append(BinarySensor( + miot_device=miot_device, spec=prop)) if new_entities: async_add_entities(new_entities) diff --git a/custom_components/xiaomi_home/climate.py b/custom_components/xiaomi_home/climate.py index ffd7ce4..7051c83 100644 --- a/custom_components/xiaomi_home/climate.py +++ b/custom_components/xiaomi_home/climate.py @@ -79,27 +79,22 @@ async def async_setup_entry( ) -> None: """Set up a config entry.""" device_list: list[MIoTDevice] = hass.data[DOMAIN]['devices'][ - config_entry.entry_id - ] + config_entry.entry_id] new_entities = [] for miot_device in device_list: for data in miot_device.entity_list.get('air-conditioner', []): new_entities.append( - AirConditioner(miot_device=miot_device, entity_data=data) - ) + AirConditioner(miot_device=miot_device, entity_data=data)) for data in miot_device.entity_list.get('heater', []): new_entities.append( - Heater(miot_device=miot_device, entity_data=data) - ) + Heater(miot_device=miot_device, entity_data=data)) for data in miot_device.entity_list.get('bath-heater', []): new_entities.append( - PtcBathHeater(miot_device=miot_device, entity_data=data) - ) + PtcBathHeater(miot_device=miot_device, entity_data=data)) for data in miot_device.entity_list.get('thermostat', []): new_entities.append( - Thermostat(miot_device=miot_device, entity_data=data) - ) + Thermostat(miot_device=miot_device, entity_data=data)) if new_entities: async_add_entities(new_entities) @@ -107,7 +102,6 @@ async def async_setup_entry( class FeatureOnOff(MIoTServiceEntity, ClimateEntity): """TURN_ON and TURN_OFF feature of the climate entity.""" - _prop_on: Optional[MIoTSpecProperty] def __init__( @@ -127,11 +121,9 @@ class FeatureOnOff(MIoTServiceEntity, ClimateEntity): or prop.service.name == 'thermostat' ): self._attr_supported_features |= ( - ClimateEntityFeature.TURN_ON - ) + ClimateEntityFeature.TURN_ON) self._attr_supported_features |= ( - ClimateEntityFeature.TURN_OFF - ) + ClimateEntityFeature.TURN_OFF) self._prop_on = prop async def async_turn_on(self) -> None: @@ -145,7 +137,6 @@ class FeatureOnOff(MIoTServiceEntity, ClimateEntity): class FeatureTargetTemperature(MIoTServiceEntity, ClimateEntity): """TARGET_TEMPERATURE feature of the climate entity.""" - _prop_target_temp: Optional[MIoTSpecProperty] def __init__( @@ -157,19 +148,17 @@ class FeatureTargetTemperature(MIoTServiceEntity, ClimateEntity): # properties for prop in entity_data.props: if prop.name == 'target-temperature': - if not isinstance(prop.value_range, dict): + if not prop.value_range: _LOGGER.error( 'invalid target-temperature value_range format, %s', - self.entity_id, - ) + self.entity_id) continue - self._attr_min_temp = prop.value_range['min'] - self._attr_max_temp = prop.value_range['max'] - self._attr_target_temperature_step = prop.value_range['step'] + self._attr_min_temp = prop.value_range.min_ + self._attr_max_temp = prop.value_range.max_ + self._attr_target_temperature_step = prop.value_range.step self._attr_temperature_unit = prop.external_unit self._attr_supported_features |= ( - ClimateEntityFeature.TARGET_TEMPERATURE - ) + ClimateEntityFeature.TARGET_TEMPERATURE) self._prop_target_temp = prop async def async_set_temperature(self, **kwargs): @@ -182,22 +171,18 @@ class FeatureTargetTemperature(MIoTServiceEntity, ClimateEntity): temp = self._attr_min_temp await self.set_property_async( - prop=self._prop_target_temp, value=temp - ) + prop=self._prop_target_temp, value=temp) @property def target_temperature(self) -> Optional[float]: """Return the target temperature.""" return ( self.get_prop_value(prop=self._prop_target_temp) - if self._prop_target_temp - else None - ) + if self._prop_target_temp else None) class FeaturePresetMode(MIoTServiceEntity, ClimateEntity): """PRESET_MODE feature of the climate entity.""" - _prop_mode: Optional[MIoTSpecProperty] _mode_map: Optional[dict[int, str]] @@ -211,46 +196,34 @@ class FeaturePresetMode(MIoTServiceEntity, ClimateEntity): # properties for prop in entity_data.props: if prop.name == 'heat-level' and prop.service.name == 'heater': - if not isinstance(prop.value_list, list) or not prop.value_list: + if not prop.value_list: _LOGGER.error( 'invalid heater heat-level value_list, %s', - self.entity_id, - ) + self.entity_id) continue - self._mode_map = { - item['value']: item['description'] - for item in prop.value_list - } - self._attr_preset_modes = list(self._mode_map.values()) + self._mode_map = prop.value_list.to_map() + self._attr_preset_modes = prop.value_list.descriptions self._attr_supported_features |= ( - ClimateEntityFeature.PRESET_MODE - ) + ClimateEntityFeature.PRESET_MODE) self._prop_mode = prop async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode.""" await self.set_property_async( self._prop_mode, - value=self.get_map_value( - map_=self._mode_map, description=preset_mode - ), - ) + value=self.get_map_value(map_=self._mode_map, key=preset_mode)) @property def preset_mode(self) -> Optional[str]: return ( - self.get_map_description( + self.get_map_key( map_=self._mode_map, - key=self.get_prop_value(prop=self._prop_mode), - ) - if self._prop_mode - else None - ) + value=self.get_prop_value(prop=self._prop_mode)) + if self._prop_mode else None) class FeatureFanMode(MIoTServiceEntity, ClimateEntity): """FAN_MODE feature of the climate entity.""" - _prop_fan_on: Optional[MIoTSpecProperty] _prop_fan_level: Optional[MIoTSpecProperty] _fan_mode_map: Optional[dict[int, str]] @@ -266,16 +239,12 @@ class FeatureFanMode(MIoTServiceEntity, ClimateEntity): # properties for prop in entity_data.props: if prop.name == 'fan-level' and prop.service.name == 'fan-control': - if not isinstance(prop.value_list, list) or not prop.value_list: + if not prop.value_list: _LOGGER.error( - 'invalid fan-level value_list, %s', self.entity_id - ) + 'invalid fan-level value_list, %s', self.entity_id) continue - self._fan_mode_map = { - item['value']: item['description'] - for item in prop.value_list - } - self._attr_fan_modes = list(self._fan_mode_map.values()) + self._fan_mode_map = prop.value_list.to_map() + self._attr_fan_modes = prop.value_list.descriptions self._attr_supported_features |= ClimateEntityFeature.FAN_MODE self._prop_fan_level = prop elif prop.name == 'on' and prop.service.name == 'fan-control': @@ -297,15 +266,13 @@ class FeatureFanMode(MIoTServiceEntity, ClimateEntity): await self.set_property_async(prop=self._prop_fan_on, value=True) return mode_value = self.get_map_value( - map_=self._fan_mode_map, description=fan_mode - ) + map_=self._fan_mode_map, key=fan_mode) if mode_value is None or not await self.set_property_async( prop=self._prop_fan_level, value=mode_value ): raise RuntimeError( f'set climate prop.fan_mode failed, {fan_mode}, ' - f'{self.entity_id}' - ) + f'{self.entity_id}') @property def fan_mode(self) -> Optional[str]: @@ -314,19 +281,15 @@ class FeatureFanMode(MIoTServiceEntity, ClimateEntity): return None if self._prop_fan_level is None and self._prop_fan_on: return ( - FAN_ON - if self.get_prop_value(prop=self._prop_fan_on) - else FAN_OFF - ) - return self.get_map_description( + FAN_ON if self.get_prop_value(prop=self._prop_fan_on) + else FAN_OFF) + return self.get_map_key( map_=self._fan_mode_map, - key=self.get_prop_value(prop=self._prop_fan_level) - ) + value=self.get_prop_value(prop=self._prop_fan_level)) class FeatureSwingMode(MIoTServiceEntity, ClimateEntity): """SWING_MODE feature of the climate entity.""" - _prop_horizontal_swing: Optional[MIoTSpecProperty] _prop_vertical_swing: Optional[MIoTSpecProperty] @@ -358,32 +321,25 @@ class FeatureSwingMode(MIoTServiceEntity, ClimateEntity): """Set the target swing operation.""" if swing_mode == SWING_BOTH: await self.set_property_async( - prop=self._prop_horizontal_swing, value=True - ) + prop=self._prop_horizontal_swing, value=True) await self.set_property_async( - prop=self._prop_vertical_swing, value=True - ) + prop=self._prop_vertical_swing, value=True) elif swing_mode == SWING_HORIZONTAL: await self.set_property_async( - prop=self._prop_horizontal_swing, value=True - ) + prop=self._prop_horizontal_swing, value=True) elif swing_mode == SWING_VERTICAL: await self.set_property_async( - prop=self._prop_vertical_swing, value=True - ) + prop=self._prop_vertical_swing, value=True) elif swing_mode == SWING_OFF: if self._prop_horizontal_swing: await self.set_property_async( - prop=self._prop_horizontal_swing, value=False - ) + prop=self._prop_horizontal_swing, value=False) if self._prop_vertical_swing: await self.set_property_async( - prop=self._prop_vertical_swing, value=False - ) + prop=self._prop_vertical_swing, value=False) else: raise RuntimeError( - f'unknown swing_mode, {swing_mode}, {self.entity_id}' - ) + f'unknown swing_mode, {swing_mode}, {self.entity_id}') @property def swing_mode(self) -> Optional[str]: @@ -395,14 +351,10 @@ class FeatureSwingMode(MIoTServiceEntity, ClimateEntity): return None horizontal: bool = ( self.get_prop_value(prop=self._prop_horizontal_swing) - if self._prop_horizontal_swing - else False - ) + if self._prop_horizontal_swing else False) vertical: bool = ( self.get_prop_value(prop=self._prop_vertical_swing) - if self._prop_vertical_swing - else False - ) + if self._prop_vertical_swing else False) if horizontal and vertical: return SWING_BOTH elif horizontal: @@ -415,7 +367,6 @@ class FeatureSwingMode(MIoTServiceEntity, ClimateEntity): class FeatureTemperature(MIoTServiceEntity, ClimateEntity): """Temperature of the climate entity.""" - _prop_env_temperature: Optional[MIoTSpecProperty] def __init__( @@ -434,14 +385,11 @@ class FeatureTemperature(MIoTServiceEntity, ClimateEntity): """The current environment temperature.""" return ( self.get_prop_value(prop=self._prop_env_temperature) - if self._prop_env_temperature - else None - ) + if self._prop_env_temperature else None) class FeatureHumidity(MIoTServiceEntity, ClimateEntity): """Humidity of the climate entity.""" - _prop_env_humidity: Optional[MIoTSpecProperty] def __init__( @@ -460,14 +408,11 @@ class FeatureHumidity(MIoTServiceEntity, ClimateEntity): """The current environment humidity.""" return ( self.get_prop_value(prop=self._prop_env_humidity) - if self._prop_env_humidity - else None - ) + if self._prop_env_humidity else None) class FeatureTargetHumidity(MIoTServiceEntity, ClimateEntity): """TARGET_HUMIDITY feature of the climate entity.""" - _prop_target_humidity: Optional[MIoTSpecProperty] def __init__( @@ -479,17 +424,15 @@ class FeatureTargetHumidity(MIoTServiceEntity, ClimateEntity): # properties for prop in entity_data.props: if prop.name == 'target-humidity': - if not isinstance(prop.value_range, dict): + if not prop.value_range: _LOGGER.error( 'invalid target-humidity value_range format, %s', - self.entity_id, - ) + self.entity_id) continue - self._attr_min_humidity = prop.value_range['min'] - self._attr_max_humidity = prop.value_range['max'] + self._attr_min_humidity = prop.value_range.min_ + self._attr_max_humidity = prop.value_range.max_ self._attr_supported_features |= ( - ClimateEntityFeature.TARGET_HUMIDITY - ) + ClimateEntityFeature.TARGET_HUMIDITY) self._prop_target_humidity = prop async def async_set_humidity(self, humidity): @@ -499,17 +442,14 @@ class FeatureTargetHumidity(MIoTServiceEntity, ClimateEntity): elif humidity < self._attr_min_humidity: humidity = self._attr_min_humidity await self.set_property_async( - prop=self._prop_target_humidity, value=humidity - ) + prop=self._prop_target_humidity, value=humidity) @property def target_humidity(self) -> Optional[int]: """The current target humidity.""" return ( self.get_prop_value(prop=self._prop_target_humidity) - if self._prop_target_humidity - else None - ) + if self._prop_target_humidity else None) class Heater( @@ -535,17 +475,14 @@ class Heater( """Set the target hvac mode.""" await self.set_property_async( prop=self._prop_on, - value=False if hvac_mode == HVACMode.OFF else True, - ) + value=False if hvac_mode == HVACMode.OFF else True) @property def hvac_mode(self) -> Optional[HVACMode]: """The current hvac mode.""" return ( - HVACMode.HEAT - if self.get_prop_value(prop=self._prop_on) - else HVACMode.OFF - ) + HVACMode.HEAT if self.get_prop_value(prop=self._prop_on) + else HVACMode.OFF) class AirConditioner( @@ -558,7 +495,6 @@ class AirConditioner( FeatureSwingMode, ): """Air conditioner""" - _prop_mode: Optional[MIoTSpecProperty] _hvac_mode_map: Optional[dict[int, HVACMode]] _prop_ac_state: Optional[MIoTSpecProperty] @@ -578,31 +514,31 @@ class AirConditioner( # hvac modes for prop in entity_data.props: if prop.name == 'mode': - if not isinstance(prop.value_list, list) or not prop.value_list: - _LOGGER.error('invalid mode value_list, %s', self.entity_id) + if not prop.value_list: + _LOGGER.error( + 'invalid mode value_list, %s', self.entity_id) continue self._hvac_mode_map = {} - for item in prop.value_list: - if item['name'].lower() in {'off', 'idle'}: - self._hvac_mode_map[item['value']] = HVACMode.OFF - elif item['name'].lower() in {'auto'}: - self._hvac_mode_map[item['value']] = HVACMode.AUTO - elif item['name'].lower() in {'cool'}: - self._hvac_mode_map[item['value']] = HVACMode.COOL - elif item['name'].lower() in {'heat'}: - self._hvac_mode_map[item['value']] = HVACMode.HEAT - elif item['name'].lower() in {'dry'}: - self._hvac_mode_map[item['value']] = HVACMode.DRY - elif item['name'].lower() in {'fan'}: - self._hvac_mode_map[item['value']] = HVACMode.FAN_ONLY + for item in prop.value_list.items: + if item.name in {'off', 'idle'}: + self._hvac_mode_map[item.value] = HVACMode.OFF + elif item.name in {'auto'}: + self._hvac_mode_map[item.value] = HVACMode.AUTO + elif item.name in {'cool'}: + self._hvac_mode_map[item.value] = HVACMode.COOL + elif item.name in {'heat'}: + self._hvac_mode_map[item.value] = HVACMode.HEAT + elif item.name in {'dry'}: + self._hvac_mode_map[item.value] = HVACMode.DRY + elif item.name in {'fan'}: + self._hvac_mode_map[item.value] = HVACMode.FAN_ONLY self._attr_hvac_modes = list(self._hvac_mode_map.values()) self._prop_mode = prop elif prop.name == 'ac-state': self._prop_ac_state = prop self._value_ac_state = {} self.sub_prop_changed( - prop=prop, handler=self.__ac_state_changed - ) + prop=prop, handler=self.__ac_state_changed) if self._attr_hvac_modes is None: self._attr_hvac_modes = [HVACMode.OFF] @@ -618,8 +554,7 @@ class AirConditioner( ): raise RuntimeError( f'set climate prop.on failed, {hvac_mode}, ' - f'{self.entity_id}' - ) + f'{self.entity_id}') return # set the device on elif self.get_prop_value(prop=self._prop_on) is False: @@ -628,14 +563,12 @@ class AirConditioner( if self._prop_mode is None: return mode_value = self.get_map_value( - map_=self._hvac_mode_map, description=hvac_mode - ) + map_=self._hvac_mode_map, key=hvac_mode) if mode_value is None or not await self.set_property_async( prop=self._prop_mode, value=mode_value ): raise RuntimeError( - f'set climate prop.mode failed, {hvac_mode}, {self.entity_id}' - ) + f'set climate prop.mode failed, {hvac_mode}, {self.entity_id}') @property def hvac_mode(self) -> Optional[HVACMode]: @@ -643,13 +576,10 @@ class AirConditioner( if self.get_prop_value(prop=self._prop_on) is False: return HVACMode.OFF return ( - self.get_map_description( + self.get_map_key( map_=self._hvac_mode_map, - key=self.get_prop_value(prop=self._prop_mode), - ) - if self._prop_mode - else None - ) + value=self.get_prop_value(prop=self._prop_mode)) + if self._prop_mode else None) def __ac_state_changed(self, prop: MIoTSpecProperty, value: Any) -> None: del prop @@ -682,19 +612,15 @@ class AirConditioner( self.set_prop_value( prop=self._prop_mode, value=self.get_map_value( - map_=self._hvac_mode_map, description=mode - ), - ) + map_=self._hvac_mode_map, key=mode)) # T: target temperature if 'T' in v_ac_state and self._prop_target_temp: self.set_prop_value( - prop=self._prop_target_temp, value=v_ac_state['T'] - ) + prop=self._prop_target_temp, value=v_ac_state['T']) # S: fan level. 0: auto, 1: low, 2: media, 3: high if 'S' in v_ac_state and self._prop_fan_level: self.set_prop_value( - prop=self._prop_fan_level, value=v_ac_state['S'] - ) + 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 ( @@ -702,15 +628,14 @@ class AirConditioner( and self._prop_horizontal_swing ): self.set_prop_value( - prop=self._prop_horizontal_swing, value=v_ac_state['D'] == 0 - ) + prop=self._prop_horizontal_swing, value=v_ac_state[ + 'D'] == 0) elif ( SWING_VERTICAL in self._attr_swing_modes and self._prop_vertical_swing ): self.set_prop_value( - prop=self._prop_vertical_swing, value=v_ac_state['D'] == 0 - ) + 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) @@ -723,7 +648,6 @@ class PtcBathHeater( FeatureSwingMode, ): """Ptc bath heater""" - _prop_mode: Optional[MIoTSpecProperty] _hvac_mode_map: Optional[dict[int, HVACMode]] @@ -739,23 +663,24 @@ class PtcBathHeater( # hvac modes for prop in entity_data.props: if prop.name == 'mode': - if not isinstance(prop.value_list, list) or not prop.value_list: - _LOGGER.error('invalid mode value_list, %s', self.entity_id) + if not prop.value_list: + _LOGGER.error( + 'invalid mode value_list, %s', self.entity_id) continue self._hvac_mode_map = {} - for item in prop.value_list: - if item['name'].lower() in {'off', 'idle'}: - self._hvac_mode_map[item['value']] = HVACMode.OFF - elif item['name'].lower() in {'auto'}: - self._hvac_mode_map[item['value']] = HVACMode.AUTO - elif item['name'].lower() in {'ventilate'}: - self._hvac_mode_map[item['value']] = HVACMode.COOL - elif item['name'].lower() in {'heat'}: - self._hvac_mode_map[item['value']] = HVACMode.HEAT - elif item['name'].lower() in {'dry'}: - self._hvac_mode_map[item['value']] = HVACMode.DRY - elif item['name'].lower() in {'fan'}: - self._hvac_mode_map[item['value']] = HVACMode.FAN_ONLY + for item in prop.value_list.items: + if item.name in {'off', 'idle'}: + self._hvac_mode_map[item.value] = HVACMode.OFF + elif item.name in {'auto'}: + self._hvac_mode_map[item.value] = HVACMode.AUTO + elif item.name in {'ventilate'}: + self._hvac_mode_map[item.value] = HVACMode.COOL + elif item.name in {'heat'}: + self._hvac_mode_map[item.value] = HVACMode.HEAT + elif item.name in {'dry'}: + self._hvac_mode_map[item.value] = HVACMode.DRY + elif item.name in {'fan'}: + self._hvac_mode_map[item.value] = HVACMode.FAN_ONLY self._attr_hvac_modes = list(self._hvac_mode_map.values()) self._prop_mode = prop @@ -764,26 +689,21 @@ class PtcBathHeater( if self._prop_mode is None: return mode_value = self.get_map_value( - map_=self._hvac_mode_map, description=hvac_mode - ) + map_=self._hvac_mode_map, key=hvac_mode) if mode_value is None or not await self.set_property_async( prop=self._prop_mode, value=mode_value ): raise RuntimeError( - f'set climate prop.mode failed, {hvac_mode}, {self.entity_id}' - ) + f'set climate prop.mode failed, {hvac_mode}, {self.entity_id}') @property def hvac_mode(self) -> Optional[HVACMode]: """The current hvac mode.""" return ( - self.get_map_description( + self.get_map_key( map_=self._hvac_mode_map, - key=self.get_prop_value(prop=self._prop_mode), - ) - if self._prop_mode - else None - ) + value=self.get_prop_value(prop=self._prop_mode)) + if self._prop_mode else None) class Thermostat( @@ -794,7 +714,6 @@ class Thermostat( FeatureFanMode, ): """Thermostat""" - _prop_mode: Optional[MIoTSpecProperty] _hvac_mode_map: Optional[dict[int, HVACMode]] @@ -810,23 +729,24 @@ class Thermostat( # hvac modes for prop in entity_data.props: if prop.name == 'mode': - if not isinstance(prop.value_list, list) or not prop.value_list: - _LOGGER.error('invalid mode value_list, %s', self.entity_id) + if not prop.value_list: + _LOGGER.error( + 'invalid mode value_list, %s', self.entity_id) continue self._hvac_mode_map = {} - for item in prop.value_list: - if item['name'].lower() in {'off', 'idle'}: - self._hvac_mode_map[item['value']] = HVACMode.OFF - elif item['name'].lower() in {'auto'}: - self._hvac_mode_map[item['value']] = HVACMode.AUTO - elif item['name'].lower() in {'cool'}: - self._hvac_mode_map[item['value']] = HVACMode.COOL - elif item['name'].lower() in {'heat'}: - self._hvac_mode_map[item['value']] = HVACMode.HEAT - elif item['name'].lower() in {'dry'}: - self._hvac_mode_map[item['value']] = HVACMode.DRY - elif item['name'].lower() in {'fan'}: - self._hvac_mode_map[item['value']] = HVACMode.FAN_ONLY + for item in prop.value_list.items: + if item.name in {'off', 'idle'}: + self._hvac_mode_map[item.value] = HVACMode.OFF + elif item.name in {'auto'}: + self._hvac_mode_map[item.value] = HVACMode.AUTO + elif item.name in {'cool'}: + self._hvac_mode_map[item.value] = HVACMode.COOL + elif item.name in {'heat'}: + self._hvac_mode_map[item.value] = HVACMode.HEAT + elif item.name in {'dry'}: + self._hvac_mode_map[item.value] = HVACMode.DRY + elif item.name in {'fan'}: + self._hvac_mode_map[item.value] = HVACMode.FAN_ONLY self._attr_hvac_modes = list(self._hvac_mode_map.values()) self._prop_mode = prop @@ -844,8 +764,7 @@ class Thermostat( ): raise RuntimeError( f'set climate prop.on failed, {hvac_mode}, ' - f'{self.entity_id}' - ) + f'{self.entity_id}') return # set the device on elif self.get_prop_value(prop=self._prop_on) is False: @@ -854,14 +773,13 @@ class Thermostat( if self._prop_mode is None: return mode_value = self.get_map_value( - map_=self._hvac_mode_map, description=hvac_mode + map_=self._hvac_mode_map, key=hvac_mode ) if mode_value is None or not await self.set_property_async( prop=self._prop_mode, value=mode_value ): raise RuntimeError( - f'set climate prop.mode failed, {hvac_mode}, {self.entity_id}' - ) + f'set climate prop.mode failed, {hvac_mode}, {self.entity_id}') @property def hvac_mode(self) -> Optional[HVACMode]: @@ -869,10 +787,7 @@ class Thermostat( if self.get_prop_value(prop=self._prop_on) is False: return HVACMode.OFF return ( - self.get_map_description( + self.get_map_key( map_=self._hvac_mode_map, - key=self.get_prop_value(prop=self._prop_mode), - ) - if self._prop_mode - else None - ) + value=self.get_prop_value(prop=self._prop_mode)) + if self._prop_mode else None) diff --git a/custom_components/xiaomi_home/config_flow.py b/custom_components/xiaomi_home/config_flow.py index 667e5fe..7c0d20a 100644 --- a/custom_components/xiaomi_home/config_flow.py +++ b/custom_components/xiaomi_home/config_flow.py @@ -68,6 +68,7 @@ from homeassistant.components.webhook import ( ) from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.instance_id import async_get import homeassistant.helpers.config_validation as cv from .miot.const import ( @@ -90,7 +91,8 @@ from .miot.miot_cloud import MIoTHttpClient, MIoTOauthClient from .miot.miot_storage import MIoTStorage, MIoTCert from .miot.miot_mdns import MipsService from .miot.web_pages import oauth_redirect_page -from .miot.miot_error import MIoTConfigError, MIoTError, MIoTOauthError +from .miot.miot_error import ( + MIoTConfigError, MIoTError, MIoTErrorCode, MIoTOauthError) from .miot.miot_i18n import MIoTI18n from .miot.miot_network import MIoTNetwork from .miot.miot_client import MIoTClient, get_miot_instance_async @@ -122,6 +124,7 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _area_name_rule: str _action_debug: bool _hide_non_standard_entities: bool + _display_binary_mode: list[str] _display_devices_changed_notify: list[str] _cloud_server: str @@ -156,6 +159,7 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._area_name_rule = self.DEFAULT_AREA_NAME_RULE self._action_debug = False self._hide_non_standard_entities = False + self._display_binary_mode = ['bool'] self._display_devices_changed_notify = ['add', 'del', 'offline'] self._auth_info = {} self._nick_name = DEFAULT_NICK_NAME @@ -247,6 +251,13 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input: self._cloud_server = user_input.get( 'cloud_server', self._cloud_server) + # Gen instance uuid + ha_uuid = await async_get(self.hass) + if not ha_uuid: + raise AbortFlow(reason='ha_uuid_get_failed') + self._uuid = hashlib.sha256( + f'{ha_uuid}.{self._virtual_did}.{self._cloud_server}'.encode( + 'utf-8')).hexdigest()[:32] self._integration_language = user_input.get( 'integration_language', DEFAULT_INTEGRATION_LANGUAGE) self._miot_i18n = MIoTI18n( @@ -415,15 +426,17 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): miot_oauth = MIoTOauthClient( client_id=OAUTH2_CLIENT_ID, redirect_url=self._oauth_redirect_url_full, - cloud_server=self._cloud_server - ) - state = str(secrets.randbits(64)) - self.hass.data[DOMAIN][self._virtual_did]['oauth_state'] = state + cloud_server=self._cloud_server, + uuid=self._uuid, + loop=self._main_loop) self._cc_oauth_auth_url = miot_oauth.gen_auth_url( - redirect_url=self._oauth_redirect_url_full, state=state) + redirect_url=self._oauth_redirect_url_full) + self.hass.data[DOMAIN][self._virtual_did]['oauth_state'] = ( + miot_oauth.state) + self.hass.data[DOMAIN][self._virtual_did]['i18n'] = ( + self._miot_i18n) _LOGGER.info( - 'async_step_oauth, oauth_url: %s', - self._cc_oauth_auth_url) + 'async_step_oauth, oauth_url: %s', self._cc_oauth_auth_url) webhook_async_unregister( self.hass, webhook_id=self._virtual_did) webhook_async_register( @@ -462,6 +475,7 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self._miot_oauth.deinit_async() self._miot_oauth = None return self.async_show_progress_done(next_step_id='homes_select') + # pylint: disable=unexpected-keyword-arg return self.async_show_progress( step_id='oauth', progress_action='oauth', @@ -470,7 +484,7 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): f'', 'link_right': '' }, - progress_task=self._cc_task_oauth, + progress_task=self._cc_task_oauth, # type: ignore ) async def __check_oauth_async(self) -> None: @@ -498,11 +512,6 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): client_id=OAUTH2_CLIENT_ID, access_token=auth_info['access_token']) self._auth_info = auth_info - # Gen uuid - self._uuid = hashlib.sha256( - f'{self._virtual_did}.{auth_info["access_token"]}'.encode( - 'utf-8') - ).hexdigest()[:32] try: self._nick_name = ( await self._miot_http.get_user_info_async() or {} @@ -721,6 +730,8 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 'action_debug', self._action_debug) self._hide_non_standard_entities = user_input.get( 'hide_non_standard_entities', self._hide_non_standard_entities) + self._display_binary_mode = user_input.get( + 'display_binary_mode', self._display_binary_mode) self._display_devices_changed_notify = user_input.get( 'display_devices_changed_notify', self._display_devices_changed_notify) @@ -743,6 +754,12 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 'hide_non_standard_entities', default=self._hide_non_standard_entities # type: ignore ): bool, + vol.Required( + 'display_binary_mode', + default=self._display_binary_mode # type: ignore + ): cv.multi_select( + self._miot_i18n.translate( + key='config.binary_mode')), # type: ignore vol.Required( 'display_devices_changed_notify', default=self._display_devices_changed_notify # type: ignore @@ -925,6 +942,7 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 'action_debug': self._action_debug, 'hide_non_standard_entities': self._hide_non_standard_entities, + 'display_binary_mode': self._display_binary_mode, 'display_devices_changed_notify': self._display_devices_changed_notify }) @@ -966,6 +984,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): _devices_filter: dict _action_debug: bool _hide_non_standard_entities: bool + _display_binary_mode: list[str] _display_devs_notify: list[str] _oauth_redirect_url_full: str @@ -980,6 +999,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): _nick_name_new: Optional[str] _action_debug_new: bool _hide_non_standard_entities_new: bool + _display_binary_mode_new: list[str] _update_user_info: bool _update_devices: bool _update_trans_rules: bool @@ -1018,6 +1038,8 @@ class OptionsFlowHandler(config_entries.OptionsFlow): self._action_debug = self._entry_data.get('action_debug', False) self._hide_non_standard_entities = self._entry_data.get( 'hide_non_standard_entities', False) + self._display_binary_mode = self._entry_data.get( + 'display_binary_mode', ['text']) self._display_devs_notify = self._entry_data.get( 'display_devices_changed_notify', ['add', 'del', 'offline']) self._home_selected_list = list( @@ -1036,6 +1058,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): self._nick_name_new = None self._action_debug_new = False self._hide_non_standard_entities_new = False + self._display_binary_mode_new = [] self._update_user_info = False self._update_devices = False self._update_trans_rules = False @@ -1145,15 +1168,14 @@ class OptionsFlowHandler(config_entries.OptionsFlow): async def async_step_oauth(self, user_input=None): try: if self._cc_task_oauth is None: - state = str(secrets.randbits(64)) - self.hass.data[DOMAIN][self._virtual_did]['oauth_state'] = state - self._miot_oauth.set_redirect_url( - redirect_url=self._oauth_redirect_url_full) self._cc_oauth_auth_url = self._miot_oauth.gen_auth_url( - redirect_url=self._oauth_redirect_url_full, state=state) + redirect_url=self._oauth_redirect_url_full) + self.hass.data[DOMAIN][self._virtual_did]['oauth_state'] = ( + self._miot_oauth.state) + self.hass.data[DOMAIN][self._virtual_did]['i18n'] = ( + self._miot_i18n) _LOGGER.info( - 'async_step_oauth, oauth_url: %s', - self._cc_oauth_auth_url) + 'async_step_oauth, oauth_url: %s', self._cc_oauth_auth_url) webhook_async_unregister( self.hass, webhook_id=self._virtual_did) webhook_async_register( @@ -1191,7 +1213,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): err, traceback.format_exc()) self._cc_config_rc = str(err) return self.async_show_progress_done(next_step_id='oauth_error') - + # pylint: disable=unexpected-keyword-arg return self.async_show_progress( step_id='oauth', progress_action='oauth', @@ -1200,7 +1222,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): f'', 'link_right': '' }, - progress_task=self._cc_task_oauth, + progress_task=self._cc_task_oauth, # type: ignore ) async def __check_oauth_async(self) -> None: @@ -1303,6 +1325,12 @@ class OptionsFlowHandler(config_entries.OptionsFlow): 'hide_non_standard_entities', default=self._hide_non_standard_entities # type: ignore ): bool, + vol.Required( + 'display_binary_mode', + default=self._display_binary_mode # type: ignore + ): cv.multi_select( + self._miot_i18n.translate( + 'config.binary_mode')), # type: ignore vol.Required( 'update_trans_rules', default=self._update_trans_rules # type: ignore @@ -1331,6 +1359,8 @@ class OptionsFlowHandler(config_entries.OptionsFlow): 'action_debug', self._action_debug) self._hide_non_standard_entities_new = user_input.get( 'hide_non_standard_entities', self._hide_non_standard_entities) + self._display_binary_mode_new = user_input.get( + 'display_binary_mode', self._display_binary_mode) self._display_devs_notify = user_input.get( 'display_devices_changed_notify', self._display_devs_notify) self._update_trans_rules = user_input.get( @@ -1934,6 +1964,10 @@ class OptionsFlowHandler(config_entries.OptionsFlow): self._entry_data['hide_non_standard_entities'] = ( self._hide_non_standard_entities_new) self._need_reload = True + if set(self._display_binary_mode) != set(self._display_binary_mode_new): + self._entry_data['display_binary_mode'] = ( + self._display_binary_mode_new) + self._need_reload = True # Update display_devices_changed_notify self._entry_data['display_devices_changed_notify'] = ( self._display_devs_notify) @@ -1967,29 +2001,61 @@ class OptionsFlowHandler(config_entries.OptionsFlow): async def _handle_oauth_webhook(hass, webhook_id, request): """Webhook to handle oauth2 callback.""" # pylint: disable=inconsistent-quotes + i18n: MIoTI18n = hass.data[DOMAIN][webhook_id].get('i18n', None) try: data = dict(request.query) if data.get('code', None) is None or data.get('state', None) is None: - raise MIoTConfigError('invalid oauth code') + raise MIoTConfigError( + 'invalid oauth code or state', + MIoTErrorCode.CODE_CONFIG_INVALID_INPUT) if data['state'] != hass.data[DOMAIN][webhook_id]['oauth_state']: raise MIoTConfigError( - f'invalid oauth state, ' - f'{hass.data[DOMAIN][webhook_id]["oauth_state"]}, ' - f'{data["state"]}') + f'inconsistent state, ' + f'{hass.data[DOMAIN][webhook_id]["oauth_state"]}!=' + f'{data["state"]}', MIoTErrorCode.CODE_CONFIG_INVALID_STATE) fut_oauth_code: asyncio.Future = hass.data[DOMAIN][webhook_id].pop( 'fut_oauth_code', None) fut_oauth_code.set_result(data['code']) _LOGGER.info('webhook code: %s', data['code']) + success_trans: dict = {} + if i18n: + success_trans = i18n.translate( + 'oauth2.success') or {} # type: ignore + # Delete + del hass.data[DOMAIN][webhook_id]['oauth_state'] + del hass.data[DOMAIN][webhook_id]['i18n'] return web.Response( - body=oauth_redirect_page( - hass.config.language, 'success'), content_type='text/html') + body=await oauth_redirect_page( + title=success_trans.get('title', 'Success'), + content=success_trans.get( + 'content', ( + 'Please close this page and return to the account ' + 'authentication page to click NEXT')), + button=success_trans.get('button', 'Close Page'), + success=True, + ), content_type='text/html') - except MIoTConfigError: + except Exception as err: # pylint: disable=broad-exception-caught + fail_trans: dict = {} + err_msg: str = str(err) + if i18n: + if isinstance(err, MIoTConfigError): + err_msg = i18n.translate( + f'oauth2.error_msg.{err.code.value}' + ) or err.message # type: ignore + fail_trans = i18n.translate('oauth2.fail') or {} # type: ignore return web.Response( - body=oauth_redirect_page(hass.config.language, 'fail'), + body=await oauth_redirect_page( + title=fail_trans.get('title', 'Authentication Failed'), + content=str(fail_trans.get('content', ( + '{error_msg}, Please close this page and return to the ' + 'account authentication page to click the authentication ' + 'link again.'))).replace('{error_msg}', err_msg), + button=fail_trans.get('button', 'Close Page'), + success=False), content_type='text/html') diff --git a/custom_components/xiaomi_home/cover.py b/custom_components/xiaomi_home/cover.py index d8236c7..78a6a02 100644 --- a/custom_components/xiaomi_home/cover.py +++ b/custom_components/xiaomi_home/cover.py @@ -132,53 +132,47 @@ class Cover(MIoTServiceEntity, CoverEntity): # properties for prop in entity_data.props: if prop.name == 'motor-control': - if ( - not isinstance(prop.value_list, list) - or not prop.value_list - ): + if not prop.value_list: _LOGGER.error( 'motor-control value_list is None, %s', self.entity_id) continue - for item in prop.value_list: - if item['name'].lower() in ['open']: + for item in prop.value_list.items: + if item.name in {'open'}: self._attr_supported_features |= ( CoverEntityFeature.OPEN) - self._prop_motor_value_open = item['value'] - elif item['name'].lower() in ['close']: + self._prop_motor_value_open = item.value + elif item.name in {'close'}: self._attr_supported_features |= ( CoverEntityFeature.CLOSE) - self._prop_motor_value_close = item['value'] - elif item['name'].lower() in ['pause']: + self._prop_motor_value_close = item.value + elif item.name in {'pause'}: self._attr_supported_features |= ( CoverEntityFeature.STOP) - self._prop_motor_value_pause = item['value'] + self._prop_motor_value_pause = item.value self._prop_motor_control = prop elif prop.name == 'status': - if ( - not isinstance(prop.value_list, list) - or not prop.value_list - ): + if not prop.value_list: _LOGGER.error( 'status value_list is None, %s', self.entity_id) continue - for item in prop.value_list: - if item['name'].lower() in ['opening', 'open']: - self._prop_status_opening = item['value'] - elif item['name'].lower() in ['closing', 'close']: - self._prop_status_closing = item['value'] - elif item['name'].lower() in ['stop', 'pause']: - self._prop_status_stop = item['value'] + for item in prop.value_list.items: + if item.name in {'opening', 'open'}: + self._prop_status_opening = item.value + elif item.name in {'closing', 'close'}: + self._prop_status_closing = item.value + elif item.name in {'stop', 'pause'}: + self._prop_status_stop = item.value self._prop_status = prop elif prop.name == 'current-position': self._prop_current_position = prop elif prop.name == 'target-position': - if not isinstance(prop.value_range, dict): + if not prop.value_range: _LOGGER.error( 'invalid target-position value_range format, %s', self.entity_id) continue - self._prop_position_value_min = prop.value_range['min'] - self._prop_position_value_max = prop.value_range['max'] + self._prop_position_value_min = prop.value_range.min_ + self._prop_position_value_max = prop.value_range.max_ self._prop_position_value_range = ( self._prop_position_value_max - self._prop_position_value_min) diff --git a/custom_components/xiaomi_home/event.py b/custom_components/xiaomi_home/event.py index 7892290..85fbf33 100644 --- a/custom_components/xiaomi_home/event.py +++ b/custom_components/xiaomi_home/event.py @@ -85,6 +85,8 @@ class Event(MIoTEventEntity, EventEntity): # Set device_class self._attr_device_class = spec.device_class - def on_event_occurred(self, name: str, arguments: list[dict[int, Any]]): + def on_event_occurred( + self, name: str, arguments: dict[str, Any] | None = None + ) -> None: """An event is occurred.""" self._trigger_event(event_type=name, event_attributes=arguments) diff --git a/custom_components/xiaomi_home/fan.py b/custom_components/xiaomi_home/fan.py index 90220db..a28b989 100644 --- a/custom_components/xiaomi_home/fan.py +++ b/custom_components/xiaomi_home/fan.py @@ -87,7 +87,7 @@ async def async_setup_entry( class Fan(MIoTServiceEntity, FanEntity): """Fan entities for Xiaomi Home.""" # pylint: disable=unused-argument - _prop_on: Optional[MIoTSpecProperty] + _prop_on: MIoTSpecProperty _prop_fan_level: Optional[MIoTSpecProperty] _prop_mode: Optional[MIoTSpecProperty] _prop_horizontal_swing: Optional[MIoTSpecProperty] @@ -100,7 +100,7 @@ class Fan(MIoTServiceEntity, FanEntity): _speed_step: int _speed_names: Optional[list] _speed_name_map: Optional[dict[int, str]] - _mode_list: Optional[dict[Any, Any]] + _mode_map: Optional[dict[Any, Any]] def __init__( self, miot_device: MIoTDevice, entity_data: MIoTEntityData @@ -111,7 +111,7 @@ class Fan(MIoTServiceEntity, FanEntity): self._attr_current_direction = None self._attr_supported_features = FanEntityFeature(0) - self._prop_on = None + # _prop_on is required self._prop_fan_level = None self._prop_mode = None self._prop_horizontal_swing = None @@ -124,7 +124,7 @@ class Fan(MIoTServiceEntity, FanEntity): self._speed_names = [] self._speed_name_map = {} - self._mode_list = None + self._mode_map = None # properties for prop in entity_data.props: @@ -133,42 +133,34 @@ class Fan(MIoTServiceEntity, FanEntity): self._attr_supported_features |= FanEntityFeature.TURN_OFF self._prop_on = prop elif prop.name == 'fan-level': - if isinstance(prop.value_range, dict): + if prop.value_range: # Fan level with value-range - self._speed_min = prop.value_range['min'] - self._speed_max = prop.value_range['max'] - self._speed_step = prop.value_range['step'] + self._speed_min = prop.value_range.min_ + self._speed_max = prop.value_range.max_ + self._speed_step = prop.value_range.step self._attr_speed_count = int(( self._speed_max - self._speed_min)/self._speed_step)+1 self._attr_supported_features |= FanEntityFeature.SET_SPEED self._prop_fan_level = prop elif ( self._prop_fan_level is None - and isinstance(prop.value_list, list) and prop.value_list ): # Fan level with value-list # Fan level with value-range is prior to fan level with # value-list when a fan has both fan level properties. - self._speed_name_map = { - item['value']: item['description'] - for item in prop.value_list} + self._speed_name_map = prop.value_list.to_map() self._speed_names = list(self._speed_name_map.values()) - self._attr_speed_count = len(prop.value_list) + self._attr_speed_count = len(self._speed_names) self._attr_supported_features |= FanEntityFeature.SET_SPEED self._prop_fan_level = prop elif prop.name == 'mode': - if ( - not isinstance(prop.value_list, list) - or not prop.value_list - ): + if not prop.value_list: _LOGGER.error( 'mode value_list is None, %s', self.entity_id) continue - self._mode_list = { - item['value']: item['description'] - for item in prop.value_list} - self._attr_preset_modes = list(self._mode_list.values()) + self._mode_map = prop.value_list.to_map() + self._attr_preset_modes = list(self._mode_map.values()) self._attr_supported_features |= FanEntityFeature.PRESET_MODE self._prop_mode = prop elif prop.name == 'horizontal-swing': @@ -178,16 +170,11 @@ class Fan(MIoTServiceEntity, FanEntity): if prop.format_ == 'bool': self._prop_wind_reverse_forward = False self._prop_wind_reverse_reverse = True - elif ( - isinstance(prop.value_list, list) - and prop.value_list - ): - for item in prop.value_list: - if item['name'].lower() in {'foreward'}: - self._prop_wind_reverse_forward = item['value'] - elif item['name'].lower() in { - 'reversal', 'reverse'}: - self._prop_wind_reverse_reverse = item['value'] + elif prop.value_list: + for item in prop.value_list.items: + if item.name in {'foreward'}: + self._prop_wind_reverse_forward = item.value + self._prop_wind_reverse_reverse = item.value if ( self._prop_wind_reverse_forward is None or self._prop_wind_reverse_reverse is None @@ -199,21 +186,9 @@ class Fan(MIoTServiceEntity, FanEntity): self._attr_supported_features |= FanEntityFeature.DIRECTION self._prop_wind_reverse = prop - def __get_mode_description(self, key: int) -> Optional[str]: - if self._mode_list is None: - return None - return self._mode_list.get(key, None) - - def __get_mode_value(self, description: str) -> Optional[int]: - if self._mode_list is None: - return None - for key, value in self._mode_list.items(): - if value == description: - return key - return None - async def async_turn_on( - self, percentage: int = None, preset_mode: str = None, **kwargs: Any + self, percentage: Optional[int] = None, + preset_mode: Optional[str] = None, **kwargs: Any ) -> None: """Turn the fan on. @@ -225,12 +200,12 @@ class Fan(MIoTServiceEntity, FanEntity): # percentage if percentage: if self._speed_names: - speed = percentage_to_ordered_list_item( - self._speed_names, percentage) - speed_value = self.get_map_value( - map_=self._speed_name_map, description=speed) await self.set_property_async( - prop=self._prop_fan_level, value=speed_value) + prop=self._prop_fan_level, + value=self.get_map_value( + map_=self._speed_name_map, + key=percentage_to_ordered_list_item( + self._speed_names, percentage))) else: await self.set_property_async( prop=self._prop_fan_level, @@ -241,7 +216,8 @@ class Fan(MIoTServiceEntity, FanEntity): if preset_mode: await self.set_property_async( self._prop_mode, - value=self.__get_mode_value(description=preset_mode)) + value=self.get_map_key( + map_=self._mode_map, value=preset_mode)) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the fan off.""" @@ -255,12 +231,12 @@ class Fan(MIoTServiceEntity, FanEntity): """Set the percentage of the fan speed.""" if percentage > 0: if self._speed_names: - speed = percentage_to_ordered_list_item( - self._speed_names, percentage) - speed_value = self.get_map_value( - map_=self._speed_name_map, description=speed) await self.set_property_async( - prop=self._prop_fan_level, value=speed_value) + prop=self._prop_fan_level, + value=self.get_map_value( + map_=self._speed_name_map, + key=percentage_to_ordered_list_item( + self._speed_names, percentage))) else: await self.set_property_async( prop=self._prop_fan_level, @@ -277,7 +253,8 @@ class Fan(MIoTServiceEntity, FanEntity): """Set the preset mode.""" await self.set_property_async( self._prop_mode, - value=self.__get_mode_value(description=preset_mode)) + value=self.get_map_key( + map_=self._mode_map, value=preset_mode)) async def async_set_direction(self, direction: str) -> None: """Set the direction of the fan.""" @@ -306,7 +283,8 @@ class Fan(MIoTServiceEntity, FanEntity): """Return the current preset mode, e.g., auto, smart, eco, favorite.""" return ( - self.__get_mode_description( + self.get_map_value( + map_=self._mode_map, key=self.get_prop_value(prop=self._prop_mode)) if self._prop_mode else None) diff --git a/custom_components/xiaomi_home/humidifier.py b/custom_components/xiaomi_home/humidifier.py index 9739da4..1bcd5c8 100644 --- a/custom_components/xiaomi_home/humidifier.py +++ b/custom_components/xiaomi_home/humidifier.py @@ -97,7 +97,7 @@ class Humidifier(MIoTServiceEntity, HumidifierEntity): _prop_target_humidity: Optional[MIoTSpecProperty] _prop_humidity: Optional[MIoTSpecProperty] - _mode_list: dict[Any, Any] + _mode_map: dict[Any, Any] def __init__( self, miot_device: MIoTDevice, entity_data: MIoTEntityData @@ -110,7 +110,7 @@ class Humidifier(MIoTServiceEntity, HumidifierEntity): self._prop_mode = None self._prop_target_humidity = None self._prop_humidity = None - self._mode_list = None + self._mode_map = None # properties for prop in entity_data.props: @@ -119,28 +119,23 @@ class Humidifier(MIoTServiceEntity, HumidifierEntity): self._prop_on = prop # target-humidity elif prop.name == 'target-humidity': - if not isinstance(prop.value_range, dict): + if not prop.value_range: _LOGGER.error( 'invalid target-humidity value_range format, %s', self.entity_id) continue - self._attr_min_humidity = prop.value_range['min'] - self._attr_max_humidity = prop.value_range['max'] + self._attr_min_humidity = prop.value_range.min_ + self._attr_max_humidity = prop.value_range.max_ self._prop_target_humidity = prop # mode elif prop.name == 'mode': - if ( - not isinstance(prop.value_list, list) - or not prop.value_list - ): + if not prop.value_list: _LOGGER.error( 'mode value_list is None, %s', self.entity_id) continue - self._mode_list = { - item['value']: item['description'] - for item in prop.value_list} + self._mode_map = prop.value_list.to_map() self._attr_available_modes = list( - self._mode_list.values()) + self._mode_map.values()) self._attr_supported_features |= HumidifierEntityFeature.MODES self._prop_mode = prop # relative-humidity @@ -163,7 +158,8 @@ class Humidifier(MIoTServiceEntity, HumidifierEntity): async def async_set_mode(self, mode: str) -> None: """Set new target preset mode.""" await self.set_property_async( - prop=self._prop_mode, value=self.__get_mode_value(description=mode)) + prop=self._prop_mode, + value=self.get_map_key(map_=self._mode_map, value=mode)) @property def is_on(self) -> Optional[bool]: @@ -183,20 +179,6 @@ class Humidifier(MIoTServiceEntity, HumidifierEntity): @property def mode(self) -> Optional[str]: """Return the current preset mode.""" - return self.__get_mode_description( + return self.get_map_value( + map_=self._mode_map, key=self.get_prop_value(prop=self._prop_mode)) - - def __get_mode_description(self, key: int) -> Optional[str]: - """Convert mode value to description.""" - if self._mode_list is None: - return None - return self._mode_list.get(key, None) - - def __get_mode_value(self, description: str) -> Optional[int]: - """Convert mode description to value.""" - if self._mode_list is None: - return None - for key, value in self._mode_list.items(): - if value == description: - return key - return None diff --git a/custom_components/xiaomi_home/light.py b/custom_components/xiaomi_home/light.py index 666464e..1667662 100644 --- a/custom_components/xiaomi_home/light.py +++ b/custom_components/xiaomi_home/light.py @@ -96,14 +96,14 @@ class Light(MIoTServiceEntity, LightEntity): """Light entities for Xiaomi Home.""" # pylint: disable=unused-argument _VALUE_RANGE_MODE_COUNT_MAX = 30 - _prop_on: Optional[MIoTSpecProperty] + _prop_on: MIoTSpecProperty _prop_brightness: Optional[MIoTSpecProperty] _prop_color_temp: Optional[MIoTSpecProperty] _prop_color: Optional[MIoTSpecProperty] _prop_mode: Optional[MIoTSpecProperty] _brightness_scale: Optional[tuple[int, int]] - _mode_list: Optional[dict[Any, Any]] + _mode_map: Optional[dict[Any, Any]] def __init__( self, miot_device: MIoTDevice, entity_data: MIoTEntityData @@ -122,7 +122,7 @@ class Light(MIoTServiceEntity, LightEntity): self._prop_color = None self._prop_mode = None self._brightness_scale = None - self._mode_list = None + self._mode_map = None # properties for prop in entity_data.props: @@ -131,20 +131,17 @@ class Light(MIoTServiceEntity, LightEntity): self._prop_on = prop # brightness if prop.name == 'brightness': - if isinstance(prop.value_range, dict): + if prop.value_range: self._brightness_scale = ( - prop.value_range['min'], prop.value_range['max']) + prop.value_range.min_, prop.value_range.max_) self._prop_brightness = prop elif ( - self._mode_list is None - and isinstance(prop.value_list, list) + self._mode_map is None and prop.value_list ): # For value-list brightness - self._mode_list = { - item['value']: item['description'] - for item in prop.value_list} - self._attr_effect_list = list(self._mode_list.values()) + self._mode_map = prop.value_list.to_map() + self._attr_effect_list = list(self._mode_map.values()) self._attr_supported_features |= LightEntityFeature.EFFECT self._prop_mode = prop else: @@ -153,13 +150,13 @@ class Light(MIoTServiceEntity, LightEntity): continue # color-temperature if prop.name == 'color-temperature': - if not isinstance(prop.value_range, dict): + if not prop.value_range: _LOGGER.info( 'invalid color-temperature value_range format, %s', self.entity_id) continue - self._attr_min_color_temp_kelvin = prop.value_range['min'] - self._attr_max_color_temp_kelvin = prop.value_range['max'] + self._attr_min_color_temp_kelvin = prop.value_range.min_ + self._attr_max_color_temp_kelvin = prop.value_range.max_ self._attr_supported_color_modes.add(ColorMode.COLOR_TEMP) self._attr_color_mode = ColorMode.COLOR_TEMP self._prop_color_temp = prop @@ -171,20 +168,15 @@ class Light(MIoTServiceEntity, LightEntity): # mode if prop.name == 'mode': mode_list = None - if ( - isinstance(prop.value_list, list) - and prop.value_list - ): - mode_list = { - item['value']: item['description'] - for item in prop.value_list} - elif isinstance(prop.value_range, dict): + if prop.value_list: + mode_list = prop.value_list.to_map() + elif prop.value_range: mode_list = {} if ( int(( - prop.value_range['max'] - - prop.value_range['min'] - ) / prop.value_range['step']) + prop.value_range.max_ + - prop.value_range.min_ + ) / prop.value_range.step) > self._VALUE_RANGE_MODE_COUNT_MAX ): _LOGGER.info( @@ -192,13 +184,13 @@ class Light(MIoTServiceEntity, LightEntity): self.entity_id, prop.name, prop.value_range) else: for value in range( - prop.value_range['min'], - prop.value_range['max'], - prop.value_range['step']): + prop.value_range.min_, + prop.value_range.max_, + prop.value_range.step): mode_list[value] = f'mode {value}' if mode_list: - self._mode_list = mode_list - self._attr_effect_list = list(self._mode_list.values()) + self._mode_map = mode_list + self._attr_effect_list = list(self._mode_map.values()) self._attr_supported_features |= LightEntityFeature.EFFECT self._prop_mode = prop else: @@ -213,21 +205,6 @@ class Light(MIoTServiceEntity, LightEntity): self._attr_supported_color_modes.add(ColorMode.ONOFF) self._attr_color_mode = ColorMode.ONOFF - def __get_mode_description(self, key: int) -> Optional[str]: - """Convert mode value to description.""" - if self._mode_list is None: - return None - return self._mode_list.get(key, None) - - def __get_mode_value(self, description: str) -> Optional[int]: - """Convert mode description to value.""" - if self._mode_list is None: - return None - for key, value in self._mode_list.items(): - if value == description: - return key - return None - @property def is_on(self) -> Optional[bool]: """Return if the light is on.""" @@ -264,7 +241,8 @@ class Light(MIoTServiceEntity, LightEntity): @property def effect(self) -> Optional[str]: """Return the current mode.""" - return self.__get_mode_description( + return self.get_map_value( + map_=self._mode_map, key=self.get_prop_value(prop=self._prop_mode)) async def async_turn_on(self, **kwargs) -> None: @@ -275,7 +253,7 @@ class Light(MIoTServiceEntity, LightEntity): result: bool = False # on # Dirty logic for lumi.gateway.mgl03 indicator light - value_on = True if self._prop_on.format_ == 'bool' else 1 + value_on = True if self._prop_on.format_ == bool else 1 result = await self.set_property_async( prop=self._prop_on, value=value_on) # brightness @@ -303,11 +281,12 @@ class Light(MIoTServiceEntity, LightEntity): if ATTR_EFFECT in kwargs: result = await self.set_property_async( prop=self._prop_mode, - value=self.__get_mode_value(description=kwargs[ATTR_EFFECT])) + value=self.get_map_key( + map_=self._mode_map, value=kwargs[ATTR_EFFECT])) return result async def async_turn_off(self, **kwargs) -> None: """Turn the light off.""" # Dirty logic for lumi.gateway.mgl03 indicator light - value_on = False if self._prop_on.format_ == 'bool' else 0 + value_on = False if self._prop_on.format_ == bool else 0 return await self.set_property_async(prop=self._prop_on, value=value_on) diff --git a/custom_components/xiaomi_home/manifest.json b/custom_components/xiaomi_home/manifest.json index 3e07f1c..ca5d71e 100644 --- a/custom_components/xiaomi_home/manifest.json +++ b/custom_components/xiaomi_home/manifest.json @@ -25,7 +25,7 @@ "cryptography", "psutil" ], - "version": "v0.1.5b0", + "version": "v0.1.5b2", "zeroconf": [ "_miot-central._tcp.local." ] diff --git a/custom_components/xiaomi_home/miot/common.py b/custom_components/xiaomi_home/miot/common.py index 0ee4f1d..21c5439 100644 --- a/custom_components/xiaomi_home/miot/common.py +++ b/custom_components/xiaomi_home/miot/common.py @@ -45,13 +45,17 @@ off Xiaomi or its affiliates' products. Common utilities. """ +import asyncio import json from os import path import random from typing import Any, Optional import hashlib +from urllib.parse import urlencode +from urllib.request import Request, urlopen from paho.mqtt.matcher import MQTTMatcher import yaml +from slugify import slugify MIOT_ROOT_PATH: str = path.dirname(path.abspath(__file__)) @@ -83,10 +87,22 @@ def randomize_int(value: int, ratio: float) -> int: """Randomize an integer value.""" return int(value * (1 - ratio + random.random()*2*ratio)) + def randomize_float(value: float, ratio: float) -> float: """Randomize a float value.""" return value * (1 - ratio + random.random()*2*ratio) + +def slugify_name(name: str, separator: str = '_') -> str: + """Slugify a name.""" + return slugify(name, separator=separator) + + +def slugify_did(cloud_server: str, did: str) -> str: + """Slugify a device id.""" + return slugify(f'{cloud_server}_{did}', separator='_') + + class MIoTMatcher(MQTTMatcher): """MIoT Pub/Sub topic matcher.""" @@ -105,3 +121,68 @@ class MIoTMatcher(MQTTMatcher): return self[topic] except KeyError: return None + + +class MIoTHttp: + """MIoT Common HTTP API.""" + @staticmethod + def get( + url: str, params: Optional[dict] = None, headers: Optional[dict] = None + ) -> Optional[str]: + full_url = url + if params: + encoded_params = urlencode(params) + full_url = f'{url}?{encoded_params}' + request = Request(full_url, method='GET', headers=headers or {}) + content: Optional[bytes] = None + with urlopen(request) as response: + content = response.read() + return str(content, 'utf-8') if content else None + + @staticmethod + def get_json( + url: str, params: Optional[dict] = None, headers: Optional[dict] = None + ) -> Optional[dict]: + response = MIoTHttp.get(url, params, headers) + return json.loads(response) if response else None + + @staticmethod + def post( + url: str, data: Optional[dict] = None, headers: Optional[dict] = None + ) -> Optional[str]: + pass + + @staticmethod + def post_json( + url: str, data: Optional[dict] = None, headers: Optional[dict] = None + ) -> Optional[dict]: + response = MIoTHttp.post(url, data, headers) + return json.loads(response) if response else None + + @staticmethod + async def get_async( + url: str, params: Optional[dict] = None, headers: Optional[dict] = None, + loop: Optional[asyncio.AbstractEventLoop] = None + ) -> Optional[str]: + # TODO: Use aiohttp + ev_loop = loop or asyncio.get_running_loop() + return await ev_loop.run_in_executor( + None, MIoTHttp.get, url, params, headers) + + @staticmethod + async def get_json_async( + url: str, params: Optional[dict] = None, headers: Optional[dict] = None, + loop: Optional[asyncio.AbstractEventLoop] = None + ) -> Optional[dict]: + ev_loop = loop or asyncio.get_running_loop() + return await ev_loop.run_in_executor( + None, MIoTHttp.get_json, url, params, headers) + + @ staticmethod + async def post_async( + url: str, data: Optional[dict] = None, headers: Optional[dict] = None, + loop: Optional[asyncio.AbstractEventLoop] = None + ) -> Optional[str]: + ev_loop = loop or asyncio.get_running_loop() + return await ev_loop.run_in_executor( + None, MIoTHttp.post, url, data, headers) diff --git a/custom_components/xiaomi_home/miot/const.py b/custom_components/xiaomi_home/miot/const.py index c586992..275b297 100644 --- a/custom_components/xiaomi_home/miot/const.py +++ b/custom_components/xiaomi_home/miot/const.py @@ -103,6 +103,7 @@ INTEGRATION_LANGUAGES = { 'en': 'English', 'es': 'Español', 'fr': 'Français', + 'it': 'Italiano', 'ja': '日本語', 'nl': 'Nederlands', 'pt': 'Português', diff --git a/custom_components/xiaomi_home/miot/i18n/de.json b/custom_components/xiaomi_home/miot/i18n/de.json index 81fb203..05ae2bf 100644 --- a/custom_components/xiaomi_home/miot/i18n/de.json +++ b/custom_components/xiaomi_home/miot/i18n/de.json @@ -54,6 +54,10 @@ "enable": "aktivieren", "disable": "deaktivieren" }, + "binary_mode": { + "text": "Textsensor-Entität", + "bool": "Binärsensor-Entität" + }, "device_state": { "add": "hinzufügen", "del": "nicht verfügbar", @@ -64,6 +68,22 @@ "net_unavailable": "Schnittstelle nicht verfügbar" } }, + "oauth2": { + "success": { + "title": "Authentifizierung erfolgreich", + "content": "Bitte schließen Sie diese Seite und kehren Sie zur Kontoauthentifizierungsseite zurück, um auf „Weiter“ zu klicken.", + "button": "Schließen" + }, + "fail": { + "title": "Authentifizierung fehlgeschlagen", + "content": "{error_msg}, bitte schließen Sie diese Seite und kehren Sie zur Kontoauthentifizierungsseite zurück, um den Authentifizierungslink erneut zu klicken.", + "button": "Schließen" + }, + "error_msg": { + "-10100": "Ungültige Antwortparameter ('code' oder 'state' Feld ist leer)", + "-10101": "Übergebenes 'state' Feld stimmt nicht überein" + } + }, "miot": { "client": { "invalid_oauth_info": "Ungültige Authentifizierungsinformationen, Cloud-Verbindung nicht verfügbar, bitte betreten Sie die Xiaomi Home-Integrationsseite und klicken Sie auf 'Optionen', um die Authentifizierung erneut durchzuführen", diff --git a/custom_components/xiaomi_home/miot/i18n/en.json b/custom_components/xiaomi_home/miot/i18n/en.json index 219b276..47187ad 100644 --- a/custom_components/xiaomi_home/miot/i18n/en.json +++ b/custom_components/xiaomi_home/miot/i18n/en.json @@ -54,6 +54,10 @@ "enable": "Enable", "disable": "Disable" }, + "binary_mode": { + "text": "Text Sensor Entity", + "bool": "Binary Sensor Entity" + }, "device_state": { "add": "Add", "del": "Unavailable", @@ -64,6 +68,22 @@ "net_unavailable": "Interface unavailable" } }, + "oauth2": { + "success": { + "title": "Authentication Successful", + "content": "Please close this page and return to the account authentication page to click 'Next'.", + "button": "Close" + }, + "fail": { + "title": "Authentication Failed", + "content": "{error_msg}, please close this page and return to the account authentication page to click the authentication link again.", + "button": "Close" + }, + "error_msg": { + "-10100": "Invalid response parameters ('code' or 'state' field is empty)", + "-10101": "Passed-in 'state' field mismatch" + } + }, "miot": { "client": { "invalid_oauth_info": "Authentication information is invalid, cloud link will be unavailable, please enter the Xiaomi Home integration page, click 'Options' to re-authenticate", diff --git a/custom_components/xiaomi_home/miot/i18n/es.json b/custom_components/xiaomi_home/miot/i18n/es.json index 49a6ea6..c6f78df 100644 --- a/custom_components/xiaomi_home/miot/i18n/es.json +++ b/custom_components/xiaomi_home/miot/i18n/es.json @@ -54,6 +54,10 @@ "enable": "habilitar", "disable": "deshabilitar" }, + "binary_mode": { + "text": "Entidad del sensor de texto", + "bool": "Entidad del sensor binario" + }, "device_state": { "add": "agregar", "del": "no disponible", @@ -64,6 +68,22 @@ "net_unavailable": "Interfaz no disponible" } }, + "oauth2": { + "success": { + "title": "Autenticación exitosa", + "content": "Por favor, cierre esta página y regrese a la página de autenticación de la cuenta para hacer clic en 'Siguiente'.", + "button": "Cerrar" + }, + "fail": { + "title": "Autenticación fallida", + "content": "{error_msg}, por favor, cierre esta página y regrese a la página de autenticación de la cuenta para hacer clic en el enlace de autenticación nuevamente.", + "button": "Cerrar" + }, + "error_msg": { + "-10100": "Parámetros de respuesta inválidos ('code' o 'state' está vacío)", + "-10101": "El campo 'state' proporcionado no coincide" + } + }, "miot": { "client": { "invalid_oauth_info": "La información de autenticación es inválida, la conexión en la nube no estará disponible, por favor, vaya a la página de integración de Xiaomi Home, haga clic en 'Opciones' para volver a autenticar", diff --git a/custom_components/xiaomi_home/miot/i18n/fr.json b/custom_components/xiaomi_home/miot/i18n/fr.json index 40feb65..2789cc6 100644 --- a/custom_components/xiaomi_home/miot/i18n/fr.json +++ b/custom_components/xiaomi_home/miot/i18n/fr.json @@ -54,6 +54,10 @@ "enable": "activer", "disable": "désactiver" }, + "binary_mode": { + "text": "Entité du capteur de texte", + "bool": "Entité du capteur binaire" + }, "device_state": { "add": "Ajouter", "del": "Supprimer", @@ -64,6 +68,22 @@ "net_unavailable": "Interface non disponible" } }, + "oauth2": { + "success": { + "title": "Authentification réussie", + "content": "Veuillez fermer cette page et revenir à la page d'authentification du compte pour cliquer sur 'Suivant'.", + "button": "Fermer" + }, + "fail": { + "title": "Échec de l'authentification", + "content": "{error_msg}, veuillez fermer cette page et revenir à la page d'authentification du compte pour cliquer à nouveau sur le lien d'authentification.", + "button": "Fermer" + }, + "error_msg": { + "-10100": "Paramètres de réponse invalides ('code' ou 'state' est vide)", + "-10101": "Le champ 'state' transmis ne correspond pas" + } + }, "miot": { "client": { "invalid_oauth_info": "Informations d'authentification non valides, le lien cloud ne sera pas disponible, veuillez accéder à la page d'intégration Xiaomi Home, cliquez sur \"Options\" pour vous réauthentifier", diff --git a/custom_components/xiaomi_home/miot/i18n/it.json b/custom_components/xiaomi_home/miot/i18n/it.json new file mode 100644 index 0000000..8ec19d3 --- /dev/null +++ b/custom_components/xiaomi_home/miot/i18n/it.json @@ -0,0 +1,156 @@ +{ + "config": { + "other": { + "devices": "Dispositivi", + "found_central_gateway": ", Trovato Gateway Hub Centrale Locale", + "without_room": "Nessuna stanza assegnata", + "no_display": "Non visualizzare" + }, + "control_mode": { + "auto": "Auto", + "cloud": "Cloud" + }, + "statistics_logic": { + "or": "Logica OR", + "and": "Logica AND" + }, + "filter_mode": { + "exclude": "Escludere", + "include": "Includere" + }, + "connect_type": { + "0": "WiFi", + "1": "Dispositivo yunyi", + "2": "Dispositivo Cloud", + "3": "ZigBee", + "4": "webSocket", + "5": "Dispositivo Virtuale", + "6": "BLE", + "7": "AP Locale", + "8": "WiFi+BLE", + "9": "Altro", + "10": "Plug-in Funzionale", + "11": "Rete Cellulare", + "12": "Cavo", + "13": "NB-IoT", + "14": "Accesso cloud di terze parti", + "15": "Dispositivo di controllo remoto a infrarossi", + "16": "BLE-Mesh", + "17": "Gruppo di Dispositivi Virtuali", + "18": "Sottodispositivo Gateway", + "19": "Sottodispositivo Gateway di livello di sicurezza", + "22": "PLC", + "23": "Solo Cavo", + "24": "Matter", + "25": "WiFi+Rete Cellulare" + }, + "room_name_rule": { + "none": "Non sincronizzare", + "home_room": "Nome Casa e Nome Stanza (Camera da Letto Xiaomi Home)", + "room": "Nome Stanza (Camera da Letto)", + "home": "Nome Casa (Xiaomi Home)" + }, + "option_status": { + "enable": "Abilita", + "disable": "Disabilita" + }, + "binary_mode": { + "text": "Entità Sensore Testo", + "bool": "Entità Sensore Binario" + }, + "device_state": { + "add": "Aggiungere", + "del": "Non disponibile", + "offline": "Offline" + }, + "lan_ctrl_config": { + "notice_net_dup": "\r\n**[Avviso]** Rilevate più schede di rete che potrebbero essere connesse alla stessa rete. Si prega di prestare attenzione alla selezione.", + "net_unavailable": "Interfaccia non disponibile" + } + }, + "oauth2": { + "success": { + "title": "Autenticazione Avvenuta", + "content": "Chiudere questa pagina e tornare alla pagina di autenticazione dell'account per fare clic su 'Avanti'.", + "button": "Chiudi" + }, + "fail": { + "title": "Autenticazione Fallita", + "content": "{error_msg}, chiudere questa pagina e tornare alla pagina di autenticazione dell'account per fare clic nuovamente sul link di autenticazione.", + "button": "Chiudi" + }, + "error_msg": { + "-10100": "Parametri di risposta non validi (i campi 'code' o 'state' sono vuoti)", + "-10101": "Campo 'state' passato non corrispondente" + } + }, + "miot": { + "client": { + "invalid_oauth_info": "Le informazioni di autenticazione non sono valide, il collegamento al cloud non sarà disponibile, si prega di accedere alla pagina di integrazione Xiaomi Home e cliccare 'Opzioni' per ri-autenticarsi", + "invalid_device_cache": "Le informazioni memorizzate nella cache del dispositivo sono anomale, si prega di accedere alla pagina di integrazione Xiaomi Home e cliccare 'Opzioni->Aggiorna elenco dispositivi' per aggiornare la cache locale", + "invalid_cert_info": "Certificato utente non valido, il collegamento centrale locale non sarà disponibile, si prega di accedere alla pagina di integrazione Xiaomi Home e cliccare 'Opzioni' per ri-autenticarsi", + "device_cloud_error": "Si è verificata un'eccezione durante l'ottenimento delle informazioni del dispositivo dal cloud, si prega di controllare la connessione alla rete locale", + "xiaomi_home_error_title": "Errore di Integrazione Xiaomi Home", + "xiaomi_home_error": "Rilevato errore per **{nick_name}({uid}, {cloud_server})**, si prega di accedere alla pagina delle opzioni per riconfigurare.\n\n**Messaggio di errore**: \n{message}", + "device_list_changed_title": "Modifiche all'elenco dispositivi Xiaomi Home", + "device_list_changed": "Rilevato cambiamento nelle informazioni del dispositivo per **{nick_name}({uid}, {cloud_server})**, si prega di accedere alla pagina delle opzioni di integrazione, cliccare `Opzioni->Aggiorna elenco dispositivi` per aggiornare le informazioni locali dei dispositivi.\n\nStato corrente della rete: {network_status}\n{message}\n", + "device_list_add": "\n**{count} nuovi dispositivi:** \n{message}", + "device_list_del": "\n**{count} dispositivi non disponibili:** \n{message}", + "device_list_offline": "\n**{count} dispositivi offline:** \n{message}", + "network_status_online": "Online", + "network_status_offline": "Offline", + "device_exec_error": "Errore di esecuzione" + } + }, + "error": { + "common": { + "-10000": "Errore sconosciuto", + "-10001": "Servizio non disponibile", + "-10002": "Parametro non valido", + "-10003": "Risorse insufficienti", + "-10004": "Errore interno", + "-10005": "Permessi insufficienti", + "-10006": "Timeout di esecuzione", + "-10007": "Dispositivo offline o inesistente", + "-10020": "Non autorizzato (OAuth2)", + "-10030": "Token non valido (HTTP)", + "-10040": "Formato messaggio non valido", + "-10050": "Certificato non valido", + "-704000000": "Errore sconosciuto", + "-704010000": "Non autorizzato (il dispositivo potrebbe essere stato eliminato)", + "-704014006": "Descrizione del dispositivo non trovata", + "-704030013": "Proprietà non leggibile", + "-704030023": "Proprietà non scrivibile", + "-704030033": "Proprietà non sottoscrivibile", + "-704040002": "Servizio inesistente", + "-704040003": "Proprietà inesistente", + "-704040004": "Evento inesistente", + "-704040005": "Azione inesistente", + "-704040999": "Funzione non online", + "-704042001": "Dispositivo inesistente", + "-704042011": "Dispositivo offline", + "-704053036": "Timeout operazione del dispositivo", + "-704053100": "Il dispositivo non può eseguire questa operazione nello stato attuale", + "-704083036": "Timeout operazione del dispositivo", + "-704090001": "Dispositivo inesistente", + "-704220008": "ID non valido", + "-704220025": "Conteggio parametri azione non corrispondente", + "-704220035": "Errore del parametro azione", + "-704220043": "Errore valore proprietà", + "-704222034": "Errore valore di ritorno dell'azione", + "-705004000": "Errore sconosciuto", + "-705004501": "Errore sconosciuto", + "-705201013": "Proprietà non leggibile", + "-705201015": "Errore di esecuzione azione", + "-705201023": "Proprietà non scrivibile", + "-705201033": "Proprietà non sottoscrivibile", + "-706012000": "Errore sconosciuto", + "-706012013": "Proprietà non leggibile", + "-706012015": "Errore di esecuzione azione", + "-706012023": "Proprietà non scrivibile", + "-706012033": "Proprietà non sottoscrivibile", + "-706012043": "Errore valore proprietà", + "-706014006": "Descrizione del dispositivo non trovata" + } + } +} \ No newline at end of file diff --git a/custom_components/xiaomi_home/miot/i18n/ja.json b/custom_components/xiaomi_home/miot/i18n/ja.json index 3ffc22a..a32d997 100644 --- a/custom_components/xiaomi_home/miot/i18n/ja.json +++ b/custom_components/xiaomi_home/miot/i18n/ja.json @@ -54,6 +54,10 @@ "enable": "有効", "disable": "無効" }, + "binary_mode": { + "text": "テキストセンサーエンティティ", + "bool": "バイナリセンサーエンティティ" + }, "device_state": { "add": "追加", "del": "利用不可", @@ -64,6 +68,22 @@ "net_unavailable": "インターフェースが利用できません" } }, + "oauth2": { + "success": { + "title": "認証成功", + "content": "このページを閉じて、アカウント認証ページに戻り、「次へ」をクリックしてください。", + "button": "閉じる" + }, + "fail": { + "title": "認証失敗", + "content": "{error_msg}、このページを閉じて、アカウント認証ページに戻り、再度認証リンクをクリックしてください。", + "button": "閉じる" + }, + "error_msg": { + "-10100": "無効な応答パラメータ('code'または'state'フィールドが空です)", + "-10101": "渡された'state'フィールドが一致しません" + } + }, "miot": { "client": { "invalid_oauth_info": "認証情報が無効です。クラウドリンクは利用できません。Xiaomi Home統合ページに入り、[オプション]をクリックして再認証してください", diff --git a/custom_components/xiaomi_home/miot/i18n/nl.json b/custom_components/xiaomi_home/miot/i18n/nl.json index 101ff3a..5417348 100644 --- a/custom_components/xiaomi_home/miot/i18n/nl.json +++ b/custom_components/xiaomi_home/miot/i18n/nl.json @@ -54,6 +54,10 @@ "enable": "Inschakelen", "disable": "Uitschakelen" }, + "binary_mode": { + "text": "Tekstsensor-entiteit", + "bool": "Binairesensor-entiteit" + }, "device_state": { "add": "Toevoegen", "del": "Niet beschikbaar", @@ -64,6 +68,22 @@ "net_unavailable": "Interface niet beschikbaar" } }, + "oauth2": { + "success": { + "title": "Authenticatie geslaagd", + "content": "Sluit deze pagina en ga terug naar de accountauthenticatiepagina om op 'Volgende' te klikken.", + "button": "Sluiten" + }, + "fail": { + "title": "Authenticatie mislukt", + "content": "{error_msg}, sluit deze pagina en ga terug naar de accountauthenticatiepagina om opnieuw op de authenticatielink te klikken.", + "button": "Sluiten" + }, + "error_msg": { + "-10100": "Ongeldige antwoordparameters ('code' of 'state' veld is leeg)", + "-10101": "Doorgegeven 'state' veld komt niet overeen" + } + }, "miot": { "client": { "invalid_oauth_info": "Authenticatie-informatie is ongeldig, cloudverbinding zal niet beschikbaar zijn. Ga naar de Xiaomi Home-integratiepagina en klik op 'Opties' om opnieuw te verifiëren.", diff --git a/custom_components/xiaomi_home/miot/i18n/pt-BR.json b/custom_components/xiaomi_home/miot/i18n/pt-BR.json index 8e37ecb..553a90c 100644 --- a/custom_components/xiaomi_home/miot/i18n/pt-BR.json +++ b/custom_components/xiaomi_home/miot/i18n/pt-BR.json @@ -54,6 +54,10 @@ "enable": "habilitado", "disable": "desabilitado" }, + "binary_mode": { + "text": "Entidade do sensor de texto", + "bool": "Entidade do sensor binário" + }, "device_state": { "add": "adicionar", "del": "indisponível", @@ -64,6 +68,22 @@ "net_unavailable": "Interface indisponível" } }, + "oauth2": { + "success": { + "title": "Autenticação bem-sucedida", + "content": "Por favor, feche esta página e volte para a página de autenticação da conta para clicar em 'Próximo'.", + "button": "Fechar" + }, + "fail": { + "title": "Falha na autenticação", + "content": "{error_msg}, por favor, feche esta página e volte para a página de autenticação da conta para clicar no link de autenticação novamente.", + "button": "Fechar" + }, + "error_msg": { + "-10100": "Parâmetros de resposta inválidos ('code' ou 'state' está vazio)", + "-10101": "O campo 'state' fornecido não corresponde" + } + }, "miot": { "client": { "invalid_oauth_info": "Informações de autenticação inválidas, a conexão com a nuvem estará indisponível. Vá para a página de integração do Xiaomi Home e clique em 'Opções' para reautenticar.", diff --git a/custom_components/xiaomi_home/miot/i18n/pt.json b/custom_components/xiaomi_home/miot/i18n/pt.json index 08afe4d..6466994 100644 --- a/custom_components/xiaomi_home/miot/i18n/pt.json +++ b/custom_components/xiaomi_home/miot/i18n/pt.json @@ -54,6 +54,10 @@ "enable": "Habilitar", "disable": "Desabilitar" }, + "binary_mode": { + "text": "Entidade do sensor de texto", + "bool": "Entidade do sensor binário" + }, "device_state": { "add": "Adicionar", "del": "Indisponível", @@ -64,6 +68,22 @@ "net_unavailable": "Interface indisponível" } }, + "oauth2": { + "success": { + "title": "Autenticação bem-sucedida", + "content": "Por favor, feche esta página e volte para a página de autenticação da conta para clicar em 'Seguinte'.", + "button": "Fechar" + }, + "fail": { + "title": "Falha na autenticação", + "content": "{error_msg}, por favor, feche esta página e volte para a página de autenticação da conta para clicar no link de autenticação novamente.", + "button": "Fechar" + }, + "error_msg": { + "-10100": "Parâmetros de resposta inválidos ('code' ou 'state' está vazio)", + "-10101": "O campo 'state' fornecido não corresponde" + } + }, "miot": { "client": { "invalid_oauth_info": "Informações de autenticação inválidas, a conexão na nuvem ficará indisponível. Por favor, acesse a página de integração do Xiaomi Home e clique em 'Opções' para autenticar novamente.", diff --git a/custom_components/xiaomi_home/miot/i18n/ru.json b/custom_components/xiaomi_home/miot/i18n/ru.json index d018603..b342ca1 100644 --- a/custom_components/xiaomi_home/miot/i18n/ru.json +++ b/custom_components/xiaomi_home/miot/i18n/ru.json @@ -54,6 +54,10 @@ "enable": "Включить", "disable": "Отключить" }, + "binary_mode": { + "text": "Сущность текстового датчика", + "bool": "Сущность бинарного датчика" + }, "device_state": { "add": "Добавить", "del": "Недоступно", @@ -64,6 +68,22 @@ "net_unavailable": "Интерфейс недоступен" } }, + "oauth2": { + "success": { + "title": "Аутентификация успешна", + "content": "Пожалуйста, закройте эту страницу и вернитесь на страницу аутентификации учетной записи, чтобы нажать 'Далее'.", + "button": "Закрыть" + }, + "fail": { + "title": "Аутентификация не удалась", + "content": "{error_msg}, пожалуйста, закройте эту страницу и вернитесь на страницу аутентификации учетной записи, чтобы снова нажать на ссылку аутентификации.", + "button": "Закрыть" + }, + "error_msg": { + "-10100": "Недействительные параметры ответа ('code' или 'state' поле пусто)", + "-10101": "Переданное поле 'state' не совпадает" + } + }, "miot": { "client": { "invalid_oauth_info": "Информация об аутентификации недействительна, облако будет недоступно, пожалуйста, войдите на страницу интеграции Xiaomi Home, нажмите 'Опции' для повторной аутентификации", diff --git a/custom_components/xiaomi_home/miot/i18n/zh-Hans.json b/custom_components/xiaomi_home/miot/i18n/zh-Hans.json index d8f7c8a..ed69254 100644 --- a/custom_components/xiaomi_home/miot/i18n/zh-Hans.json +++ b/custom_components/xiaomi_home/miot/i18n/zh-Hans.json @@ -54,6 +54,10 @@ "enable": "启用", "disable": "禁用" }, + "binary_mode": { + "text": "文本传感器实体", + "bool": "二进制传感器实体" + }, "device_state": { "add": "新增", "del": "不可用", @@ -64,6 +68,22 @@ "net_unavailable": "接口不可用" } }, + "oauth2": { + "success": { + "title": "认证成功", + "content": "请关闭此页面,返回账号认证页面点击“下一步”", + "button": "关闭" + }, + "fail": { + "title": "认证失败", + "content": "{error_msg},请关闭此页面,返回账号认证页面重新点击认链接进行认证。", + "button": "关闭" + }, + "error_msg": { + "-10100": "无效的响应参数(“code”或者“state”字段为空)", + "-10101": "传入“state”字段不一致" + } + }, "miot": { "client": { "invalid_oauth_info": "认证信息失效,云端链路将不可用,请进入 Xiaomi Home 集成页面,点击“选项”重新认证", diff --git a/custom_components/xiaomi_home/miot/i18n/zh-Hant.json b/custom_components/xiaomi_home/miot/i18n/zh-Hant.json index 73bfa98..c354733 100644 --- a/custom_components/xiaomi_home/miot/i18n/zh-Hant.json +++ b/custom_components/xiaomi_home/miot/i18n/zh-Hant.json @@ -54,6 +54,10 @@ "enable": "啟用", "disable": "禁用" }, + "binary_mode": { + "text": "文本傳感器實體", + "bool": "二進制傳感器實體" + }, "device_state": { "add": "新增", "del": "不可用", @@ -64,6 +68,22 @@ "net_unavailable": "接口不可用" } }, + "oauth2": { + "success": { + "title": "認證成功", + "content": "請關閉此頁面,返回帳號認證頁面點擊“下一步”", + "button": "關閉" + }, + "fail": { + "title": "認證失敗", + "content": "{error_msg},請關閉此頁面,返回帳號認證頁面重新點擊認鏈接進行認證。", + "button": "關閉" + }, + "error_msg": { + "-10100": "無效的響應參數(“code”或者“state”字段為空)", + "-10101": "傳入的“state”字段不一致" + } + }, "miot": { "client": { "invalid_oauth_info": "認證信息失效,雲端鏈路將不可用,請進入 Xiaomi Home 集成頁面,點擊“選項”重新認證", diff --git a/custom_components/xiaomi_home/miot/miot_client.py b/custom_components/xiaomi_home/miot/miot_client.py index b762ce3..5f69062 100644 --- a/custom_components/xiaomi_home/miot/miot_client.py +++ b/custom_components/xiaomi_home/miot/miot_client.py @@ -59,7 +59,7 @@ from homeassistant.core import HomeAssistant from homeassistant.components import zeroconf # pylint: disable=relative-beyond-top-level -from .common import MIoTMatcher +from .common import MIoTMatcher, slugify_did from .const import ( DEFAULT_CTRL_MODE, DEFAULT_INTEGRATION_LANGUAGE, DEFAULT_NICK_NAME, DOMAIN, MIHOME_CERT_EXPIRE_MARGIN, NETWORK_REFRESH_INTERVAL, @@ -150,7 +150,7 @@ class MIoTClient: # Device list update timestamp _device_list_update_ts: int - _sub_source_list: dict[str] + _sub_source_list: dict[str, str] _sub_tree: MIoTMatcher _sub_device_state: dict[str, MipsDeviceState] @@ -169,6 +169,10 @@ class MIoTClient: _show_devices_changed_notify_timer: Optional[asyncio.TimerHandle] # Display devices changed notify _display_devs_notify: list[str] + _display_notify_content_hash: Optional[int] + # Display binary mode + _display_binary_text: bool + _display_binary_bool: bool def __init__( self, @@ -235,6 +239,11 @@ class MIoTClient: self._display_devs_notify = entry_data.get( 'display_devices_changed_notify', ['add', 'del', 'offline']) + self._display_notify_content_hash = None + self._display_binary_text = 'text' in entry_data.get( + 'display_binary_mode', ['text']) + self._display_binary_bool = 'bool' in entry_data.get( + 'display_binary_mode', ['text']) async def init_async(self) -> None: # Load user config and check @@ -257,6 +266,7 @@ class MIoTClient: client_id=OAUTH2_CLIENT_ID, redirect_url=self._entry_data['oauth_redirect_url'], cloud_server=self._cloud_server, + uuid=self._entry_data["uuid"], loop=self._main_loop) # MIoT http client instance self._http = MIoTHttpClient( @@ -356,7 +366,7 @@ class MIoTClient: # Cloud mips self._mips_cloud.unsub_mips_state( key=f'{self._uid}-{self._cloud_server}') - self._mips_cloud.disconnect() + self._mips_cloud.deinit() # Cancel refresh cloud devices if self._refresh_cloud_devices_timer: self._refresh_cloud_devices_timer.cancel() @@ -369,7 +379,7 @@ class MIoTClient: for mips in self._mips_local.values(): mips.on_dev_list_changed = None mips.unsub_mips_state(key=mips.group_id) - mips.disconnect() + mips.deinit() if self._mips_local_state_changed_timers: for timer_item in ( self._mips_local_state_changed_timers.values()): @@ -468,6 +478,14 @@ class MIoTClient: def display_devices_changed_notify(self) -> list[str]: return self._display_devs_notify + @property + def display_binary_text(self) -> bool: + return self._display_binary_text + + @property + def display_binary_bool(self) -> bool: + return self._display_binary_bool + @display_devices_changed_notify.setter def display_devices_changed_notify(self, value: list[str]) -> None: if set(value) == set(self._display_devs_notify): @@ -542,7 +560,8 @@ class MIoTClient: return True except Exception as err: self.__show_client_error_notify( - message=self._i18n.translate('miot.client.invalid_oauth_info'), + message=self._i18n.translate( + 'miot.client.invalid_oauth_info'), # type: ignore notify_key='oauth_info') _LOGGER.error( 'refresh oauth info error (%s, %s), %s, %s', @@ -585,7 +604,8 @@ class MIoTClient: return True except MIoTClientError as error: self.__show_client_error_notify( - message=self._i18n.translate('miot.client.invalid_cert_info'), + message=self._i18n.translate( + 'miot.client.invalid_cert_info'), # type: ignore notify_key='user_cert') _LOGGER.error( 'refresh user cert error, %s, %s', @@ -847,8 +867,40 @@ class MIoTClient: _LOGGER.debug('client unsub device state, %s', did) return True + async def remove_device_async(self, did: str) -> None: + if did not in self._device_list_cache: + return + sub_from = self._sub_source_list.pop(did, None) + # Unsub + if sub_from: + if sub_from == 'cloud': + self._mips_cloud.unsub_prop(did=did) + self._mips_cloud.unsub_event(did=did) + elif sub_from == 'lan': + self._miot_lan.unsub_prop(did=did) + self._miot_lan.unsub_event(did=did) + elif sub_from in self._mips_local: + mips = self._mips_local[sub_from] + mips.unsub_prop(did=did) + mips.unsub_event(did=did) + # Storage + await self._storage.save_async( + domain='miot_devices', + name=f'{self._uid}_{self._cloud_server}', + data=self._device_list_cache) + # Update notify + self.__request_show_devices_changed_notify() + + async def remove_device2_async(self, did_tag: str) -> None: + for did in self._device_list_cache: + d_tag = slugify_did(cloud_server=self._cloud_server, did=did) + if did_tag == d_tag: + await self.remove_device_async(did) + break + def __get_exec_error_with_rc(self, rc: int) -> str: - err_msg: str = self._i18n.translate(key=f'error.common.{rc}') + err_msg: str = self._i18n.translate( + key=f'error.common.{rc}') # type: ignore if not err_msg: err_msg = f'{self._i18n.translate(key="error.common.-10000")}, ' err_msg += f'code={rc}' @@ -1255,7 +1307,7 @@ class MIoTClient: if not cache_list: self.__show_client_error_notify( message=self._i18n.translate( - 'miot.client.invalid_device_cache'), + 'miot.client.invalid_device_cache'), # type: ignore notify_key='device_cache') raise MIoTClientError('load device list from cache error') else: @@ -1343,7 +1395,8 @@ class MIoTClient: home_ids=list(self._entry_data.get('home_selected', {}).keys())) if not result and 'devices' not in result: self.__show_client_error_notify( - message=self._i18n.translate('miot.client.device_cloud_error'), + message=self._i18n.translate( + 'miot.client.device_cloud_error'), # type: ignore notify_key='device_cloud') return else: @@ -1700,13 +1753,14 @@ class MIoTClient: @final def __show_client_error_notify( - self, message: str, notify_key: str = '' + self, message: Optional[str], notify_key: str = '' ) -> None: if message: + self._persistence_notify( f'{DOMAIN}{self._uid}{self._cloud_server}{notify_key}error', self._i18n.translate( - key='miot.client.xiaomi_home_error_title'), + key='miot.client.xiaomi_home_error_title'), # type: ignore self._i18n.translate( key='miot.client.xiaomi_home_error', replace={ @@ -1714,8 +1768,7 @@ class MIoTClient: 'nick_name', DEFAULT_NICK_NAME), 'uid': self._uid, 'cloud_server': self._cloud_server, - 'message': message - })) + 'message': message})) # type: ignore else: self._persistence_notify( f'{DOMAIN}{self._uid}{self._cloud_server}{notify_key}error', @@ -1781,27 +1834,34 @@ class MIoTClient: key='miot.client.device_list_add', replace={ 'count': count_add, - 'message': message_add}) + 'message': message_add}) # type: ignore if 'del' in self._display_devs_notify and count_del: message += self._i18n.translate( key='miot.client.device_list_del', replace={ 'count': count_del, - 'message': message_del}) + 'message': message_del}) # type: ignore if 'offline' in self._display_devs_notify and count_offline: message += self._i18n.translate( key='miot.client.device_list_offline', replace={ 'count': count_offline, - 'message': message_offline}) + 'message': message_offline}) # type: ignore if message != '': + msg_hash = hash(message) + if msg_hash == self._display_notify_content_hash: + # Notify content no change, return + _LOGGER.debug( + 'device list changed notify content no change, return') + return network_status = self._i18n.translate( key='miot.client.network_status_online' if self._network.network_status else 'miot.client.network_status_offline') self._persistence_notify( self.__gen_notify_key('dev_list_changed'), - self._i18n.translate('miot.client.device_list_changed_title'), + self._i18n.translate( + 'miot.client.device_list_changed_title'), # type: ignore self._i18n.translate( key='miot.client.device_list_changed', replace={ @@ -1810,8 +1870,8 @@ class MIoTClient: 'uid': self._uid, 'cloud_server': self._cloud_server, 'network_status': network_status, - 'message': message - })) + 'message': message})) # type: ignore + self._display_notify_content_hash = msg_hash _LOGGER.debug( 'show device list changed notify, add %s, del %s, offline %s', count_add, count_del, count_offline) diff --git a/custom_components/xiaomi_home/miot/miot_cloud.py b/custom_components/xiaomi_home/miot/miot_cloud.py index 7ed3875..59d0b50 100644 --- a/custom_components/xiaomi_home/miot/miot_cloud.py +++ b/custom_components/xiaomi_home/miot/miot_cloud.py @@ -47,6 +47,7 @@ MIoT http client. """ import asyncio import base64 +import hashlib import json import logging import re @@ -75,10 +76,12 @@ class MIoTOauthClient: _oauth_host: str _client_id: int _redirect_url: str + _device_id: str + _state: str def __init__( self, client_id: str, redirect_url: str, cloud_server: str, - loop: Optional[asyncio.AbstractEventLoop] = None + uuid: str, loop: Optional[asyncio.AbstractEventLoop] = None ) -> None: self._main_loop = loop or asyncio.get_running_loop() if client_id is None or client_id.strip() == '': @@ -87,6 +90,8 @@ class MIoTOauthClient: raise MIoTOauthError('invalid redirect_url') if not cloud_server: raise MIoTOauthError('invalid cloud_server') + if not uuid: + raise MIoTOauthError('invalid uuid') self._client_id = int(client_id) self._redirect_url = redirect_url @@ -94,8 +99,15 @@ class MIoTOauthClient: self._oauth_host = DEFAULT_OAUTH2_API_HOST else: self._oauth_host = f'{cloud_server}.{DEFAULT_OAUTH2_API_HOST}' + self._device_id = f'ha.{uuid}' + self._state = hashlib.sha1( + f'd={self._device_id}'.encode('utf-8')).hexdigest() self._session = aiohttp.ClientSession(loop=self._main_loop) + @property + def state(self) -> str: + return self._state + async def deinit_async(self) -> None: if self._session and not self._session.closed: await self._session.close() @@ -132,6 +144,8 @@ class MIoTOauthClient: 'redirect_uri': redirect_url or self._redirect_url, 'client_id': self._client_id, 'response_type': 'code', + 'device_id': self._device_id, + 'state': self._state } if state: params['state'] = state @@ -191,6 +205,7 @@ class MIoTOauthClient: 'client_id': self._client_id, 'redirect_uri': self._redirect_url, 'code': code, + 'device_id': self._device_id }) async def refresh_access_token_async(self, refresh_token: str) -> dict: @@ -729,7 +744,7 @@ class MIoTHttpClient: prop_obj['fut'].set_result(None) if props_req: _LOGGER.info( - 'get prop from cloud failed, %s, %s', len(key), props_req) + 'get prop from cloud failed, %s', props_req) if self._get_prop_list: self._get_prop_timer = self._main_loop.call_later( diff --git a/custom_components/xiaomi_home/miot/miot_device.py b/custom_components/xiaomi_home/miot/miot_device.py index 353b28f..991e2b1 100644 --- a/custom_components/xiaomi_home/miot/miot_device.py +++ b/custom_components/xiaomi_home/miot/miot_device.py @@ -75,7 +75,7 @@ from homeassistant.const import ( ) from homeassistant.helpers.entity import DeviceInfo from homeassistant.components.switch import SwitchDeviceClass -from homeassistant.util import slugify + # pylint: disable=relative-beyond-top-level from .specs.specv2entity import ( @@ -85,6 +85,7 @@ from .specs.specv2entity import ( SPEC_PROP_TRANS_MAP, SPEC_SERVICE_TRANS_MAP ) +from .common import slugify_name, slugify_did from .const import DOMAIN from .miot_client import MIoTClient from .miot_error import MIoTClientError, MIoTDeviceError @@ -94,7 +95,9 @@ from .miot_spec import ( MIoTSpecEvent, MIoTSpecInstance, MIoTSpecProperty, - MIoTSpecService + MIoTSpecService, + MIoTSpecValueList, + MIoTSpecValueRange ) _LOGGER = logging.getLogger(__name__) @@ -142,9 +145,12 @@ class MIoTDevice: _room_id: str _room_name: str - _suggested_area: str + _suggested_area: Optional[str] - _device_state_sub_list: dict[str, Callable[[str, MIoTDeviceState], None]] + _sub_id: int + _device_state_sub_list: dict[str, dict[ + str, Callable[[str, MIoTDeviceState], None]]] + _value_sub_list: dict[str, dict[str, Callable[[dict, Any], None]]] _entity_list: dict[str, list[MIoTEntityData]] _prop_list: dict[str, list[MIoTSpecProperty]] @@ -153,7 +159,7 @@ class MIoTDevice: def __init__( self, miot_client: MIoTClient, - device_info: dict[str, str], + device_info: dict[str, Any], spec_instance: MIoTSpecInstance ) -> None: self.miot_client = miot_client @@ -183,7 +189,9 @@ class MIoTDevice: case _: self._suggested_area = None + self._sub_id = 0 self._device_state_sub_list = {} + self._value_sub_list = {} self._entity_list = {} self._prop_list = {} self._event_list = {} @@ -234,36 +242,76 @@ class MIoTDevice: def sub_device_state( self, key: str, handler: Callable[[str, MIoTDeviceState], None] - ) -> bool: - self._device_state_sub_list[key] = handler - return True + ) -> int: + self._sub_id += 1 + if key in self._device_state_sub_list: + self._device_state_sub_list[key][str(self._sub_id)] = handler + else: + self._device_state_sub_list[key] = {str(self._sub_id): handler} + return self._sub_id - def unsub_device_state(self, key: str) -> bool: - self._device_state_sub_list.pop(key, None) - return True + def unsub_device_state(self, key: str, sub_id: int) -> None: + sub_list = self._device_state_sub_list.get(key, None) + if sub_list: + sub_list.pop(str(sub_id), None) + if not sub_list: + self._device_state_sub_list.pop(key, None) def sub_property( - self, handler: Callable[[dict, Any], None], siid: int = None, - piid: int = None, handler_ctx: Any = None - ) -> bool: - return self.miot_client.sub_prop( - did=self._did, handler=handler, siid=siid, piid=piid, - handler_ctx=handler_ctx) + self, handler: Callable[[dict, Any], None], siid: int, piid: int + ) -> int: + key: str = f'p.{siid}.{piid}' - def unsub_property(self, siid: int = None, piid: int = None) -> bool: - return self.miot_client.unsub_prop(did=self._did, siid=siid, piid=piid) + def _on_prop_changed(params: dict, ctx: Any) -> None: + for handler in self._value_sub_list[key].values(): + handler(params, ctx) + + self._sub_id += 1 + if key in self._value_sub_list: + self._value_sub_list[key][str(self._sub_id)] = handler + else: + self._value_sub_list[key] = {str(self._sub_id): handler} + self.miot_client.sub_prop( + did=self._did, handler=_on_prop_changed, siid=siid, piid=piid) + return self._sub_id + + def unsub_property(self, siid: int, piid: int, sub_id: int) -> None: + key: str = f'p.{siid}.{piid}' + + sub_list = self._value_sub_list.get(key, None) + if sub_list: + sub_list.pop(str(sub_id), None) + if not sub_list: + self.miot_client.unsub_prop(did=self._did, siid=siid, piid=piid) + self._value_sub_list.pop(key, None) def sub_event( - self, handler: Callable[[dict, Any], None], siid: int = None, - eiid: int = None, handler_ctx: Any = None - ) -> bool: - return self.miot_client.sub_event( - did=self._did, handler=handler, siid=siid, eiid=eiid, - handler_ctx=handler_ctx) + self, handler: Callable[[dict, Any], None], siid: int, eiid: int + ) -> int: + key: str = f'e.{siid}.{eiid}' - def unsub_event(self, siid: int = None, eiid: int = None) -> bool: - return self.miot_client.unsub_event( - did=self._did, siid=siid, eiid=eiid) + def _on_event_occurred(params: dict, ctx: Any) -> None: + for handler in self._value_sub_list[key].values(): + handler(params, ctx) + + self._sub_id += 1 + if key in self._value_sub_list: + self._value_sub_list[key][str(self._sub_id)] = handler + else: + self._value_sub_list[key] = {str(self._sub_id): handler} + self.miot_client.sub_event( + did=self._did, handler=_on_event_occurred, siid=siid, eiid=eiid) + return self._sub_id + + def unsub_event(self, siid: int, eiid: int, sub_id: int) -> None: + key: str = f'e.{siid}.{eiid}' + + sub_list = self._value_sub_list.get(key, None) + if sub_list: + sub_list.pop(str(sub_id), None) + if not sub_list: + self.miot_client.unsub_event(did=self._did, siid=siid, eiid=eiid) + self._value_sub_list.pop(key, None) @property def device_info(self) -> DeviceInfo: @@ -287,11 +335,8 @@ class MIoTDevice: @property def did_tag(self) -> str: - return slugify(f'{self.miot_client.cloud_server}_{self._did}') - - @staticmethod - def gen_did_tag(cloud_server: str, did: str) -> str: - return slugify(f'{cloud_server}_{did}') + return slugify_did( + cloud_server=self.miot_client.cloud_server, did=self._did) def gen_device_entity_id(self, ha_domain: str) -> str: return ( @@ -308,21 +353,24 @@ class MIoTDevice: ) -> str: return ( f'{ha_domain}.{self._model_strs[0][:9]}_{self.did_tag}_' - f'{self._model_strs[-1][:20]}_{slugify(spec_name)}_p_{siid}_{piid}') + f'{self._model_strs[-1][:20]}_{slugify_name(spec_name)}' + f'_p_{siid}_{piid}') def gen_event_entity_id( self, ha_domain: str, spec_name: str, siid: int, eiid: int ) -> str: return ( f'{ha_domain}.{self._model_strs[0][:9]}_{self.did_tag}_' - f'{self._model_strs[-1][:20]}_{slugify(spec_name)}_e_{siid}_{eiid}') + f'{self._model_strs[-1][:20]}_{slugify_name(spec_name)}' + f'_e_{siid}_{eiid}') def gen_action_entity_id( self, ha_domain: str, spec_name: str, siid: int, aiid: int ) -> str: return ( f'{ha_domain}.{self._model_strs[0][:9]}_{self.did_tag}_' - f'{self._model_strs[-1][:20]}_{slugify(spec_name)}_a_{siid}_{aiid}') + f'{self._model_strs[-1][:20]}_{slugify_name(spec_name)}' + f'_a_{siid}_{aiid}') @property def name(self) -> str: @@ -341,14 +389,20 @@ class MIoTDevice: self._entity_list[entity_data.platform].append(entity_data) def append_prop(self, prop: MIoTSpecProperty) -> None: + if not prop.platform: + return self._prop_list.setdefault(prop.platform, []) self._prop_list[prop.platform].append(prop) def append_event(self, event: MIoTSpecEvent) -> None: + if not event.platform: + return self._event_list.setdefault(event.platform, []) self._event_list[event.platform].append(event) def append_action(self, action: MIoTSpecAction) -> None: + if not action.platform: + return self._action_list.setdefault(action.platform, []) self._action_list[action.platform].append(action) @@ -507,7 +561,7 @@ class MIoTDevice: if prop_access != (SPEC_PROP_TRANS_MAP[ 'entities'][platform]['access']): return None - if prop.format_ not in SPEC_PROP_TRANS_MAP[ + if prop.format_.__name__ not in SPEC_PROP_TRANS_MAP[ 'entities'][platform]['format']: return None if prop.unit: @@ -560,9 +614,9 @@ class MIoTDevice: # general conversion if not prop.platform: if prop.writable: - if prop.format_ == 'str': + if prop.format_ == str: prop.platform = 'text' - elif prop.format_ == 'bool': + elif prop.format_ == bool: prop.platform = 'switch' prop.device_class = SwitchDeviceClass.SWITCH elif prop.value_list: @@ -573,9 +627,11 @@ class MIoTDevice: # Irregular property will not be transformed. pass elif prop.readable or prop.notifiable: - prop.platform = 'sensor' - if prop.platform: - self.append_prop(prop=prop) + if prop.format_ == bool: + prop.platform = 'binary_sensor' + else: + prop.platform = 'sensor' + self.append_prop(prop=prop) # STEP 3.2: event conversion for event in service.events: if event.platform: @@ -703,10 +759,11 @@ class MIoTDevice: def __on_device_state_changed( self, did: str, state: MIoTDeviceState, ctx: Any ) -> None: - self._online = state - for key, handler in self._device_state_sub_list.items(): - self.miot_client.main_loop.call_soon_threadsafe( - handler, key, state) + self._online = state == MIoTDeviceState.ONLINE + for key, sub_list in self._device_state_sub_list.items(): + for handler in sub_list.values(): + self.miot_client.main_loop.call_soon_threadsafe( + handler, key, state) class MIoTServiceEntity(Entity): @@ -718,8 +775,11 @@ class MIoTServiceEntity(Entity): _main_loop: asyncio.AbstractEventLoop _prop_value_map: dict[MIoTSpecProperty, Any] + _state_sub_id: int + _value_sub_ids: dict[str, int] - _event_occurred_handler: Callable[[MIoTSpecEvent, dict], None] + _event_occurred_handler: Optional[ + Callable[[MIoTSpecEvent, dict], None]] _prop_changed_subs: dict[ MIoTSpecProperty, Callable[[MIoTSpecProperty, Any], None]] @@ -738,13 +798,15 @@ class MIoTServiceEntity(Entity): self.entity_data = entity_data self._main_loop = miot_device.miot_client.main_loop self._prop_value_map = {} + self._state_sub_id = 0 + self._value_sub_ids = {} # Gen entity id - if isinstance(entity_data.spec, MIoTSpecInstance): + if isinstance(self.entity_data.spec, MIoTSpecInstance): self.entity_id = miot_device.gen_device_entity_id(DOMAIN) self._attr_name = f' {self.entity_data.spec.description_trans}' - elif isinstance(entity_data.spec, MIoTSpecService): + elif isinstance(self.entity_data.spec, MIoTSpecService): self.entity_id = miot_device.gen_service_entity_id( - DOMAIN, siid=entity_data.spec.iid) + DOMAIN, siid=self.entity_data.spec.iid) self._attr_name = ( f'{"* "if self.entity_data.spec.proprietary else " "}' f'{self.entity_data.spec.description_trans}') @@ -763,7 +825,9 @@ class MIoTServiceEntity(Entity): self.entity_id) @property - def event_occurred_handler(self) -> Callable[[MIoTSpecEvent, dict], None]: + def event_occurred_handler( + self + ) -> Optional[Callable[[MIoTSpecEvent, dict], None]]: return self._event_occurred_handler @event_occurred_handler.setter @@ -784,25 +848,27 @@ class MIoTServiceEntity(Entity): self._prop_changed_subs.pop(prop, None) @property - def device_info(self) -> dict: + def device_info(self) -> Optional[DeviceInfo]: return self.miot_device.device_info async def async_added_to_hass(self) -> None: state_id = 's.0' if isinstance(self.entity_data.spec, MIoTSpecService): state_id = f's.{self.entity_data.spec.iid}' - self.miot_device.sub_device_state( + self._state_sub_id = self.miot_device.sub_device_state( key=state_id, handler=self.__on_device_state_changed) # Sub prop for prop in self.entity_data.props: if not prop.notifiable and not prop.readable: continue - self.miot_device.sub_property( + key = f'p.{prop.service.iid}.{prop.iid}' + self._value_sub_ids[key] = self.miot_device.sub_property( handler=self.__on_properties_changed, siid=prop.service.iid, piid=prop.iid) # Sub event for event in self.entity_data.events: - self.miot_device.sub_event( + key = f'e.{event.service.iid}.{event.iid}' + self._value_sub_ids[key] = self.miot_device.sub_event( handler=self.__on_event_occurred, siid=event.service.iid, eiid=event.iid) @@ -817,30 +883,39 @@ class MIoTServiceEntity(Entity): state_id = 's.0' if isinstance(self.entity_data.spec, MIoTSpecService): state_id = f's.{self.entity_data.spec.iid}' - self.miot_device.unsub_device_state(key=state_id) + self.miot_device.unsub_device_state( + key=state_id, sub_id=self._state_sub_id) # Unsub prop for prop in self.entity_data.props: if not prop.notifiable and not prop.readable: continue - self.miot_device.unsub_property( - siid=prop.service.iid, piid=prop.iid) + sub_id = self._value_sub_ids.pop( + f'p.{prop.service.iid}.{prop.iid}', None) + if sub_id: + self.miot_device.unsub_property( + siid=prop.service.iid, piid=prop.iid, sub_id=sub_id) # Unsub event for event in self.entity_data.events: - self.miot_device.unsub_event( - siid=event.service.iid, eiid=event.iid) + sub_id = self._value_sub_ids.pop( + f'e.{event.service.iid}.{event.iid}', None) + if sub_id: + self.miot_device.unsub_event( + siid=event.service.iid, eiid=event.iid, sub_id=sub_id) - def get_map_description(self, map_: dict[int, Any], key: int) -> Any: + def get_map_value( + self, map_: dict[int, Any], key: int + ) -> Any: if map_ is None: return None return map_.get(key, None) - def get_map_value( - self, map_: dict[int, Any], description: Any + def get_map_key( + self, map_: dict[int, Any], value: Any ) -> Optional[int]: if map_ is None: return None - for key, value in map_.items(): - if value == description: + for key, value_ in map_.items(): + if value_ == value: return key return None @@ -999,11 +1074,12 @@ class MIoTPropertyEntity(Entity): service: MIoTSpecService _main_loop: asyncio.AbstractEventLoop - # {'min':int, 'max':int, 'step': int} - _value_range: dict[str, int] + _value_range: Optional[MIoTSpecValueRange] # {Any: Any} - _value_list: dict[Any, Any] + _value_list: Optional[MIoTSpecValueList] _value: Any + _state_sub_id: int + _value_sub_id: int _pending_write_ha_state_timer: Optional[asyncio.TimerHandle] @@ -1015,12 +1091,10 @@ class MIoTPropertyEntity(Entity): self.service = spec.service self._main_loop = miot_device.miot_client.main_loop self._value_range = spec.value_range - if spec.value_list: - self._value_list = { - item['value']: item['description'] for item in spec.value_list} - else: - self._value_list = None + self._value_list = spec.value_list self._value = None + self._state_sub_id = 0 + self._value_sub_id = 0 self._pending_write_ha_state_timer = None # Gen entity_id self.entity_id = self.miot_device.gen_prop_entity_id( @@ -1042,16 +1116,16 @@ class MIoTPropertyEntity(Entity): self._value_list) @property - def device_info(self) -> dict: + def device_info(self) -> Optional[DeviceInfo]: return self.miot_device.device_info async def async_added_to_hass(self) -> None: # Sub device state changed - self.miot_device.sub_device_state( + self._state_sub_id = self.miot_device.sub_device_state( key=f'{ self.service.iid}.{self.spec.iid}', handler=self.__on_device_state_changed) # Sub value changed - self.miot_device.sub_property( + self._value_sub_id = self.miot_device.sub_property( handler=self.__on_value_changed, siid=self.service.iid, piid=self.spec.iid) # Refresh value @@ -1063,22 +1137,21 @@ class MIoTPropertyEntity(Entity): self._pending_write_ha_state_timer.cancel() self._pending_write_ha_state_timer = None self.miot_device.unsub_device_state( - key=f'{ self.service.iid}.{self.spec.iid}') + key=f'{ self.service.iid}.{self.spec.iid}', + sub_id=self._state_sub_id) self.miot_device.unsub_property( - siid=self.service.iid, piid=self.spec.iid) + siid=self.service.iid, piid=self.spec.iid, + sub_id=self._value_sub_id) - def get_vlist_description(self, value: Any) -> str: + def get_vlist_description(self, value: Any) -> Optional[str]: if not self._value_list: return None - return self._value_list.get(value, None) + return self._value_list.get_description_by_value(value) def get_vlist_value(self, description: str) -> Any: if not self._value_list: return None - for key, value in self._value_list.items(): - if value == description: - return key - return None + return self._value_list.get_value_by_description(description) async def set_property_async(self, value: Any) -> bool: if not self.spec.writable: @@ -1148,9 +1221,10 @@ class MIoTEventEntity(Entity): service: MIoTSpecService _main_loop: asyncio.AbstractEventLoop - _value: Any _attr_event_types: list[str] _arguments_map: dict[int, str] + _state_sub_id: int + _value_sub_id: int def __init__(self, miot_device: MIoTDevice, spec: MIoTSpecEvent) -> None: if miot_device is None or spec is None or spec.service is None: @@ -1159,7 +1233,6 @@ class MIoTEventEntity(Entity): self.spec = spec self.service = spec.service self._main_loop = miot_device.miot_client.main_loop - self._value = None # Gen entity_id self.entity_id = self.miot_device.gen_event_entity_id( ha_domain=DOMAIN, spec_name=spec.name, @@ -1177,6 +1250,8 @@ class MIoTEventEntity(Entity): self._arguments_map = {} for prop in spec.argument: self._arguments_map[prop.iid] = prop.description_trans + self._state_sub_id = 0 + self._value_sub_id = 0 _LOGGER.info( 'new miot event entity, %s, %s, %s, %s, %s', @@ -1184,29 +1259,31 @@ class MIoTEventEntity(Entity): spec.device_class, self.entity_id) @property - def device_info(self) -> dict: + def device_info(self) -> Optional[DeviceInfo]: return self.miot_device.device_info async def async_added_to_hass(self) -> None: # Sub device state changed - self.miot_device.sub_device_state( + self._state_sub_id = self.miot_device.sub_device_state( key=f'event.{ self.service.iid}.{self.spec.iid}', handler=self.__on_device_state_changed) # Sub value changed - self.miot_device.sub_event( + self._value_sub_id = self.miot_device.sub_event( handler=self.__on_event_occurred, siid=self.service.iid, eiid=self.spec.iid) async def async_will_remove_from_hass(self) -> None: self.miot_device.unsub_device_state( - key=f'event.{ self.service.iid}.{self.spec.iid}') + key=f'event.{ self.service.iid}.{self.spec.iid}', + sub_id=self._state_sub_id) self.miot_device.unsub_event( - siid=self.service.iid, eiid=self.spec.iid) + siid=self.service.iid, eiid=self.spec.iid, + sub_id=self._value_sub_id) @abstractmethod def on_event_occurred( - self, name: str, arguments: list[dict[int, Any]] - ): ... + self, name: str, arguments: dict[str, Any] | None = None + ) -> None: ... def __on_event_occurred(self, params: dict, ctx: Any) -> None: _LOGGER.debug('event occurred, %s', params) @@ -1253,11 +1330,11 @@ class MIoTActionEntity(Entity): miot_device: MIoTDevice spec: MIoTSpecAction service: MIoTSpecService - action_platform: str _main_loop: asyncio.AbstractEventLoop _in_map: dict[int, MIoTSpecProperty] _out_map: dict[int, MIoTSpecProperty] + _state_sub_id: int def __init__(self, miot_device: MIoTDevice, spec: MIoTSpecAction) -> None: if miot_device is None or spec is None or spec.service is None: @@ -1265,8 +1342,8 @@ class MIoTActionEntity(Entity): self.miot_device = miot_device self.spec = spec self.service = spec.service - self.action_platform = 'action' self._main_loop = miot_device.miot_client.main_loop + self._state_sub_id = 0 # Gen entity_id self.entity_id = self.miot_device.gen_action_entity_id( ha_domain=DOMAIN, spec_name=spec.name, @@ -1286,19 +1363,22 @@ class MIoTActionEntity(Entity): spec.device_class, self.entity_id) @property - def device_info(self) -> dict: + def device_info(self) -> Optional[DeviceInfo]: return self.miot_device.device_info async def async_added_to_hass(self) -> None: - self.miot_device.sub_device_state( - key=f'{self.action_platform}.{ self.service.iid}.{self.spec.iid}', + self._state_sub_id = self.miot_device.sub_device_state( + key=f'a.{ self.service.iid}.{self.spec.iid}', handler=self.__on_device_state_changed) async def async_will_remove_from_hass(self) -> None: self.miot_device.unsub_device_state( - key=f'{self.action_platform}.{ self.service.iid}.{self.spec.iid}') + key=f'a.{ self.service.iid}.{self.spec.iid}', + sub_id=self._state_sub_id) - async def action_async(self, in_list: list = None) -> Optional[list]: + async def action_async( + self, in_list: Optional[list] = None + ) -> Optional[list]: try: return await self.miot_device.miot_client.action_async( did=self.miot_device.did, diff --git a/custom_components/xiaomi_home/miot/miot_error.py b/custom_components/xiaomi_home/miot/miot_error.py index 6e65ad8..e32103e 100644 --- a/custom_components/xiaomi_home/miot/miot_error.py +++ b/custom_components/xiaomi_home/miot/miot_error.py @@ -72,6 +72,8 @@ class MIoTErrorCode(Enum): # MIoT ev error code, -10080 # Mips service error code, -10090 # Config flow error code, -10100 + CODE_CONFIG_INVALID_INPUT = -10100 + CODE_CONFIG_INVALID_STATE = -10101 # Options flow error code , -10110 # MIoT lan error code, -10120 CODE_LAN_UNAVAILABLE = -10120 diff --git a/custom_components/xiaomi_home/miot/miot_ev.py b/custom_components/xiaomi_home/miot/miot_ev.py deleted file mode 100644 index c0cc97f..0000000 --- a/custom_components/xiaomi_home/miot/miot_ev.py +++ /dev/null @@ -1,324 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Copyright (C) 2024 Xiaomi Corporation. - -The ownership and intellectual property rights of Xiaomi Home Assistant -Integration and related Xiaomi cloud service API interface provided under this -license, including source code and object code (collectively, "Licensed Work"), -are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi -hereby grants you a personal, limited, non-exclusive, non-transferable, -non-sublicensable, and royalty-free license to reproduce, use, modify, and -distribute the Licensed Work only for your use of Home Assistant for -non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize -you to use the Licensed Work for any other purpose, including but not limited -to use Licensed Work to develop applications (APP), Web services, and other -forms of software. - -You may reproduce and distribute copies of the Licensed Work, with or without -modifications, whether in source or object form, provided that you must give -any other recipients of the Licensed Work a copy of this License and retain all -copyright and disclaimers. - -Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR -CONDITIONS OF ANY KIND, either express or implied, including, without -limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR -OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or -FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible -for any direct, indirect, special, incidental, or consequential damages or -losses arising from the use or inability to use the Licensed Work. - -Xiaomi reserves all rights not expressly granted to you in this License. -Except for the rights expressly granted by Xiaomi under this License, Xiaomi -does not authorize you in any form to use the trademarks, copyrights, or other -forms of intellectual property rights of Xiaomi and its affiliates, including, -without limitation, without obtaining other written permission from Xiaomi, you -shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that -may make the public associate with Xiaomi in any form to publicize or promote -the software or hardware devices that use the Licensed Work. - -Xiaomi has the right to immediately terminate all your authorization under this -License in the event: -1. You assert patent invalidation, litigation, or other claims against patents -or other intellectual property rights of Xiaomi or its affiliates; or, -2. You make, have made, manufacture, sell, or offer to sell products that knock -off Xiaomi or its affiliates' products. - -MIoT event loop. -""" -import selectors -import heapq -import time -import traceback -from typing import Any, Callable, TypeVar -import logging -import threading - -# pylint: disable=relative-beyond-top-level -from .miot_error import MIoTEvError - -_LOGGER = logging.getLogger(__name__) - -TimeoutHandle = TypeVar('TimeoutHandle') - - -class MIoTFdHandler: - """File descriptor handler.""" - fd: int - read_handler: Callable[[Any], None] - read_handler_ctx: Any - write_handler: Callable[[Any], None] - write_handler_ctx: Any - - def __init__( - self, fd: int, - read_handler: Callable[[Any], None] = None, - read_handler_ctx: Any = None, - write_handler: Callable[[Any], None] = None, - write_handler_ctx: Any = None - ) -> None: - self.fd = fd - self.read_handler = read_handler - self.read_handler_ctx = read_handler_ctx - self.write_handler = write_handler - self.write_handler_ctx = write_handler_ctx - - -class MIoTTimeout: - """Timeout handler.""" - key: TimeoutHandle - target: int - handler: Callable[[Any], None] - handler_ctx: Any - - def __init__( - self, key: str = None, target: int = None, - handler: Callable[[Any], None] = None, - handler_ctx: Any = None - ) -> None: - self.key = key - self.target = target - self.handler = handler - self.handler_ctx = handler_ctx - - def __lt__(self, other): - return self.target < other.target - - -class MIoTEventLoop: - """MIoT event loop.""" - _poll_fd: selectors.DefaultSelector - - _fd_handlers: dict[str, MIoTFdHandler] - - _timer_heap: list[MIoTTimeout] - _timer_handlers: dict[str, MIoTTimeout] - _timer_handle_seed: int - - # Label if the current fd handler is freed inside a read handler to - # avoid invalid reading. - _fd_handler_freed_in_read_handler: bool - - def __init__(self) -> None: - self._poll_fd = selectors.DefaultSelector() - self._timer_heap = [] - self._timer_handlers = {} - self._timer_handle_seed = 1 - self._fd_handlers = {} - self._fd_handler_freed_in_read_handler = False - - def loop_forever(self) -> None: - """Run an event loop in current thread.""" - next_timeout: int - while True: - next_timeout = 0 - # Handle timer - now_ms: int = self.__get_monotonic_ms - while len(self._timer_heap) > 0: - timer: MIoTTimeout = self._timer_heap[0] - if timer is None: - break - if timer.target <= now_ms: - heapq.heappop(self._timer_heap) - del self._timer_handlers[timer.key] - if timer.handler: - timer.handler(timer.handler_ctx) - else: - next_timeout = timer.target-now_ms - break - # Are there any files to listen to - if next_timeout == 0 and self._fd_handlers: - next_timeout = None # None == infinite - # Wait for timers & fds - if next_timeout == 0: - # Neither timer nor fds exist, exit loop - break - # Handle fd event - events = self._poll_fd.select( - timeout=next_timeout/1000.0 if next_timeout else next_timeout) - for key, mask in events: - fd_handler: MIoTFdHandler = key.data - if fd_handler is None: - continue - self._fd_handler_freed_in_read_handler = False - fd_key = str(id(fd_handler.fd)) - if fd_key not in self._fd_handlers: - continue - if ( - mask & selectors.EVENT_READ > 0 - and fd_handler.read_handler - ): - fd_handler.read_handler(fd_handler.read_handler_ctx) - if ( - mask & selectors.EVENT_WRITE > 0 - and self._fd_handler_freed_in_read_handler is False - and fd_handler.write_handler - ): - fd_handler.write_handler(fd_handler.write_handler_ctx) - - def loop_stop(self) -> None: - """Stop the event loop.""" - if self._poll_fd: - self._poll_fd.close() - self._poll_fd = None - self._fd_handlers = {} - self._timer_heap = [] - self._timer_handlers = {} - - def set_timeout( - self, timeout_ms: int, handler: Callable[[Any], None], - handler_ctx: Any = None - ) -> TimeoutHandle: - """Set a timer.""" - if timeout_ms is None or handler is None: - raise MIoTEvError('invalid params') - new_timeout: MIoTTimeout = MIoTTimeout() - new_timeout.key = self.__get_next_timeout_handle - new_timeout.target = self.__get_monotonic_ms + timeout_ms - new_timeout.handler = handler - new_timeout.handler_ctx = handler_ctx - heapq.heappush(self._timer_heap, new_timeout) - self._timer_handlers[new_timeout.key] = new_timeout - return new_timeout.key - - def clear_timeout(self, timer_key: TimeoutHandle) -> None: - """Stop and remove the timer.""" - if timer_key is None: - return - timer: MIoTTimeout = self._timer_handlers.pop(timer_key, None) - if timer: - self._timer_heap = list(self._timer_heap) - self._timer_heap.remove(timer) - heapq.heapify(self._timer_heap) - - def set_read_handler( - self, fd: int, handler: Callable[[Any], None], handler_ctx: Any = None - ) -> bool: - """Set a read handler for a file descriptor. - - Returns: - bool: True, success. False, failed. - """ - self.__set_handler( - fd, is_read=True, handler=handler, handler_ctx=handler_ctx) - - def set_write_handler( - self, fd: int, handler: Callable[[Any], None], handler_ctx: Any = None - ) -> bool: - """Set a write handler for a file descriptor. - - Returns: - bool: True, success. False, failed. - """ - self.__set_handler( - fd, is_read=False, handler=handler, handler_ctx=handler_ctx) - - def __set_handler( - self, fd, is_read: bool, handler: Callable[[Any], None], - handler_ctx: Any = None - ) -> bool: - """Set a handler.""" - if fd is None: - raise MIoTEvError('invalid params') - - if not self._poll_fd: - raise MIoTEvError('event loop not started') - - fd_key: str = str(id(fd)) - fd_handler = self._fd_handlers.get(fd_key, None) - - if fd_handler is None: - fd_handler = MIoTFdHandler(fd=fd) - fd_handler.fd = fd - self._fd_handlers[fd_key] = fd_handler - - read_handler_existed = fd_handler.read_handler is not None - write_handler_existed = fd_handler.write_handler is not None - if is_read is True: - fd_handler.read_handler = handler - fd_handler.read_handler_ctx = handler_ctx - else: - fd_handler.write_handler = handler - fd_handler.write_handler_ctx = handler_ctx - - if fd_handler.read_handler is None and fd_handler.write_handler is None: - # Remove from epoll and map - try: - self._poll_fd.unregister(fd) - except (KeyError, ValueError, OSError) as e: - del e - self._fd_handlers.pop(fd_key, None) - # May be inside a read handler, if not, this has no effect - self._fd_handler_freed_in_read_handler = True - elif read_handler_existed is False and write_handler_existed is False: - # Add to epoll - events = 0x0 - if fd_handler.read_handler: - events |= selectors.EVENT_READ - if fd_handler.write_handler: - events |= selectors.EVENT_WRITE - try: - self._poll_fd.register(fd, events=events, data=fd_handler) - except (KeyError, ValueError, OSError) as e: - _LOGGER.error( - '%s, register fd, error, %s, %s, %s, %s, %s', - threading.current_thread().name, - 'read' if is_read else 'write', - fd_key, handler, e, traceback.format_exc()) - self._fd_handlers.pop(fd_key, None) - return False - elif ( - read_handler_existed != (fd_handler.read_handler is not None) - or write_handler_existed != (fd_handler.write_handler is not None) - ): - # Modify epoll - events = 0x0 - if fd_handler.read_handler: - events |= selectors.EVENT_READ - if fd_handler.write_handler: - events |= selectors.EVENT_WRITE - try: - self._poll_fd.modify(fd, events=events, data=fd_handler) - except (KeyError, ValueError, OSError) as e: - _LOGGER.error( - '%s, modify fd, error, %s, %s, %s, %s, %s', - threading.current_thread().name, - 'read' if is_read else 'write', - fd_key, handler, e, traceback.format_exc()) - self._fd_handlers.pop(fd_key, None) - return False - - return True - - @property - def __get_next_timeout_handle(self) -> str: - # Get next timeout handle, that is not larger than the maximum - # value of UINT64 type. - self._timer_handle_seed += 1 - # uint64 max - self._timer_handle_seed %= 0xFFFFFFFFFFFFFFFF - return str(self._timer_handle_seed) - - @property - def __get_monotonic_ms(self) -> int: - """Get monotonic ms timestamp.""" - return int(time.monotonic()*1000) diff --git a/custom_components/xiaomi_home/miot/miot_i18n.py b/custom_components/xiaomi_home/miot/miot_i18n.py index 152bc08..b6e96f4 100644 --- a/custom_components/xiaomi_home/miot/miot_i18n.py +++ b/custom_components/xiaomi_home/miot/miot_i18n.py @@ -48,7 +48,7 @@ MIoT internationalization translation. import asyncio import logging import os -from typing import Optional +from typing import Optional, Union # pylint: disable=relative-beyond-top-level from .common import load_json_file @@ -98,7 +98,7 @@ class MIoTI18n: def translate( self, key: str, replace: Optional[dict[str, str]] = None - ) -> str | dict | None: + ) -> Union[str, dict, None]: result = self._data for item in key.split('.'): if item not in result: diff --git a/custom_components/xiaomi_home/miot/miot_lan.py b/custom_components/xiaomi_home/miot/miot_lan.py index 3191166..fd9ff47 100644 --- a/custom_components/xiaomi_home/miot/miot_lan.py +++ b/custom_components/xiaomi_home/miot/miot_lan.py @@ -381,7 +381,8 @@ class _MIoTLanDevice: _MIoTLanDeviceState(state.value+1)) # Fast ping if self._if_name is None: - _LOGGER.error('if_name is Not set for device, %s', self.did) + _LOGGER.error( + 'if_name is Not set for device, %s', self.did) return if self.ip is None: _LOGGER.error('ip is Not set for device, %s', self.did) @@ -419,10 +420,10 @@ class _MIoTLanDevice: self.online = True else: _LOGGER.info('unstable device detected, %s', self.did) - self._online_offline_timer = \ + self._online_offline_timer = ( self._manager.internal_loop.call_later( self.NETWORK_UNSTABLE_RESUME_TH, - self.__online_resume_handler) + self.__online_resume_handler)) def __online_resume_handler(self) -> None: _LOGGER.info('unstable resume threshold past, %s', self.did) @@ -508,9 +509,9 @@ class MIoTLan: key='miot_lan', group_id='*', handler=self.__on_mips_service_change) self._enable_subscribe = enable_subscribe - self._virtual_did = str(virtual_did) \ - if (virtual_did is not None) \ - else str(secrets.randbits(64)) + self._virtual_did = ( + str(virtual_did) if (virtual_did is not None) + else str(secrets.randbits(64))) # Init socket probe message probe_bytes = bytearray(self.OT_PROBE_LEN) probe_bytes[:20] = ( @@ -948,7 +949,7 @@ class MIoTLan: # The following methods SHOULD ONLY be called in the internal loop - def ping(self, if_name: str | None, target_ip: str) -> None: + def ping(self, if_name: Optional[str], target_ip: str) -> None: if not target_ip: return self.__sendto( @@ -964,7 +965,7 @@ class MIoTLan: ) -> None: if timeout_ms and not handler: raise ValueError('handler is required when timeout_ms is set') - device: _MIoTLanDevice | None = self._lan_devices.get(did) + device: Optional[_MIoTLanDevice] = self._lan_devices.get(did) if not device: raise ValueError('invalid device') if not device.cipher: @@ -1232,7 +1233,7 @@ class MIoTLan: return # Keep alive message did: str = str(struct.unpack('>Q', data[4:12])[0]) - device: _MIoTLanDevice | None = self._lan_devices.get(did) + device: Optional[_MIoTLanDevice] = self._lan_devices.get(did) if not device: return timestamp: int = struct.unpack('>I', data[12:16])[0] @@ -1272,8 +1273,8 @@ class MIoTLan: _LOGGER.warning('invalid message, no id, %s, %s', did, msg) return # Reply - req: _MIoTLanRequestData | None = \ - self._pending_requests.pop(msg['id'], None) + req: Optional[_MIoTLanRequestData] = ( + self._pending_requests.pop(msg['id'], None)) if req: if req.timeout: req.timeout.cancel() @@ -1334,7 +1335,7 @@ class MIoTLan: return False def __sendto( - self, if_name: str | None, data: bytes, address: str, port: int + self, if_name: Optional[str], data: bytes, address: str, port: int ) -> None: if if_name is None: # Broadcast @@ -1356,7 +1357,7 @@ class MIoTLan: try: # Scan devices self.ping(if_name=None, target_ip='255.255.255.255') - except Exception as err: # pylint: disable=broad-exception-caught + except Exception as err: # pylint: disable=broad-exception-caught # Ignore any exceptions to avoid blocking the loop _LOGGER.error('ping device error, %s', err) pass diff --git a/custom_components/xiaomi_home/miot/miot_mdns.py b/custom_components/xiaomi_home/miot/miot_mdns.py index a6b3002..ba661aa 100644 --- a/custom_components/xiaomi_home/miot/miot_mdns.py +++ b/custom_components/xiaomi_home/miot/miot_mdns.py @@ -117,7 +117,7 @@ class MipsServiceData: self.type = service_info.type self.server = service_info.server or '' # Parse profile - self.did = str(int.from_bytes(self.profile_bin[1:9])) + self.did = str(int.from_bytes(self.profile_bin[1:9], byteorder='big')) self.group_id = binascii.hexlify( self.profile_bin[9:17][::-1]).decode('utf-8') self.role = int(self.profile_bin[20] >> 4) diff --git a/custom_components/xiaomi_home/miot/miot_mips.py b/custom_components/xiaomi_home/miot/miot_mips.py index 6c6b358..b1bb65b 100644 --- a/custom_components/xiaomi_home/miot/miot_mips.py +++ b/custom_components/xiaomi_home/miot/miot_mips.py @@ -48,8 +48,6 @@ MIoT Pub/Sub client. import asyncio import json import logging -import os -import queue import random import re import ssl @@ -58,24 +56,24 @@ import threading from abc import ABC, abstractmethod from dataclasses import dataclass from enum import Enum, auto -from typing import Any, Callable, Optional, final +from typing import Any, Callable, Optional, final, Coroutine from paho.mqtt.client import ( MQTT_ERR_SUCCESS, MQTT_ERR_UNKNOWN, Client, - MQTTv5) + MQTTv5, + MQTTMessage) # pylint: disable=relative-beyond-top-level from .common import MIoTMatcher from .const import MIHOME_MQTT_KEEPALIVE from .miot_error import MIoTErrorCode, MIoTMipsError -from .miot_ev import MIoTEventLoop, TimeoutHandle _LOGGER = logging.getLogger(__name__) -class MipsMsgTypeOptions(Enum): +class _MipsMsgTypeOptions(Enum): """MIoT Pub/Sub message type.""" ID = 0 RET_TOPIC = auto() @@ -84,16 +82,16 @@ class MipsMsgTypeOptions(Enum): MAX = auto() -class MipsMessage: +class _MipsMessage: """MIoT Pub/Sub message.""" mid: int = 0 - msg_from: str = None - ret_topic: str = None - payload: str = None + msg_from: Optional[str] = None + ret_topic: Optional[str] = None + payload: Optional[str] = None @staticmethod - def unpack(data: bytes): - mips_msg = MipsMessage() + def unpack(data: bytes) -> '_MipsMessage': + mips_msg = _MipsMessage() data_len = len(data) data_start = 0 data_end = 0 @@ -104,15 +102,15 @@ class MipsMessage: unpack_data = data[data_end:data_end+unpack_len] # string end with \x00 match unpack_type: - case MipsMsgTypeOptions.ID.value: + case _MipsMsgTypeOptions.ID.value: mips_msg.mid = int.from_bytes( unpack_data, byteorder='little') - case MipsMsgTypeOptions.RET_TOPIC.value: + case _MipsMsgTypeOptions.RET_TOPIC.value: mips_msg.ret_topic = str( unpack_data.strip(b'\x00'), 'utf-8') - case MipsMsgTypeOptions.PAYLOAD.value: + case _MipsMsgTypeOptions.PAYLOAD.value: mips_msg.payload = str(unpack_data.strip(b'\x00'), 'utf-8') - case MipsMsgTypeOptions.FROM.value: + case _MipsMsgTypeOptions.FROM.value: mips_msg.msg_from = str( unpack_data.strip(b'\x00'), 'utf-8') case _: @@ -122,155 +120,73 @@ class MipsMessage: @staticmethod def pack( - mid: int, payload: str, msg_from: str = None, ret_topic: str = None + mid: int, + payload: str, + msg_from: Optional[str] = None, + ret_topic: Optional[str] = None ) -> bytes: if mid is None or payload is None: raise MIoTMipsError('invalid mid or payload') pack_msg: bytes = b'' # mid - pack_msg += struct.pack(' str: return f'{self.mid}, {self.msg_from}, {self.ret_topic}, {self.payload}' -class MipsCmdType(Enum): - """MIoT Pub/Sub command type.""" - CONNECT = 0 - DISCONNECT = auto() - DEINIT = auto() - SUB = auto() - UNSUB = auto() - CALL_API = auto() - REG_BROADCAST = auto() - UNREG_BROADCAST = auto() - - REG_MIPS_STATE = auto() - UNREG_MIPS_STATE = auto() - REG_DEVICE_STATE = auto() - UNREG_DEVICE_STATE = auto() - - @dataclass -class MipsCmd: - """MIoT Pub/Sub command.""" - type_: MipsCmdType - data: Any - - def __init__(self, type_: MipsCmdType, data: Any) -> None: - self.type_ = type_ - self.data = data - - -@dataclass -class MipsRequest: +class _MipsRequest: """MIoT Pub/Sub request.""" - mid: int = None - on_reply: Callable[[str, Any], None] = None - on_reply_ctx: Any = None - timer: TimeoutHandle = None + mid: int + on_reply: Callable[[str, Any], None] + on_reply_ctx: Any + timer: Optional[asyncio.TimerHandle] @dataclass -class MipsRequestData: - """MIoT Pub/Sub request data.""" - topic: str = None - payload: str = None - on_reply: Callable[[str, Any], None] = None - on_reply_ctx: Any = None - timeout_ms: int = None - - -@dataclass -class MipsSendBroadcastData: - """MIoT Pub/Sub send broadcast data.""" - topic: str = None - payload: str = None - - -@dataclass -class MipsIncomingApiCall: - """MIoT Pub/Sub incoming API call.""" - mid: int = None - ret_topic: str = None - timer: TimeoutHandle = None - - -@dataclass -class MipsApi: - """MIoT Pub/Sub API.""" - topic: str = None - """ - param1: session - param2: payload - param3: handler_ctx - """ - handler: Callable[[MipsIncomingApiCall, str, Any], None] = None - handler_ctx: Any = None - - -class MipsRegApi(MipsApi): - """.MIoT Pub/Sub register API.""" - - -@dataclass -class MipsReplyData: - """MIoT Pub/Sub reply data.""" - session: MipsIncomingApiCall = None - payload: str = None - - -@dataclass -class MipsBroadcast: +class _MipsBroadcast: """MIoT Pub/Sub broadcast.""" - topic: str = None + topic: str """ param 1: msg topic param 2: msg payload param 3: handle_ctx """ - handler: Callable[[str, str, Any], None] = None - handler_ctx: Any = None + handler: Callable[[str, str, Any], None] + handler_ctx: Any def __str__(self) -> str: return f'{self.topic}, {id(self.handler)}, {id(self.handler_ctx)}' -class MipsRegBroadcast(MipsBroadcast): - """MIoT Pub/Sub register broadcast.""" - - @dataclass -class MipsState: +class _MipsState: """MIoT Pub/Sub state.""" - key: str = None + key: str """ str: key bool: mips connect state """ - handler: Callable[[str, bool], asyncio.Future] = None - - -class MipsRegState(MipsState): - """MIoT Pub/Sub register state.""" + handler: Callable[[str, bool], Coroutine] class MIoTDeviceState(Enum): @@ -283,69 +199,65 @@ class MIoTDeviceState(Enum): @dataclass class MipsDeviceState: """MIoT Pub/Sub device state.""" - did: str = None + did: Optional[str] = None """handler str: did MIoTDeviceState: online/offline/disable Any: ctx """ - handler: Callable[[str, MIoTDeviceState, Any], None] = None + handler: Optional[Callable[[str, MIoTDeviceState, Any], None]] = None handler_ctx: Any = None -class MipsRegDeviceState(MipsDeviceState): - """MIoT Pub/Sub register device state.""" - - -class MipsClient(ABC): +class _MipsClient(ABC): """MIoT Pub/Sub client.""" # pylint: disable=unused-argument - MQTT_INTERVAL_MS = 1000 + MQTT_INTERVAL_S = 1 MIPS_QOS: int = 2 UINT32_MAX: int = 0xFFFFFFFF - MIPS_RECONNECT_INTERVAL_MIN: int = 30000 - MIPS_RECONNECT_INTERVAL_MAX: int = 600000 + MIPS_RECONNECT_INTERVAL_MIN: float = 30 + MIPS_RECONNECT_INTERVAL_MAX: float = 600 MIPS_SUB_PATCH: int = 300 - MIPS_SUB_INTERVAL: int = 1000 + MIPS_SUB_INTERVAL: float = 1 main_loop: asyncio.AbstractEventLoop - _logger: logging.Logger + _logger: Optional[logging.Logger] _client_id: str _host: str _port: int - _username: str - _password: str - _ca_file: str - _cert_file: str - _key_file: str + _username: Optional[str] + _password: Optional[str] + _ca_file: Optional[str] + _cert_file: Optional[str] + _key_file: Optional[str] - _mqtt_logger: logging.Logger - _mqtt: Client + _mqtt_logger: Optional[logging.Logger] + _mqtt: Optional[Client] _mqtt_fd: int - _mqtt_timer: TimeoutHandle + _mqtt_timer: Optional[asyncio.TimerHandle] _mqtt_state: bool _event_connect: asyncio.Event _event_disconnect: asyncio.Event - _mev: MIoTEventLoop - _mips_thread: threading.Thread - _mips_queue: queue.Queue - _cmd_event_fd: os.eventfd + _internal_loop: asyncio.AbstractEventLoop + _mips_thread: Optional[threading.Thread] _mips_reconnect_tag: bool - _mips_reconnect_interval: int - _mips_reconnect_timer: Optional[TimeoutHandle] - _mips_state_sub_map: dict[str, MipsState] + _mips_reconnect_interval: float + _mips_reconnect_timer: Optional[asyncio.TimerHandle] + _mips_state_sub_map: dict[str, _MipsState] + _mips_state_sub_map_lock: threading.Lock _mips_sub_pending_map: dict[str, int] - _mips_sub_pending_timer: Optional[TimeoutHandle] - - _on_mips_cmd: Callable[[MipsCmd], None] - _on_mips_message: Callable[[str, bytes], None] - _on_mips_connect: Callable[[int, dict], None] - _on_mips_disconnect: Callable[[int, dict], None] + _mips_sub_pending_timer: Optional[asyncio.TimerHandle] def __init__( - self, client_id: str, host: str, port: int, - username: str = None, password: str = None, - ca_file: str = None, cert_file: str = None, key_file: str = None, + self, + client_id: str, + host: str, + port: int, + username: Optional[str] = None, + password: Optional[str] = None, + ca_file: Optional[str] = None, + cert_file: Optional[str] = None, + key_file: Optional[str] = None, loop: Optional[asyncio.AbstractEventLoop] = None ) -> None: # MUST run with running loop @@ -364,34 +276,20 @@ class MipsClient(ABC): self._mqtt_fd = -1 self._mqtt_timer = None self._mqtt_state = False - # mqtt init for API_VERSION2, - # callback_api_version=CallbackAPIVersion.VERSION2, - self._mqtt = Client(client_id=self._client_id, protocol=MQTTv5) - self._mqtt.enable_logger(logger=self._mqtt_logger) + self._mqtt = None # Mips init self._event_connect = asyncio.Event() self._event_disconnect = asyncio.Event() + self._mips_thread = None self._mips_reconnect_tag = False self._mips_reconnect_interval = 0 self._mips_reconnect_timer = None self._mips_state_sub_map = {} + self._mips_state_sub_map_lock = threading.Lock() self._mips_sub_pending_map = {} self._mips_sub_pending_timer = None - self._mev = MIoTEventLoop() - self._mips_queue = queue.Queue() - self._cmd_event_fd = os.eventfd(0, os.O_NONBLOCK) - self.mev_set_read_handler( - self._cmd_event_fd, self.__mips_cmd_read_handler, None) - self._mips_thread = threading.Thread(target=self.__mips_loop_thread) - self._mips_thread.daemon = True - self._mips_thread.name = self._client_id - self._mips_thread.start() - - self._on_mips_cmd = None - self._on_mips_message = None - self._on_mips_connect = None - self._on_mips_disconnect = None + # DO NOT start the thread yet. Do that on connect @property def client_id(self) -> str: @@ -413,35 +311,78 @@ class MipsClient(ABC): Returns: bool: True: connected, False: disconnected """ - return self._mqtt and self._mqtt.is_connected() + if self._mqtt: + return self._mqtt.is_connected() + return False - @final - def mips_deinit(self) -> None: - self._mips_send_cmd(type_=MipsCmdType.DEINIT, data=None) + def connect(self, thread_name: Optional[str] = None) -> None: + """mips connect.""" + # Start mips thread + if self._mips_thread: + return + self._internal_loop = asyncio.new_event_loop() + self._mips_thread = threading.Thread(target=self.__mips_loop_thread) + self._mips_thread.daemon = True + self._mips_thread.name = ( + self._client_id if thread_name is None else thread_name) + self._mips_thread.start() + + async def connect_async(self) -> None: + """mips connect async.""" + self.connect() + await self._event_connect.wait() + + def disconnect(self) -> None: + """mips disconnect.""" + if not self._mips_thread: + return + self._internal_loop.call_soon_threadsafe(self.__mips_disconnect) self._mips_thread.join() self._mips_thread = None + self._internal_loop.close() + + async def disconnect_async(self) -> None: + """mips disconnect async.""" + self.disconnect() + await self._event_disconnect.wait() + + @final + def deinit(self) -> None: + self.disconnect() self._logger = None - self._client_id = None - self._host = None - self._port = None self._username = None self._password = None self._ca_file = None self._cert_file = None self._key_file = None self._mqtt_logger = None - self._mips_state_sub_map = None - self._mips_sub_pending_map = None + with self._mips_state_sub_map_lock: + self._mips_state_sub_map.clear() + self._mips_sub_pending_map.clear() self._mips_sub_pending_timer = None - self._event_connect = None - self._event_disconnect = None + @final + async def deinit_async(self) -> None: + await self.disconnect_async() + + self._logger = None + self._username = None + self._password = None + self._ca_file = None + self._cert_file = None + self._key_file = None + self._mqtt_logger = None + with self._mips_state_sub_map_lock: + self._mips_state_sub_map.clear() + self._mips_sub_pending_map.clear() + self._mips_sub_pending_timer = None def update_mqtt_password(self, password: str) -> None: self._password = password - self._mqtt.username_pw_set( - username=self._username, password=self._password) + if self._mqtt: + self._mqtt.username_pw_set( + username=self._username, password=self._password) def log_debug(self, msg, *args, **kwargs) -> None: if self._logger: @@ -461,171 +402,81 @@ class MipsClient(ABC): def enable_mqtt_logger( self, logger: Optional[logging.Logger] = None ) -> None: - if logger: - self._mqtt.enable_logger(logger=logger) - else: - self._mqtt.disable_logger() - - @final - def mips_connect(self) -> None: - """mips connect.""" - return self._mips_send_cmd(type_=MipsCmdType.CONNECT, data=None) - - @final - async def mips_connect_async(self) -> None: - """mips connect async.""" - self._mips_send_cmd(type_=MipsCmdType.CONNECT, data=None) - return await self._event_connect.wait() - - @final - def mips_disconnect(self) -> None: - """mips disconnect.""" - return self._mips_send_cmd(type_=MipsCmdType.DISCONNECT, data=None) - - @final - async def mips_disconnect_async(self) -> None: - """mips disconnect async.""" - self._mips_send_cmd(type_=MipsCmdType.DISCONNECT, data=None) - return await self._event_disconnect.wait() + self._mqtt_logger = logger + if self._mqtt: + if logger: + self._mqtt.enable_logger(logger=logger) + else: + self._mqtt.disable_logger() @final def sub_mips_state( - self, key: str, handler: Callable[[str, bool], asyncio.Future] + self, key: str, handler: Callable[[str, bool], Coroutine] ) -> bool: """Subscribe mips state. NOTICE: callback to main loop thread + This will be called before the client is connected. + So use mutex instead of IPC. """ if isinstance(key, str) is False or handler is None: raise MIoTMipsError('invalid params') - return self._mips_send_cmd( - type_=MipsCmdType.REG_MIPS_STATE, - data=MipsRegState(key=key, handler=handler)) + state = _MipsState(key=key, handler=handler) + with self._mips_state_sub_map_lock: + self._mips_state_sub_map[key] = state + self.log_debug(f'mips register mips state, {key}') + return True @final def unsub_mips_state(self, key: str) -> bool: """Unsubscribe mips state.""" if isinstance(key, str) is False: raise MIoTMipsError('invalid params') - return self._mips_send_cmd( - type_=MipsCmdType.UNREG_MIPS_STATE, data=MipsRegState(key=key)) - - @final - def mev_set_timeout( - self, timeout_ms: int, handler: Callable[[Any], None], - handler_ctx: Any = None - ) -> Optional[TimeoutHandle]: - """set timeout. - NOTICE: Internal function, only mips threads are allowed to call - """ - if self._mev is None: - return None - return self._mev.set_timeout( - timeout_ms=timeout_ms, handler=handler, handler_ctx=handler_ctx) - - @final - def mev_clear_timeout(self, handle: TimeoutHandle) -> None: - """clear timeout. - NOTICE: Internal function, only mips threads are allowed to call - """ - if self._mev is None: - return - self._mev.clear_timeout(handle) - - @final - def mev_set_read_handler( - self, fd: int, handler: Callable[[Any], None], handler_ctx: Any - ) -> bool: - """set read handler. - NOTICE: Internal function, only mips threads are allowed to call - """ - if self._mev is None: - return False - return self._mev.set_read_handler( - fd=fd, handler=handler, handler_ctx=handler_ctx) - - @final - def mev_set_write_handler( - self, fd: int, handler: Callable[[Any], None], handler_ctx: Any - ) -> bool: - """set write handler. - NOTICE: Internal function, only mips threads are allowed to call - """ - if self._mev is None: - return False - return self._mev.set_write_handler( - fd=fd, handler=handler, handler_ctx=handler_ctx) - - @property - def on_mips_cmd(self) -> Callable[[MipsCmd], None]: - return self._on_mips_cmd - - @on_mips_cmd.setter - def on_mips_cmd(self, handler: Callable[[MipsCmd], None]) -> None: - """MUST set after __init__ done. - NOTICE thread safe, this function will be called at the **mips** thread - """ - self._on_mips_cmd = handler - - @property - def on_mips_message(self) -> Callable[[str, bytes], None]: - return self._on_mips_message - - @on_mips_message.setter - def on_mips_message(self, handler: Callable[[str, bytes], None]) -> None: - """MUST set after __init__ done. - NOTICE thread safe, this function will be called at the **mips** thread - """ - self._on_mips_message = handler - - @property - def on_mips_connect(self) -> Callable[[int, dict], None]: - return self._on_mips_connect - - @on_mips_connect.setter - def on_mips_connect(self, handler: Callable[[int, dict], None]) -> None: - """MUST set after __init__ done. - NOTICE thread safe, this function will be called at the - **main loop** thread - """ - self._on_mips_connect = handler - - @property - def on_mips_disconnect(self) -> Callable[[int, dict], None]: - return self._on_mips_disconnect - - @on_mips_disconnect.setter - def on_mips_disconnect(self, handler: Callable[[int, dict], None]) -> None: - """MUST set after __init__ done. - NOTICE thread safe, this function will be called at the - **main loop** thread - """ - self._on_mips_disconnect = handler + with self._mips_state_sub_map_lock: + del self._mips_state_sub_map[key] + self.log_debug(f'mips unregister mips state, {key}') + return True @abstractmethod def sub_prop( - self, did: str, handler: Callable[[dict, Any], None], - siid: int = None, piid: int = None, handler_ctx: Any = None + self, + did: str, + handler: Callable[[dict, Any], None], + siid: Optional[int] = None, + piid: Optional[int] = None, + handler_ctx: Any = None ) -> bool: ... @abstractmethod def unsub_prop( - self, did: str, siid: int = None, piid: int = None + self, + did: str, + siid: Optional[int] = None, + piid: Optional[int] = None ) -> bool: ... @abstractmethod def sub_event( - self, did: str, handler: Callable[[dict, Any], None], - siid: int = None, eiid: int = None, handler_ctx: Any = None + self, + did: str, + handler: Callable[[dict, Any], None], + siid: Optional[int] = None, + eiid: Optional[int] = None, + handler_ctx: Any = None ) -> bool: ... @abstractmethod def unsub_event( - self, did: str, siid: int = None, eiid: int = None + self, + did: str, + siid: Optional[int] = None, + eiid: Optional[int] = None ) -> bool: ... @abstractmethod async def get_dev_list_async( - self, payload: str = None, timeout_ms: int = 10000 + self, + payload: Optional[str] = None, + timeout_ms: int = 10000 ) -> dict[str, dict]: ... @abstractmethod @@ -637,13 +488,22 @@ class MipsClient(ABC): async def set_prop_async( self, did: str, siid: int, piid: int, value: Any, timeout_ms: int = 10000 - ) -> bool: ... + ) -> dict: ... @abstractmethod async def action_async( self, did: str, siid: int, aiid: int, in_list: list, timeout_ms: int = 10000 - ) -> tuple[bool, list]: ... + ) -> dict: ... + + @abstractmethod + def _on_mips_message(self, topic: str, payload: bytes) -> None: ... + + @abstractmethod + def _on_mips_connect(self, rc: int, props: dict) -> None: ... + + @abstractmethod + def _on_mips_disconnect(self, rc: int, props: dict) -> None: ... @final def _mips_sub_internal(self, topic: str) -> None: @@ -657,8 +517,8 @@ class MipsClient(ABC): if topic not in self._mips_sub_pending_map: self._mips_sub_pending_map[topic] = 0 if not self._mips_sub_pending_timer: - self._mips_sub_pending_timer = self.mev_set_timeout( - 10, self.__mips_sub_internal_pending_handler, topic) + self._mips_sub_pending_timer = self._internal_loop.call_later( + 0.01, self.__mips_sub_internal_pending_handler, topic) except Exception as err: # pylint: disable=broad-exception-caught # Catch all exception self.log_error(f'mips sub internal error, {topic}. {err}') @@ -707,75 +567,24 @@ class MipsClient(ABC): self.log_error(f'mips publish internal error, {err}') return False - @final - def _mips_send_cmd(self, type_: MipsCmdType, data: Any) -> bool: - if self._mips_queue is None or self._cmd_event_fd is None: - raise MIoTMipsError('send mips cmd disable') - # Put data to queue - self._mips_queue.put(MipsCmd(type_=type_, data=data)) - # Write event fd - os.eventfd_write(self._cmd_event_fd, 1) - # self.log_debug(f'send mips cmd, {type}, {data}') - return True - def __thread_check(self) -> None: if threading.current_thread() is not self._mips_thread: raise MIoTMipsError('illegal call') - def __mips_cmd_read_handler(self, ctx: Any) -> None: - fd_value = os.eventfd_read(self._cmd_event_fd) - if fd_value == 0: - return - while self._mips_queue.empty() is False: - mips_cmd: MipsCmd = self._mips_queue.get(block=False) - if mips_cmd.type_ == MipsCmdType.CONNECT: - self._mips_reconnect_tag = True - self.__mips_try_reconnect(immediately=True) - elif mips_cmd.type_ == MipsCmdType.DISCONNECT: - self._mips_reconnect_tag = False - self.__mips_disconnect() - elif mips_cmd.type_ == MipsCmdType.DEINIT: - self.log_info('mips client recv deinit cmd') - self.__mips_disconnect() - # Close cmd event fd - if self._cmd_event_fd: - self.mev_set_read_handler( - self._cmd_event_fd, None, None) - os.close(self._cmd_event_fd) - self._cmd_event_fd = None - if self._mips_queue: - self._mips_queue = None - # ev loop stop - if self._mev: - self._mev.loop_stop() - self._mev = None - break - elif mips_cmd.type_ == MipsCmdType.REG_MIPS_STATE: - state: MipsState = mips_cmd.data - self._mips_state_sub_map[state.key] = state - self.log_debug(f'mips register mips state, {state.key}') - elif mips_cmd.type_ == MipsCmdType.UNREG_MIPS_STATE: - state: MipsState = mips_cmd.data - del self._mips_state_sub_map[state.key] - self.log_debug(f'mips unregister mips state, {state.key}') - else: - if self._on_mips_cmd: - self._on_mips_cmd(mips_cmd=mips_cmd) + def __mqtt_read_handler(self) -> None: + self.__mqtt_loop_handler() - def __mqtt_read_handler(self, ctx: Any) -> None: - self.__mqtt_loop_handler(ctx=ctx) + def __mqtt_write_handler(self) -> None: + self._internal_loop.remove_writer(self._mqtt_fd) + self.__mqtt_loop_handler() - def __mqtt_write_handler(self, ctx: Any) -> None: - self.mev_set_write_handler(self._mqtt_fd, None, None) - self.__mqtt_loop_handler(ctx=ctx) - - def __mqtt_timer_handler(self, ctx: Any) -> None: - self.__mqtt_loop_handler(ctx=ctx) + def __mqtt_timer_handler(self) -> None: + self.__mqtt_loop_handler() if self._mqtt: - self._mqtt_timer = self.mev_set_timeout( - self.MQTT_INTERVAL_MS, self.__mqtt_timer_handler, None) + self._mqtt_timer = self._internal_loop.call_later( + self.MQTT_INTERVAL_S, self.__mqtt_timer_handler) - def __mqtt_loop_handler(self, ctx: Any) -> None: + def __mqtt_loop_handler(self) -> None: try: if self._mqtt: self._mqtt.loop_read() @@ -784,8 +593,8 @@ class MipsClient(ABC): if self._mqtt: self._mqtt.loop_misc() if self._mqtt and self._mqtt.want_write(): - self.mev_set_write_handler( - self._mqtt_fd, self.__mqtt_write_handler, None) + self._internal_loop.add_writer( + self._mqtt_fd, self.__mqtt_write_handler) except Exception as err: # pylint: disable=broad-exception-caught # Catch all exception self.log_error(f'__mqtt_loop_handler, {err}') @@ -793,6 +602,10 @@ class MipsClient(ABC): def __mips_loop_thread(self) -> None: self.log_info('mips_loop_thread start') + # mqtt init for API_VERSION2, + # callback_api_version=CallbackAPIVersion.VERSION2, + self._mqtt = Client(client_id=self._client_id, protocol=MQTTv5) + self._mqtt.enable_logger(logger=self._mqtt_logger) # Set mqtt config if self._username: self._mqtt.username_pw_set( @@ -814,88 +627,89 @@ class MipsClient(ABC): self._mqtt.on_connect_fail = self.__on_connect_failed self._mqtt.on_disconnect = self.__on_disconnect self._mqtt.on_message = self.__on_message + # Connect to mips + self.__mips_start_connect_tries() # Run event loop - self._mev.loop_forever() + self._internal_loop.run_forever() self.log_info('mips_loop_thread exit!') def __on_connect(self, client, user_data, flags, rc, props) -> None: + if not self._mqtt: + _LOGGER.error('__on_connect, but mqtt is None') + return if not self._mqtt.is_connected(): return self.log_info(f'mips connect, {flags}, {rc}, {props}') self._mqtt_state = True - if self._on_mips_connect: - self.mev_set_timeout( - timeout_ms=0, - handler=lambda ctx: - self._on_mips_connect(rc, props)) - for item in self._mips_state_sub_map.values(): - if item.handler is None: - continue - self.main_loop.call_soon_threadsafe( - self.main_loop.create_task, - item.handler(item.key, True)) - # Resolve future - self._event_connect.set() - self._event_disconnect.clear() - - def __on_connect_failed(self, client, user_data, flags, rc) -> None: - self.log_error(f'mips connect failed, {flags}, {rc}') - # Try to reconnect - self.__mips_try_reconnect() - - def __on_disconnect(self, client, user_data, rc, props) -> None: - if self._mqtt_state: - self.log_error(f'mips disconnect, {rc}, {props}') - self._mqtt_state = False - if self._mqtt_timer: - self.mev_clear_timeout(self._mqtt_timer) - self._mqtt_timer = None - if self._mqtt_fd != -1: - self.mev_set_read_handler(self._mqtt_fd, None, None) - self.mev_set_write_handler(self._mqtt_fd, None, None) - self._mqtt_fd = -1 - # Clear retry sub - if self._mips_sub_pending_timer: - self.mev_clear_timeout(self._mips_sub_pending_timer) - self._mips_sub_pending_timer = None - self._mips_sub_pending_map = {} - if self._on_mips_disconnect: - self.mev_set_timeout( - timeout_ms=0, - handler=lambda ctx: - self._on_mips_disconnect(rc, props)) - # Call state sub handler + self._internal_loop.call_soon( + self._on_mips_connect, rc, props) + with self._mips_state_sub_map_lock: for item in self._mips_state_sub_map.values(): if item.handler is None: continue self.main_loop.call_soon_threadsafe( self.main_loop.create_task, - item.handler(item.key, False)) + item.handler(item.key, True)) + # Resolve future + self.main_loop.call_soon_threadsafe( + self._event_connect.set) + self.main_loop.call_soon_threadsafe( + self._event_disconnect.clear) + + def __on_connect_failed(self, client: Client, user_data: Any) -> None: + self.log_error('mips connect failed') + # Try to reconnect + self.__mips_try_reconnect() + + def __on_disconnect(self, client, user_data, rc, props) -> None: + if self._mqtt_state: + (self.log_info if rc == 0 else self.log_error)( + f'mips disconnect, {rc}, {props}') + self._mqtt_state = False + if self._mqtt_timer: + self._mqtt_timer.cancel() + self._mqtt_timer = None + if self._mqtt_fd != -1: + self._internal_loop.remove_reader(self._mqtt_fd) + self._internal_loop.remove_writer(self._mqtt_fd) + self._mqtt_fd = -1 + # Clear retry sub + if self._mips_sub_pending_timer: + self._mips_sub_pending_timer.cancel() + self._mips_sub_pending_timer = None + self._mips_sub_pending_map = {} + self._internal_loop.call_soon( + self._on_mips_disconnect, rc, props) + # Call state sub handler + with self._mips_state_sub_map_lock: + for item in self._mips_state_sub_map.values(): + if item.handler is None: + continue + self.main_loop.call_soon_threadsafe( + self.main_loop.create_task, + item.handler(item.key, False)) # Try to reconnect self.__mips_try_reconnect() # Set event - self._event_disconnect.set() - self._event_connect.clear() + self.main_loop.call_soon_threadsafe( + self._event_disconnect.set) + self.main_loop.call_soon_threadsafe( + self._event_connect.clear) - def __on_message(self, client, user_data, msg) -> None: + def __on_message( + self, + client: Client, + user_data: Any, + msg: MQTTMessage + ) -> None: self._on_mips_message(topic=msg.topic, payload=msg.payload) - def __mips_try_reconnect(self, immediately: bool = False) -> None: - if self._mips_reconnect_timer: - self.mev_clear_timeout(self._mips_reconnect_timer) - self._mips_reconnect_timer = None - if not self._mips_reconnect_tag: - return - interval: int = 0 - if not immediately: - interval = self.__get_next_reconnect_time() - self.log_error( - 'mips try reconnect after %sms', interval) - self._mips_reconnect_timer = self.mev_set_timeout( - interval, self.__mips_connect, None) - def __mips_sub_internal_pending_handler(self, ctx: Any) -> None: + if not self._mqtt or not self._mqtt.is_connected(): + _LOGGER.error( + 'mips sub internal pending, but mqtt is None or disconnected') + return subbed_count = 1 for topic in list(self._mips_sub_pending_map.keys()): if subbed_count > self.MIPS_SUB_PATCH: @@ -916,25 +730,28 @@ class MipsClient(ABC): f'retry mips sub internal, {count}, {topic}, {result}, {mid}') if len(self._mips_sub_pending_map): - self._mips_sub_pending_timer = self.mev_set_timeout( + self._mips_sub_pending_timer = self._internal_loop.call_later( self.MIPS_SUB_INTERVAL, self.__mips_sub_internal_pending_handler, None) else: self._mips_sub_pending_timer = None - def __mips_connect(self, ctx: Any = None) -> None: + def __mips_connect(self) -> None: + if not self._mqtt: + _LOGGER.error('__mips_connect, but mqtt is None') + return result = MQTT_ERR_UNKNOWN if self._mips_reconnect_timer: - self.mev_clear_timeout(self._mips_reconnect_timer) + self._mips_reconnect_timer.cancel() self._mips_reconnect_timer = None try: # Try clean mqtt fd before mqtt connect if self._mqtt_timer: - self.mev_clear_timeout(self._mqtt_timer) + self._mqtt_timer.cancel() self._mqtt_timer = None if self._mqtt_fd != -1: - self.mev_set_read_handler(self._mqtt_fd, None, None) - self.mev_set_write_handler(self._mqtt_fd, None, None) + self._internal_loop.remove_reader(self._mqtt_fd) + self._internal_loop.remove_writer(self._mqtt_fd) self._mqtt_fd = -1 result = self._mqtt.connect( host=self._host, port=self._port, @@ -944,33 +761,66 @@ class MipsClient(ABC): self.log_error('__mips_connect, connect error, %s', error) if result == MQTT_ERR_SUCCESS: - self._mqtt_fd = self._mqtt.socket() + socket = self._mqtt.socket() + if socket is None: + self.log_error( + '__mips_connect, connect success, but socket is None') + self.__mips_try_reconnect() + return + self._mqtt_fd = socket.fileno() self.log_debug(f'__mips_connect, _mqtt_fd, {self._mqtt_fd}') - self.mev_set_read_handler( - self._mqtt_fd, self.__mqtt_read_handler, None) + self._internal_loop.add_reader( + self._mqtt_fd, self.__mqtt_read_handler) if self._mqtt.want_write(): - self.mev_set_write_handler( - self._mqtt_fd, self.__mqtt_write_handler, None) - self._mqtt_timer = self.mev_set_timeout( - self.MQTT_INTERVAL_MS, self.__mqtt_timer_handler, None) + self._internal_loop.add_writer( + self._mqtt_fd, self.__mqtt_write_handler) + self._mqtt_timer = self._internal_loop.call_later( + self.MQTT_INTERVAL_S, self.__mqtt_timer_handler) else: self.log_error(f'__mips_connect error result, {result}') self.__mips_try_reconnect() - def __mips_disconnect(self) -> None: + def __mips_try_reconnect(self, immediately: bool = False) -> None: if self._mips_reconnect_timer: - self.mev_clear_timeout(self._mips_reconnect_timer) + self._mips_reconnect_timer.cancel() + self._mips_reconnect_timer = None + if not self._mips_reconnect_tag: + return + interval: float = 0 + if not immediately: + interval = self.__get_next_reconnect_time() + self.log_error( + 'mips try reconnect after %ss', interval) + self._mips_reconnect_timer = self._internal_loop.call_later( + interval, self.__mips_connect) + + def __mips_start_connect_tries(self) -> None: + self._mips_reconnect_tag = True + self.__mips_try_reconnect(immediately=True) + + def __mips_disconnect(self) -> None: + self._mips_reconnect_tag = False + if self._mips_reconnect_timer: + self._mips_reconnect_timer.cancel() self._mips_reconnect_timer = None if self._mqtt_timer: - self.mev_clear_timeout(self._mqtt_timer) + self._mqtt_timer.cancel() self._mqtt_timer = None if self._mqtt_fd != -1: - self.mev_set_read_handler(self._mqtt_fd, None, None) - self.mev_set_write_handler(self._mqtt_fd, None, None) + self._internal_loop.remove_reader(self._mqtt_fd) + self._internal_loop.remove_writer(self._mqtt_fd) self._mqtt_fd = -1 - self._mqtt.disconnect() + # Clear retry sub + if self._mips_sub_pending_timer: + self._mips_sub_pending_timer.cancel() + self._mips_sub_pending_timer = None + self._mips_sub_pending_map = {} + if self._mqtt: + self._mqtt.disconnect() + self._mqtt = None + self._internal_loop.stop() - def __get_next_reconnect_time(self) -> int: + def __get_next_reconnect_time(self) -> float: if self._mips_reconnect_interval == 0: self._mips_reconnect_interval = self.MIPS_RECONNECT_INTERVAL_MIN else: @@ -980,7 +830,7 @@ class MipsClient(ABC): return self._mips_reconnect_interval -class MipsCloudClient(MipsClient): +class MipsCloudClient(_MipsClient): """MIoT Pub/Sub Cloud Client.""" # pylint: disable=unused-argument # pylint: disable=inconsistent-quotes @@ -996,45 +846,25 @@ class MipsCloudClient(MipsClient): client_id=f'ha.{uuid}', host=f'{cloud_server}-ha.mqtt.io.mi.com', port=port, username=app_id, password=token, loop=loop) - self.on_mips_cmd = self.__on_mips_cmd_handler - self.on_mips_message = self.__on_mips_message_handler - self.on_mips_connect = self.__on_mips_connect_handler - self.on_mips_disconnect = self.__on_mips_disconnect_handler - - def deinit(self) -> None: - self.mips_deinit() - self._msg_matcher = None - self.on_mips_cmd = None - self.on_mips_message = None - self.on_mips_connect = None - - @final - def connect(self) -> None: - self.mips_connect() - - @final - async def connect_async(self) -> None: - await self.mips_connect_async() - @final def disconnect(self) -> None: - self.mips_disconnect() - self._msg_matcher = MIoTMatcher() - - @final - async def disconnect_async(self) -> None: - await self.mips_disconnect_async() + super().disconnect() self._msg_matcher = MIoTMatcher() def update_access_token(self, access_token: str) -> bool: if not isinstance(access_token, str): raise MIoTMipsError('invalid token') - return self.update_mqtt_password(password=access_token) + self.update_mqtt_password(password=access_token) + return True @final def sub_prop( - self, did: str, handler: Callable[[dict, Any], None], - siid: int = None, piid: int = None, handler_ctx: Any = None + self, + did: str, + handler: Callable[[dict, Any], None], + siid: Optional[int] = None, + piid: Optional[int] = None, + handler_ctx: Any = None ) -> bool: if not isinstance(did, str) or handler is None: raise MIoTMipsError('invalid params') @@ -1043,7 +873,7 @@ class MipsCloudClient(MipsClient): f'device/{did}/up/properties_changed/' f'{"#" if siid is None or piid is None else f"{siid}/{piid}"}') - def on_prop_msg(topic: str, payload: str, ctx: Any) -> bool: + def on_prop_msg(topic: str, payload: str, ctx: Any) -> None: try: msg: dict = json.loads(payload) except json.JSONDecodeError: @@ -1062,22 +892,31 @@ class MipsCloudClient(MipsClient): if handler: self.log_debug('on properties_changed, %s', payload) handler(msg['params'], ctx) - return self.__reg_broadcast( + return self.__reg_broadcast_external( topic=topic, handler=on_prop_msg, handler_ctx=handler_ctx) @final - 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: if not isinstance(did, str): raise MIoTMipsError('invalid params') topic: str = ( f'device/{did}/up/properties_changed/' f'{"#" if siid is None or piid is None else f"{siid}/{piid}"}') - return self.__unreg_broadcast(topic=topic) + return self.__unreg_broadcast_external(topic=topic) @final def sub_event( - self, did: str, handler: Callable[[dict, Any], None], - siid: int = None, eiid: int = None, handler_ctx: Any = None + self, + did: str, + handler: Callable[[dict, Any], None], + siid: Optional[int] = None, + eiid: Optional[int] = None, + handler_ctx: Any = None ) -> bool: if not isinstance(did, str) or handler is None: raise MIoTMipsError('invalid params') @@ -1086,7 +925,7 @@ class MipsCloudClient(MipsClient): f'device/{did}/up/event_occured/' f'{"#" if siid is None or eiid is None else f"{siid}/{eiid}"}') - def on_event_msg(topic: str, payload: str, ctx: Any) -> bool: + def on_event_msg(topic: str, payload: str, ctx: Any) -> None: try: msg: dict = json.loads(payload) except json.JSONDecodeError: @@ -1106,18 +945,23 @@ class MipsCloudClient(MipsClient): self.log_debug('on on_event_msg, %s', payload) msg['params']['from'] = 'cloud' handler(msg['params'], ctx) - return self.__reg_broadcast( + return self.__reg_broadcast_external( topic=topic, handler=on_event_msg, handler_ctx=handler_ctx) @final - 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: if not isinstance(did, str): raise MIoTMipsError('invalid params') # Spelling error: event_occured topic: str = ( f'device/{did}/up/event_occured/' f'{"#" if siid is None or eiid is None else f"{siid}/{eiid}"}') - return self.__unreg_broadcast(topic=topic) + return self.__unreg_broadcast_external(topic=topic) @final def sub_device_state( @@ -1145,7 +989,7 @@ class MipsCloudClient(MipsClient): handler( did, MIoTDeviceState.ONLINE if msg['event'] == 'online' else MIoTDeviceState.OFFLINE, ctx) - return self.__reg_broadcast( + return self.__reg_broadcast_external( topic=topic, handler=on_state_msg, handler_ctx=handler_ctx) @final @@ -1153,10 +997,10 @@ class MipsCloudClient(MipsClient): if not isinstance(did, str): raise MIoTMipsError('invalid params') topic: str = f'device/{did}/state/#' - return self.__unreg_broadcast(topic=topic) + return self.__unreg_broadcast_external(topic=topic) async def get_dev_list_async( - self, payload: str = None, timeout_ms: int = 10000 + self, payload: Optional[str] = None, timeout_ms: int = 10000 ) -> dict[str, dict]: raise NotImplementedError('please call in http client') @@ -1168,97 +1012,95 @@ class MipsCloudClient(MipsClient): async def set_prop_async( self, did: str, siid: int, piid: int, value: Any, timeout_ms: int = 10000 - ) -> bool: + ) -> dict: raise NotImplementedError('please call in http client') async def action_async( self, did: str, siid: int, aiid: int, in_list: list, timeout_ms: int = 10000 - ) -> tuple[bool, list]: + ) -> dict: raise NotImplementedError('please call in http client') - def __on_mips_cmd_handler(self, mips_cmd: MipsCmd) -> None: - """ - NOTICE thread safe, this function will be called at the **mips** thread - """ - if mips_cmd.type_ == MipsCmdType.REG_BROADCAST: - reg_bc: MipsRegBroadcast = mips_cmd.data - if not self._msg_matcher.get(topic=reg_bc.topic): - sub_bc: MipsBroadcast = MipsBroadcast( - topic=reg_bc.topic, handler=reg_bc.handler, - handler_ctx=reg_bc.handler_ctx) - self._msg_matcher[reg_bc.topic] = sub_bc - self._mips_sub_internal(topic=reg_bc.topic) - else: - self.log_debug(f'mips cloud re-reg broadcast, {reg_bc.topic}') - elif mips_cmd.type_ == MipsCmdType.UNREG_BROADCAST: - unreg_bc: MipsRegBroadcast = mips_cmd.data - if self._msg_matcher.get(topic=unreg_bc.topic): - del self._msg_matcher[unreg_bc.topic] - self._mips_unsub_internal(topic=unreg_bc.topic) + def __reg_broadcast_external( + self, topic: str, handler: Callable[[str, str, Any], None], + handler_ctx: Any = None + ) -> bool: + self._internal_loop.call_soon_threadsafe( + self.__reg_broadcast, topic, handler, handler_ctx) + return True + + def __unreg_broadcast_external(self, topic: str) -> bool: + self._internal_loop.call_soon_threadsafe( + self.__unreg_broadcast, topic) + return True def __reg_broadcast( self, topic: str, handler: Callable[[str, str, Any], None], handler_ctx: Any = None - ) -> bool: - return self._mips_send_cmd( - type_=MipsCmdType.REG_BROADCAST, - data=MipsRegBroadcast( - topic=topic, handler=handler, handler_ctx=handler_ctx)) + ) -> None: + if not self._msg_matcher.get(topic=topic): + sub_bc: _MipsBroadcast = _MipsBroadcast( + topic=topic, handler=handler, + handler_ctx=handler_ctx) + self._msg_matcher[topic] = sub_bc + self._mips_sub_internal(topic=topic) + else: + self.log_debug(f'mips cloud re-reg broadcast, {topic}') - def __unreg_broadcast(self, topic: str) -> bool: - return self._mips_send_cmd( - type_=MipsCmdType.UNREG_BROADCAST, - data=MipsRegBroadcast(topic=topic)) + def __unreg_broadcast(self, topic: str) -> None: + if self._msg_matcher.get(topic=topic): + del self._msg_matcher[topic] + self._mips_unsub_internal(topic=topic) - def __on_mips_connect_handler(self, rc, props) -> None: + def _on_mips_connect(self, rc: int, props: dict) -> None: """sub topic.""" for topic, _ in list( self._msg_matcher.iter_all_nodes()): self._mips_sub_internal(topic=topic) - def __on_mips_disconnect_handler(self, rc, props) -> None: + def _on_mips_disconnect(self, rc: int, props: dict) -> None: """unsub topic.""" pass - def __on_mips_message_handler(self, topic: str, payload) -> None: + def _on_mips_message(self, topic: str, payload: bytes) -> None: """ NOTICE thread safe, this function will be called at the **mips** thread """ # broadcast - bc_list: list[MipsBroadcast] = list( + bc_list: list[_MipsBroadcast] = list( self._msg_matcher.iter_match(topic)) if not bc_list: return + # The message from the cloud is not packed. + payload_str: str = payload.decode('utf-8') # self.log_debug(f"on broadcast, {topic}, {payload}") for item in bc_list or []: if item.handler is None: continue # NOTICE: call threadsafe self.main_loop.call_soon_threadsafe( - item.handler, topic, payload, item.handler_ctx) + item.handler, topic, payload_str, item.handler_ctx) -class MipsLocalClient(MipsClient): +class MipsLocalClient(_MipsClient): """MIoT Pub/Sub Local Client.""" # pylint: disable=unused-argument # pylint: disable=inconsistent-quotes - MIPS_RECONNECT_INTERVAL_MIN: int = 6000 - MIPS_RECONNECT_INTERVAL_MAX: int = 60000 + MIPS_RECONNECT_INTERVAL_MIN: float = 6 + MIPS_RECONNECT_INTERVAL_MAX: float = 60 MIPS_SUB_PATCH: int = 1000 - MIPS_SUB_INTERVAL: int = 100 + MIPS_SUB_INTERVAL: float = 0.1 _did: str _group_id: str _home_name: str _mips_seed_id: int _reply_topic: str _dev_list_change_topic: str - _request_map: dict[str, MipsRequest] + _request_map: dict[str, _MipsRequest] _msg_matcher: MIoTMatcher - _device_state_sub_map: dict[str, MipsDeviceState] _get_prop_queue: dict[str, list] - _get_prop_timer: asyncio.TimerHandle - _on_dev_list_changed: Callable[[Any, list[str]], asyncio.Future] + _get_prop_timer: Optional[asyncio.TimerHandle] + _on_dev_list_changed: Optional[Callable[[Any, list[str]], Coroutine]] def __init__( self, did: str, host: str, group_id: str, @@ -1274,7 +1116,6 @@ class MipsLocalClient(MipsClient): self._dev_list_change_topic = f'{did}/appMsg/devListChange' self._request_map = {} self._msg_matcher = MIoTMatcher() - self._device_state_sub_map = {} self._get_prop_queue = {} self._get_prop_timer = None self._on_dev_list_changed = None @@ -1282,34 +1123,11 @@ class MipsLocalClient(MipsClient): super().__init__( client_id=did, host=host, port=port, ca_file=ca_file, cert_file=cert_file, key_file=key_file, loop=loop) - # MIPS local thread name use group_id - self._mips_thread.name = self._group_id - - self.on_mips_cmd = self.__on_mips_cmd_handler - self.on_mips_message = self.__on_mips_message_handler - self.on_mips_connect = self.__on_mips_connect_handler @property def group_id(self) -> str: return self._group_id - def deinit(self) -> None: - self.mips_deinit() - self._did = None - self._mips_seed_id = None - self._reply_topic = None - self._dev_list_change_topic = None - self._request_map = None - self._msg_matcher = None - self._device_state_sub_map = None - self._get_prop_queue = None - self._get_prop_timer = None - self._on_dev_list_changed = None - - self.on_mips_cmd = None - self.on_mips_message = None - self.on_mips_connect = None - def log_debug(self, msg, *args, **kwargs) -> None: if self._logger: self._logger.debug(f'{self._home_name}, '+msg, *args, **kwargs) @@ -1323,31 +1141,24 @@ class MipsLocalClient(MipsClient): self._logger.error(f'{self._home_name}, '+msg, *args, **kwargs) @final - def connect(self) -> None: - self.mips_connect() - - @final - async def connect_async(self) -> None: - await self.mips_connect_async() + def connect(self, thread_name: Optional[str] = None) -> None: + # MIPS local thread name use group_id + super().connect(self._group_id) @final def disconnect(self) -> None: - self.mips_disconnect() + super().disconnect() self._request_map = {} self._msg_matcher = MIoTMatcher() - self._device_state_sub_map = {} - - @final - async def disconnect_async(self) -> None: - await self.mips_disconnect_async() - self._request_map = {} - self._msg_matcher = MIoTMatcher() - self._device_state_sub_map = {} @final def sub_prop( - self, did: str, handler: Callable[[dict, Any], None], - siid: int = None, piid: int = None, handler_ctx: Any = None + self, + did: str, + handler: Callable[[dict, Any], None], + siid: Optional[int] = None, + piid: Optional[int] = None, + handler_ctx: Any = None ) -> bool: topic: str = ( f'appMsg/notify/iot/{did}/property/' @@ -1367,20 +1178,29 @@ class MipsLocalClient(MipsClient): if handler: self.log_debug('local, on properties_changed, %s', payload) handler(msg, ctx) - return self.__reg_broadcast( + return self.__reg_broadcast_external( topic=topic, handler=on_prop_msg, handler_ctx=handler_ctx) @final - 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: str = ( f'appMsg/notify/iot/{did}/property/' f'{"#" if siid is None or piid is None else f"{siid}.{piid}"}') - return self.__unreg_broadcast(topic=topic) + return self.__unreg_broadcast_external(topic=topic) @final def sub_event( - self, did: str, handler: Callable[[dict, Any], None], - siid: int = None, eiid: int = None, handler_ctx: Any = None + self, + did: str, + handler: Callable[[dict, Any], None], + siid: Optional[int] = None, + eiid: Optional[int] = None, + handler_ctx: Any = None ) -> bool: topic: str = ( f'appMsg/notify/iot/{did}/event/' @@ -1400,15 +1220,20 @@ class MipsLocalClient(MipsClient): if handler: self.log_debug('local, on event_occurred, %s', payload) handler(msg, ctx) - return self.__reg_broadcast( + return self.__reg_broadcast_external( topic=topic, handler=on_event_msg, handler_ctx=handler_ctx) @final - 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: str = ( f'appMsg/notify/iot/{did}/event/' f'{"#" if siid is None or eiid is None else f"{siid}.{eiid}"}') - return self.__unreg_broadcast(topic=topic) + return self.__unreg_broadcast_external(topic=topic) @final async def get_prop_safe_async( @@ -1426,7 +1251,9 @@ class MipsLocalClient(MipsClient): 'timeout_ms': timeout_ms }) if self._get_prop_timer is None: - self._get_prop_timer = self.main_loop.create_task( + self._get_prop_timer = self.main_loop.call_later( + 0.1, + self.main_loop.create_task, self.__get_prop_timer_handle()) return await fut @@ -1515,13 +1342,13 @@ class MipsLocalClient(MipsClient): @final async def get_dev_list_async( - self, payload: str = None, timeout_ms: int = 10000 + self, payload: Optional[str] = None, timeout_ms: int = 10000 ) -> dict[str, dict]: result_obj = await self.__request_async( topic='proxy/getDevList', payload=payload or '{}', timeout_ms=timeout_ms) if not result_obj or 'devList' not in result_obj: - return None + raise MIoTMipsError('invalid result') device_list = {} for did, info in result_obj['devList'].items(): name: str = info.get('name', None) @@ -1557,7 +1384,7 @@ class MipsLocalClient(MipsClient): payload='{}', timeout_ms=timeout_ms) if not result_obj or 'result' not in result_obj: - return None + raise MIoTMipsError('invalid result') return result_obj['result'] @final @@ -1579,79 +1406,73 @@ class MipsLocalClient(MipsClient): @final @property - def on_dev_list_changed(self) -> Callable[[Any, list[str]], asyncio.Future]: + def on_dev_list_changed( + self + ) -> Optional[Callable[[Any, list[str]], Coroutine]]: return self._on_dev_list_changed @final @on_dev_list_changed.setter def on_dev_list_changed( - self, func: Callable[[Any, list[str]], asyncio.Future] + self, func: Callable[[Any, list[str]], Coroutine] ) -> None: """run in main loop.""" self._on_dev_list_changed = func - @final - def __on_mips_cmd_handler(self, mips_cmd: MipsCmd) -> None: - if mips_cmd.type_ == MipsCmdType.CALL_API: - req_data: MipsRequestData = mips_cmd.data - req = MipsRequest() - req.mid = self.__gen_mips_id - req.on_reply = req_data.on_reply - req.on_reply_ctx = req_data.on_reply_ctx - pub_topic: str = f'master/{req_data.topic}' - result = self.__mips_publish( - topic=pub_topic, payload=req_data.payload, mid=req.mid, - ret_topic=self._reply_topic) - self.log_debug( - f'mips local call api, {result}, {req.mid}, {pub_topic}, ' - f'{req_data.payload}') + def __request( + self, topic: str, payload: str, + on_reply: Callable[[str, Any], None], + on_reply_ctx: Any = None, timeout_ms: int = 10000 + ) -> None: + req = _MipsRequest( + mid=self.__gen_mips_id, + on_reply=on_reply, + on_reply_ctx=on_reply_ctx, + timer=None) + pub_topic: str = f'master/{topic}' + result = self.__mips_publish( + topic=pub_topic, payload=payload, mid=req.mid, + ret_topic=self._reply_topic) + self.log_debug( + f'mips local call api, {result}, {req.mid}, {pub_topic}, ' + f'{payload}') - def on_request_timeout(req: MipsRequest): - self.log_error( - f'on mips request timeout, {req.mid}, {pub_topic}' - f', {req_data.payload}') - self._request_map.pop(str(req.mid), None) - req.on_reply( - '{"error":{"code":-10006, "message":"timeout"}}', - req.on_reply_ctx) - req.timer = self.mev_set_timeout( - req_data.timeout_ms, on_request_timeout, req) - self._request_map[str(req.mid)] = req - elif mips_cmd.type_ == MipsCmdType.REG_BROADCAST: - reg_bc: MipsRegBroadcast = mips_cmd.data - sub_topic: str = f'{self._did}/{reg_bc.topic}' - if not self._msg_matcher.get(sub_topic): - sub_bc: MipsBroadcast = MipsBroadcast( - topic=sub_topic, handler=reg_bc.handler, - handler_ctx=reg_bc.handler_ctx) - self._msg_matcher[sub_topic] = sub_bc - self._mips_sub_internal(topic=f'master/{reg_bc.topic}') - else: - self.log_debug(f'mips re-reg broadcast, {sub_topic}') - elif mips_cmd.type_ == MipsCmdType.UNREG_BROADCAST: - unreg_bc: MipsRegBroadcast = mips_cmd.data - # Central hub gateway needs to add prefix - unsub_topic: str = f'{self._did}/{unreg_bc.topic}' - if self._msg_matcher.get(unsub_topic): - del self._msg_matcher[unsub_topic] - self._mips_unsub_internal( - topic=re.sub(f'^{self._did}', 'master', unsub_topic)) - elif mips_cmd.type_ == MipsCmdType.REG_DEVICE_STATE: - reg_dev_state: MipsRegDeviceState = mips_cmd.data - self._device_state_sub_map[reg_dev_state.did] = reg_dev_state - self.log_debug( - f'mips local reg device state, {reg_dev_state.did}') - elif mips_cmd.type_ == MipsCmdType.UNREG_DEVICE_STATE: - unreg_dev_state: MipsRegDeviceState = mips_cmd.data - del self._device_state_sub_map[unreg_dev_state.did] - self.log_debug( - f'mips local unreg device state, {unreg_dev_state.did}') - else: + def on_request_timeout(req: _MipsRequest): self.log_error( - f'mips local recv unknown cmd, {mips_cmd.type_}, ' - f'{mips_cmd.data}') + f'on mips request timeout, {req.mid}, {pub_topic}' + f', {payload}') + self._request_map.pop(str(req.mid), None) + req.on_reply( + '{"error":{"code":-10006, "message":"timeout"}}', + req.on_reply_ctx) + req.timer = self._internal_loop.call_later( + timeout_ms/1000, on_request_timeout, req) + self._request_map[str(req.mid)] = req - def __on_mips_connect_handler(self, rc, props) -> None: + def __reg_broadcast( + self, topic: str, handler: Callable[[str, str, Any], None], + handler_ctx: Any + ) -> None: + sub_topic: str = f'{self._did}/{topic}' + if not self._msg_matcher.get(sub_topic): + sub_bc: _MipsBroadcast = _MipsBroadcast( + topic=sub_topic, handler=handler, + handler_ctx=handler_ctx) + self._msg_matcher[sub_topic] = sub_bc + self._mips_sub_internal(topic=f'master/{topic}') + else: + self.log_debug(f'mips re-reg broadcast, {sub_topic}') + + def __unreg_broadcast(self, topic) -> None: + # Central hub gateway needs to add prefix + unsub_topic: str = f'{self._did}/{topic}' + if self._msg_matcher.get(unsub_topic): + del self._msg_matcher[unsub_topic] + self._mips_unsub_internal( + topic=re.sub(f'^{self._did}', 'master', unsub_topic)) + + @final + def _on_mips_connect(self, rc: int, props: dict) -> None: self.log_debug('__on_mips_connect_handler') # Sub did/#, include reply topic self._mips_sub_internal(f'{self._did}/#') @@ -1665,24 +1486,30 @@ class MipsLocalClient(MipsClient): topic=re.sub(f'^{self._did}', 'master', topic)) @final - def __on_mips_message_handler(self, topic: str, payload: bytes) -> None: - mips_msg: MipsMessage = MipsMessage.unpack(payload) + def _on_mips_disconnect(self, rc: int, props: dict) -> None: + pass + + @final + def _on_mips_message(self, topic: str, payload: bytes) -> None: + mips_msg: _MipsMessage = _MipsMessage.unpack(payload) # self.log_debug( # f"mips local client, on_message, {topic} -> {mips_msg}") # Reply if topic == self._reply_topic: self.log_debug(f'on request reply, {mips_msg}') - req: MipsRequest = self._request_map.pop(str(mips_msg.mid), None) + req: Optional[_MipsRequest] = self._request_map.pop( + str(mips_msg.mid), None) if req: # Cancel timer - self.mev_clear_timeout(req.timer) + if req.timer: + req.timer.cancel() if req.on_reply: self.main_loop.call_soon_threadsafe( req.on_reply, mips_msg.payload or '{}', req.on_reply_ctx) return # Broadcast - bc_list: list[MipsBroadcast] = list(self._msg_matcher.iter_match( + bc_list: list[_MipsBroadcast] = list(self._msg_matcher.iter_match( topic=topic)) if bc_list: self.log_debug(f'on broadcast, {topic}, {mips_msg}') @@ -1695,6 +1522,9 @@ class MipsLocalClient(MipsClient): return # Device list change if topic == self._dev_list_change_topic: + if mips_msg.payload is None: + self.log_error('devListChange msg is None') + return payload_obj: dict = json.loads(mips_msg.payload) dev_list = payload_obj.get('devList', None) if not isinstance(dev_list, list) or not dev_list: @@ -1704,7 +1534,7 @@ class MipsLocalClient(MipsClient): if self._on_dev_list_changed: self.main_loop.call_soon_threadsafe( self.main_loop.create_task, - self._on_dev_list_changed(self, payload_obj['devList'])) + self._on_dev_list_changed(self, dev_list)) return self.log_debug( @@ -1717,45 +1547,45 @@ class MipsLocalClient(MipsClient): return mips_id def __mips_publish( - self, topic: str, payload: str | bytes, mid: int = None, - ret_topic: str = None, wait_for_publish: bool = False, - timeout_ms: int = 10000 + self, + topic: str, + payload: str, + mid: Optional[int] = None, + ret_topic: Optional[str] = None, + wait_for_publish: bool = False, + timeout_ms: int = 10000 ) -> bool: - mips_msg: bytes = MipsMessage.pack( + mips_msg: bytes = _MipsMessage.pack( mid=mid or self.__gen_mips_id, payload=payload, msg_from='local', ret_topic=ret_topic) return self._mips_publish_internal( topic=topic.strip(), payload=mips_msg, wait_for_publish=wait_for_publish, timeout_ms=timeout_ms) - def __request( + def __request_external( self, topic: str, payload: str, on_reply: Callable[[str, Any], None], on_reply_ctx: Any = None, timeout_ms: int = 10000 ) -> bool: if topic is None or payload is None or on_reply is None: raise MIoTMipsError('invalid params') - req_data: MipsRequestData = MipsRequestData() - req_data.topic = topic - req_data.payload = payload - req_data.on_reply = on_reply - req_data.on_reply_ctx = on_reply_ctx - req_data.timeout_ms = timeout_ms - return self._mips_send_cmd(type_=MipsCmdType.CALL_API, data=req_data) + self._internal_loop.call_soon_threadsafe( + self.__request, topic, payload, on_reply, on_reply_ctx, timeout_ms) + return True - def __reg_broadcast( + def __reg_broadcast_external( self, topic: str, handler: Callable[[str, str, Any], None], handler_ctx: Any ) -> bool: - return self._mips_send_cmd( - type_=MipsCmdType.REG_BROADCAST, - data=MipsRegBroadcast( - topic=topic, handler=handler, handler_ctx=handler_ctx)) + self._internal_loop.call_soon_threadsafe( + self.__reg_broadcast, + topic, handler, handler_ctx) + return True - def __unreg_broadcast(self, topic) -> bool: - return self._mips_send_cmd( - type_=MipsCmdType.UNREG_BROADCAST, - data=MipsRegBroadcast(topic=topic)) + def __unreg_broadcast_external(self, topic) -> bool: + self._internal_loop.call_soon_threadsafe( + self.__unreg_broadcast, topic) + return True @final async def __request_async( @@ -1767,7 +1597,7 @@ class MipsLocalClient(MipsClient): fut: asyncio.Future = ctx if fut: self.main_loop.call_soon_threadsafe(fut.set_result, payload) - if not self.__request( + if not self.__request_external( topic=topic, payload=payload, on_reply=on_msg_reply, diff --git a/custom_components/xiaomi_home/miot/miot_spec.py b/custom_components/xiaomi_home/miot/miot_spec.py index 33022a1..67fde7b 100644 --- a/custom_components/xiaomi_home/miot/miot_spec.py +++ b/custom_components/xiaomi_home/miot/miot_spec.py @@ -46,342 +46,200 @@ off Xiaomi or its affiliates' products. MIoT-Spec-V2 parser. """ import asyncio -import json +import os import platform import time -from typing import Any, Optional -from urllib.parse import urlencode -from urllib.request import Request, urlopen +from typing import Any, Optional, Type, Union import logging +from slugify import slugify + # pylint: disable=relative-beyond-top-level from .const import DEFAULT_INTEGRATION_LANGUAGE, SPEC_STD_LIB_EFFECTIVE_TIME +from .common import MIoTHttp, load_yaml_file from .miot_error import MIoTSpecError -from .miot_storage import ( - MIoTStorage, - SpecBoolTranslation, - SpecFilter, - SpecMultiLang) +from .miot_storage import MIoTStorage _LOGGER = logging.getLogger(__name__) -class MIoTSpecBase: - """MIoT SPEC base class.""" - iid: int - type_: str - description: str - description_trans: Optional[str] - proprietary: bool - need_filter: bool - name: Optional[str] +class MIoTSpecValueRange: + """MIoT SPEC value range class.""" + min_: int + max_: int + step: int - # External params - platform: str - device_class: Any - state_class: Any - icon: str - external_unit: Any + def __init__(self, value_range: Union[dict, list]) -> None: + if isinstance(value_range, dict): + self.load(value_range) + elif isinstance(value_range, list): + self.from_spec(value_range) + else: + raise MIoTSpecError('invalid value range format') - spec_id: str + def load(self, value_range: dict) -> None: + if ( + 'min' not in value_range + or 'max' not in value_range + or 'step' not in value_range + ): + raise MIoTSpecError('invalid value range') + self.min_ = value_range['min'] + self.max_ = value_range['max'] + self.step = value_range['step'] - def __init__(self, spec: dict) -> None: - self.iid = spec['iid'] - self.type_ = spec['type'] - self.description = spec['description'] - - self.description_trans = spec.get('description_trans', None) - self.proprietary = spec.get('proprietary', False) - self.need_filter = spec.get('need_filter', False) - self.name = spec.get('name', None) - - self.platform = None - self.device_class = None - self.state_class = None - self.icon = None - self.external_unit = None - - self.spec_id = hash(f'{self.type_}.{self.iid}') - - def __hash__(self) -> int: - return self.spec_id - - def __eq__(self, value: object) -> bool: - return self.spec_id == value.spec_id - - -class MIoTSpecProperty(MIoTSpecBase): - """MIoT SPEC property class.""" - format_: str - precision: int - unit: str - - value_range: list - value_list: list[dict] - - _access: list - _writable: bool - _readable: bool - _notifiable: bool - - service: MIoTSpecBase - - def __init__( - self, spec: dict, service: MIoTSpecBase = None, - format_: str = None, access: list = None, - unit: str = None, value_range: list = None, - value_list: list[dict] = None, precision: int = 0 - ) -> None: - super().__init__(spec=spec) - self.service = service - self.format_ = format_ - self.access = access - self.unit = unit - self.value_range = value_range - self.value_list = value_list - self.precision = precision - - self.spec_id = hash( - f'p.{self.name}.{self.service.iid}.{self.iid}') - - @property - def access(self) -> list: - return self._access - - @access.setter - def access(self, value: list) -> None: - self._access = value - if isinstance(value, list): - self._writable = 'write' in value - self._readable = 'read' in value - self._notifiable = 'notify' in value - - @property - def writable(self) -> bool: - return self._writable - - @property - def readable(self) -> bool: - return self._readable - - @property - def notifiable(self): - return self._notifiable - - def value_format(self, value: Any) -> Any: - if value is None: - return None - if self.format_ == 'int': - return int(value) - if self.format_ == 'float': - return round(value, self.precision) - if self.format_ == 'bool': - return bool(value in [True, 1, 'true', '1']) - return value + def from_spec(self, value_range: list) -> None: + if len(value_range) != 3: + raise MIoTSpecError('invalid value range') + self.min_ = value_range[0] + self.max_ = value_range[1] + self.step = value_range[2] def dump(self) -> dict: return { - 'type': self.type_, - 'name': self.name, - 'iid': self.iid, - 'description': self.description, - 'description_trans': self.description_trans, - 'proprietary': self.proprietary, - 'need_filter': self.need_filter, - 'format': self.format_, - 'access': self._access, - 'unit': self.unit, - 'value_range': self.value_range, - 'value_list': self.value_list, - 'precision': self.precision + 'min': self.min_, + 'max': self.max_, + 'step': self.step } - -class MIoTSpecEvent(MIoTSpecBase): - """MIoT SPEC event class.""" - argument: list[MIoTSpecProperty] - service: MIoTSpecBase - - def __init__( - self, spec: dict, service: MIoTSpecBase = None, - argument: list[MIoTSpecProperty] = None - ) -> None: - super().__init__(spec=spec) - self.argument = argument - self.service = service - - self.spec_id = hash( - f'e.{self.name}.{self.service.iid}.{self.iid}') - - def dump(self) -> dict: - return { - 'type': self.type_, - 'name': self.name, - 'iid': self.iid, - 'description': self.description, - 'description_trans': self.description_trans, - 'proprietary': self.proprietary, - 'need_filter': self.need_filter, - 'argument': [prop.iid for prop in self.argument], - } + def __str__(self) -> str: + return f'[{self.min_}, {self.max_}, {self.step}' -class MIoTSpecAction(MIoTSpecBase): - """MIoT SPEC action class.""" - in_: list[MIoTSpecProperty] - out: list[MIoTSpecProperty] - service: MIoTSpecBase - - def __init__( - self, spec: dict, service: MIoTSpecBase = None, - in_: list[MIoTSpecProperty] = None, - out: list[MIoTSpecProperty] = None - ) -> None: - super().__init__(spec=spec) - self.in_ = in_ - self.out = out - self.service = service - - self.spec_id = hash( - f'a.{self.name}.{self.service.iid}.{self.iid}') - - def dump(self) -> dict: - return { - 'type': self.type_, - 'name': self.name, - 'iid': self.iid, - 'description': self.description, - 'description_trans': self.description_trans, - 'proprietary': self.proprietary, - 'need_filter': self.need_filter, - 'in': [prop.iid for prop in self.in_], - 'out': [prop.iid for prop in self.out] - } - - -class MIoTSpecService(MIoTSpecBase): - """MIoT SPEC service class.""" - properties: list[MIoTSpecProperty] - events: list[MIoTSpecEvent] - actions: list[MIoTSpecAction] - - def __init__(self, spec: dict) -> None: - super().__init__(spec=spec) - self.properties = [] - self.events = [] - self.actions = [] - - def dump(self) -> dict: - return { - 'type': self.type_, - 'name': self.name, - 'iid': self.iid, - 'description': self.description, - 'description_trans': self.description_trans, - 'proprietary': self.proprietary, - 'properties': [prop.dump() for prop in self.properties], - 'need_filter': self.need_filter, - 'events': [event.dump() for event in self.events], - 'actions': [action.dump() for action in self.actions], - } - - -# @dataclass -class MIoTSpecInstance: - """MIoT SPEC instance class.""" - urn: str +class MIoTSpecValueListItem: + """MIoT SPEC value list item class.""" + # NOTICE: bool type without name name: str - # urn_name: str + # Value + value: Any + # Descriptions after multilingual conversion. description: str - description_trans: str - services: list[MIoTSpecService] - # External params - platform: str - device_class: Any - icon: str + def __init__(self, item: dict) -> None: + self.load(item) - def __init__( - self, urn: str = None, name: str = None, - description: str = None, description_trans: str = None - ) -> None: - self.urn = urn - self.name = name - self.description = description - self.description_trans = description_trans - self.services = [] + def load(self, item: dict) -> None: + if 'value' not in item or 'description' not in item: + raise MIoTSpecError('invalid value list item, %s') - def load(self, specs: dict) -> 'MIoTSpecInstance': - self.urn = specs['urn'] - self.name = specs['name'] - self.description = specs['description'] - self.description_trans = specs['description_trans'] - self.services = [] - for service in specs['services']: - spec_service = MIoTSpecService(spec=service) - for prop in service['properties']: - spec_prop = MIoTSpecProperty( - spec=prop, - service=spec_service, - format_=prop['format'], - access=prop['access'], - unit=prop['unit'], - value_range=prop['value_range'], - value_list=prop['value_list'], - precision=prop.get('precision', 0)) - spec_service.properties.append(spec_prop) - for event in service['events']: - spec_event = MIoTSpecEvent( - spec=event, service=spec_service) - arg_list: list[MIoTSpecProperty] = [] - for piid in event['argument']: - for prop in spec_service.properties: - if prop.iid == piid: - arg_list.append(prop) - break - spec_event.argument = arg_list - spec_service.events.append(spec_event) - for action in service['actions']: - spec_action = MIoTSpecAction( - spec=action, service=spec_service, in_=action['in']) - in_list: list[MIoTSpecProperty] = [] - for piid in action['in']: - for prop in spec_service.properties: - if prop.iid == piid: - in_list.append(prop) - break - spec_action.in_ = in_list - out_list: list[MIoTSpecProperty] = [] - for piid in action['out']: - for prop in spec_service.properties: - if prop.iid == piid: - out_list.append(prop) - break - spec_action.out = out_list - spec_service.actions.append(spec_action) - self.services.append(spec_service) - return self + self.name = item.get('name', None) + self.value = item['value'] + self.description = item['description'] + + @staticmethod + def from_spec(item: dict) -> 'MIoTSpecValueListItem': + if ( + 'name' not in item + or 'value' not in item + or 'description' not in item + ): + raise MIoTSpecError('invalid value list item, %s') + # Slugify name and convert to lower-case. + cache = { + 'name': slugify(text=item['name'], separator='_').lower(), + 'value': item['value'], + 'description': item['description'] + } + return MIoTSpecValueListItem(cache) def dump(self) -> dict: return { - 'urn': self.urn, 'name': self.name, - 'description': self.description, - 'description_trans': self.description_trans, - 'services': [service.dump() for service in self.services] + 'value': self.value, + 'description': self.description } + def __str__(self) -> str: + return f'{self.name}: {self.value} - {self.description}' -class SpecStdLib: + +class MIoTSpecValueList: + """MIoT SPEC value list class.""" + # pylint: disable=inconsistent-quotes + items: list[MIoTSpecValueListItem] + + def __init__(self, value_list: list[dict]) -> None: + if not isinstance(value_list, list): + raise MIoTSpecError('invalid value list format') + self.items = [] + self.load(value_list) + + @property + def names(self) -> list[str]: + return [item.name for item in self.items] + + @property + def values(self) -> list[Any]: + return [item.value for item in self.items] + + @property + def descriptions(self) -> list[str]: + return [item.description for item in self.items] + + @staticmethod + def from_spec(value_list: list[dict]) -> 'MIoTSpecValueList': + result = MIoTSpecValueList([]) + dup_desc: dict[str, int] = {} + for item in value_list: + # Handle duplicate descriptions. + count = 0 + if item['description'] in dup_desc: + count = dup_desc[item['description']] + count += 1 + dup_desc[item['description']] = count + if count > 1: + item['name'] = f'{item["name"]}_{count}' + item['description'] = f'{item["description"]}_{count}' + + result.items.append(MIoTSpecValueListItem.from_spec(item)) + return result + + def load(self, value_list: list[dict]) -> None: + for item in value_list: + self.items.append(MIoTSpecValueListItem(item)) + + def to_map(self) -> dict: + return {item.value: item.description for item in self.items} + + def get_value_by_description(self, description: str) -> Any: + for item in self.items: + if item.description == description: + return item.value + return None + + def get_description_by_value(self, value: Any) -> Optional[str]: + for item in self.items: + if item.value == value: + return item.description + return None + + def dump(self) -> list: + return [item.dump() for item in self.items] + + +class _SpecStdLib: """MIoT-Spec-V2 standard library.""" + # pylint: disable=inconsistent-quotes _lang: str - _spec_std_lib: Optional[dict[str, dict[str, dict[str, str]]]] + _devices: dict[str, dict[str, str]] + _services: dict[str, dict[str, str]] + _properties: dict[str, dict[str, str]] + _events: dict[str, dict[str, str]] + _actions: dict[str, dict[str, str]] + _values: dict[str, dict[str, str]] def __init__(self, lang: str) -> None: self._lang = lang + self._devices = {} + self._services = {} + self._properties = {} + self._events = {} + self._actions = {} + self._values = {} + self._spec_std_lib = None - def init(self, std_lib: dict[str, dict[str, str]]) -> None: + def load(self, std_lib: dict[str, dict[str, dict[str, str]]]) -> None: if ( not isinstance(std_lib, dict) or 'devices' not in std_lib @@ -392,246 +250,80 @@ class SpecStdLib: or 'values' not in std_lib ): return - self._spec_std_lib = std_lib - - def deinit(self) -> None: - self._spec_std_lib = None + self._devices = std_lib['devices'] + self._services = std_lib['services'] + self._properties = std_lib['properties'] + self._events = std_lib['events'] + self._actions = std_lib['actions'] + self._values = std_lib['values'] def device_translate(self, key: str) -> Optional[str]: - if not self._spec_std_lib or key not in self._spec_std_lib['devices']: + if not self._devices or key not in self._devices: return None - if self._lang not in self._spec_std_lib['devices'][key]: - return self._spec_std_lib['devices'][key].get( + if self._lang not in self._devices[key]: + return self._devices[key].get( DEFAULT_INTEGRATION_LANGUAGE, None) - return self._spec_std_lib['devices'][key][self._lang] + return self._devices[key][self._lang] def service_translate(self, key: str) -> Optional[str]: - if not self._spec_std_lib or key not in self._spec_std_lib['services']: + if not self._services or key not in self._services: return None - if self._lang not in self._spec_std_lib['services'][key]: - return self._spec_std_lib['services'][key].get( + if self._lang not in self._services[key]: + return self._services[key].get( DEFAULT_INTEGRATION_LANGUAGE, None) - return self._spec_std_lib['services'][key][self._lang] + return self._services[key][self._lang] def property_translate(self, key: str) -> Optional[str]: - if ( - not self._spec_std_lib - or key not in self._spec_std_lib['properties'] - ): + if not self._properties or key not in self._properties: return None - if self._lang not in self._spec_std_lib['properties'][key]: - return self._spec_std_lib['properties'][key].get( + if self._lang not in self._properties[key]: + return self._properties[key].get( DEFAULT_INTEGRATION_LANGUAGE, None) - return self._spec_std_lib['properties'][key][self._lang] + return self._properties[key][self._lang] def event_translate(self, key: str) -> Optional[str]: - if not self._spec_std_lib or key not in self._spec_std_lib['events']: + if not self._events or key not in self._events: return None - if self._lang not in self._spec_std_lib['events'][key]: - return self._spec_std_lib['events'][key].get( + if self._lang not in self._events[key]: + return self._events[key].get( DEFAULT_INTEGRATION_LANGUAGE, None) - return self._spec_std_lib['events'][key][self._lang] + return self._events[key][self._lang] def action_translate(self, key: str) -> Optional[str]: - if not self._spec_std_lib or key not in self._spec_std_lib['actions']: + if not self._actions or key not in self._actions: return None - if self._lang not in self._spec_std_lib['actions'][key]: - return self._spec_std_lib['actions'][key].get( + if self._lang not in self._actions[key]: + return self._actions[key].get( DEFAULT_INTEGRATION_LANGUAGE, None) - return self._spec_std_lib['actions'][key][self._lang] + return self._actions[key][self._lang] def value_translate(self, key: str) -> Optional[str]: - if not self._spec_std_lib or key not in self._spec_std_lib['values']: + if not self._values or key not in self._values: return None - if self._lang not in self._spec_std_lib['values'][key]: - return self._spec_std_lib['values'][key].get( + if self._lang not in self._values[key]: + return self._values[key].get( DEFAULT_INTEGRATION_LANGUAGE, None) - return self._spec_std_lib['values'][key][self._lang] + return self._values[key][self._lang] - def dump(self) -> dict[str, dict[str, str]]: - return self._spec_std_lib + def dump(self) -> dict[str, dict[str, dict[str, str]]]: + return { + 'devices': self._devices, + 'services': self._services, + 'properties': self._properties, + 'events': self._events, + 'actions': self._actions, + 'values': self._values + } + async def refresh_async(self) -> bool: + std_lib_new = await self.__request_from_cloud_async() + if std_lib_new: + self.load(std_lib_new) + return True + return False -class MIoTSpecParser: - """MIoT SPEC parser.""" - # pylint: disable=inconsistent-quotes - VERSION: int = 1 - DOMAIN: str = 'miot_specs' - _lang: str - _storage: MIoTStorage - _main_loop: asyncio.AbstractEventLoop - - _init_done: bool - _ram_cache: dict - - _std_lib: SpecStdLib - _bool_trans: SpecBoolTranslation - _multi_lang: SpecMultiLang - _spec_filter: SpecFilter - - def __init__( - self, lang: str = DEFAULT_INTEGRATION_LANGUAGE, - storage: MIoTStorage = None, - loop: Optional[asyncio.AbstractEventLoop] = None - ) -> None: - self._lang = lang - self._storage = storage - self._main_loop = loop or asyncio.get_running_loop() - - self._init_done = False - self._ram_cache = {} - - self._std_lib = SpecStdLib(lang=self._lang) - self._bool_trans = SpecBoolTranslation( - lang=self._lang, loop=self._main_loop) - self._multi_lang = SpecMultiLang(lang=self._lang, loop=self._main_loop) - self._spec_filter = SpecFilter(loop=self._main_loop) - - async def init_async(self) -> None: - if self._init_done is True: - return - await self._bool_trans.init_async() - await self._multi_lang.init_async() - await self._spec_filter.init_async() - std_lib_cache: dict = None - if self._storage: - std_lib_cache: dict = await self._storage.load_async( - domain=self.DOMAIN, name='spec_std_lib', type_=dict) - if ( - isinstance(std_lib_cache, dict) - and 'data' in std_lib_cache - and 'ts' in std_lib_cache - and isinstance(std_lib_cache['ts'], int) - and int(time.time()) - std_lib_cache['ts'] < - SPEC_STD_LIB_EFFECTIVE_TIME - ): - # Use the cache if the update time is less than 14 day - _LOGGER.debug( - 'use local spec std cache, ts->%s', std_lib_cache['ts']) - self._std_lib.init(std_lib_cache['data']) - self._init_done = True - return - # Update spec std lib - spec_lib_new = await self.__request_spec_std_lib_async() - if spec_lib_new: - self._std_lib.init(spec_lib_new) - if self._storage: - if not await self._storage.save_async( - domain=self.DOMAIN, name='spec_std_lib', - data={ - 'data': self._std_lib.dump(), - 'ts': int(time.time()) - } - ): - _LOGGER.error('save spec std lib failed') - else: - if std_lib_cache: - self._std_lib.init(std_lib_cache['data']) - _LOGGER.error('get spec std lib failed, use local cache') - else: - _LOGGER.error('get spec std lib failed') - self._init_done = True - - async def deinit_async(self) -> None: - self._init_done = False - self._std_lib.deinit() - await self._bool_trans.deinit_async() - await self._multi_lang.deinit_async() - await self._spec_filter.deinit_async() - self._ram_cache.clear() - - async def parse( - self, urn: str, skip_cache: bool = False, - ) -> MIoTSpecInstance: - """MUST await init first !!!""" - if not skip_cache: - cache_result = await self.__cache_get(urn=urn) - if isinstance(cache_result, dict): - _LOGGER.debug('get from cache, %s', urn) - return MIoTSpecInstance().load(specs=cache_result) - # Retry three times - for index in range(3): - try: - return await self.__parse(urn=urn) - except Exception as err: # pylint: disable=broad-exception-caught - _LOGGER.error( - 'parse error, retry, %d, %s, %s', index, urn, err) - return None - - async def refresh_async(self, urn_list: list[str]) -> int: - """MUST await init first !!!""" - if not urn_list: - return False - spec_std_new: dict = await self.__request_spec_std_lib_async() - if spec_std_new: - self._std_lib.init(spec_std_new) - if self._storage: - if not await self._storage.save_async( - domain=self.DOMAIN, name='spec_std_lib', - data={ - 'data': self._std_lib.dump(), - 'ts': int(time.time()) - } - ): - _LOGGER.error('save spec std lib failed') - else: - raise MIoTSpecError('get spec std lib failed') - success_count = 0 - for index in range(0, len(urn_list), 5): - batch = urn_list[index:index+5] - task_list = [self._main_loop.create_task( - self.parse(urn=urn, skip_cache=True)) for urn in batch] - results = await asyncio.gather(*task_list) - success_count += sum(1 for result in results if result is not None) - return success_count - - def __http_get( - self, url: str, params: dict = None, headers: dict = None - ) -> dict: - if params: - encoded_params = urlencode(params) - full_url = f'{url}?{encoded_params}' - else: - full_url = url - request = Request(full_url, method='GET', headers=headers or {}) - content: bytes = None - with urlopen(request) as response: - content = response.read() - return ( - json.loads(str(content, 'utf-8')) - if content is not None else None) - - async def __http_get_async( - self, url: str, params: dict = None, headers: dict = None - ) -> dict: - return await self._main_loop.run_in_executor( - None, self.__http_get, url, params, headers) - - async def __cache_get(self, urn: str) -> Optional[dict]: - if self._storage is not None: - if platform.system() == 'Windows': - urn = urn.replace(':', '_') - return await self._storage.load_async( - domain=self.DOMAIN, name=f'{urn}_{self._lang}', type_=dict) - return self._ram_cache.get(urn, None) - - async def __cache_set(self, urn: str, data: dict) -> bool: - if self._storage is not None: - if platform.system() == 'Windows': - urn = urn.replace(':', '_') - return await self._storage.save_async( - domain=self.DOMAIN, name=f'{urn}_{self._lang}', data=data) - self._ram_cache[urn] = data - return True - - def __spec_format2dtype(self, format_: str) -> str: - # 'string'|'bool'|'uint8'|'uint16'|'uint32'| - # 'int8'|'int16'|'int32'|'int64'|'float' - return {'string': 'str', 'bool': 'bool', 'float': 'float'}.get( - format_, 'int') - - async def __request_spec_std_lib_async(self) -> Optional[SpecStdLib]: - std_libs: dict = None + async def __request_from_cloud_async(self) -> Optional[dict]: + std_libs: Optional[dict] = None for index in range(3): try: tasks: list = [] @@ -659,7 +351,7 @@ class MIoTSpecParser: for name in [ 'device', 'service', 'property', 'event', 'action', 'property_value']: - tasks.append(self.__http_get_async( + tasks.append(MIoTHttp.get_json_async( 'https://cdn.cnbj1.fds.api.mi-img.com/res-conf/' f'xiaomi-home/std_ex_{name}.json')) results = await asyncio.gather(*tasks) @@ -719,7 +411,7 @@ class MIoTSpecParser: return None async def __get_property_value(self) -> dict: - reply = await self.__http_get_async( + reply = await MIoTHttp.get_json_async( url='https://miot-spec.org/miot-spec-v2' '/normalization/list/property_value') if reply is None or 'result' not in reply: @@ -743,7 +435,7 @@ class MIoTSpecParser: return result async def __get_template_list(self, url: str) -> dict: - reply = await self.__http_get_async(url=url) + reply = await MIoTHttp.get_json_async(url=url) if reply is None or 'result' not in reply: raise MIoTSpecError(f'get service failed, {url}') result: dict = {} @@ -767,20 +459,809 @@ class MIoTSpecParser: result[item['type']] = item['description'] return result - async def __get_instance(self, urn: str) -> dict: - return await self.__http_get_async( - url='https://miot-spec.org/miot-spec-v2/instance', - params={'type': urn}) - async def __get_translation(self, urn: str) -> dict: - return await self.__http_get_async( +class _MIoTSpecBase: + """MIoT SPEC base class.""" + iid: int + type_: str + description: str + description_trans: str + proprietary: bool + need_filter: bool + name: str + + # External params + platform: Optional[str] + device_class: Any + state_class: Any + icon: Optional[str] + external_unit: Any + expression: Optional[str] + + spec_id: int + + def __init__(self, spec: dict) -> None: + self.iid = spec['iid'] + self.type_ = spec['type'] + self.description = spec['description'] + + self.description_trans = spec.get('description_trans', None) + self.proprietary = spec.get('proprietary', False) + self.need_filter = spec.get('need_filter', False) + self.name = spec.get('name', 'xiaomi') + + 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}') + + def __hash__(self) -> int: + return self.spec_id + + def __eq__(self, value) -> bool: + return self.spec_id == value.spec_id + + +class MIoTSpecProperty(_MIoTSpecBase): + """MIoT SPEC property class.""" + unit: Optional[str] + precision: int + + _format_: Type + _value_range: Optional[MIoTSpecValueRange] + _value_list: Optional[MIoTSpecValueList] + + _access: list + _writable: bool + _readable: bool + _notifiable: bool + + service: 'MIoTSpecService' + + def __init__( + self, + spec: dict, + service: 'MIoTSpecService', + format_: str, + access: list, + unit: Optional[str] = None, + value_range: Optional[dict] = None, + value_list: Optional[list[dict]] = None, + precision: Optional[int] = None + ) -> None: + super().__init__(spec=spec) + self.service = service + self.format_ = format_ + self.access = access + self.unit = unit + self.value_range = value_range + self.value_list = value_list + self.precision = precision or 1 + + self.spec_id = hash( + f'p.{self.name}.{self.service.iid}.{self.iid}') + + @property + def format_(self) -> Type: + return self._format_ + + @format_.setter + def format_(self, value: str) -> None: + self._format_ = { + 'string': str, + 'str': str, + 'bool': bool, + 'float': float}.get( + value, int) + + @property + def access(self) -> list: + return self._access + + @access.setter + def access(self, value: list) -> None: + self._access = value + if isinstance(value, list): + self._writable = 'write' in value + self._readable = 'read' in value + self._notifiable = 'notify' in value + + @property + def writable(self) -> bool: + return self._writable + + @property + def readable(self) -> bool: + return self._readable + + @property + def notifiable(self): + return self._notifiable + + @property + def value_range(self) -> Optional[MIoTSpecValueRange]: + return self._value_range + + @value_range.setter + def value_range(self, value: Union[dict, list, None]) -> None: + """Set value-range, precision.""" + if not value: + self._value_range = None + return + self._value_range = MIoTSpecValueRange(value_range=value) + if isinstance(value, list): + self.precision = len(str(value[2]).split( + '.')[1].rstrip('0')) if '.' in str(value[2]) else 0 + + @property + def value_list(self) -> Optional[MIoTSpecValueList]: + return self._value_list + + @value_list.setter + def value_list( + self, value: Union[list[dict], MIoTSpecValueList, None] + ) -> None: + if not value: + self._value_list = None + return + if isinstance(value, list): + self._value_list = MIoTSpecValueList(value_list=value) + elif isinstance(value, MIoTSpecValueList): + self._value_list = value + + def value_format(self, value: Any) -> Any: + if value is None: + return None + if self.format_ == int: + return int(value) + if self.format_ == float: + return round(value, self.precision) + if self.format_ == bool: + return bool(value in [True, 1, 'True', 'true', '1']) + return value + + def dump(self) -> dict: + return { + 'type': self.type_, + 'name': self.name, + 'iid': self.iid, + 'description': self.description, + 'description_trans': self.description_trans, + 'proprietary': self.proprietary, + 'need_filter': self.need_filter, + 'format': self.format_.__name__, + 'access': self._access, + 'unit': self.unit, + '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 + } + + +class MIoTSpecEvent(_MIoTSpecBase): + """MIoT SPEC event class.""" + argument: list[MIoTSpecProperty] + service: 'MIoTSpecService' + + def __init__( + self, spec: dict, service: 'MIoTSpecService', + argument: Optional[list[MIoTSpecProperty]] = None + ) -> None: + super().__init__(spec=spec) + self.argument = argument or [] + self.service = service + + self.spec_id = hash( + f'e.{self.name}.{self.service.iid}.{self.iid}') + + def dump(self) -> dict: + return { + 'type': self.type_, + 'name': self.name, + 'iid': self.iid, + 'description': self.description, + 'description_trans': self.description_trans, + 'proprietary': self.proprietary, + 'argument': [prop.iid for prop in self.argument], + 'need_filter': self.need_filter + } + + +class MIoTSpecAction(_MIoTSpecBase): + """MIoT SPEC action class.""" + in_: list[MIoTSpecProperty] + out: list[MIoTSpecProperty] + service: 'MIoTSpecService' + + def __init__( + self, spec: dict, service: 'MIoTSpecService', + in_: Optional[list[MIoTSpecProperty]] = None, + out: Optional[list[MIoTSpecProperty]] = None + ) -> None: + super().__init__(spec=spec) + self.in_ = in_ or [] + self.out = out or [] + self.service = service + + self.spec_id = hash( + f'a.{self.name}.{self.service.iid}.{self.iid}') + + def dump(self) -> dict: + return { + 'type': self.type_, + 'name': self.name, + 'iid': self.iid, + 'description': self.description, + 'description_trans': self.description_trans, + 'in': [prop.iid for prop in self.in_], + 'out': [prop.iid for prop in self.out], + 'proprietary': self.proprietary, + 'need_filter': self.need_filter + } + + +class MIoTSpecService(_MIoTSpecBase): + """MIoT SPEC service class.""" + properties: list[MIoTSpecProperty] + events: list[MIoTSpecEvent] + actions: list[MIoTSpecAction] + + def __init__(self, spec: dict) -> None: + super().__init__(spec=spec) + self.properties = [] + self.events = [] + self.actions = [] + + def dump(self) -> dict: + return { + 'type': self.type_, + 'name': self.name, + 'iid': self.iid, + 'description': self.description, + 'description_trans': self.description_trans, + 'proprietary': self.proprietary, + 'properties': [prop.dump() for prop in self.properties], + 'events': [event.dump() for event in self.events], + 'actions': [action.dump() for action in self.actions], + 'need_filter': self.need_filter + } + + +# @dataclass +class MIoTSpecInstance: + """MIoT SPEC instance class.""" + urn: str + name: str + # urn_name: str + description: str + description_trans: str + services: list[MIoTSpecService] + + # External params + platform: str + device_class: Any + icon: str + + def __init__( + self, urn: str, name: str, description: str, description_trans: str + ) -> None: + self.urn = urn + self.name = name + self.description = description + self.description_trans = description_trans + self.services = [] + + @staticmethod + def load(specs: dict) -> 'MIoTSpecInstance': + instance = MIoTSpecInstance( + urn=specs['urn'], + name=specs['name'], + description=specs['description'], + description_trans=specs['description_trans']) + for service in specs['services']: + spec_service = MIoTSpecService(spec=service) + for prop in service['properties']: + spec_prop = MIoTSpecProperty( + spec=prop, + service=spec_service, + format_=prop['format'], + access=prop['access'], + unit=prop['unit'], + value_range=prop['value_range'], + value_list=prop['value_list'], + precision=prop.get('precision', None)) + spec_service.properties.append(spec_prop) + for event in service['events']: + spec_event = MIoTSpecEvent( + spec=event, service=spec_service) + arg_list: list[MIoTSpecProperty] = [] + for piid in event['argument']: + for prop in spec_service.properties: + if prop.iid == piid: + arg_list.append(prop) + break + spec_event.argument = arg_list + spec_service.events.append(spec_event) + for action in service['actions']: + spec_action = MIoTSpecAction( + spec=action, service=spec_service, in_=action['in']) + in_list: list[MIoTSpecProperty] = [] + for piid in action['in']: + for prop in spec_service.properties: + if prop.iid == piid: + in_list.append(prop) + break + spec_action.in_ = in_list + out_list: list[MIoTSpecProperty] = [] + for piid in action['out']: + for prop in spec_service.properties: + if prop.iid == piid: + out_list.append(prop) + break + spec_action.out = out_list + spec_service.actions.append(spec_action) + instance.services.append(spec_service) + return instance + + def dump(self) -> dict: + return { + 'urn': self.urn, + 'name': self.name, + 'description': self.description, + 'description_trans': self.description_trans, + 'services': [service.dump() for service in self.services] + } + + +class _MIoTSpecMultiLang: + """MIoT SPEC multi lang class.""" + # pylint: disable=broad-exception-caught + _DOMAIN: str = 'miot_specs_multi_lang' + _lang: str + _storage: MIoTStorage + _main_loop: asyncio.AbstractEventLoop + + _custom_cache: dict[str, dict] + _current_data: Optional[dict[str, str]] + + def __init__( + self, lang: Optional[str], + storage: MIoTStorage, + loop: Optional[asyncio.AbstractEventLoop] = None + ) -> None: + self._lang = lang or DEFAULT_INTEGRATION_LANGUAGE + self._storage = storage + self._main_loop = loop or asyncio.get_running_loop() + + self._custom_cache = {} + self._current_data = None + + async def set_spec_async(self, urn: str) -> None: + if urn in self._custom_cache: + self._current_data = self._custom_cache[urn] + return + + trans_cache: dict[str, str] = {} + trans_cloud: dict = {} + trans_local: dict = {} + # Get multi lang from cloud + try: + trans_cloud = await self.__get_multi_lang_async(urn) + if self._lang == 'zh-Hans': + # Simplified Chinese + trans_cache = trans_cloud.get('zh_cn', {}) + elif self._lang == 'zh-Hant': + # Traditional Chinese, zh_hk or zh_tw + trans_cache = trans_cloud.get('zh_hk', {}) + if not trans_cache: + trans_cache = trans_cloud.get('zh_tw', {}) + else: + trans_cache = trans_cloud.get(self._lang, {}) + except Exception as err: + trans_cloud = {} + _LOGGER.info('get multi lang from cloud failed, %s, %s', urn, err) + # Get multi lang from local + try: + trans_local = await self._storage.load_async( + domain=self._DOMAIN, name=urn, type_=dict) # type: ignore + if ( + isinstance(trans_local, dict) + and self._lang in trans_local + ): + trans_cache.update(trans_local[self._lang]) + except Exception as err: + trans_local = {} + _LOGGER.info('get multi lang from local failed, %s, %s', urn, err) + # Default language + if not trans_cache: + if trans_cloud and DEFAULT_INTEGRATION_LANGUAGE in trans_cloud: + trans_cache = trans_cloud[DEFAULT_INTEGRATION_LANGUAGE] + if trans_local and DEFAULT_INTEGRATION_LANGUAGE in trans_local: + trans_cache.update( + trans_local[DEFAULT_INTEGRATION_LANGUAGE]) + trans_data: dict[str, str] = {} + for tag, value in trans_cache.items(): + if value is None or value.strip() == '': + continue + # The dict key is like: + # 'service:002:property:001:valuelist:000' or + # 'service:002:property:001' or 'service:002' + strs: list = tag.split(':') + strs_len = len(strs) + if strs_len == 2: + trans_data[f's:{int(strs[1])}'] = value + elif strs_len == 4: + type_ = 'p' if strs[2] == 'property' else ( + 'a' if strs[2] == 'action' else 'e') + trans_data[ + f'{type_}:{int(strs[1])}:{int(strs[3])}' + ] = value + elif strs_len == 6: + trans_data[ + f'v:{int(strs[1])}:{int(strs[3])}:{int(strs[5])}' + ] = value + + self._custom_cache[urn] = trans_data + self._current_data = trans_data + + def translate(self, key: str) -> Optional[str]: + if not self._current_data: + return None + return self._current_data.get(key, None) + + async def __get_multi_lang_async(self, urn: str) -> dict: + res_trans = await MIoTHttp.get_json_async( url='https://miot-spec.org/instance/v2/multiLanguage', params={'urn': urn}) + if ( + not isinstance(res_trans, dict) + or 'data' not in res_trans + or not isinstance(res_trans['data'], dict) + ): + raise MIoTSpecError('invalid translation data') + return res_trans['data'] + + +class _SpecBoolTranslation: + """ + Boolean value translation. + """ + _BOOL_TRANS_FILE = 'specs/bool_trans.yaml' + _main_loop: asyncio.AbstractEventLoop + _lang: str + _data: Optional[dict[str, list]] + _data_default: Optional[list[dict]] + + def __init__( + self, lang: str, loop: Optional[asyncio.AbstractEventLoop] = None + ) -> None: + self._main_loop = loop or asyncio.get_event_loop() + self._lang = lang + self._data = None + self._data_default = None + + async def init_async(self) -> None: + if isinstance(self._data, dict): + return + data = None + self._data = {} + try: + data = await self._main_loop.run_in_executor( + None, load_yaml_file, + os.path.join( + os.path.dirname(os.path.abspath(__file__)), + self._BOOL_TRANS_FILE)) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.error('bool trans, load file error, %s', err) + return + # Check if the file is a valid file + if ( + not isinstance(data, dict) + or 'data' not in data + or not isinstance(data['data'], dict) + or 'translate' not in data + or not isinstance(data['translate'], dict) + ): + _LOGGER.error('bool trans, valid file') + return + + if 'default' in data['translate']: + data_default = ( + data['translate']['default'].get(self._lang, None) + or data['translate']['default'].get( + DEFAULT_INTEGRATION_LANGUAGE, None)) + if data_default: + self._data_default = [ + {'value': True, 'description': data_default['true']}, + {'value': False, 'description': data_default['false']} + ] + + for urn, key in data['data'].items(): + if key not in data['translate']: + _LOGGER.error('bool trans, unknown key, %s, %s', urn, key) + continue + trans_data = ( + data['translate'][key].get(self._lang, None) + or data['translate'][key].get( + DEFAULT_INTEGRATION_LANGUAGE, None)) + if trans_data: + self._data[urn] = [ + {'value': True, 'description': trans_data['true']}, + {'value': False, 'description': trans_data['false']} + ] + + async def deinit_async(self) -> None: + self._data = None + self._data_default = None + + async def translate_async(self, urn: str) -> Optional[list[dict]]: + """ + MUST call init_async() before calling this method. + [ + {'value': True, 'description': 'True'}, + {'value': False, 'description': 'False'} + ] + """ + if not self._data or urn not in self._data: + return self._data_default + return self._data[urn] + + +class _SpecFilter: + """ + MIoT-Spec-V2 filter for entity conversion. + """ + _SPEC_FILTER_FILE = 'specs/spec_filter.yaml' + _main_loop: asyncio.AbstractEventLoop + _data: Optional[dict[str, dict[str, set]]] + _cache: Optional[dict] + + def __init__(self, loop: Optional[asyncio.AbstractEventLoop]) -> None: + self._main_loop = loop or asyncio.get_event_loop() + self._data = None + self._cache = None + + async def init_async(self) -> None: + if isinstance(self._data, dict): + return + filter_data = None + self._data = {} + try: + filter_data = await self._main_loop.run_in_executor( + None, load_yaml_file, + os.path.join( + os.path.dirname(os.path.abspath(__file__)), + self._SPEC_FILTER_FILE)) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.error('spec filter, load file error, %s', err) + return + if not isinstance(filter_data, dict): + _LOGGER.error('spec filter, invalid spec filter content') + return + for values in list(filter_data.values()): + if not isinstance(values, dict): + _LOGGER.error('spec filter, invalid spec filter data') + return + for value in values.values(): + if not isinstance(value, list): + _LOGGER.error('spec filter, invalid spec filter rules') + return + + self._data = filter_data + + async def deinit_async(self) -> None: + self._cache = None + self._data = None + + async def set_spec_spec(self, urn_key: str) -> None: + """MUST call init_async() first.""" + if not self._data: + return + self._cache = self._data.get(urn_key, None) + + def filter_service(self, siid: int) -> bool: + """Filter service by siid. + MUST call init_async() and set_spec_spec() first.""" + if ( + self._cache + and 'services' in self._cache + and ( + str(siid) in self._cache['services'] + or '*' in self._cache['services']) + ): + return True + + return False + + def filter_property(self, siid: int, piid: int) -> bool: + """Filter property by piid. + MUST call init_async() and set_spec_spec() first.""" + if ( + self._cache + and 'properties' in self._cache + and ( + f'{siid}.{piid}' in self._cache['properties'] + or f'{siid}.*' in self._cache['properties']) + ): + return True + return False + + def filter_event(self, siid: int, eiid: int) -> bool: + """Filter event by eiid. + MUST call init_async() and set_spec_spec() first.""" + if ( + self._cache + and 'events' in self._cache + and ( + f'{siid}.{eiid}' in self._cache['events'] + or f'{siid}.*' in self._cache['events'] + ) + ): + return True + return False + + def filter_action(self, siid: int, aiid: int) -> bool: + """"Filter action by aiid. + MUST call init_async() and set_spec_spec() first.""" + if ( + self._cache + and 'actions' in self._cache + and ( + f'{siid}.{aiid}' in self._cache['actions'] + or f'{siid}.*' in self._cache['actions']) + ): + return True + return False + + +class MIoTSpecParser: + """MIoT SPEC parser.""" + # pylint: disable=inconsistent-quotes + VERSION: int = 1 + _DOMAIN: str = 'miot_specs' + _lang: str + _storage: MIoTStorage + _main_loop: asyncio.AbstractEventLoop + + _std_lib: _SpecStdLib + _multi_lang: _MIoTSpecMultiLang + _bool_trans: _SpecBoolTranslation + _spec_filter: _SpecFilter + + _init_done: bool + + def __init__( + self, lang: Optional[str], + storage: MIoTStorage, + loop: Optional[asyncio.AbstractEventLoop] = None + ) -> None: + self._lang = lang or DEFAULT_INTEGRATION_LANGUAGE + self._storage = storage + self._main_loop = loop or asyncio.get_running_loop() + self._std_lib = _SpecStdLib(lang=self._lang) + self._multi_lang = _MIoTSpecMultiLang( + lang=self._lang, storage=self._storage, loop=self._main_loop) + self._bool_trans = _SpecBoolTranslation( + lang=self._lang, loop=self._main_loop) + self._spec_filter = _SpecFilter(loop=self._main_loop) + + self._init_done = False + + async def init_async(self) -> None: + if self._init_done is True: + return + await self._bool_trans.init_async() + await self._spec_filter.init_async() + std_lib_cache = await self._storage.load_async( + domain=self._DOMAIN, name='spec_std_lib', type_=dict) + if ( + isinstance(std_lib_cache, dict) + and 'data' in std_lib_cache + and 'ts' in std_lib_cache + and isinstance(std_lib_cache['ts'], int) + and int(time.time()) - std_lib_cache['ts'] < + SPEC_STD_LIB_EFFECTIVE_TIME + ): + # Use the cache if the update time is less than 14 day + _LOGGER.debug( + 'use local spec std cache, ts->%s', std_lib_cache['ts']) + self._std_lib.load(std_lib_cache['data']) + self._init_done = True + return + # Update spec std lib + if await self._std_lib.refresh_async(): + if not await self._storage.save_async( + domain=self._DOMAIN, name='spec_std_lib', + data={ + 'data': self._std_lib.dump(), + 'ts': int(time.time()) + } + ): + _LOGGER.error('save spec std lib failed') + else: + if isinstance(std_lib_cache, dict) and 'data' in std_lib_cache: + self._std_lib.load(std_lib_cache['data']) + _LOGGER.info('get spec std lib failed, use local cache') + else: + _LOGGER.error('load spec std lib failed') + self._init_done = True + + async def deinit_async(self) -> None: + self._init_done = False + # self._std_lib.deinit() + await self._bool_trans.deinit_async() + await self._spec_filter.deinit_async() + + async def parse( + self, urn: str, skip_cache: bool = False, + ) -> Optional[MIoTSpecInstance]: + """MUST await init first !!!""" + if not skip_cache: + cache_result = await self.__cache_get(urn=urn) + if isinstance(cache_result, dict): + _LOGGER.debug('get from cache, %s', urn) + return MIoTSpecInstance.load(specs=cache_result) + # Retry three times + for index in range(3): + try: + return await self.__parse(urn=urn) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.error( + 'parse error, retry, %d, %s, %s', index, urn, err) + return None + + async def refresh_async(self, urn_list: list[str]) -> int: + """MUST await init first !!!""" + if not urn_list: + return False + if await self._std_lib.refresh_async(): + if not await self._storage.save_async( + domain=self._DOMAIN, name='spec_std_lib', + data={ + 'data': self._std_lib.dump(), + 'ts': int(time.time()) + } + ): + _LOGGER.error('save spec std lib failed') + else: + raise MIoTSpecError('get spec std lib failed') + success_count = 0 + for index in range(0, len(urn_list), 5): + batch = urn_list[index:index+5] + task_list = [self._main_loop.create_task( + self.parse(urn=urn, skip_cache=True)) for urn in batch] + results = await asyncio.gather(*task_list) + success_count += sum(1 for result in results if result is not None) + return success_count + + async def __cache_get(self, urn: str) -> Optional[dict]: + if platform.system() == 'Windows': + urn = urn.replace(':', '_') + return await self._storage.load_async( + domain=self._DOMAIN, + name=f'{urn}_{self._lang}', + type_=dict) # type: ignore + + async def __cache_set(self, urn: str, data: dict) -> bool: + if platform.system() == 'Windows': + urn = urn.replace(':', '_') + return await self._storage.save_async( + domain=self._DOMAIN, name=f'{urn}_{self._lang}', data=data) + + async def __get_instance(self, urn: str) -> Optional[dict]: + return await MIoTHttp.get_json_async( + url='https://miot-spec.org/miot-spec-v2/instance', + params={'type': urn}) async def __parse(self, urn: str) -> MIoTSpecInstance: _LOGGER.debug('parse urn, %s', urn) # Load spec instance - instance: dict = await self.__get_instance(urn=urn) + instance = await self.__get_instance(urn=urn) if ( not isinstance(instance, dict) or 'type' not in instance @@ -788,69 +1269,12 @@ class MIoTSpecParser: or 'services' not in instance ): raise MIoTSpecError(f'invalid urn instance, {urn}') - translation: dict = {} - try: - # Load multiple language configuration. - res_trans = await self.__get_translation(urn=urn) - if ( - not isinstance(res_trans, dict) - or 'data' not in res_trans - or not isinstance(res_trans['data'], dict) - ): - raise MIoTSpecError('invalid translation data') - urn_strs: list[str] = urn.split(':') - urn_key: str = ':'.join(urn_strs[:6]) - trans_data: dict[str, str] = None - if self._lang == 'zh-Hans': - # Simplified Chinese - trans_data = res_trans['data'].get('zh_cn', {}) - elif self._lang == 'zh-Hant': - # Traditional Chinese, zh_hk or zh_tw - trans_data = res_trans['data'].get('zh_hk', {}) - if not trans_data: - trans_data = res_trans['data'].get('zh_tw', {}) - else: - trans_data = res_trans['data'].get(self._lang, {}) - # Load local multiple language configuration. - multi_lang: dict = await self._multi_lang.translate_async( - urn_key=urn_key) - if multi_lang: - trans_data.update(multi_lang) - if not trans_data: - trans_data = res_trans['data'].get( - DEFAULT_INTEGRATION_LANGUAGE, {}) - if not trans_data: - raise MIoTSpecError( - f'the language is not supported, {self._lang}') - else: - _LOGGER.error( - 'the language is not supported, %s, try using the ' - 'default language, %s, %s', - self._lang, DEFAULT_INTEGRATION_LANGUAGE, urn) - for tag, value in trans_data.items(): - if value is None or value.strip() == '': - continue - # The dict key is like: - # 'service:002:property:001:valuelist:000' or - # 'service:002:property:001' or 'service:002' - strs: list = tag.split(':') - strs_len = len(strs) - if strs_len == 2: - translation[f's:{int(strs[1])}'] = value - elif strs_len == 4: - type_ = 'p' if strs[2] == 'property' else ( - 'a' if strs[2] == 'action' else 'e') - translation[ - f'{type_}:{int(strs[1])}:{int(strs[3])}' - ] = value - elif strs_len == 6: - translation[ - f'v:{int(strs[1])}:{int(strs[3])}:{int(strs[5])}' - ] = value - except MIoTSpecError as e: - _LOGGER.error('get translation error, %s, %s', urn, e) - # Spec filter - self._spec_filter.filter_spec(urn_key=urn_key) + urn_strs: list[str] = urn.split(':') + urn_key: str = ':'.join(urn_strs[:6]) + # Set translation cache + await self._multi_lang.set_spec_async(urn=urn) + # Set spec filter + await self._spec_filter.set_spec_spec(urn_key=urn_key) # Parse device type spec_instance: MIoTSpecInstance = MIoTSpecInstance( urn=urn, name=urn_strs[3], @@ -880,7 +1304,7 @@ class MIoTSpecParser: if type_strs[1] != 'miot-spec-v2': spec_service.proprietary = True spec_service.description_trans = ( - translation.get(f's:{service["iid"]}', None) + self._multi_lang.translate(f's:{service["iid"]}') or self._std_lib.service_translate(key=':'.join(type_strs[:5])) or service['description'] or spec_service.name @@ -899,7 +1323,7 @@ class MIoTSpecParser: spec_prop: MIoTSpecProperty = MIoTSpecProperty( spec=property_, service=spec_service, - format_=self.__spec_format2dtype(property_['format']), + format_=property_['format'], access=property_['access'], unit=property_.get('unit', None)) spec_prop.name = p_type_strs[3] @@ -911,41 +1335,35 @@ class MIoTSpecParser: if p_type_strs[1] != 'miot-spec-v2': spec_prop.proprietary = spec_service.proprietary or True spec_prop.description_trans = ( - translation.get( - f'p:{service["iid"]}:{property_["iid"]}', None) + self._multi_lang.translate( + f'p:{service["iid"]}:{property_["iid"]}') or self._std_lib.property_translate( key=':'.join(p_type_strs[:5])) or property_['description'] or spec_prop.name) if 'value-range' in property_: - spec_prop.value_range = { - 'min': property_['value-range'][0], - 'max': property_['value-range'][1], - 'step': property_['value-range'][2] - } - spec_prop.precision = len(str( - property_['value-range'][2]).split( - '.')[1].rstrip('0')) if '.' in str( - property_['value-range'][2]) else 0 + spec_prop.value_range = property_['value-range'] elif 'value-list' in property_: v_list: list[dict] = property_['value-list'] for index, v in enumerate(v_list): + if v['description'].strip() == '': + v['description'] = f'v_{v["value"]}' v['name'] = v['description'] v['description'] = ( - translation.get( + self._multi_lang.translate( f'v:{service["iid"]}:{property_["iid"]}:' - f'{index}', None) + f'{index}') or self._std_lib.value_translate( key=f'{type_strs[:5]}|{p_type_strs[3]}|' f'{v["description"]}') - or v['name'] - ) - spec_prop.value_list = v_list + or v['name']) + spec_prop.value_list = MIoTSpecValueList.from_spec(v_list) elif property_['format'] == 'bool': v_tag = ':'.join(p_type_strs[:5]) - v_descriptions: dict = ( + v_descriptions = ( await self._bool_trans.translate_async(urn=v_tag)) if v_descriptions: + # bool without value-list.name spec_prop.value_list = v_descriptions spec_service.properties.append(spec_prop) # Parse service event @@ -969,8 +1387,8 @@ class MIoTSpecParser: if e_type_strs[1] != 'miot-spec-v2': spec_event.proprietary = spec_service.proprietary or True spec_event.description_trans = ( - translation.get( - f'e:{service["iid"]}:{event["iid"]}', None) + self._multi_lang.translate( + f'e:{service["iid"]}:{event["iid"]}') or self._std_lib.event_translate( key=':'.join(e_type_strs[:5])) or event['description'] @@ -1005,8 +1423,8 @@ class MIoTSpecParser: if a_type_strs[1] != 'miot-spec-v2': spec_action.proprietary = spec_service.proprietary or True spec_action.description_trans = ( - translation.get( - f'a:{service["iid"]}:{action["iid"]}', None) + self._multi_lang.translate( + f'a:{service["iid"]}:{action["iid"]}') or self._std_lib.action_translate( key=':'.join(a_type_strs[:5])) or action['description'] diff --git a/custom_components/xiaomi_home/miot/miot_storage.py b/custom_components/xiaomi_home/miot/miot_storage.py index 85b25c9..3767365 100644 --- a/custom_components/xiaomi_home/miot/miot_storage.py +++ b/custom_components/xiaomi_home/miot/miot_storage.py @@ -58,7 +58,6 @@ from enum import Enum, auto from pathlib import Path from typing import Any, Optional, Union import logging -from urllib.request import Request, urlopen from cryptography.hazmat.primitives import serialization from cryptography.hazmat.backends import default_backend from cryptography.x509.oid import NameOID @@ -66,13 +65,13 @@ from cryptography import x509 from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import ed25519 + # pylint: disable=relative-beyond-top-level -from .common import load_json_file from .const import ( - DEFAULT_INTEGRATION_LANGUAGE, MANUFACTURER_EFFECTIVE_TIME, MIHOME_CA_CERT_STR, MIHOME_CA_CERT_SHA256) +from .common import MIoTHttp from .miot_error import MIoTCertError, MIoTError, MIoTStorageError _LOGGER = logging.getLogger(__name__) @@ -93,10 +92,10 @@ class MIoTStorage: User data will be stored in the `.storage` directory of Home Assistant. """ - _main_loop: asyncio.AbstractEventLoop = None + _main_loop: asyncio.AbstractEventLoop _file_future: dict[str, tuple[MIoTStorageType, asyncio.Future]] - _root_path: str = None + _root_path: str def __init__( self, root_path: str, @@ -140,7 +139,7 @@ class MIoTStorage: if r_data is None: _LOGGER.error('load error, empty file, %s', full_path) return None - data_bytes: bytes = None + data_bytes: bytes # Hash check if with_hash_check: if len(r_data) <= 32: @@ -209,17 +208,17 @@ class MIoTStorage: else: os.makedirs(os.path.dirname(full_path), exist_ok=True) try: - type_: type = type(data) - w_bytes: bytes = None - if type_ == bytes: + w_bytes: bytes + if isinstance(data, bytes): w_bytes = data - elif type_ == str: + elif isinstance(data, str): w_bytes = data.encode('utf-8') - elif type_ in [dict, list]: + elif isinstance(data, (dict, list)): w_bytes = json.dumps(data).encode('utf-8') else: _LOGGER.error( - 'save error, unsupported data type, %s', type_.__name__) + 'save error, unsupported data type, %s', + type(data).__name__) return False with open(full_path, 'wb') as w_file: w_file.write(w_bytes) @@ -353,7 +352,8 @@ class MIoTStorage: def load_file(self, domain: str, name_with_suffix: str) -> Optional[bytes]: full_path = os.path.join(self._root_path, domain, name_with_suffix) return self.__load( - full_path=full_path, type_=bytes, with_hash_check=False) + full_path=full_path, type_=bytes, + with_hash_check=False) # type: ignore async def load_file_async( self, domain: str, name_with_suffix: str @@ -371,7 +371,7 @@ class MIoTStorage: None, self.__load, full_path, bytes, False) if not fut.done(): self.__add_file_future(full_path, MIoTStorageType.LOAD_FILE, fut) - return await fut + return await fut # type: ignore def remove_file(self, domain: str, name_with_suffix: str) -> bool: full_path = os.path.join(self._root_path, domain, name_with_suffix) @@ -438,7 +438,7 @@ class MIoTStorage: domain=config_domain, name=config_name, data=config) local_config = (self.load(domain=config_domain, name=config_name, type_=dict)) or {} - local_config.update(config) + local_config.update(config) # type: ignore return self.save( domain=config_domain, name=config_name, data=local_config) @@ -474,27 +474,31 @@ class MIoTStorage: domain=config_domain, name=config_name, data=config) local_config = (await self.load_async( domain=config_domain, name=config_name, type_=dict)) or {} - local_config.update(config) + local_config.update(config) # type: ignore return await self.save_async( domain=config_domain, name=config_name, data=local_config) def load_user_config( self, uid: str, cloud_server: str, keys: Optional[list[str]] = None ) -> dict[str, Any]: - if keys is not None and len(keys) == 0: + if isinstance(keys, list) and len(keys) == 0: # Do nothing return {} config_domain = 'miot_config' config_name = f'{uid}_{cloud_server}' local_config = (self.load(domain=config_domain, - name=config_name, type_=dict)) or {} + name=config_name, type_=dict)) + if not isinstance(local_config, dict): + return {} if keys is None: return local_config - return {key: local_config.get(key, None) for key in keys} + return { + key: local_config[key] for key in keys + if key in local_config} async def load_user_config_async( self, uid: str, cloud_server: str, keys: Optional[list[str]] = None - ) -> dict[str, Any]: + ) -> dict: """Load user configuration. Args: @@ -505,13 +509,15 @@ class MIoTStorage: Returns: dict[str, Any]: query result """ - if keys is not None and len(keys) == 0: + if isinstance(keys, list) and len(keys) == 0: # Do nothing return {} config_domain = 'miot_config' config_name = f'{uid}_{cloud_server}' local_config = (await self.load_async( - domain=config_domain, name=config_name, type_=dict)) or {} + domain=config_domain, name=config_name, type_=dict)) + if not isinstance(local_config, dict): + return {} if keys is None: return local_config return { @@ -519,7 +525,8 @@ class MIoTStorage: if key in local_config} def gen_storage_path( - self, domain: str = None, name_with_suffix: str = None + self, domain: Optional[str] = None, + name_with_suffix: Optional[str] = None ) -> str: """Generate file path.""" result = self._root_path @@ -609,9 +616,8 @@ class MIoTCert: if cert_data is None: return 0 # Check user cert - user_cert: x509.Certificate = None try: - user_cert = x509.load_pem_x509_certificate( + user_cert: x509.Certificate = x509.load_pem_x509_certificate( cert_data, default_backend()) cert_info = {} for attribute in user_cert.subject: @@ -669,7 +675,8 @@ class MIoTCert: NameOID.COMMON_NAME, f'mips.{self._uid}.{did_hash}.2'), ])) csr = builder.sign( - private_key, algorithm=None, backend=default_backend()) + private_key, algorithm=None, # type: ignore + backend=default_backend()) return csr.public_bytes(serialization.Encoding.PEM).decode('utf-8') async def load_user_key_async(self) -> Optional[str]: @@ -719,250 +726,6 @@ class MIoTCert: return binascii.hexlify(sha1_hash.finalize()).decode('utf-8') -class SpecMultiLang: - """ - MIoT-Spec-V2 multi-language for entities. - """ - MULTI_LANG_FILE = 'specs/multi_lang.json' - _main_loop: asyncio.AbstractEventLoop - _lang: str - _data: Optional[dict[str, dict]] - - def __init__( - self, lang: str, loop: Optional[asyncio.AbstractEventLoop] = None - ) -> None: - self._main_loop = loop or asyncio.get_event_loop() - self._lang = lang - self._data = None - - async def init_async(self) -> None: - if isinstance(self._data, dict): - return - multi_lang_data = None - self._data = {} - try: - multi_lang_data = await self._main_loop.run_in_executor( - None, load_json_file, - os.path.join( - os.path.dirname(os.path.abspath(__file__)), - self.MULTI_LANG_FILE)) - except Exception as err: # pylint: disable=broad-exception-caught - _LOGGER.error('multi lang, load file error, %s', err) - return - # Check if the file is a valid JSON file - if not isinstance(multi_lang_data, dict): - _LOGGER.error('multi lang, invalid file data') - return - for lang_data in multi_lang_data.values(): - if not isinstance(lang_data, dict): - _LOGGER.error('multi lang, invalid lang data') - return - for data in lang_data.values(): - if not isinstance(data, dict): - _LOGGER.error('multi lang, invalid lang data item') - return - self._data = multi_lang_data - - async def deinit_async(self) -> str: - self._data = None - - async def translate_async(self, urn_key: str) -> dict[str, str]: - """MUST call init_async() first.""" - if urn_key in self._data: - return self._data[urn_key].get(self._lang, {}) - return {} - - -class SpecBoolTranslation: - """ - Boolean value translation. - """ - BOOL_TRANS_FILE = 'specs/bool_trans.json' - _main_loop: asyncio.AbstractEventLoop - _lang: str - _data: Optional[dict[str, dict]] - _data_default: dict[str, dict] - - def __init__( - self, lang: str, loop: Optional[asyncio.AbstractEventLoop] = None - ) -> None: - self._main_loop = loop or asyncio.get_event_loop() - self._lang = lang - self._data = None - - async def init_async(self) -> None: - if isinstance(self._data, dict): - return - data = None - self._data = {} - try: - data = await self._main_loop.run_in_executor( - None, load_json_file, - os.path.join( - os.path.dirname(os.path.abspath(__file__)), - self.BOOL_TRANS_FILE)) - except Exception as err: # pylint: disable=broad-exception-caught - _LOGGER.error('bool trans, load file error, %s', err) - return - # Check if the file is a valid JSON file - if ( - not isinstance(data, dict) - or 'data' not in data - or not isinstance(data['data'], dict) - or 'translate' not in data - or not isinstance(data['translate'], dict) - ): - _LOGGER.error('bool trans, valid file') - return - - if 'default' in data['translate']: - data_default = ( - data['translate']['default'].get(self._lang, None) - or data['translate']['default'].get( - DEFAULT_INTEGRATION_LANGUAGE, None)) - if data_default: - self._data_default = [ - {'value': True, 'description': data_default['true']}, - {'value': False, 'description': data_default['false']} - ] - - for urn, key in data['data'].items(): - if key not in data['translate']: - _LOGGER.error('bool trans, unknown key, %s, %s', urn, key) - continue - trans_data = ( - data['translate'][key].get(self._lang, None) - or data['translate'][key].get( - DEFAULT_INTEGRATION_LANGUAGE, None)) - if trans_data: - self._data[urn] = [ - {'value': True, 'description': trans_data['true']}, - {'value': False, 'description': trans_data['false']} - ] - - async def deinit_async(self) -> None: - self._data = None - self._data_default = None - - async def translate_async(self, urn: str) -> list[dict[bool, str]]: - """ - MUST call init_async() before calling this method. - [ - {'value': True, 'description': 'True'}, - {'value': False, 'description': 'False'} - ] - """ - - return self._data.get(urn, self._data_default) - - -class SpecFilter: - """ - MIoT-Spec-V2 filter for entity conversion. - """ - SPEC_FILTER_FILE = 'specs/spec_filter.json' - _main_loop: asyncio.AbstractEventLoop - _data: dict[str, dict[str, set]] - _cache: Optional[dict] - - def __init__(self, loop: Optional[asyncio.AbstractEventLoop]) -> None: - self._main_loop = loop or asyncio.get_event_loop() - self._data = None - self._cache = None - - async def init_async(self) -> None: - if isinstance(self._data, dict): - return - filter_data = None - self._data = {} - try: - filter_data = await self._main_loop.run_in_executor( - None, load_json_file, - os.path.join( - os.path.dirname(os.path.abspath(__file__)), - self.SPEC_FILTER_FILE)) - except Exception as err: # pylint: disable=broad-exception-caught - _LOGGER.error('spec filter, load file error, %s', err) - return - if not isinstance(filter_data, dict): - _LOGGER.error('spec filter, invalid spec filter content') - return - for values in list(filter_data.values()): - if not isinstance(values, dict): - _LOGGER.error('spec filter, invalid spec filter data') - return - for value in values.values(): - if not isinstance(value, list): - _LOGGER.error('spec filter, invalid spec filter rules') - return - - self._data = filter_data - - async def deinit_async(self) -> None: - self._cache = None - self._data = None - - def filter_spec(self, urn_key: str) -> None: - """MUST call init_async() first.""" - if not self._data: - return - self._cache = self._data.get(urn_key, None) - - def filter_service(self, siid: int) -> bool: - """Filter service by siid. - MUST call init_async() and filter_spec() first.""" - if ( - self._cache - and 'services' in self._cache - and ( - str(siid) in self._cache['services'] - or '*' in self._cache['services']) - ): - return True - - return False - - def filter_property(self, siid: int, piid: int) -> bool: - """Filter property by piid. - MUST call init_async() and filter_spec() first.""" - if ( - self._cache - and 'properties' in self._cache - and ( - f'{siid}.{piid}' in self._cache['properties'] - or f'{siid}.*' in self._cache['properties']) - ): - return True - return False - - def filter_event(self, siid: int, eiid: int) -> bool: - """Filter event by eiid. - MUST call init_async() and filter_spec() first.""" - if ( - self._cache - and 'events' in self._cache - and ( - f'{siid}.{eiid}' in self._cache['events'] - or f'{siid}.*' in self._cache['events'] - ) - ): - return True - return False - - def filter_action(self, siid: int, aiid: int) -> bool: - """"Filter action by aiid. - MUST call init_async() and filter_spec() first.""" - if ( - self._cache - and 'actions' in self._cache - and ( - f'{siid}.{aiid}' in self._cache['actions'] - or f'{siid}.*' in self._cache['actions']) - ): - return True - return False - - class DeviceManufacturer: """Device manufacturer.""" DOMAIN: str = 'miot_specs' @@ -976,12 +739,11 @@ class DeviceManufacturer: ) -> None: self._main_loop = loop or asyncio.get_event_loop() self._storage = storage - self._data = None + self._data = {} async def init_async(self) -> None: if self._data: return - data_cache: dict = None data_cache = await self._storage.load_async( domain=self.DOMAIN, name='manufacturer', type_=dict) if ( @@ -995,8 +757,15 @@ class DeviceManufacturer: _LOGGER.debug('load manufacturer data success') return - data_cloud = await self._main_loop.run_in_executor( - None, self.__get_manufacturer_data) + data_cloud = None + try: + data_cloud = await MIoTHttp.get_json_async( + url='https://cdn.cnbj1.fds.api.mi-img.com/res-conf/xiaomi-home/' + 'manufacturer.json', + loop=self._main_loop) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.error('get manufacturer info failed, %s', err) + if data_cloud: await self._storage.save_async( domain=self.DOMAIN, name='manufacturer', @@ -1004,32 +773,16 @@ class DeviceManufacturer: self._data = data_cloud _LOGGER.debug('update manufacturer data success') else: - if data_cache: - self._data = data_cache.get('data', None) + if isinstance(data_cache, dict): + self._data = data_cache.get('data', {}) _LOGGER.error('load manufacturer data failed, use local data') else: _LOGGER.error('load manufacturer data failed') async def deinit_async(self) -> None: - self._data = None + self._data.clear() def get_name(self, short_name: str) -> str: if not self._data or not short_name or short_name not in self._data: return short_name return self._data[short_name].get('name', None) or short_name - - def __get_manufacturer_data(self) -> dict: - try: - request = Request( - 'https://cdn.cnbj1.fds.api.mi-img.com/res-conf/xiaomi-home/' - 'manufacturer.json', - method='GET') - content: bytes = None - with urlopen(request) as response: - content = response.read() - return ( - json.loads(str(content, 'utf-8')) - if content else None) - except Exception as err: # pylint: disable=broad-exception-caught - _LOGGER.error('get manufacturer info failed, %s', err) - return None diff --git a/custom_components/xiaomi_home/miot/resource/oauth_redirect_page.html b/custom_components/xiaomi_home/miot/resource/oauth_redirect_page.html new file mode 100644 index 0000000..1205f10 --- /dev/null +++ b/custom_components/xiaomi_home/miot/resource/oauth_redirect_page.html @@ -0,0 +1,136 @@ + + + + + + + + TITLE_PLACEHOLDER + + + + +
+ +
+ + 编组 + Created with Sketch. + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + \ No newline at end of file diff --git a/custom_components/xiaomi_home/miot/specs/bool_trans.json b/custom_components/xiaomi_home/miot/specs/bool_trans.json deleted file mode 100644 index 0bf193f..0000000 --- a/custom_components/xiaomi_home/miot/specs/bool_trans.json +++ /dev/null @@ -1,295 +0,0 @@ -{ - "data": { - "urn:miot-spec-v2:property:air-cooler:000000EB": "open_close", - "urn:miot-spec-v2:property:alarm:00000012": "open_close", - "urn:miot-spec-v2:property:anion:00000025": "open_close", - "urn:miot-spec-v2:property:anti-fake:00000130": "yes_no", - "urn:miot-spec-v2:property:arrhythmia:000000B4": "yes_no", - "urn:miot-spec-v2:property:auto-cleanup:00000124": "open_close", - "urn:miot-spec-v2:property:auto-deodorization:00000125": "open_close", - "urn:miot-spec-v2:property:auto-keep-warm:0000002B": "open_close", - "urn:miot-spec-v2:property:automatic-feeding:000000F0": "open_close", - "urn:miot-spec-v2:property:blow:000000CD": "open_close", - "urn:miot-spec-v2:property:card-insertion-state:00000106": "yes_no", - "urn:miot-spec-v2:property:contact-state:0000007C": "contact_state", - "urn:miot-spec-v2:property:current-physical-control-lock:00000099": "open_close", - "urn:miot-spec-v2:property:delay:0000014F": "yes_no", - "urn:miot-spec-v2:property:deodorization:000000C6": "open_close", - "urn:miot-spec-v2:property:dns-auto-mode:000000DC": "open_close", - "urn:miot-spec-v2:property:driving-status:000000B9": "yes_no", - "urn:miot-spec-v2:property:dryer:00000027": "open_close", - "urn:miot-spec-v2:property:eco:00000024": "open_close", - "urn:miot-spec-v2:property:glimmer-full-color:00000089": "open_close", - "urn:miot-spec-v2:property:guard-mode:000000B6": "open_close", - "urn:miot-spec-v2:property:heater:00000026": "open_close", - "urn:miot-spec-v2:property:heating:000000C7": "open_close", - "urn:miot-spec-v2:property:horizontal-swing:00000017": "open_close", - "urn:miot-spec-v2:property:hot-water-recirculation:0000011C": "open_close", - "urn:miot-spec-v2:property:image-distortion-correction:0000010F": "open_close", - "urn:miot-spec-v2:property:local-storage:0000011E": "yes_no", - "urn:miot-spec-v2:property:motion-detection:00000056": "open_close", - "urn:miot-spec-v2:property:motion-state:0000007D": "motion_state", - "urn:miot-spec-v2:property:motion-tracking:0000008A": "open_close", - "urn:miot-spec-v2:property:motor-reverse:00000072": "yes_no", - "urn:miot-spec-v2:property:mute:00000040": "open_close", - "urn:miot-spec-v2:property:off-delay:00000053": "open_close", - "urn:miot-spec-v2:property:on:00000006": "open_close", - "urn:miot-spec-v2:property:physical-controls-locked:0000001D": "open_close", - "urn:miot-spec-v2:property:plasma:00000132": "yes_no", - "urn:miot-spec-v2:property:preheat:00000103": "open_close", - "urn:miot-spec-v2:property:seating-state:000000B8": "yes_no", - "urn:miot-spec-v2:property:silent-execution:000000FB": "yes_no", - "urn:miot-spec-v2:property:sleep-aid-mode:0000010B": "open_close", - "urn:miot-spec-v2:property:sleep-mode:00000028": "open_close", - "urn:miot-spec-v2:property:snore-state:0000012A": "yes_no", - "urn:miot-spec-v2:property:soft-wind:000000CF": "open_close", - "urn:miot-spec-v2:property:speed-control:000000E8": "open_close", - "urn:miot-spec-v2:property:submersion-state:0000007E": "yes_no", - "urn:miot-spec-v2:property:time-watermark:00000087": "open_close", - "urn:miot-spec-v2:property:un-straight-blowing:00000100": "open_close", - "urn:miot-spec-v2:property:uv:00000029": "open_close", - "urn:miot-spec-v2:property:valve-switch:000000FE": "open_close", - "urn:miot-spec-v2:property:ventilation:000000CE": "open_close", - "urn:miot-spec-v2:property:vertical-swing:00000018": "open_close", - "urn:miot-spec-v2:property:wake-up-mode:00000107": "open_close", - "urn:miot-spec-v2:property:water-pump:000000F2": "open_close", - "urn:miot-spec-v2:property:watering:000000CC": "open_close", - "urn:miot-spec-v2:property:wdr-mode:00000088": "open_close", - "urn:miot-spec-v2:property:wet:0000002A": "open_close", - "urn:miot-spec-v2:property:wifi-band-combine:000000E0": "open_close", - "urn:miot-spec-v2:property:wifi-ssid-hidden:000000E3": "yes_no", - "urn:miot-spec-v2:property:wind-reverse:00000117": "yes_no" - }, - "translate": { - "default": { - "de": { - "true": "Wahr", - "false": "Falsch" - }, - "en": { - "true": "True", - "false": "False" - }, - "es": { - "true": "Verdadero", - "false": "Falso" - }, - "fr": { - "true": "Vrai", - "false": "Faux" - }, - "ja": { - "true": "真", - "false": "偽" - }, - "nl": { - "true": "True", - "false": "False" - }, - "pt": { - "true": "True", - "false": "False" - }, - "pt-BR": { - "true": "True", - "false": "False" - }, - "ru": { - "true": "Истина", - "false": "Ложь" - }, - "zh-Hans": { - "true": "真", - "false": "假" - }, - "zh-Hant": { - "true": "真", - "false": "假" - } - }, - "open_close": { - "de": { - "true": "Öffnen", - "false": "Schließen" - }, - "en": { - "true": "Open", - "false": "Close" - }, - "es": { - "true": "Abierto", - "false": "Cerrado" - }, - "fr": { - "true": "Ouvert", - "false": "Fermer" - }, - "ja": { - "true": "開く", - "false": "閉じる" - }, - "nl": { - "true": "Open", - "false": "Dicht" - }, - "pt": { - "true": "Aberto", - "false": "Fechado" - }, - "pt-BR": { - "true": "Aberto", - "false": "Fechado" - }, - "ru": { - "true": "Открыть", - "false": "Закрыть" - }, - "zh-Hans": { - "true": "开启", - "false": "关闭" - }, - "zh-Hant": { - "true": "開啟", - "false": "關閉" - } - }, - "yes_no": { - "de": { - "true": "Ja", - "false": "Nein" - }, - "en": { - "true": "Yes", - "false": "No" - }, - "es": { - "true": "Sí", - "false": "No" - }, - "fr": { - "true": "Oui", - "false": "Non" - }, - "ja": { - "true": "はい", - "false": "いいえ" - }, - "nl": { - "true": "Ja", - "false": "Nee" - }, - "pt": { - "true": "Sim", - "false": "Não" - }, - "pt-BR": { - "true": "Sim", - "false": "Não" - }, - "ru": { - "true": "Да", - "false": "Нет" - }, - "zh-Hans": { - "true": "是", - "false": "否" - }, - "zh-Hant": { - "true": "是", - "false": "否" - } - }, - "motion_state": { - "de": { - "true": "Bewegung erkannt", - "false": "Keine Bewegung erkannt" - }, - "en": { - "true": "Motion Detected", - "false": "No Motion Detected" - }, - "es": { - "true": "Movimiento detectado", - "false": "No se detecta movimiento" - }, - "fr": { - "true": "Mouvement détecté", - "false": "Aucun mouvement détecté" - }, - "ja": { - "true": "動きを検知", - "false": "動きが検出されません" - }, - "nl": { - "true": "Contact", - "false": "Geen contact" - }, - "pt": { - "true": "Contato", - "false": "Sem contato" - }, - "pt-BR": { - "true": "Contato", - "false": "Sem contato" - }, - "ru": { - "true": "Обнаружено движение", - "false": "Движение не обнаружено" - }, - "zh-Hans": { - "true": "有人", - "false": "无人" - }, - "zh-Hant": { - "true": "有人", - "false": "無人" - } - }, - "contact_state": { - "de": { - "true": "Kontakt", - "false": "Kein Kontakt" - }, - "en": { - "true": "Contact", - "false": "No Contact" - }, - "es": { - "true": "Contacto", - "false": "Sin contacto" - }, - "fr": { - "true": "Contact", - "false": "Pas de contact" - }, - "ja": { - "true": "接触", - "false": "非接触" - }, - "nl": { - "true": "Contact", - "false": "Geen contact" - }, - "pt": { - "true": "Contato", - "false": "Sem contato" - }, - "pt-BR": { - "true": "Contato", - "false": "Sem contato" - }, - "ru": { - "true": "Контакт", - "false": "Нет контакта" - }, - "zh-Hans": { - "true": "接触", - "false": "分离" - }, - "zh-Hant": { - "true": "接觸", - "false": "分離" - } - } - } -} \ No newline at end of file diff --git a/custom_components/xiaomi_home/miot/specs/bool_trans.yaml b/custom_components/xiaomi_home/miot/specs/bool_trans.yaml new file mode 100644 index 0000000..7bb3483 --- /dev/null +++ b/custom_components/xiaomi_home/miot/specs/bool_trans.yaml @@ -0,0 +1,246 @@ +data: + urn:miot-spec-v2:property:air-cooler:000000EB: open_close + urn:miot-spec-v2:property:alarm:00000012: open_close + urn:miot-spec-v2:property:anion:00000025: open_close + urn:miot-spec-v2:property:anti-fake:00000130: yes_no + urn:miot-spec-v2:property:arrhythmia:000000B4: yes_no + urn:miot-spec-v2:property:auto-cleanup:00000124: open_close + urn:miot-spec-v2:property:auto-deodorization:00000125: open_close + urn:miot-spec-v2:property:auto-keep-warm:0000002B: open_close + urn:miot-spec-v2:property:automatic-feeding:000000F0: open_close + urn:miot-spec-v2:property:blow:000000CD: open_close + urn:miot-spec-v2:property:card-insertion-state:00000106: yes_no + urn:miot-spec-v2:property:contact-state:0000007C: contact_state + urn:miot-spec-v2:property:current-physical-control-lock:00000099: open_close + urn:miot-spec-v2:property:delay:0000014F: yes_no + urn:miot-spec-v2:property:deodorization:000000C6: open_close + urn:miot-spec-v2:property:dns-auto-mode:000000DC: open_close + urn:miot-spec-v2:property:driving-status:000000B9: yes_no + urn:miot-spec-v2:property:dryer:00000027: open_close + urn:miot-spec-v2:property:eco:00000024: open_close + urn:miot-spec-v2:property:glimmer-full-color:00000089: open_close + urn:miot-spec-v2:property:guard-mode:000000B6: open_close + urn:miot-spec-v2:property:heater:00000026: open_close + urn:miot-spec-v2:property:heating:000000C7: open_close + urn:miot-spec-v2:property:horizontal-swing:00000017: open_close + urn:miot-spec-v2:property:hot-water-recirculation:0000011C: open_close + urn:miot-spec-v2:property:image-distortion-correction:0000010F: open_close + urn:miot-spec-v2:property:local-storage:0000011E: yes_no + urn:miot-spec-v2:property:motion-detection:00000056: open_close + urn:miot-spec-v2:property:motion-state:0000007D: motion_state + urn:miot-spec-v2:property:motion-tracking:0000008A: open_close + urn:miot-spec-v2:property:motor-reverse:00000072: yes_no + urn:miot-spec-v2:property:mute:00000040: open_close + urn:miot-spec-v2:property:off-delay:00000053: open_close + urn:miot-spec-v2:property:on:00000006: open_close + urn:miot-spec-v2:property:physical-controls-locked:0000001D: open_close + urn:miot-spec-v2:property:plasma:00000132: yes_no + urn:miot-spec-v2:property:preheat:00000103: open_close + urn:miot-spec-v2:property:seating-state:000000B8: yes_no + urn:miot-spec-v2:property:silent-execution:000000FB: yes_no + urn:miot-spec-v2:property:sleep-aid-mode:0000010B: open_close + urn:miot-spec-v2:property:sleep-mode:00000028: open_close + urn:miot-spec-v2:property:snore-state:0000012A: yes_no + urn:miot-spec-v2:property:soft-wind:000000CF: open_close + urn:miot-spec-v2:property:speed-control:000000E8: open_close + urn:miot-spec-v2:property:submersion-state:0000007E: yes_no + urn:miot-spec-v2:property:time-watermark:00000087: open_close + urn:miot-spec-v2:property:un-straight-blowing:00000100: open_close + urn:miot-spec-v2:property:uv:00000029: open_close + urn:miot-spec-v2:property:valve-switch:000000FE: open_close + urn:miot-spec-v2:property:ventilation:000000CE: open_close + urn:miot-spec-v2:property:vertical-swing:00000018: open_close + urn:miot-spec-v2:property:wake-up-mode:00000107: open_close + urn:miot-spec-v2:property:water-pump:000000F2: open_close + urn:miot-spec-v2:property:watering:000000CC: open_close + urn:miot-spec-v2:property:wdr-mode:00000088: open_close + urn:miot-spec-v2:property:wet:0000002A: open_close + urn:miot-spec-v2:property:wifi-band-combine:000000E0: open_close + 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 + 'true': Wahr + en: + 'false': 'False' + 'true': 'True' + es: + 'false': Falso + 'true': Verdadero + fr: + 'false': Faux + 'true': Vrai + it: + 'false': Falso + 'true': Vero + ja: + 'false': 偽 + 'true': 真 + nl: + 'false': 'False' + 'true': 'True' + pt: + 'false': 'False' + 'true': 'True' + pt-BR: + 'false': 'False' + 'true': 'True' + ru: + 'false': Ложь + 'true': Истина + zh-Hans: + 'false': 假 + 'true': 真 + zh-Hant: + 'false': 假 + 'true': 真 + motion_state: + de: + 'false': Keine Bewegung erkannt + 'true': Bewegung erkannt + en: + 'false': No Motion Detected + 'true': Motion Detected + es: + 'false': No se detecta movimiento + 'true': Movimiento detectado + fr: + 'false': Aucun mouvement détecté + 'true': Mouvement détecté + it: + 'false': Nessun Movimento Rilevato + 'true': Movimento Rilevato + 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': 有人 + open_close: + de: + 'false': Schließen + 'true': Öffnen + en: + 'false': Close + 'true': Open + es: + 'false': Cerrado + 'true': Abierto + fr: + 'false': Fermer + 'true': Ouvert + it: + 'false': Chiuso + 'true': Aperto + ja: + 'false': 閉じる + 'true': 開く + nl: + 'false': Dicht + 'true': Open + pt: + 'false': Fechado + 'true': Aberto + pt-BR: + 'false': Fechado + 'true': Aberto + ru: + 'false': Закрыть + 'true': Открыть + zh-Hans: + 'false': 关闭 + 'true': 开启 + zh-Hant: + 'false': 關閉 + 'true': 開啟 + yes_no: + de: + 'false': Nein + 'true': Ja + en: + 'false': 'No' + 'true': 'Yes' + es: + 'false': 'No' + 'true': Sí + fr: + 'false': Non + 'true': Oui + it: + 'false': 'No' + 'true': Si + ja: + 'false': いいえ + 'true': はい + nl: + 'false': Nee + 'true': Ja + pt: + 'false': Não + 'true': Sim + pt-BR: + 'false': Não + 'true': Sim + ru: + 'false': Нет + 'true': Да + zh-Hans: + 'false': 否 + 'true': 是 + zh-Hant: + 'false': 否 + 'true': 是 diff --git a/custom_components/xiaomi_home/miot/specs/multi_lang.json b/custom_components/xiaomi_home/miot/specs/multi_lang.json deleted file mode 100644 index 5e48bcc..0000000 --- a/custom_components/xiaomi_home/miot/specs/multi_lang.json +++ /dev/null @@ -1,172 +0,0 @@ -{ - "urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1": { - "de": { - "service:001": "Geräteinformationen", - "service:001:property:003": "Geräte-ID", - "service:001:property:005": "Seriennummer (SN)", - "service:002": "Gateway", - "service:002:event:001": "Netzwerk geändert", - "service:002:event:002": "Netzwerk geändert", - "service:002:property:001": "Zugriffsmethode", - "service:002:property:001:valuelist:000": "Kabelgebunden", - "service:002:property:001:valuelist:001": "5G Drahtlos", - "service:002:property:001:valuelist:002": "2.4G Drahtlos", - "service:002:property:002": "IP-Adresse", - "service:002:property:003": "WiFi-Netzwerkname", - "service:002:property:004": "Aktuelle Zeit", - "service:002:property:005": "DHCP-Server-MAC-Adresse", - "service:003": "Anzeigelampe", - "service:003:property:001": "Schalter", - "service:004": "Virtueller Dienst", - "service:004:action:001": "Virtuelles Ereignis erzeugen", - "service:004:event:001": "Virtuelles Ereignis aufgetreten", - "service:004:property:001": "Ereignisname" - }, - "en": { - "service:001": "Device Information", - "service:001:property:003": "Device ID", - "service:001:property:005": "Serial Number (SN)", - "service:002": "Gateway", - "service:002:event:001": "Network Changed", - "service:002:event:002": "Network Changed", - "service:002:property:001": "Access Method", - "service:002:property:001:valuelist:000": "Wired", - "service:002:property:001:valuelist:001": "5G Wireless", - "service:002:property:001:valuelist:002": "2.4G Wireless", - "service:002:property:002": "IP Address", - "service:002:property:003": "WiFi Network Name", - "service:002:property:004": "Current Time", - "service:002:property:005": "DHCP Server MAC Address", - "service:003": "Indicator Light", - "service:003:property:001": "Switch", - "service:004": "Virtual Service", - "service:004:action:001": "Generate Virtual Event", - "service:004:event:001": "Virtual Event Occurred", - "service:004:property:001": "Event Name" - }, - "es": { - "service:001": "Información del dispositivo", - "service:001:property:003": "ID del dispositivo", - "service:001:property:005": "Número de serie (SN)", - "service:002": "Puerta de enlace", - "service:002:event:001": "Cambio de red", - "service:002:event:002": "Cambio de red", - "service:002:property:001": "Método de acceso", - "service:002:property:001:valuelist:000": "Cableado", - "service:002:property:001:valuelist:001": "5G inalámbrico", - "service:002:property:001:valuelist:002": "2.4G inalámbrico", - "service:002:property:002": "Dirección IP", - "service:002:property:003": "Nombre de red WiFi", - "service:002:property:004": "Hora actual", - "service:002:property:005": "Dirección MAC del servidor DHCP", - "service:003": "Luz indicadora", - "service:003:property:001": "Interruptor", - "service:004": "Servicio virtual", - "service:004:action:001": "Generar evento virtual", - "service:004:event:001": "Ocurrió un evento virtual", - "service:004:property:001": "Nombre del evento" - }, - "fr": { - "service:001": "Informations sur l'appareil", - "service:001:property:003": "ID de l'appareil", - "service:001:property:005": "Numéro de série (SN)", - "service:002": "Passerelle", - "service:002:event:001": "Changement de réseau", - "service:002:event:002": "Changement de réseau", - "service:002:property:001": "Méthode d'accès", - "service:002:property:001:valuelist:000": "Câblé", - "service:002:property:001:valuelist:001": "Sans fil 5G", - "service:002:property:001:valuelist:002": "Sans fil 2.4G", - "service:002:property:002": "Adresse IP", - "service:002:property:003": "Nom du réseau WiFi", - "service:002:property:004": "Heure actuelle", - "service:002:property:005": "Adresse MAC du serveur DHCP", - "service:003": "Voyant lumineux", - "service:003:property:001": "Interrupteur", - "service:004": "Service virtuel", - "service:004:action:001": "Générer un événement virtuel", - "service:004:event:001": "Événement virtuel survenu", - "service:004:property:001": "Nom de l'événement" - }, - "ja": { - "service:001": "デバイス情報", - "service:001:property:003": "デバイスID", - "service:001:property:005": "シリアル番号 (SN)", - "service:002": "ゲートウェイ", - "service:002:event:001": "ネットワークが変更されました", - "service:002:event:002": "ネットワークが変更されました", - "service:002:property:001": "アクセス方法", - "service:002:property:001:valuelist:000": "有線", - "service:002:property:001:valuelist:001": "5G ワイヤレス", - "service:002:property:001:valuelist:002": "2.4G ワイヤレス", - "service:002:property:002": "IPアドレス", - "service:002:property:003": "WiFiネットワーク名", - "service:002:property:004": "現在の時間", - "service:002:property:005": "DHCPサーバーMACアドレス", - "service:003": "インジケータライト", - "service:003:property:001": "スイッチ", - "service:004": "バーチャルサービス", - "service:004:action:001": "バーチャルイベントを生成", - "service:004:event:001": "バーチャルイベントが発生しました", - "service:004:property:001": "イベント名" - }, - "ru": { - "service:001": "Информация об устройстве", - "service:001:property:003": "ID устройства", - "service:001:property:005": "Серийный номер (SN)", - "service:002": "Шлюз", - "service:002:event:001": "Сеть изменена", - "service:002:event:002": "Сеть изменена", - "service:002:property:001": "Метод доступа", - "service:002:property:001:valuelist:000": "Проводной", - "service:002:property:001:valuelist:001": "5G Беспроводной", - "service:002:property:001:valuelist:002": "2.4G Беспроводной", - "service:002:property:002": "IP Адрес", - "service:002:property:003": "Название WiFi сети", - "service:002:property:004": "Текущее время", - "service:002:property:005": "MAC адрес DHCP сервера", - "service:003": "Световой индикатор", - "service:003:property:001": "Переключатель", - "service:004": "Виртуальная служба", - "service:004:action:001": "Создать виртуальное событие", - "service:004:event:001": "Произошло виртуальное событие", - "service:004:property:001": "Название события" - }, - "zh-Hant": { - "service:001": "設備信息", - "service:001:property:003": "設備ID", - "service:001:property:005": "序號 (SN)", - "service:002": "網關", - "service:002:event:001": "網路發生變化", - "service:002:event:002": "網路發生變化", - "service:002:property:001": "接入方式", - "service:002:property:001:valuelist:000": "有線", - "service:002:property:001:valuelist:001": "5G 無線", - "service:002:property:001:valuelist:002": "2.4G 無線", - "service:002:property:002": "IP地址", - "service:002:property:003": "WiFi網路名稱", - "service:002:property:004": "當前時間", - "service:002:property:005": "DHCP伺服器MAC地址", - "service:003": "指示燈", - "service:003:property:001": "開關", - "service:004": "虛擬服務", - "service:004:action:001": "產生虛擬事件", - "service:004:event:001": "虛擬事件發生", - "service:004:property:001": "事件名稱" - } - }, - "urn:miot-spec-v2:device:switch:0000A003:lumi-acn040:1": { - "en": { - "service:011": "Right Button On and Off", - "service:011:property:001": "Right Button On and Off", - "service:015:action:001": "Left Button Identify", - "service:016:action:001": "Middle Button Identify", - "service:017:action:001": "Right Button Identify" - }, - "zh-Hans": { - "service:015:action:001": "左键确认", - "service:016:action:001": "中键确认", - "service:017:action:001": "右键确认" - } - } -} \ No newline at end of file diff --git a/custom_components/xiaomi_home/miot/specs/spec_filter.json b/custom_components/xiaomi_home/miot/specs/spec_filter.json deleted file mode 100644 index 274fb34..0000000 --- a/custom_components/xiaomi_home/miot/specs/spec_filter.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "urn:miot-spec-v2:device:air-purifier:0000A007:zhimi-ma4": { - "properties": [ - "9.*", - "13.*", - "15.*" - ], - "services": [ - "10" - ] - }, - "urn:miot-spec-v2:device:curtain:0000A00C:lumi-hmcn01": { - "properties": [ - "5.1" - ], - "services": [ - "4", - "7", - "8" - ] - }, - "urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1": { - "events": [ - "2.1" - ] - }, - "urn:miot-spec-v2:device:health-pot:0000A051:chunmi-a1": { - "services": [ - "5" - ] - }, - "urn:miot-spec-v2:device:light:0000A001:philips-strip3": { - "properties": [ - "2.2" - ], - "services": [ - "1", - "3" - ] - }, - "urn:miot-spec-v2:device:light:0000A001:yeelink-color2": { - "properties": [ - "3.*", - "2.5" - ] - }, - "urn:miot-spec-v2:device:light:0000A001:yeelink-dnlight2": { - "services": [ - "3" - ] - }, - "urn:miot-spec-v2:device:light:0000A001:yeelink-mbulb3": { - "services": [ - "3" - ] - }, - "urn:miot-spec-v2:device:motion-sensor:0000A014:xiaomi-pir1": { - "services": [ - "1", - "5" - ] - }, - "urn:miot-spec-v2:device:router:0000A036:xiaomi-rd03": { - "services": [ - "*" - ] - } -} \ No newline at end of file diff --git a/custom_components/xiaomi_home/miot/specs/spec_filter.yaml b/custom_components/xiaomi_home/miot/specs/spec_filter.yaml new file mode 100644 index 0000000..2102e48 --- /dev/null +++ b/custom_components/xiaomi_home/miot/specs/spec_filter.yaml @@ -0,0 +1,43 @@ +urn:miot-spec-v2:device:air-purifier:0000A007:zhimi-ma4: + properties: + - 9.* + - 13.* + - 15.* + services: + - '10' +urn:miot-spec-v2:device:curtain:0000A00C:lumi-hmcn01: + properties: + - '5.1' + services: + - '4' + - '7' + - '8' +urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1: + events: + - '2.1' +urn:miot-spec-v2:device:health-pot:0000A051:chunmi-a1: + services: + - '5' +urn:miot-spec-v2:device:light:0000A001:philips-strip3: + properties: + - '2.2' + services: + - '1' + - '3' +urn:miot-spec-v2:device:light:0000A001:yeelink-color2: + properties: + - 3.* + - '2.5' +urn:miot-spec-v2:device:light:0000A001:yeelink-dnlight2: + services: + - '3' +urn:miot-spec-v2:device:light:0000A001:yeelink-mbulb3: + services: + - '3' +urn:miot-spec-v2:device:motion-sensor:0000A014:xiaomi-pir1: + services: + - '1' + - '5' +urn:miot-spec-v2:device:router:0000A036:xiaomi-rd03: + services: + - '*' diff --git a/custom_components/xiaomi_home/miot/specs/specv2entity.py b/custom_components/xiaomi_home/miot/specs/specv2entity.py index e0d9dd5..0b5c364 100644 --- a/custom_components/xiaomi_home/miot/specs/specv2entity.py +++ b/custom_components/xiaomi_home/miot/specs/specv2entity.py @@ -228,8 +228,9 @@ SPEC_DEVICE_TRANS_MAP: dict[str, dict | str] = { } }, 'optional': { - 'properties': {'target-temperature', 'mode', 'fan-level', - 'temperature'} + 'properties': { + 'target-temperature', 'mode', 'fan-level', + 'temperature'} }, } }, @@ -479,6 +480,14 @@ SPEC_PROP_TRANS_MAP: dict[str, dict | str] = { 'unit_of_measurement': UnitOfEnergy.KILO_WATT_HOUR } }, + 'power': { + 'device_class': SensorDeviceClass.POWER, + 'entity': 'sensor', + 'optional': { + 'state_class': SensorStateClass.MEASUREMENT, + 'unit_of_measurement': UnitOfPower.WATT + } + }, 'total-battery': { 'device_class': SensorDeviceClass.ENERGY, 'entity': 'sensor', diff --git a/custom_components/xiaomi_home/miot/web_pages.py b/custom_components/xiaomi_home/miot/web_pages.py index e4cde5a..d6ffd9f 100644 --- a/custom_components/xiaomi_home/miot/web_pages.py +++ b/custom_components/xiaomi_home/miot/web_pages.py @@ -46,237 +46,31 @@ off Xiaomi or its affiliates' products. MIoT redirect web pages. """ -# pylint: disable=line-too-long +import os +import asyncio -def oauth_redirect_page(lang: str, status: str) -> str: +_template = '' + + +def _load_page_template(): + path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + 'resource/oauth_redirect_page.html') + with open(path, 'r', encoding='utf-8') as f: + global _template + _template = f.read() + + +async def oauth_redirect_page( + title: str, content: str, button: str, success: bool +) -> str: """Return oauth redirect page.""" - return ''' - - - - - - - - - - -
- -
- 编组 - Created with Sketch. - - - - - - - - - - - - - - - - -
- -
- -
- -
- -
- - -
- - - - ''' + if _template == '': + await asyncio.get_running_loop().run_in_executor( + None, _load_page_template) + web_page = _template.replace('TITLE_PLACEHOLDER', title) + web_page = web_page.replace('CONTENT_PLACEHOLDER', content) + web_page = web_page.replace('BUTTON_PLACEHOLDER', button) + web_page = web_page.replace( + 'STATUS_PLACEHOLDER', 'true' if success else 'false') + return web_page diff --git a/custom_components/xiaomi_home/notify.py b/custom_components/xiaomi_home/notify.py index ba0844a..5cf3fd8 100644 --- a/custom_components/xiaomi_home/notify.py +++ b/custom_components/xiaomi_home/notify.py @@ -90,7 +90,7 @@ class Notify(MIoTActionEntity, NotifyEntity): super().__init__(miot_device=miot_device, spec=spec) self._attr_extra_state_attributes = {} action_in: str = ', '.join([ - f'{prop.description_trans}({prop.format_})' + f'{prop.description_trans}({prop.format_.__name__})' for prop in self.spec.in_]) self._attr_extra_state_attributes['action params'] = f'[{action_in}]' @@ -122,24 +122,24 @@ class Notify(MIoTActionEntity, NotifyEntity): return in_value: list[dict] = [] for index, prop in enumerate(self.spec.in_): - if prop.format_ == 'str': + if prop.format_ == str: if isinstance(in_list[index], (bool, int, float, str)): in_value.append( {'piid': prop.iid, 'value': str(in_list[index])}) continue - elif prop.format_ == 'bool': + elif prop.format_ == bool: if isinstance(in_list[index], (bool, int)): # yes, no, on, off, true, false and other bool types # will also be parsed as 0 and 1 of int. in_value.append( {'piid': prop.iid, 'value': bool(in_list[index])}) continue - elif prop.format_ == 'float': + elif prop.format_ == float: if isinstance(in_list[index], (int, float)): in_value.append( {'piid': prop.iid, 'value': in_list[index]}) continue - elif prop.format_ == 'int': + elif prop.format_ == int: if isinstance(in_list[index], int): in_value.append( {'piid': prop.iid, 'value': in_list[index]}) diff --git a/custom_components/xiaomi_home/number.py b/custom_components/xiaomi_home/number.py index 53bc09c..29bd6b7 100644 --- a/custom_components/xiaomi_home/number.py +++ b/custom_components/xiaomi_home/number.py @@ -92,9 +92,9 @@ class Number(MIoTPropertyEntity, NumberEntity): self._attr_icon = self.spec.icon # Set value range if self._value_range: - self._attr_native_min_value = self._value_range['min'] - self._attr_native_max_value = self._value_range['max'] - self._attr_native_step = self._value_range['step'] + self._attr_native_min_value = self._value_range.min_ + self._attr_native_max_value = self._value_range.max_ + self._attr_native_step = self._value_range.step @property def native_value(self) -> Optional[float]: diff --git a/custom_components/xiaomi_home/select.py b/custom_components/xiaomi_home/select.py index 4c9bad3..21b5e78 100644 --- a/custom_components/xiaomi_home/select.py +++ b/custom_components/xiaomi_home/select.py @@ -82,7 +82,8 @@ class Select(MIoTPropertyEntity, SelectEntity): def __init__(self, miot_device: MIoTDevice, spec: MIoTSpecProperty) -> None: """Initialize the Select.""" super().__init__(miot_device=miot_device, spec=spec) - self._attr_options = list(self._value_list.values()) + if self._value_list: + self._attr_options = self._value_list.descriptions async def async_select_option(self, option: str) -> None: """Change the selected option.""" diff --git a/custom_components/xiaomi_home/sensor.py b/custom_components/xiaomi_home/sensor.py index 39b3bdb..88b4bac 100644 --- a/custom_components/xiaomi_home/sensor.py +++ b/custom_components/xiaomi_home/sensor.py @@ -76,6 +76,12 @@ async def async_setup_entry( for prop in miot_device.prop_list.get('sensor', []): new_entities.append(Sensor(miot_device=miot_device, spec=prop)) + if miot_device.miot_client.display_binary_text: + for prop in miot_device.prop_list.get('binary_sensor', []): + if not prop.value_list: + continue + new_entities.append(Sensor(miot_device=miot_device, spec=prop)) + if new_entities: async_add_entities(new_entities) @@ -91,7 +97,7 @@ class Sensor(MIoTPropertyEntity, SensorEntity): self._attr_device_class = SensorDeviceClass.ENUM self._attr_icon = 'mdi:message-text' self._attr_native_unit_of_measurement = None - self._attr_options = list(self._value_list.values()) + self._attr_options = self._value_list.descriptions else: self._attr_device_class = spec.device_class if spec.external_unit: @@ -100,29 +106,29 @@ class Sensor(MIoTPropertyEntity, SensorEntity): # device_class is not empty but unit is empty. # Set the default unit according to device_class. unit_sets = DEVICE_CLASS_UNITS.get( - self._attr_device_class, None) + self._attr_device_class, None) # type: ignore self._attr_native_unit_of_measurement = list( unit_sets)[0] if unit_sets else None + # Set state_class + if spec.state_class: + self._attr_state_class = spec.state_class # Set icon if spec.icon: self._attr_icon = spec.icon - # Set state_class - if spec.state_class: - self._attr_state_class = spec.state_class @property def native_value(self) -> Any: """Return the current value of the sensor.""" if self._value_range and isinstance(self._value, (int, float)): if ( - self._value < self._value_range['min'] - or self._value > self._value_range['max'] + self._value < self._value_range.min_ + or self._value > self._value_range.max_ ): _LOGGER.info( '%s, data exception, out of range, %s, %s', self.entity_id, self._value, self._value_range) if self._value_list: - return self._value_list.get(self._value, None) + return self.get_vlist_description(self._value) if isinstance(self._value, str): return self._value[:255] return self._value diff --git a/custom_components/xiaomi_home/text.py b/custom_components/xiaomi_home/text.py index 8a6b9ae..ff6ac3e 100644 --- a/custom_components/xiaomi_home/text.py +++ b/custom_components/xiaomi_home/text.py @@ -76,9 +76,10 @@ async def async_setup_entry( for prop in miot_device.prop_list.get('text', []): new_entities.append(Text(miot_device=miot_device, spec=prop)) - for action in miot_device.action_list.get('action_text', []): - new_entities.append(ActionText( - miot_device=miot_device, spec=action)) + if miot_device.miot_client.action_debug: + for action in miot_device.action_list.get('notify', []): + new_entities.append(ActionText( + miot_device=miot_device, spec=action)) if new_entities: async_add_entities(new_entities) @@ -111,11 +112,9 @@ class ActionText(MIoTActionEntity, TextEntity): self._attr_extra_state_attributes = {} self._attr_native_value = '' action_in: str = ', '.join([ - f'{prop.description_trans}({prop.format_})' + f'{prop.description_trans}({prop.format_.__name__})' for prop in self.spec.in_]) self._attr_extra_state_attributes['action params'] = f'[{action_in}]' - # For action debug - self.action_platform = 'action_text' async def async_set_value(self, value: str) -> None: if not value: @@ -141,24 +140,24 @@ class ActionText(MIoTActionEntity, TextEntity): f'invalid action params, {value}') in_value: list[dict] = [] for index, prop in enumerate(self.spec.in_): - if prop.format_ == 'str': + if prop.format_ == str: if isinstance(in_list[index], (bool, int, float, str)): in_value.append( {'piid': prop.iid, 'value': str(in_list[index])}) continue - elif prop.format_ == 'bool': + elif prop.format_ == bool: if isinstance(in_list[index], (bool, int)): # yes, no, on, off, true, false and other bool types # will also be parsed as 0 and 1 of int. in_value.append( {'piid': prop.iid, 'value': bool(in_list[index])}) continue - elif prop.format_ == 'float': + elif prop.format_ == float: if isinstance(in_list[index], (int, float)): in_value.append( {'piid': prop.iid, 'value': in_list[index]}) continue - elif prop.format_ == 'int': + elif prop.format_ == int: if isinstance(in_list[index], int): in_value.append( {'piid': prop.iid, 'value': in_list[index]}) diff --git a/custom_components/xiaomi_home/translations/de.json b/custom_components/xiaomi_home/translations/de.json index 68a0373..15f5c0f 100644 --- a/custom_components/xiaomi_home/translations/de.json +++ b/custom_components/xiaomi_home/translations/de.json @@ -42,12 +42,13 @@ }, "advanced_options": { "title": "Erweiterte Einstellungen", - "description": "## Gebrauchsanweisung\r\n### Wenn Sie die Bedeutung der folgenden Optionen nicht genau kennen, belassen Sie sie bitte auf den Standardeinstellungen.\r\n### Geräte filtern\r\nUnterstützt das Filtern von Geräten nach Raumnamen und Gerätetypen sowie das Filtern nach Gerätedimensionen.\r\n\r\n### Steuerungsmodus\r\n- Automatisch: Wenn ein verfügbarer Xiaomi-Hub im lokalen Netzwerk vorhanden ist, priorisiert Home Assistant das Senden von Steuerbefehlen über den Hub, um eine lokale Steuerung zu ermöglichen. Wenn kein Hub vorhanden ist, wird versucht, Steuerbefehle über das Xiaomi-OT-Protokoll zu senden. Nur wenn diese Bedingungen für die lokale Steuerung nicht erfüllt sind, werden die Befehle über die Cloud gesendet.\r\n- Cloud: Steuerbefehle werden ausschließlich über die Cloud gesendet.\r\n### Action-Debug-Modus\r\nFür Methoden, die von MIoT-Spec-V2-Geräten definiert werden, wird neben der Benachrichtigungsentität auch eine Texteingabe-Entität erstellt, mit der Sie während des Debuggens Steuerbefehle an das Gerät senden können.\r\n### Nicht standardmäßige Entitäten ausblenden\r\nBlendet Entitäten aus, die von nicht-standardmäßigen MIoT-Spec-V2-Instanzen generiert werden und deren Name mit „*“ beginnt.\r\n### Gerätestatusänderungen anzeigen\r\nDetaillierte Anzeige von Gerätestatusänderungen, es werden nur die ausgewählten Benachrichtigungen angezeigt.", + "description": "## Gebrauchsanweisung\r\n### Wenn Sie die Bedeutung der folgenden Optionen nicht genau kennen, belassen Sie sie bitte auf den Standardeinstellungen.\r\n### Geräte filtern\r\nUnterstützt das Filtern von Geräten nach Raumnamen und Gerätetypen sowie das Filtern nach Gerätedimensionen.\r\n\r\n### Steuerungsmodus\r\n- Automatisch: Wenn ein verfügbarer Xiaomi-Hub im lokalen Netzwerk vorhanden ist, priorisiert Home Assistant das Senden von Steuerbefehlen über den Hub, um eine lokale Steuerung zu ermöglichen. Wenn kein Hub vorhanden ist, wird versucht, Steuerbefehle über das Xiaomi-OT-Protokoll zu senden. Nur wenn diese Bedingungen für die lokale Steuerung nicht erfüllt sind, werden die Befehle über die Cloud gesendet.\r\n- Cloud: Steuerbefehle werden ausschließlich über die Cloud gesendet.\r\n### Action-Debug-Modus\r\nFür Methoden, die von MIoT-Spec-V2-Geräten definiert werden, wird neben der Benachrichtigungsentität auch eine Texteingabe-Entität erstellt, mit der Sie während des Debuggens Steuerbefehle an das Gerät senden können.\r\n### Nicht standardmäßige Entitäten ausblenden\r\nBlendet Entitäten aus, die von nicht-standardmäßigen MIoT-Spec-V2-Instanzen generiert werden und deren Name mit „*“ beginnt.\r\n### Binärsensor-Anzeigemodus\r\nZeigt Binärsensoren in Xiaomi Home als Textsensor-Entität oder Binärsensor-Entität an。\r\n### Gerätestatusänderungen anzeigen\r\nDetaillierte Anzeige von Gerätestatusänderungen, es werden nur die ausgewählten Benachrichtigungen angezeigt.", "data": { "devices_filter": "Geräte filtern", "ctrl_mode": "Steuerungsmodus", "action_debug": "Action-Debug-Modus", "hide_non_standard_entities": "Nicht standardmäßige Entitäten ausblenden", + "display_binary_mode": "Binärsensor-Anzeigemodus", "display_devices_changed_notify": "Gerätestatusänderungen anzeigen" } }, @@ -90,6 +91,7 @@ "unreachable_mqtt_broker": "Xiaomi MQTT Broker-Adresse ist nicht erreichbar, bitte überprüfen Sie die Netzwerkkonfiguration." }, "abort": { + "ha_uuid_get_failed": "Fehler beim Abrufen der Home Assistant-UUID.", "network_connect_error": "Konfiguration fehlgeschlagen. Netzwerkverbindung fehlgeschlagen. Überprüfen Sie die Netzwerkkonfiguration des Geräts.", "already_configured": "Dieser Benutzer hat die Konfiguration bereits abgeschlossen. Gehen Sie zur Integrationsseite und klicken Sie auf die Schaltfläche \"Konfiguration\", um die Konfiguration zu ändern.", "invalid_auth_info": "Authentifizierungsinformationen sind abgelaufen. Gehen Sie zur Integrationsseite und klicken Sie auf die Schaltfläche \"Konfiguration\", um die Authentifizierung erneut durchzuführen.", @@ -118,6 +120,7 @@ "update_devices": "Geräteliste aktualisieren", "action_debug": "Action-Debug-Modus", "hide_non_standard_entities": "Verstecke Nicht-Standard-Entitäten", + "display_binary_mode": "Binärsensor-Anzeigemodus", "display_devices_changed_notify": "Gerätestatusänderungen anzeigen", "update_trans_rules": "Entitätskonvertierungsregeln aktualisieren", "update_lan_ctrl_config": "LAN-Steuerungskonfiguration aktualisieren", diff --git a/custom_components/xiaomi_home/translations/en.json b/custom_components/xiaomi_home/translations/en.json index 2244730..7832fd1 100644 --- a/custom_components/xiaomi_home/translations/en.json +++ b/custom_components/xiaomi_home/translations/en.json @@ -42,12 +42,13 @@ }, "advanced_options": { "title": "Advanced Settings", - "description": "## Introduction\r\n### Unless you are very clear about the meaning of the following options, please keep the default settings.\r\n### Filter Devices\r\nSupports filtering devices by room name and device type, and also supports device dimension filtering.\r\n### Control Mode\r\n- Auto: When there is an available Xiaomi central hub gateway in the local area network, Home Assistant will prioritize sending device control commands through the central hub gateway to achieve local control. If there is no central hub gateway in the local area network, it will attempt to send control commands through Xiaomi OT protocol to achieve local control. Only when the above local control conditions are not met, the device control commands will be sent through the cloud.\r\n- Cloud: All control commands are sent through the cloud.\r\n### Action Debug Mode\r\nFor the methods defined by the device MIoT-Spec-V2, in addition to generating notification entities, a text input box entity will also be generated. You can use it to send control commands to the device during debugging.\r\n### Hide Non-Standard Generated Entities\r\nHide entities generated by non-standard MIoT-Spec-V2 instances with names starting with \"*\".\r\n### Display Device Status Change Notifications\r\nDisplay detailed device status change notifications, only showing the selected notifications.", + "description": "## Introduction\r\n### Unless you are very clear about the meaning of the following options, please keep the default settings.\r\n### Filter Devices\r\nSupports filtering devices by room name and device type, and also supports device dimension filtering.\r\n### Control Mode\r\n- Auto: When there is an available Xiaomi central hub gateway in the local area network, Home Assistant will prioritize sending device control commands through the central hub gateway to achieve local control. If there is no central hub gateway in the local area network, it will attempt to send control commands through Xiaomi OT protocol to achieve local control. Only when the above local control conditions are not met, the device control commands will be sent through the cloud.\r\n- Cloud: All control commands are sent through the cloud.\r\n### Action Debug Mode\r\nFor the methods defined by the device MIoT-Spec-V2, in addition to generating notification entities, a text input box entity will also be generated. You can use it to send control commands to the device during debugging.\r\n### Hide Non-Standard Generated Entities\r\nHide entities generated by non-standard MIoT-Spec-V2 instances with names starting with \"*\".\r\n### Binary Sensor Display Mode\r\nDisplay binary sensors in Xiaomi Home as text sensor entity or binary sensor entity。\r\n### Display Device Status Change Notifications\r\nDisplay detailed device status change notifications, only showing the selected notifications.", "data": { "devices_filter": "Filter Devices", "ctrl_mode": "Control Mode", "action_debug": "Action Debug Mode", "hide_non_standard_entities": "Hide Non-Standard Generated Entities", + "display_binary_mode": "Binary Sensor Display Mode", "display_devices_changed_notify": "Display Device Status Change Notifications" } }, @@ -90,6 +91,7 @@ "unreachable_mqtt_broker": "Unable to reach Xiaomi MQTT Broker address, please check network configuration." }, "abort": { + "ha_uuid_get_failed": "Failed to get Home Assistant UUID.", "network_connect_error": "Configuration failed. The network connection is abnormal. Please check the equipment network configuration.", "already_configured": "Configuration for this user is already completed. Please go to the integration page and click the CONFIGURE button for modifications.", "invalid_auth_info": "Authentication information has expired. Please go to the integration page and click the CONFIGURE button to re-authenticate.", @@ -118,6 +120,7 @@ "update_devices": "Update device list", "action_debug": "Debug mode for action", "hide_non_standard_entities": "Hide non-standard created entities", + "display_binary_mode": "Binary Sensor Display Mode", "display_devices_changed_notify": "Display device status change notifications", "update_trans_rules": "Update entity conversion rules", "update_lan_ctrl_config": "Update LAN control configuration", diff --git a/custom_components/xiaomi_home/translations/es.json b/custom_components/xiaomi_home/translations/es.json index 2942567..eb52c74 100644 --- a/custom_components/xiaomi_home/translations/es.json +++ b/custom_components/xiaomi_home/translations/es.json @@ -42,12 +42,13 @@ }, "advanced_options": { "title": "Opciones Avanzadas", - "description": "## Introducción\r\n### A menos que entienda claramente el significado de las siguientes opciones, manténgalas en su configuración predeterminada.\r\n### Filtrar dispositivos\r\nAdmite la filtración de dispositivos por nombre de habitación y tipo de dispositivo, y también admite la filtración por familia.\r\n### Modo de Control\r\n- Automático: Cuando hay una puerta de enlace central de Xiaomi disponible en la red local, Home Assistant enviará comandos de control de dispositivos a través de la puerta de enlace central para lograr la función de control local. Cuando no hay una puerta de enlace central en la red local, intentará enviar comandos de control a través del protocolo OT de Xiaomi para lograr la función de control local. Solo cuando no se cumplan las condiciones de control local anteriores, los comandos de control de dispositivos se enviarán a través de la nube.\r\n- Nube: Los comandos de control solo se envían a través de la nube.\r\n### Modo de Depuración de Acciones\r\nPara los métodos definidos por el dispositivo MIoT-Spec-V2, además de generar una entidad de notificación, también se generará una entidad de cuadro de texto que se puede utilizar para enviar comandos de control al dispositivo durante la depuración.\r\n### Ocultar Entidades Generadas No Estándar\r\nOcultar entidades generadas por instancias MIoT-Spec-V2 no estándar que comienzan con \"*\".\r\n### Mostrar notificaciones de cambio de estado del dispositivo\r\nMostrar notificaciones detalladas de cambio de estado del dispositivo, mostrando solo las notificaciones seleccionadas.", + "description": "## Introducción\r\n### A menos que entienda claramente el significado de las siguientes opciones, manténgalas en su configuración predeterminada.\r\n### Filtrar dispositivos\r\nAdmite la filtración de dispositivos por nombre de habitación y tipo de dispositivo, y también admite la filtración por familia.\r\n### Modo de Control\r\n- Automático: Cuando hay una puerta de enlace central de Xiaomi disponible en la red local, Home Assistant enviará comandos de control de dispositivos a través de la puerta de enlace central para lograr la función de control local. Cuando no hay una puerta de enlace central en la red local, intentará enviar comandos de control a través del protocolo OT de Xiaomi para lograr la función de control local. Solo cuando no se cumplan las condiciones de control local anteriores, los comandos de control de dispositivos se enviarán a través de la nube.\r\n- Nube: Los comandos de control solo se envían a través de la nube.\r\n### Modo de Depuración de Acciones\r\nPara los métodos definidos por el dispositivo MIoT-Spec-V2, además de generar una entidad de notificación, también se generará una entidad de cuadro de texto que se puede utilizar para enviar comandos de control al dispositivo durante la depuración.\r\n### Ocultar Entidades Generadas No Estándar\r\nOcultar entidades generadas por instancias MIoT-Spec-V2 no estándar que comienzan con \"*\".\r\n### Modo de visualización del sensor binario\r\nMuestra los sensores binarios en Xiaomi Home como entidad de sensor de texto o entidad de sensor binario。\r\n### Mostrar notificaciones de cambio de estado del dispositivo\r\nMostrar notificaciones detalladas de cambio de estado del dispositivo, mostrando solo las notificaciones seleccionadas.", "data": { "devices_filter": "Filtrar Dispositivos", "ctrl_mode": "Modo de Control", "action_debug": "Modo de Depuración de Acciones", "hide_non_standard_entities": "Ocultar Entidades Generadas No Estándar", + "display_binary_mode": "Modo de visualización del sensor binario", "display_devices_changed_notify": "Mostrar notificaciones de cambio de estado del dispositivo" } }, @@ -90,6 +91,7 @@ "unreachable_mqtt_broker": "No se puede acceder a la dirección del Broker MQTT de Xiaomi, por favor verifique la configuración de la red." }, "abort": { + "ha_uuid_get_failed": "Error al obtener el UUID de Home Assistant.", "network_connect_error": "La configuración ha fallado. Existe un problema con la conexión de red, verifique la configuración de red del dispositivo.", "already_configured": "Esta cuenta ya ha finalizado la configuración. Ingrese a la página de integración y haga clic en el botón \"Configurar\" para modificar la configuración.", "invalid_auth_info": "La información de autorización ha caducado. Ingrese a la página de integración y haga clic en el botón \"Configurar\" para volver a autenticarse.", @@ -118,6 +120,7 @@ "update_devices": "Actualizar lista de dispositivos", "action_debug": "Modo de depuración de Action", "hide_non_standard_entities": "Ocultar entidades generadas no estándar", + "display_binary_mode": "Modo de visualización del sensor binario", "display_devices_changed_notify": "Mostrar notificaciones de cambio de estado del dispositivo", "update_trans_rules": "Actualizar reglas de conversión de entidad", "update_lan_ctrl_config": "Actualizar configuración de control LAN", diff --git a/custom_components/xiaomi_home/translations/fr.json b/custom_components/xiaomi_home/translations/fr.json index fa1b84d..07c2245 100644 --- a/custom_components/xiaomi_home/translations/fr.json +++ b/custom_components/xiaomi_home/translations/fr.json @@ -42,12 +42,13 @@ }, "advanced_options": { "title": "Paramètres Avancés", - "description": "## Introduction\r\n### Sauf si vous comprenez très bien la signification des options suivantes, veuillez les laisser par défaut.\r\n### Filtrer les appareils\r\nPrend en charge le filtrage des appareils en fonction du nom de la pièce et du type d'appareil, ainsi que le filtrage basé sur les appareils.\r\n### Mode de Contrôle\r\n- Automatique : Lorsqu'une passerelle Xiaomi est disponible dans le réseau local, Home Assistant enverra les commandes de contrôle des appareils via la passerelle pour permettre le contrôle local. Si aucune passerelle n'est disponible dans le réseau local, Home Assistant essaiera d'envoyer les commandes de contrôle des appareils via le protocole OT Xiaomi pour permettre le contrôle local. Seules si les conditions de contrôle local ci-dessus ne sont pas remplies, les commandes de contrôle des appareils seront envoyées via le cloud.\r\n- Cloud : Les commandes de contrôle des appareils sont envoyées uniquement via le cloud.\r\n### Mode de Débogage d’Actions\r\nPour les méthodes définies par les appareils MIoT-Spec-V2, en plus de générer une entité de notification, une entité de champ de texte sera également générée pour vous permettre d'envoyer des commandes de contrôle aux appareils lors du débogage.\r\n### Masquer les Entités Non Standard\r\nMasquer les entités générées par des instances MIoT-Spec-V2 non standard et commençant par \"*\".\r\n### Afficher les notifications de changement d'état de l'appareil\r\nAfficher les notifications détaillées de changement d'état de l'appareil, en affichant uniquement les notifications sélectionnées.", + "description": "## Introduction\r\n### Sauf si vous comprenez très bien la signification des options suivantes, veuillez les laisser par défaut.\r\n### Filtrer les appareils\r\nPrend en charge le filtrage des appareils en fonction du nom de la pièce et du type d'appareil, ainsi que le filtrage basé sur les appareils.\r\n### Mode de Contrôle\r\n- Automatique : Lorsqu'une passerelle Xiaomi est disponible dans le réseau local, Home Assistant enverra les commandes de contrôle des appareils via la passerelle pour permettre le contrôle local. Si aucune passerelle n'est disponible dans le réseau local, Home Assistant essaiera d'envoyer les commandes de contrôle des appareils via le protocole OT Xiaomi pour permettre le contrôle local. Seules si les conditions de contrôle local ci-dessus ne sont pas remplies, les commandes de contrôle des appareils seront envoyées via le cloud.\r\n- Cloud : Les commandes de contrôle des appareils sont envoyées uniquement via le cloud.\r\n### Mode de Débogage d’Actions\r\nPour les méthodes définies par les appareils MIoT-Spec-V2, en plus de générer une entité de notification, une entité de champ de texte sera également générée pour vous permettre d'envoyer des commandes de contrôle aux appareils lors du débogage.\r\n### Masquer les Entités Non Standard\r\nMasquer les entités générées par des instances MIoT-Spec-V2 non standard et commençant par \"*\".\r\n### Mode d'affichage du capteur binaire\r\nAffiche les capteurs binaires dans Xiaomi Home comme entité de capteur de texte ou entité de capteur binaire。\r\n### Afficher les notifications de changement d'état de l'appareil\r\nAfficher les notifications détaillées de changement d'état de l'appareil, en affichant uniquement les notifications sélectionnées.", "data": { "devices_filter": "Filtrer les Appareils", "ctrl_mode": "Mode de Contrôle", "action_debug": "Mode de Débogage d’Actions", "hide_non_standard_entities": "Masquer les Entités Non Standard", + "display_binary_mode": "Mode d'affichage du capteur binaire", "display_devices_changed_notify": "Afficher les notifications de changement d'état de l'appareil" } }, @@ -90,6 +91,7 @@ "unreachable_mqtt_broker": "Impossible d'atteindre l'adresse du Broker MQTT de Xiaomi, veuillez vérifier la configuration réseau." }, "abort": { + "ha_uuid_get_failed": "Échec de l'obtention de l'UUID de Home Assistant.", "network_connect_error": "La configuration a échoué. Erreur de connexion réseau. Veuillez vérifier la configuration du réseau de l'appareil.", "already_configured": "Cet utilisateur a déjà terminé la configuration. Veuillez accéder à la page d'intégration et cliquer sur le bouton \"Configurer\" pour modifier la configuration.", "invalid_auth_info": "Les informations d'authentification ont expiré. Veuillez accéder à la page d'intégration et cliquer sur le bouton \"Configurer\" pour vous authentifier à nouveau.", @@ -118,6 +120,7 @@ "update_devices": "Mettre à jour la liste des appareils", "action_debug": "Mode de débogage d'action", "hide_non_standard_entities": "Masquer les entités générées non standard", + "display_binary_mode": "Mode d'affichage du capteur binaire", "display_devices_changed_notify": "Afficher les notifications de changement d'état de l'appareil", "update_trans_rules": "Mettre à jour les règles de conversion d'entités", "update_lan_ctrl_config": "Mettre à jour la configuration de contrôle LAN", diff --git a/custom_components/xiaomi_home/translations/it.json b/custom_components/xiaomi_home/translations/it.json new file mode 100644 index 0000000..b384103 --- /dev/null +++ b/custom_components/xiaomi_home/translations/it.json @@ -0,0 +1,224 @@ +{ + "config": { + "flow_title": "Integrazione Xiaomi Home", + "step": { + "eula": { + "title": "Avviso sui Rischi", + "description": "1. Le informazioni del tuo utente Xiaomi e le informazioni del dispositivo saranno memorizzate nel sistema Home Assistant. **Xiaomi non può garantire la sicurezza del meccanismo di archiviazione di Home Assistant**. Sei responsabile per prevenire il furto delle tue informazioni.\r\n2. Questa integrazione è mantenuta dalla comunità open-source. Potrebbero esserci problemi di stabilità o altri problemi. In caso di problemi o bug con questa integrazione, **dovresti cercare aiuto dalla comunità open-source piuttosto che contattare il servizio clienti Xiaomi**.\r\n3. È necessaria una certa abilità tecnica per mantenere il tuo ambiente operativo locale. L'integrazione non è user-friendly per i principianti.\r\n4. Si prega di leggere il file README prima di iniziare.\n\n5. Per garantire un uso stabile dell'integrazione e prevenire l'abuso dell'interfaccia, **questa integrazione può essere utilizzata solo in Home Assistant. Per i dettagli, consulta il LICENSE**.", + "data": { + "eula": "Sono consapevole dei rischi sopra indicati e sono disposto ad assumermi volontariamente qualsiasi rischio associato all'uso dell'integrazione." + } + }, + "auth_config": { + "title": "Configurazione di base", + "description": "### Regione di Login\r\nSeleziona la regione del tuo account Xiaomi. Puoi trovarla nell'APP Xiaomi Home > Profilo (nel menu in basso) > Impostazioni aggiuntive > Informazioni su Xiaomi Home.\r\n### Lingua\r\nSeleziona la lingua dei nomi dei dispositivi e delle entità. Alcune frasi senza traduzione verranno visualizzate in inglese.\r\n### URL di reindirizzamento OAuth2\r\nL'indirizzo di reindirizzamento dell'autenticazione OAuth2 è **[http://homeassistant.local:8123](http://homeassistant.local:8123)**. Home Assistant deve trovarsi nella stessa rete locale del terminale operativo corrente (ad esempio, il computer personale) e il terminale operativo deve poter accedere alla home page di Home Assistant tramite questo indirizzo. Altrimenti, l'autenticazione del login potrebbe fallire.\r\n### Nota\r\n- Per gli utenti con centinaia o più dispositivi Mi Home, l'aggiunta iniziale dell'integrazione richiederà del tempo. Si prega di essere pazienti.\r\n- Se Home Assistant è in esecuzione in un ambiente Docker, assicurarsi che la modalità di rete Docker sia impostata su host, altrimenti la funzionalità di controllo locale potrebbe non funzionare correttamente.\r\n- La funzionalità di controllo locale dell'integrazione ha alcune dipendenze. Si prega di leggere attentamente il README.", + "data": { + "cloud_server": "Regione di Login", + "integration_language": "Lingua", + "oauth_redirect_url": "URL di reindirizzamento OAuth2", + "network_detect_config": "Configurazione di rete integrata" + } + }, + "network_detect_config": { + "title": "Configurazione di Rete Integrata", + "description": "## Introduzione all'uso\r\n### Indirizzo di Rilevamento della Rete\r\nUtilizzato per verificare se la rete funziona correttamente. Se non impostato, verrà utilizzato l'indirizzo di default del sistema. Se il controllo dell'indirizzo predefinito fallisce, puoi provare a inserire un indirizzo personalizzato.\r\n- Puoi inserire più indirizzi di rilevamento, separati da virgole, come `8.8.8.8,https://www.bing.com`\r\n- Se è un indirizzo IP, il rilevamento verrà eseguito tramite ping. Se è un indirizzo HTTP(s), il rilevamento verrà eseguito tramite richiesta HTTP GET.\r\n- Se desideri ripristinare l'indirizzo di rilevamento predefinito del sistema, inserisci una virgola `,` e fai clic su 'Avanti'.\r\n- **Questa configurazione è globale e le modifiche influenzeranno altre istanze di integrazione. Si prega di modificare con cautela.**\r\n### Controlla le Dipendenze di Rete\r\nControlla una per una le seguenti dipendenze di rete per vedere se sono accessibili. Se gli indirizzi correlati non sono accessibili, causerà problemi di integrazione.\r\n- Indirizzo di Autenticazione OAuth2: `https://account.xiaomi.com/oauth2/authorize`.\r\n- Indirizzo API HTTP di Xiaomi: `https://{http_host}/app/v2/ha/oauth/get_token`.\r\n- Indirizzo API SPEC di Xiaomi: `https://miot-spec.org/miot-spec-v2/template/list/device`.\r\n- Indirizzo del Broker MQTT di Xiaomi: `mqtts://{cloud_server}-ha.mqtt.io.mi.com:8883`.", + "data": { + "network_detect_addr": "Indirizzo di Rilevamento della Rete", + "check_network_deps": "Controlla le Dipendenze di Rete" + } + }, + "oauth_error": { + "title": "Errore di Login", + "description": "Clicca AVANTI per riprovare." + }, + "homes_select": { + "title": "Seleziona Famiglia e Dispositivo", + "description": "## Introduzione\r\n### Importa la Famiglia del Dispositivo\r\nL'integrazione aggiungerà i dispositivi dalla famiglia selezionata.\r\n### Modalità di Sincronizzazione del Nome della Stanza\r\nQuando si sincronizzano i dispositivi dall'app Mi Home a Home Assistant, la denominazione dell'area in Home Assistant seguirà le regole indicate di seguito. Si noti che il processo di sincronizzazione non modificherà le impostazioni di famiglia e stanza nell'app Mi Home.\r\n- Non sincronizzare: Il dispositivo non verrà aggiunto a nessuna area.\r\n- Altre opzioni: L'area a cui viene aggiunto il dispositivo verrà denominata come la famiglia o il nome della stanza nell'app Mi Home.\r\n### Impostazioni Avanzate\r\nMostra le impostazioni avanzate per modificare le opzioni di configurazione professionale dell'integrazione.\r\n\r\n \r\n### {nick_name} Ciao! Seleziona la famiglia a cui desideri aggiungere il dispositivo.", + "data": { + "home_infos": "Importa la Famiglia del Dispositivo", + "area_name_rule": "Modalità di Sincronizzazione del Nome della Stanza", + "advanced_options": "Impostazioni Avanzate" + } + }, + "advanced_options": { + "title": "Impostazioni Avanzate", + "description": "## Introduzione\r\n### A meno che tu non abbia chiaro il significato delle seguenti opzioni, si prega di mantenere le impostazioni predefinite.\r\n### Filtra Dispositivi\r\nSupporta il filtraggio dei dispositivi per nome della stanza e tipo di dispositivo, e supporta anche il filtraggio delle dimensioni del dispositivo.\r\n### Modalità di Controllo\r\n- Automatico: Quando è disponibile un gateway hub centrale Xiaomi nella rete locale, Home Assistant darà priorità all'invio dei comandi di controllo dei dispositivi tramite il gateway hub centrale per ottenere il controllo locale. Se non è presente un gateway hub centrale nella rete locale, tenterà di inviare comandi di controllo tramite il protocollo OT di Xiaomi per ottenere il controllo locale. Solo quando le condizioni di controllo locale sopra indicate non sono soddisfatte, i comandi di controllo del dispositivo verranno inviati tramite il cloud.\r\n- Cloud: Tutti i comandi di controllo vengono inviati tramite il cloud.\r\n### Modalità di Debug delle Azioni\r\nPer i metodi definiti dal dispositivo MIoT-Spec-V2, oltre a generare entità di notifica, verrà generata anche un'entità di casella di input di testo. È possibile utilizzarla per inviare comandi di controllo al dispositivo durante il debug.\r\n### Nascondi Entità Generate Non Standard\r\nNasconde le entità generate da istanze non standard MIoT-Spec-V2 con nomi che iniziano con \"*\".\r\n### Modalità di visualizzazione del sensore binario\r\nVisualizza i sensori binari in Mi Home come entità del sensore di testo o entità del sensore binario。\r\n### Mostra Notifiche di Cambio di Stato del Dispositivo\r\nMostra notifiche dettagliate sui cambiamenti di stato del dispositivo, mostrando solo le notifiche selezionate.", + "data": { + "devices_filter": "Filtra Dispositivi", + "ctrl_mode": "Modalità di Controllo", + "action_debug": "Modalità di Debug delle Azioni", + "hide_non_standard_entities": "Nascondi Entità Generate Non Standard", + "display_binary_mode": "Modalità di visualizzazione del sensore binario", + "display_devices_changed_notify": "Mostra Notifiche di Cambio di Stato del Dispositivo" + } + }, + "devices_filter": { + "title": "Filtra Dispositivi", + "description": "## Istruzioni per l'uso\r\nSupporta il filtraggio dei dispositivi per nome della stanza, tipo di accesso al dispositivo e modello del dispositivo, e supporta anche il filtraggio delle dimensioni del dispositivo. La logica di filtraggio è la seguente:\r\n- Prima, secondo la logica statistica, ottieni l'unione o l'intersezione di tutti gli elementi inclusi, poi ottieni l'intersezione o l'unione degli elementi esclusi e infine sottrai il [risultato riassuntivo incluso] dal [risultato riassuntivo escluso] per ottenere il [risultato del filtro].\r\n- Se non vengono selezionati elementi inclusi, significa che tutti sono inclusi.\r\n### Modalità di Filtro\r\n- Escludi: Rimuovi gli elementi indesiderati.\r\n- Includi: Includi gli elementi desiderati.\r\n### Logica Statistica\r\n- Logica AND: Prendi l'intersezione di tutti gli elementi nella stessa modalità.\r\n- Logica OR: Prendi l'unione di tutti gli elementi nella stessa modalità.\r\n\r\nPuoi anche andare alla pagina [Configurazione > Aggiorna Elenco Dispositivi] dell'elemento di integrazione e controllare [Filtra Dispositivi] per rifiltrare.", + "data": { + "room_filter_mode": "Filtra Stanze della Famiglia", + "room_list": "Stanze della Famiglia", + "type_filter_mode": "Filtra Tipo di Connessione del Dispositivo", + "type_list": "Tipo di Connessione del Dispositivo", + "model_filter_mode": "Filtra Modello del Dispositivo", + "model_list": "Modello del Dispositivo", + "devices_filter_mode": "Filtra Dispositivi", + "device_list": "Elenco Dispositivi", + "statistics_logic": "Logica Statistica" + } + } + }, + "progress": { + "oauth": "### {link_left}Clicca qui per accedere{link_right}\r\n(Verrai reindirizzato automaticamente alla pagina successiva dopo un accesso riuscito)" + }, + "error": { + "eula_not_agree": "Si prega di leggere l'avviso sui rischi.", + "get_token_error": "Impossibile recuperare le informazioni di autorizzazione per il login (token OAuth).", + "get_homeinfo_error": "Impossibile recuperare le informazioni della casa.", + "mdns_discovery_error": "Eccezione del servizio di scoperta dei dispositivi locali.", + "get_cert_error": "Impossibile recuperare il certificato del gateway centrale.", + "no_family_selected": "Nessuna casa selezionata.", + "no_devices": "La casa selezionata non ha dispositivi. Si prega di scegliere una casa che contiene dispositivi e continuare.", + "no_filter_devices": "I dispositivi filtrati sono vuoti. Si prega di selezionare criteri di filtro validi e continuare.", + "no_central_device": "[Modalità Gateway Hub Centrale] richiede un gateway hub centrale Xiaomi disponibile nella rete locale in cui esiste Home Assistant. Si prega di verificare se la casa selezionata soddisfa il requisito.", + "invalid_network_addr": "Rilevato indirizzo IP o indirizzo HTTP non valido, si prega di inserire un indirizzo valido.", + "invalid_ip_addr": "Rilevato indirizzo IP non raggiungibile, si prega di inserire un indirizzo IP valido.", + "invalid_http_addr": "Rilevato indirizzo HTTP non raggiungibile, si prega di inserire un indirizzo HTTP valido.", + "invalid_default_addr": "Indirizzo di rilevamento della rete predefinito non raggiungibile, si prega di verificare la configurazione della rete o utilizzare un indirizzo di rilevamento della rete personalizzato.", + "unreachable_oauth2_host": "Impossibile raggiungere l'indirizzo di autenticazione OAuth2, si prega di verificare la configurazione della rete.", + "unreachable_http_host": "Impossibile raggiungere l'indirizzo API HTTP di Xiaomi, si prega di verificare la configurazione della rete.", + "unreachable_spec_host": "Impossibile raggiungere l'indirizzo API SPEC di Xiaomi, si prega di verificare la configurazione della rete.", + "unreachable_mqtt_broker": "Impossibile raggiungere l'indirizzo del broker MQTT di Xiaomi, si prega di verificare la configurazione della rete." + }, + "abort": { + "ha_uuid_get_failed": "Impossibile ottenere l'UUID di Home Assistant.", + "network_connect_error": "Configurazione fallita. La connessione di rete è anomala. Si prega di controllare la configurazione della rete del dispositivo.", + "already_configured": "La configurazione per questo utente è già completata. Si prega di andare alla pagina dell'integrazione e cliccare sul pulsante CONFIGURA per le modifiche.", + "invalid_auth_info": "Le informazioni di autenticazione sono scadute. Si prega di andare alla pagina dell'integrazione e cliccare sul pulsante CONFIGURA per ri-autenticarsi.", + "config_flow_error": "Errore di configurazione dell'integrazione: {error}." + } + }, + "options": { + "step": { + "auth_config": { + "title": "Configurazione dell'Autenticazione", + "description": "Le informazioni di autenticazione locale sono scadute. Si prega di riavviare il processo di autenticazione.\r\n### Regione di Login Corrente: {cloud_server}\r\n### URL di reindirizzamento OAuth2\r\nL'indirizzo di reindirizzamento dell'autenticazione OAuth2 è **[http://homeassistant.local:8123](http://homeassistant.local:8123)**. Home Assistant deve trovarsi nella stessa rete locale del terminale operativo corrente (ad esempio, il computer personale) e il terminale operativo deve poter accedere alla home page di Home Assistant tramite questo indirizzo. Altrimenti, l'autenticazione del login potrebbe fallire.", + "data": { + "oauth_redirect_url": "URL di reindirizzamento OAuth2" + } + }, + "oauth_error": { + "title": "Si è verificato un errore durante il login.", + "description": "Clicca AVANTI per riprovare." + }, + "config_options": { + "title": "Opzioni di Configurazione", + "description": "### Ciao, {nick_name}\r\n\r\nID Xiaomi: {uid}\r\nRegione di Login Corrente: {cloud_server}\r\n\r\nSeleziona le opzioni che desideri configurare, poi clicca AVANTI.", + "data": { + "integration_language": "Lingua dell'Integrazione", + "update_user_info": "Aggiorna le informazioni dell'utente", + "update_devices": "Aggiorna l'elenco dei dispositivi", + "action_debug": "Modalità debug per azione", + "hide_non_standard_entities": "Nascondi entità create non standard", + "display_binary_mode": "Modalità di visualizzazione del sensore binario", + "display_devices_changed_notify": "Mostra notifiche di cambio stato del dispositivo", + "update_trans_rules": "Aggiorna le regole di conversione delle entità", + "update_lan_ctrl_config": "Aggiorna configurazione del controllo LAN", + "network_detect_config": "Configurazione di Rete Integrata" + } + }, + "update_user_info": { + "title": "Aggiorna il Nickname dell'Utente", + "description": "Ciao {nick_name}, puoi modificare il tuo nickname personalizzato qui sotto.", + "data": { + "nick_name": "Nickname" + } + }, + "homes_select": { + "title": "Seleziona Nuovamente Casa e Dispositivi", + "description": "## Istruzioni per l'uso\r\n### Importa dispositivi da casa\r\nL'integrazione aggiungerà dispositivi dalle case selezionate.\r\n### Filtra Dispositivi\r\nSupporta il filtraggio dei dispositivi per nome della stanza, tipo di accesso al dispositivo e modello del dispositivo, e supporta anche il filtraggio delle dimensioni del dispositivo. **{local_count}** dispositivi sono stati filtrati.\r\n### Modalità di Controllo\r\n- Automatico: Quando è disponibile un gateway hub centrale Xiaomi nella rete locale, Home Assistant darà priorità all'invio dei comandi di controllo dei dispositivi tramite il gateway hub centrale per ottenere il controllo locale. Se non è presente un gateway hub centrale nella rete locale, tenterà di inviare comandi di controllo tramite la funzione di controllo LAN di Xiaomi. Solo quando le condizioni di controllo locale sopra indicate non sono soddisfatte, i comandi di controllo del dispositivo verranno inviati tramite il cloud.\r\n- Cloud: Tutti i comandi di controllo vengono inviati tramite il cloud.", + "data": { + "home_infos": "Importa dispositivi da casa", + "devices_filter": "Filtra dispositivi", + "ctrl_mode": "Modalità di controllo" + } + }, + "devices_filter": { + "title": "Filtra Dispositivi", + "description": "## Istruzioni per l'uso\r\nSupporta il filtraggio dei dispositivi per nome della stanza, tipo di accesso al dispositivo e modello del dispositivo, e supporta anche il filtraggio delle dimensioni del dispositivo. La logica di filtraggio è la seguente:\r\n- Prima, secondo la logica statistica, ottieni l'unione o l'intersezione di tutti gli elementi inclusi, poi ottieni l'intersezione o l'unione degli elementi esclusi e infine sottrai il [risultato riassuntivo incluso] dal [risultato riassuntivo escluso] per ottenere il [risultato del filtro].\r\n- Se non vengono selezionati elementi inclusi, significa che tutti sono inclusi.\r\n### Modalità di Filtro\r\n- Escludi: Rimuovi gli elementi indesiderati.\r\n- Includi: Includi gli elementi desiderati.\r\n### Logica Statistica\r\n- Logica AND: Prendi l'intersezione di tutti gli elementi nella stessa modalità.\r\n- Logica OR: Prendi l'unione di tutti gli elementi nella stessa modalità.\r\n\r\nPuoi anche andare alla pagina [Configurazione > Aggiorna Elenco Dispositivi] dell'elemento di integrazione e controllare [Filtra Dispositivi] per rifiltrare.", + "data": { + "room_filter_mode": "Filtra Stanze della Famiglia", + "room_list": "Stanze della Famiglia", + "type_filter_mode": "Filtra Tipo di Connessione del Dispositivo", + "type_list": "Tipo di Connessione del Dispositivo", + "model_filter_mode": "Filtra Modello del Dispositivo", + "model_list": "Modello del Dispositivo", + "devices_filter_mode": "Filtra Dispositivi", + "device_list": "Elenco Dispositivi", + "statistics_logic": "Logica Statistica" + } + }, + "update_trans_rules": { + "title": "Aggiorna le Regole di Trasformazione delle Entità", + "description": "## Istruzioni per l'uso\r\n- Aggiorna le informazioni delle entità dei dispositivi nell'istanza dell'integrazione corrente, incluse la configurazione multilingue MIoT-Spec-V2, la traduzione booleana e il filtro dei modelli.\r\n- **Avviso**: Questa è una configurazione globale e aggiornerà la cache locale. Influenzando tutte le istanze di integrazione.\r\n- Questa operazione richiederà del tempo, si prega di essere pazienti. Seleziona \"Conferma Aggiornamento\" e clicca \"Avanti\" per iniziare l'aggiornamento di **{urn_count}** regole, altrimenti salta l'aggiornamento.", + "data": { + "confirm": "Conferma l'aggiornamento" + } + }, + "update_lan_ctrl_config": { + "title": "Aggiorna configurazione del controllo LAN", + "description": "## Istruzioni per l'uso\r\nAggiorna le configurazioni per la funzione di controllo LAN di Xiaomi. Quando il cloud e il gateway centrale non possono controllare i dispositivi, l'integrazione tenterà di controllare i dispositivi tramite la LAN. Se nessuna scheda di rete è selezionata, la funzione di controllo LAN non avrà effetto.\r\n- Solo i dispositivi compatibili con MIoT-Spec-V2 nella LAN sono supportati. Alcuni dispositivi prodotti prima del 2020 potrebbero non supportare il controllo LAN o l'abbonamento LAN.\r\n- Seleziona la/le scheda/e di rete nella stessa rete dei dispositivi da controllare. È possibile selezionare più schede di rete. Se Home Assistant ha due o più connessioni alla rete locale a causa della selezione multipla delle schede di rete, si consiglia di selezionare quella con la migliore connessione di rete, altrimenti potrebbe avere un effetto negativo sui dispositivi.\r\n- Se ci sono dispositivi terminali (altoparlanti Xiaomi con schermo, telefono cellulare, ecc.) nella LAN che supportano il controllo locale, abilitare l'abbonamento LAN potrebbe causare anomalie nell'automazione locale e nei dispositivi.\r\n- **Avviso**: Questa è una configurazione globale. Influenzando tutte le istanze di integrazione. Usala con cautela.\r\n{notice_net_dup}", + "data": { + "net_interfaces": "Si prega di selezionare la scheda di rete da utilizzare", + "enable_subscribe": "Abilita Sottoscrizione LAN" + } + }, + "network_detect_config": { + "title": "Configurazione di Rete Integrata", + "description": "## Introduzione all'uso\r\n### Indirizzo di Rilevamento della Rete\r\nUtilizzato per verificare se la rete funziona correttamente. Se non impostato, verrà utilizzato l'indirizzo di default del sistema. Se il controllo dell'indirizzo predefinito fallisce, puoi provare a inserire un indirizzo personalizzato.\r\n- Puoi inserire più indirizzi di rilevamento, separati da virgole, come `8.8.8.8,https://www.bing.com`\r\n- Se è un indirizzo IP, il rilevamento verrà eseguito tramite ping. Se è un indirizzo HTTP(s), il rilevamento verrà eseguito tramite richiesta HTTP GET.\r\n- Se desideri ripristinare l'indirizzo di rilevamento predefinito del sistema, inserisci una virgola `,` e fai clic su 'Avanti'.\r\n- **Questa configurazione è globale e le modifiche influenzeranno altre istanze di integrazione. Si prega di modificare con cautela.**\r\n### Controlla le Dipendenze di Rete\r\nControlla una per una le seguenti dipendenze di rete per vedere se sono accessibili. Se gli indirizzi correlati non sono accessibili, causerà problemi di integrazione.\r\n- Indirizzo di Autenticazione OAuth2: `https://account.xiaomi.com/oauth2/authorize`.\r\n- Indirizzo API HTTP di Xiaomi: `https://{http_host}/app/v2/ha/oauth/get_token`.\r\n- Indirizzo API SPEC di Xiaomi: `https://miot-spec.org/miot-spec-v2/template/list/device`.\r\n- Indirizzo del Broker MQTT di Xiaomi: `mqtts://{cloud_server}-ha.mqtt.io.mi.com:8883`.", + "data": { + "network_detect_addr": "Indirizzo di Rilevamento della Rete", + "check_network_deps": "Controlla le Dipendenze di Rete" + } + }, + "config_confirm": { + "title": "Conferma Configurazione", + "description": "Ciao **{nick_name}**, si prega di confermare le informazioni di configurazione più recenti e poi fare clic su INVIA.\r\nL'integrazione verrà ricaricata utilizzando la configurazione aggiornata.\r\n\r\nLingua dell'Integrazione: \t{lang_new}\r\nSoprannome: \t{nick_name_new}\r\nModalità di debug per azione: \t{action_debug}\r\nNascondi entità create non standard: \t{hide_non_standard_entities}\r\nMostra notifiche di cambio stato del dispositivo:\t{display_devices_changed_notify}\r\nCambiamenti del Dispositivo: \tAggiungi **{devices_add}** dispositivi, Rimuovi **{devices_remove}** dispositivi\r\nCambiamenti delle regole di trasformazione: \tCi sono un totale di **{trans_rules_count}** regole, e aggiornate **{trans_rules_count_success}** regole", + "data": { + "confirm": "Conferma la modifica" + } + } + }, + "progress": { + "oauth": "### {link_left}Clicca qui per riaccedere{link_right}" + }, + "error": { + "not_auth": "Non autenticato. Si prega di fare clic sul link di autenticazione per autenticare l'identità dell'utente.", + "get_token_error": "Impossibile recuperare le informazioni di autorizzazione all'accesso (token OAuth).", + "get_homeinfo_error": "Impossibile recuperare le informazioni sulla casa.", + "get_cert_error": "Impossibile recuperare il certificato del gateway hub centrale.", + "no_devices": "Non ci sono dispositivi nella casa selezionata. Si prega di selezionare una casa con dispositivi e continuare.", + "no_filter_devices": "I dispositivi filtrati sono vuoti. Si prega di selezionare criteri di filtro validi e continuare.", + "no_family_selected": "Nessuna casa selezionata.", + "no_central_device": "[Modalità Gateway Hub Centrale] richiede un gateway hub centrale Xiaomi disponibile nella rete locale in cui esiste Home Assistant. Si prega di verificare se la casa selezionata soddisfa il requisito.", + "mdns_discovery_error": "Eccezione nel servizio di rilevamento dei dispositivi locali.", + "update_config_error": "Impossibile aggiornare le informazioni di configurazione.", + "not_confirm": "Le modifiche non sono confermate. Si prega di confermare la modifica prima di inviare.", + "invalid_network_addr": "Rilevato indirizzo IP o indirizzo HTTP non valido, si prega di inserire un indirizzo valido.", + "invalid_ip_addr": "Rilevato indirizzo IP non raggiungibile, si prega di inserire un indirizzo IP valido.", + "invalid_http_addr": "Rilevato indirizzo HTTP non raggiungibile, si prega di inserire un indirizzo HTTP valido.", + "invalid_default_addr": "Indirizzo di rilevamento della rete predefinito non raggiungibile, si prega di verificare la configurazione della rete o utilizzare un indirizzo di rilevamento della rete personalizzato.", + "unreachable_oauth2_host": "Impossibile raggiungere l'indirizzo di autenticazione OAuth2, si prega di verificare la configurazione della rete.", + "unreachable_http_host": "Impossibile raggiungere l'indirizzo API HTTP di Xiaomi, si prega di verificare la configurazione della rete.", + "unreachable_spec_host": "Impossibile raggiungere l'indirizzo API SPEC di Xiaomi, si prega di verificare la configurazione della rete.", + "unreachable_mqtt_broker": "Impossibile raggiungere l'indirizzo del broker MQTT di Xiaomi, si prega di verificare la configurazione della rete." + }, + "abort": { + "network_connect_error": "Configurazione fallita. La connessione di rete è anomala. Si prega di controllare la configurazione della rete del dispositivo.", + "options_flow_error": "Errore di riconfigurazione dell'integrazione: {error}", + "re_add": "Si prega di riaggiungere l'integrazione. Messaggio di errore: {error}", + "storage_error": "Eccezione del modulo di archiviazione dell'integrazione. Si prega di riprovare o riaggiungere l'integrazione: {error}", + "inconsistent_account": "Le informazioni dell'account sono incoerenti." + } + } +} \ No newline at end of file diff --git a/custom_components/xiaomi_home/translations/ja.json b/custom_components/xiaomi_home/translations/ja.json index d63201b..2dda890 100644 --- a/custom_components/xiaomi_home/translations/ja.json +++ b/custom_components/xiaomi_home/translations/ja.json @@ -42,12 +42,13 @@ }, "advanced_options": { "title": "高度な設定オプション", - "description": "## 紹介\r\n### 以下のオプションの意味がよくわからない場合は、デフォルトのままにしてください。\r\n### デバイスのフィルタリング\r\n部屋名とデバイスタイプでデバイスをフィルタリングすることができます。デバイスの次元でフィルタリングすることもできます。\r\n### コントロールモード\r\n- 自動:ローカルネットワーク内に利用可能なXiaomi中央ゲートウェイがある場合、Home Assistantはデバイス制御命令を送信するために優先的に中央ゲートウェイを使用します。ローカルネットワークに中央ゲートウェイがない場合、Xiaomi OTプロトコルを使用してデバイス制御命令を送信し、ローカル制御機能を実現します。上記のローカル制御条件が満たされない場合のみ、デバイス制御命令はクラウドを介して送信されます。\r\n- クラウド:制御命令はクラウドを介してのみ送信されます。\r\n### Actionデバッグモード\r\nデバイスが定義するMIoT-Spec-V2のメソッドに対して、通知エンティティを生成するだけでなく、デバイスに制御命令を送信するためのテキスト入力ボックスエンティティも生成されます。デバッグ時にデバイスに制御命令を送信するために使用できます。\r\n### 非標準生成エンティティを隠す\r\n「*」で始まる名前の非標準MIoT-Spec-V2インスタンスによって生成されたエンティティを非表示にします。\r\n### デバイスの状態変化通知を表示\r\nデバイスの状態変化通知を詳細に表示し、選択された通知のみを表示します。", + "description": "## 紹介\r\n### 以下のオプションの意味がよくわからない場合は、デフォルトのままにしてください。\r\n### デバイスのフィルタリング\r\n部屋名とデバイスタイプでデバイスをフィルタリングすることができます。デバイスの次元でフィルタリングすることもできます。\r\n### コントロールモード\r\n- 自動:ローカルネットワーク内に利用可能なXiaomi中央ゲートウェイがある場合、Home Assistantはデバイス制御命令を送信するために優先的に中央ゲートウェイを使用します。ローカルネットワークに中央ゲートウェイがない場合、Xiaomi OTプロトコルを使用してデバイス制御命令を送信し、ローカル制御機能を実現します。上記のローカル制御条件が満たされない場合のみ、デバイス制御命令はクラウドを介して送信されます。\r\n- クラウド:制御命令はクラウドを介してのみ送信されます。\r\n### Actionデバッグモード\r\nデバイスが定義するMIoT-Spec-V2のメソッドに対して、通知エンティティを生成するだけでなく、デバイスに制御命令を送信するためのテキスト入力ボックスエンティティも生成されます。デバッグ時にデバイスに制御命令を送信するために使用できます。\r\n### 非標準生成エンティティを隠す\r\n「*」で始まる名前の非標準MIoT-Spec-V2インスタンスによって生成されたエンティティを非表示にします。\r\n### バイナリセンサー表示モード\r\nXiaomi Homeのバイナリセンサーをテキストセンサーエンティティまたはバイナリセンサーエンティティとして表示します。\r\n### デバイスの状態変化通知を表示\r\nデバイスの状態変化通知を詳細に表示し、選択された通知のみを表示します。", "data": { "devices_filter": "デバイスをフィルタリング", "ctrl_mode": "コントロールモード", "action_debug": "Actionデバッグモード", "hide_non_standard_entities": "非標準生成エンティティを隠す", + "display_binary_mode": "バイナリセンサー表示モード", "display_devices_changed_notify": "デバイスの状態変化通知を表示" } }, @@ -90,6 +91,7 @@ "unreachable_mqtt_broker": "Xiaomi MQTT ブローカーアドレスにアクセスできません。ネットワーク設定を確認してください。" }, "abort": { + "ha_uuid_get_failed": "Home Assistant インスタンスIDを取得できませんでした。", "network_connect_error": "設定に失敗しました。ネットワーク接続に異常があります。デバイスのネットワーク設定を確認してください。", "already_configured": "このユーザーはすでに設定が完了しています。統合ページにアクセスして、「設定」ボタンをクリックして設定を変更してください。", "invalid_auth_info": "認証情報が期限切れになりました。統合ページにアクセスして、「設定」ボタンをクリックして再度認証してください。", @@ -118,6 +120,7 @@ "update_devices": "デバイスリストを更新する", "action_debug": "Action デバッグモード", "hide_non_standard_entities": "非標準生成エンティティを非表示にする", + "display_binary_mode": "バイナリセンサー表示モード", "display_devices_changed_notify": "デバイスの状態変化通知を表示", "update_trans_rules": "エンティティ変換ルールを更新する", "update_lan_ctrl_config": "LAN制御構成を更新する", diff --git a/custom_components/xiaomi_home/translations/nl.json b/custom_components/xiaomi_home/translations/nl.json index 6c4c436..c9d8e40 100644 --- a/custom_components/xiaomi_home/translations/nl.json +++ b/custom_components/xiaomi_home/translations/nl.json @@ -42,12 +42,13 @@ }, "advanced_options": { "title": "Geavanceerde Instellingen", - "description": "## Inleiding\r\n### Tenzij u zeer goed op de hoogte bent van de betekenis van de volgende opties, houdt u de standaardinstellingen.\r\n### Apparaten filteren\r\nOndersteunt het filteren van apparaten op basis van kamer- en apparaattypen, en ondersteunt ook apparaatdimensiefiltering.\r\n### Besturingsmodus\r\n- Automatisch: Wanneer er een beschikbare Xiaomi centrale hubgateway in het lokale netwerk is, zal Home Assistant eerst apparaatbesturingsinstructies via de centrale hubgateway verzenden om lokale controlefunctionaliteit te bereiken. Als er geen centrale hub in het lokale netwerk is, zal het proberen om besturingsinstructies via het Xiaomi OT-protocol te verzenden om lokale controlefunctionaliteit te bereiken. Alleen als de bovenstaande lokale controlevoorwaarden niet worden vervuld, worden apparaatbesturingsinstructies via de cloud verzonden.\r\n- Cloud: Besturingsinstructies worden alleen via de cloud verzonden.\r\n### Actie-debugmodus\r\nVoor methoden die zijn gedefinieerd in de MIoT-Spec-V2 van het apparaat, wordt naast het genereren van een meldingsentiteit ook een tekstinvoerveldentiteit gegenereerd. U kunt dit gebruiken om besturingsinstructies naar het apparaat te sturen tijdens het debuggen.\r\n### Niet-standaard entiteiten verbergen\r\nVerberg entiteiten die zijn gegenereerd door niet-standaard MIoT-Spec-V2-instanties die beginnen met \"*\".\r\n### Apparaatstatuswijzigingen weergeven\r\nGedetailleerde apparaatstatuswijzigingen weergeven, alleen de geselecteerde meldingen weergeven.", + "description": "## Inleiding\r\n### Tenzij u zeer goed op de hoogte bent van de betekenis van de volgende opties, houdt u de standaardinstellingen.\r\n### Apparaten filteren\r\nOndersteunt het filteren van apparaten op basis van kamer- en apparaattypen, en ondersteunt ook apparaatdimensiefiltering.\r\n### Besturingsmodus\r\n- Automatisch: Wanneer er een beschikbare Xiaomi centrale hubgateway in het lokale netwerk is, zal Home Assistant eerst apparaatbesturingsinstructies via de centrale hubgateway verzenden om lokale controlefunctionaliteit te bereiken. Als er geen centrale hub in het lokale netwerk is, zal het proberen om besturingsinstructies via het Xiaomi OT-protocol te verzenden om lokale controlefunctionaliteit te bereiken. Alleen als de bovenstaande lokale controlevoorwaarden niet worden vervuld, worden apparaatbesturingsinstructies via de cloud verzonden.\r\n- Cloud: Besturingsinstructies worden alleen via de cloud verzonden.\r\n### Actie-debugmodus\r\nVoor methoden die zijn gedefinieerd in de MIoT-Spec-V2 van het apparaat, wordt naast het genereren van een meldingsentiteit ook een tekstinvoerveldentiteit gegenereerd. U kunt dit gebruiken om besturingsinstructies naar het apparaat te sturen tijdens het debuggen.\r\n### Niet-standaard entiteiten verbergen\r\nVerberg entiteiten die zijn gegenereerd door niet-standaard MIoT-Spec-V2-instanties die beginnen met \"*\".\r\n### Binaire sensorweergavemodus\r\nToont binaire sensoren in Xiaomi Home als tekstsensor-entiteit of binairesensor-entiteit。\r\n### Apparaatstatuswijzigingen weergeven\r\nGedetailleerde apparaatstatuswijzigingen weergeven, alleen de geselecteerde meldingen weergeven.", "data": { "devices_filter": "Apparaten filteren", "ctrl_mode": "Besturingsmodus", "action_debug": "Actie-debugmodus", "hide_non_standard_entities": "Niet-standaard entiteiten verbergen", + "display_binary_mode": "Binaire sensorweergavemodus", "display_devices_changed_notify": "Apparaatstatuswijzigingen weergeven" } }, @@ -90,6 +91,7 @@ "unreachable_mqtt_broker": "Kan Xiaomi MQTT Broker-adres niet bereiken, controleer de netwerkconfiguratie." }, "abort": { + "ha_uuid_get_failed": "Mislukt bij het ophalen van Home Assistant UUID.", "network_connect_error": "Configuratie mislukt. De netwerkverbinding is abnormaal. Controleer de netwerkinstellingen van de apparatuur.", "already_configured": "Configuratie voor deze gebruiker is al voltooid. Ga naar de integratiepagina en klik op de CONFIGUREER-knop om wijzigingen aan te brengen.", "invalid_auth_info": "Authenticatie-informatie is verlopen. Ga naar de integratiepagina en klik op de CONFIGUREER-knop om opnieuw te authentiseren.", @@ -118,6 +120,7 @@ "update_devices": "Werk apparatenlijst bij", "action_debug": "Debugmodus voor actie", "hide_non_standard_entities": "Verberg niet-standaard gemaakte entiteiten", + "display_binary_mode": "Binaire sensorweergavemodus", "display_devices_changed_notify": "Apparaatstatuswijzigingen weergeven", "update_trans_rules": "Werk entiteitsconversieregels bij", "update_lan_ctrl_config": "Werk LAN controleconfiguratie bij", diff --git a/custom_components/xiaomi_home/translations/pt-BR.json b/custom_components/xiaomi_home/translations/pt-BR.json index 0c453b5..12286d5 100644 --- a/custom_components/xiaomi_home/translations/pt-BR.json +++ b/custom_components/xiaomi_home/translations/pt-BR.json @@ -42,12 +42,13 @@ }, "advanced_options": { "title": "Configurações Avançadas", - "description": "## Introdução\r\n### A menos que você entenda claramente o significado das opções a seguir, mantenha as configurações padrão.\r\n### Filtrar Dispositivos\r\nSuporte para filtrar dispositivos por nome da sala e tipo de dispositivo, bem como filtragem por família.\r\n### Modo de Controle\r\n- Automático: Quando um gateway central Xiaomi disponível na rede local está disponível, o Home Assistant enviará comandos de controle de dispositivo através do gateway central para realizar a função de controle local. Quando não há gateway central na rede local, ele tentará enviar comandos de controle através do protocolo OT da Xiaomi para realizar a função de controle local. Somente quando as condições de controle local acima não forem atendidas, os comandos de controle do dispositivo serão enviados através da nuvem.\r\n- Nuvem: Os comandos de controle são enviados apenas através da nuvem.\r\n### Modo de Depuração de Ações\r\nPara métodos definidos pelo MIoT-Spec-V2 do dispositivo, além de gerar uma entidade de notificação, também será gerada uma entidade de caixa de texto para você enviar comandos de controle ao dispositivo durante a depuração.\r\n### Ocultar Entidades Geradas Não Padrão\r\nOcultar entidades geradas por instâncias MIoT-Spec-V2 não padrão que começam com \"*\".\r\n### Exibir notificações de mudança de status do dispositivo\r\nExibir notificações detalhadas de mudança de status do dispositivo, mostrando apenas as notificações selecionadas.", + "description": "## Introdução\r\n### A menos que você entenda claramente o significado das opções a seguir, mantenha as configurações padrão.\r\n### Filtrar Dispositivos\r\nSuporte para filtrar dispositivos por nome da sala e tipo de dispositivo, bem como filtragem por família.\r\n### Modo de Controle\r\n- Automático: Quando um gateway central Xiaomi disponível na rede local está disponível, o Home Assistant enviará comandos de controle de dispositivo através do gateway central para realizar a função de controle local. Quando não há gateway central na rede local, ele tentará enviar comandos de controle através do protocolo OT da Xiaomi para realizar a função de controle local. Somente quando as condições de controle local acima não forem atendidas, os comandos de controle do dispositivo serão enviados através da nuvem.\r\n- Nuvem: Os comandos de controle são enviados apenas através da nuvem.\r\n### Modo de Depuração de Ações\r\nPara métodos definidos pelo MIoT-Spec-V2 do dispositivo, além de gerar uma entidade de notificação, também será gerada uma entidade de caixa de texto para você enviar comandos de controle ao dispositivo durante a depuração.\r\n### Ocultar Entidades Geradas Não Padrão\r\nOcultar entidades geradas por instâncias MIoT-Spec-V2 não padrão que começam com \"*\".\r\n### Modo de exibição do sensor binário\r\nExibe sensores binários no Xiaomi Home como entidade de sensor de texto ou entidade de sensor binário。\r\n### Exibir notificações de mudança de status do dispositivo\r\nExibir notificações detalhadas de mudança de status do dispositivo, mostrando apenas as notificações selecionadas.", "data": { "devices_filter": "Filtrar Dispositivos", "ctrl_mode": "Modo de Controle", "action_debug": "Modo de Depuração de Ações", "hide_non_standard_entities": "Ocultar Entidades Geradas Não Padrão", + "display_binary_mode": "Modo de exibição do sensor binário", "display_devices_changed_notify": "Exibir notificações de mudança de status do dispositivo" } }, @@ -90,6 +91,7 @@ "unreachable_mqtt_broker": "Não é possível acessar o endereço do Broker MQTT da Xiaomi, verifique a configuração da rede." }, "abort": { + "ha_uuid_get_failed": "Falha ao obter o UUID do Home Assistant.", "network_connect_error": "Configuração falhou. A conexão de rede está anormal. Verifique a configuração de rede do equipamento.", "already_configured": "A configuração para este usuário já foi concluída. Vá para a página de integrações e clique no botão CONFIGURAR para modificações.", "invalid_auth_info": "As informações de autenticação expiraram. Vá para a página de integrações e clique em CONFIGURAR para reautenticar.", @@ -118,6 +120,7 @@ "update_devices": "Atualizar lista de dispositivos", "action_debug": "Modo de depuração para ação", "hide_non_standard_entities": "Ocultar entidades não padrão criadas", + "display_binary_mode": "Modo de exibição do sensor binário", "display_devices_changed_notify": "Exibir notificações de mudança de status do dispositivo", "update_trans_rules": "Atualizar regras de conversão de entidades", "update_lan_ctrl_config": "Atualizar configuração de controle LAN", diff --git a/custom_components/xiaomi_home/translations/pt.json b/custom_components/xiaomi_home/translations/pt.json index 787ddcd..2287585 100644 --- a/custom_components/xiaomi_home/translations/pt.json +++ b/custom_components/xiaomi_home/translations/pt.json @@ -42,12 +42,13 @@ }, "advanced_options": { "title": "Opções Avançadas", - "description": "## Introdução\r\n### A menos que você entenda claramente o significado das opções abaixo, mantenha as configurações padrão.\r\n### Filtrar Dispositivos\r\nSuporte para filtrar dispositivos por nome da sala e tipo de dispositivo, bem como filtragem por família.\r\n### Modo de Controle\r\n- Automático: Quando um gateway central Xiaomi está disponível na rede local, o Home Assistant enviará comandos de controlo de dispositivos através do gateway central para realizar o controlo local. Quando não há gateway central na rede local, tentará enviar comandos de controlo através do protocolo Xiaomi OT para realizar o controlo local. Apenas quando as condições de controlo local acima não são atendidas, os comandos de controlo de dispositivos serão enviados através da nuvem.\r\n- Nuvem: Os comandos de controlo são enviados apenas através da nuvem.\r\n### Modo de Depuração de Ações\r\nPara métodos definidos pelo MIoT-Spec-V2, além de gerar uma entidade de notificação, também será gerada uma entidade de caixa de texto para depuração de controlo de dispositivos.\r\n### Ocultar Entidades Geradas Não Padrão\r\nOcultar entidades geradas por instâncias MIoT-Spec-V2 não padrão, cujos nomes começam com \"*\".\r\n### Exibir notificações de mudança de status do dispositivo\r\nExibir notificações detalhadas de mudança de status do dispositivo, mostrando apenas as notificações selecionadas.", + "description": "## Introdução\r\n### A menos que você entenda claramente o significado das opções abaixo, mantenha as configurações padrão.\r\n### Filtrar Dispositivos\r\nSuporte para filtrar dispositivos por nome da sala e tipo de dispositivo, bem como filtragem por família.\r\n### Modo de Controle\r\n- Automático: Quando um gateway central Xiaomi está disponível na rede local, o Home Assistant enviará comandos de controlo de dispositivos através do gateway central para realizar o controlo local. Quando não há gateway central na rede local, tentará enviar comandos de controlo através do protocolo Xiaomi OT para realizar o controlo local. Apenas quando as condições de controlo local acima não são atendidas, os comandos de controlo de dispositivos serão enviados através da nuvem.\r\n- Nuvem: Os comandos de controlo são enviados apenas através da nuvem.\r\n### Modo de Depuração de Ações\r\nPara métodos definidos pelo MIoT-Spec-V2, além de gerar uma entidade de notificação, também será gerada uma entidade de caixa de texto para depuração de controlo de dispositivos.\r\n### Ocultar Entidades Geradas Não Padrão\r\nOcultar entidades geradas por instâncias MIoT-Spec-V2 não padrão, cujos nomes começam com \"*\".\r\n### Modo de exibição do sensor binário\r\nExibe sensores binários no Xiaomi Home como entidade de sensor de texto ou entidade de sensor binário。\r\n### Exibir notificações de mudança de status do dispositivo\r\nExibir notificações detalhadas de mudança de status do dispositivo, mostrando apenas as notificações selecionadas.", "data": { "devices_filter": "Filtrar Dispositivos", "ctrl_mode": "Modo de Controlo", "action_debug": "Modo de Depuração de Ações", "hide_non_standard_entities": "Ocultar Entidades Geradas Não Padrão", + "display_binary_mode": "Modo de exibição do sensor binário", "display_devices_changed_notify": "Exibir notificações de mudança de status do dispositivo" } }, @@ -90,6 +91,7 @@ "unreachable_mqtt_broker": "Não é possível acessar o endereço do Broker MQTT da Xiaomi, verifique a configuração da rede." }, "abort": { + "ha_uuid_get_failed": "Não foi possível obter o UUID do Home Assistant.", "network_connect_error": "A configuração falhou. A ligação de rede é anormal. Verifique a configuração de rede do equipamento.", "already_configured": "A configuração para este utilizador já foi concluída. Vá à página de integrações e clique em CONFIGURAR para efetuar alterações.", "invalid_auth_info": "A informação de autenticação expirou. Vá à página de integrações e clique em CONFIGURAR para reautenticar.", @@ -118,6 +120,7 @@ "update_devices": "Atualizar lista de dispositivos", "action_debug": "Modo de depuração de ação", "hide_non_standard_entities": "Ocultar entidades não padrão", + "display_binary_mode": "Modo de exibição do sensor binário", "display_devices_changed_notify": "Exibir notificações de mudança de status do dispositivo", "update_trans_rules": "Atualizar regras de conversão de entidades", "update_lan_ctrl_config": "Atualizar configuração de controlo LAN", diff --git a/custom_components/xiaomi_home/translations/ru.json b/custom_components/xiaomi_home/translations/ru.json index 7e06055..fba3edc 100644 --- a/custom_components/xiaomi_home/translations/ru.json +++ b/custom_components/xiaomi_home/translations/ru.json @@ -42,12 +42,13 @@ }, "advanced_options": { "title": "Расширенные настройки", - "description": "## Введение\r\n### Если вы не очень хорошо понимаете значение следующих параметров, оставьте их по умолчанию.\r\n### Фильтрация устройств\r\nПоддерживает фильтрацию устройств по названию комнаты и типу устройства, а также фильтрацию по уровню устройства.\r\n### Режим управления\r\n- Автоматически: при наличии доступного центрального шлюза Xiaomi в локальной сети Home Assistant Home Assistant будет отправлять команды управления устройствами через центральный шлюз для локального управления. Если центрального шлюза нет в локальной сети, Home Assistant попытается отправить команды управления устройствами через протокол OT Xiaomi для локального управления. Только если вышеуказанные условия локального управления не выполняются, команды управления устройствами будут отправляться через облако.\r\n- Облако: команды управления отправляются только через облако.\r\n### Режим отладки действий\r\nДля методов, определенных устройством MIoT-Spec-V2, помимо создания уведомления, будет создана сущность текстового поля, которую вы можете использовать для отправки команд управления устройством во время отладки.\r\n### Скрыть нестандартные сущности\r\nСкрыть сущности, созданные нестандартными экземплярами MIoT-Spec-V2, имена которых начинаются с «*».\r\n### Отображать уведомления о изменении состояния устройства\r\nОтображать подробные уведомления о изменении состояния устройства, показывая только выбранные уведомления.", + "description": "## Введение\r\n### Если вы не очень хорошо понимаете значение следующих параметров, оставьте их по умолчанию.\r\n### Фильтрация устройств\r\nПоддерживает фильтрацию устройств по названию комнаты и типу устройства, а также фильтрацию по уровню устройства.\r\n### Режим управления\r\n- Автоматически: при наличии доступного центрального шлюза Xiaomi в локальной сети Home Assistant Home Assistant будет отправлять команды управления устройствами через центральный шлюз для локального управления. Если центрального шлюза нет в локальной сети, Home Assistant попытается отправить команды управления устройствами через протокол OT Xiaomi для локального управления. Только если вышеуказанные условия локального управления не выполняются, команды управления устройствами будут отправляться через облако.\r\n- Облако: команды управления отправляются только через облако.\r\n### Режим отладки действий\r\nДля методов, определенных устройством MIoT-Spec-V2, помимо создания уведомления, будет создана сущность текстового поля, которую вы можете использовать для отправки команд управления устройством во время отладки.\r\n### Скрыть нестандартные сущности\r\nСкрыть сущности, созданные нестандартными экземплярами MIoT-Spec-V2, имена которых начинаются с «*».\r\n### Режим отображения бинарного датчика\r\nОтображает бинарные датчики в Xiaomi Home как сущность текстового датчика или сущность бинарного датчика。\r\n### Отображать уведомления о изменении состояния устройства\r\nОтображать подробные уведомления о изменении состояния устройства, показывая только выбранные уведомления.", "data": { "devices_filter": "Фильтрация устройств", "ctrl_mode": "Режим управления", "action_debug": "Режим отладки действий", "hide_non_standard_entities": "Скрыть нестандартные сущности", + "display_binary_mode": "Режим отображения бинарного датчика", "display_devices_changed_notify": "Отображать уведомления о изменении состояния устройства" } }, @@ -90,6 +91,7 @@ "unreachable_mqtt_broker": "Не удается подключиться к адресу MQTT брокера Xiaomi, проверьте настройки сети." }, "abort": { + "ha_uuid_get_failed": "Не удалось получить UUID Home Assistant.", "network_connect_error": "Ошибка настройки. Сетевое подключение недоступно. Проверьте настройки сети устройства.", "already_configured": "Этот пользователь уже настроен. Перейдите на страницу интеграции и нажмите кнопку «Настроить», чтобы изменить настройки.", "invalid_auth_info": "Информация об авторизации истекла. Перейдите на страницу интеграции и нажмите кнопку «Настроить», чтобы переавторизоваться.", @@ -118,6 +120,7 @@ "update_devices": "Обновить список устройств", "action_debug": "Режим отладки Action", "hide_non_standard_entities": "Скрыть нестандартные сущности", + "display_binary_mode": "Режим отображения бинарного датчика", "display_devices_changed_notify": "Отображать уведомления о изменении состояния устройства", "update_trans_rules": "Обновить правила преобразования сущностей", "update_lan_ctrl_config": "Обновить конфигурацию управления LAN", diff --git a/custom_components/xiaomi_home/translations/zh-Hans.json b/custom_components/xiaomi_home/translations/zh-Hans.json index 1b6a138..67c134c 100644 --- a/custom_components/xiaomi_home/translations/zh-Hans.json +++ b/custom_components/xiaomi_home/translations/zh-Hans.json @@ -42,12 +42,13 @@ }, "advanced_options": { "title": "高级设置选项", - "description": "## 使用介绍\r\n### 除非您非常清楚下列选项的含义,否则请保持默认。\r\n### 筛选设备\r\n支持按照家庭房间名称、设备接入类型、设备型号筛选设备,同时也支持设备维度筛选。\r\n### 控制模式\r\n- 自动:本地局域网内存在可用的小米中枢网关时, Home Assistant 会优先通过中枢网关发送设备控制指令,以实现本地化控制功能。本地局域网不存在中枢时,会尝试通过小米OT协议发送控制指令,以实现本地化控制功能。只有当上述本地化控制条件不满足时,设备控制指令才会通过云端发送。\r\n- 云端:控制指令仅通过云端发送。\r\n### Action 调试模式\r\n对于设备 MIoT-Spec-V2 定义的方法,在生成通知实体之外,还会生成一个文本输入框实体,您可以在调试时用它向设备发送控制指令。\r\n### 隐藏非标准生成实体\r\n隐藏名称以“*”开头的非标准 MIoT-Spec-V2 实例生成的实体。\r\n### 显示设备状态变化通知\r\n细化显示设备状态变化通知,只显示勾选的通知消息。", + "description": "## 使用介绍\r\n### 除非您非常清楚下列选项的含义,否则请保持默认。\r\n### 筛选设备\r\n支持按照家庭房间名称、设备接入类型、设备型号筛选设备,同时也支持设备维度筛选。\r\n### 控制模式\r\n- 自动:本地局域网内存在可用的小米中枢网关时, Home Assistant 会优先通过中枢网关发送设备控制指令,以实现本地化控制功能。本地局域网不存在中枢时,会尝试通过小米OT协议发送控制指令,以实现本地化控制功能。只有当上述本地化控制条件不满足时,设备控制指令才会通过云端发送。\r\n- 云端:控制指令仅通过云端发送。\r\n### Action 调试模式\r\n对于设备 MIoT-Spec-V2 定义的方法,在生成通知实体之外,还会生成一个文本输入框实体,您可以在调试时用它向设备发送控制指令。\r\n### 隐藏非标准生成实体\r\n隐藏名称以“*”开头的非标准 MIoT-Spec-V2 实例生成的实体。\r\n### 二进制传感器显示模式\r\n将米家中的二进制传感器显示为文本传感器实体或者二进制传感器实体。\r\n### 显示设备状态变化通知\r\n细化显示设备状态变化通知,只显示勾选的通知消息。", "data": { "devices_filter": "筛选设备", "ctrl_mode": "控制模式", "action_debug": "Action 调试模式", "hide_non_standard_entities": "隐藏非标准生成实体", + "display_binary_mode": "二进制传感器显示模式", "display_devices_changed_notify": "显示设备状态变化通知" } }, @@ -90,6 +91,7 @@ "unreachable_mqtt_broker": "无法访问小米 MQTT Broker 地址,请检查网络配置。" }, "abort": { + "ha_uuid_get_failed": "获取 Home Assistant UUID 失败。", "network_connect_error": "配置失败。网络连接异常,请检查设备网络配置。", "already_configured": "该用户已配置完成。请进入集成页面,点击“配置”按钮修改配置。", "invalid_auth_info": "认证信息已过期。请进入集成页面,点击“配置”按钮重新认证。", @@ -118,6 +120,7 @@ "update_devices": "更新设备列表", "action_debug": "Action 调试模式", "hide_non_standard_entities": "隐藏非标准生成实体", + "display_binary_mode": "二进制传感器显示模式", "display_devices_changed_notify": "显示设备状态变化通知", "update_trans_rules": "更新实体转换规则", "update_lan_ctrl_config": "更新局域网控制配置", diff --git a/custom_components/xiaomi_home/translations/zh-Hant.json b/custom_components/xiaomi_home/translations/zh-Hant.json index 7fcfb67..68cc982 100644 --- a/custom_components/xiaomi_home/translations/zh-Hant.json +++ b/custom_components/xiaomi_home/translations/zh-Hant.json @@ -42,12 +42,13 @@ }, "advanced_options": { "title": "高級設置選項", - "description": "## 使用介紹\r\n### 除非您非常清楚下列選項的含義,否則請保持默認。\r\n### 篩選設備\r\n支持按照房間名稱和設備類型篩選設備,同時也支持設備維度篩選。\r\n### 控制模式\r\n- 自動:本地局域網內存在可用的小米中樞網關時, Home Assistant 會優先通過中樞網關發送設備控制指令,以實現本地化控制功能。本地局域網不存在中樞時,會嘗試通過小米OT協議發送控制指令,以實現本地化控制功能。只有當上述本地化控制條件不滿足時,設備控制指令才會通過雲端發送。\r\n- 雲端:控制指令僅通過雲端發送。\r\n### Action 調試模式\r\n對於設備 MIoT-Spec-V2 定義的方法,在生成通知實體之外,還會生成一個文本輸入框實體,您可以在調試時用它向設備發送控制指令。\r\n### 隱藏非標準生成實體\r\n隱藏名稱以“*”開頭的非標準 MIoT-Spec-V2 實例生成的實體。\r\n### 顯示設備狀態變化通知\r\n細化顯示設備狀態變化通知,只顯示勾選的通知消息。", + "description": "## 使用介紹\r\n### 除非您非常清楚下列選項的含義,否則請保持默認。\r\n### 篩選設備\r\n支持按照房間名稱和設備類型篩選設備,同時也支持設備維度篩選。\r\n### 控制模式\r\n- 自動:本地局域網內存在可用的小米中樞網關時, Home Assistant 會優先通過中樞網關發送設備控制指令,以實現本地化控制功能。本地局域網不存在中樞時,會嘗試通過小米OT協議發送控制指令,以實現本地化控制功能。只有當上述本地化控制條件不滿足時,設備控制指令才會通過雲端發送。\r\n- 雲端:控制指令僅通過雲端發送。\r\n### Action 調試模式\r\n對於設備 MIoT-Spec-V2 定義的方法,在生成通知實體之外,還會生成一個文本輸入框實體,您可以在調試時用它向設備發送控制指令。\r\n### 隱藏非標準生成實體\r\n隱藏名稱以“*”開頭的非標準 MIoT-Spec-V2 實例生成的實體。\r\n### 二進制傳感器顯示模式\r\n將米家中的二進制傳感器顯示為文本傳感器實體或者二進制傳感器實體。\r\n### 顯示設備狀態變化通知\r\n細化顯示設備狀態變化通知,只顯示勾選的通知消息。", "data": { "devices_filter": "篩選設備", "ctrl_mode": "控制模式", "action_debug": "Action 調試模式", "hide_non_standard_entities": "隱藏非標準生成實體", + "display_binary_mode": "二進制傳感器顯示模式", "display_devices_changed_notify": "顯示設備狀態變化通知" } }, @@ -90,6 +91,7 @@ "unreachable_mqtt_broker": "無法訪問小米 MQTT Broker 地址,請檢查網絡配置。" }, "abort": { + "ha_uuid_get_failed": "獲取 Home Assistant UUID 失敗。", "network_connect_error": "配置失敗。網絡連接異常,請檢查設備網絡配置。", "already_configured": "該用戶已配置完成。請進入集成頁面,點擊“配置”按鈕修改配置。", "invalid_auth_info": "認證信息已過期。請進入集成頁面,點擊“配置”按鈕重新認證。", @@ -118,6 +120,7 @@ "update_devices": "更新設備列表", "action_debug": "Action 調試模式", "hide_non_standard_entities": "隱藏非標準生成實體", + "display_binary_mode": "二進制傳感器顯示模式", "display_devices_changed_notify": "顯示設備狀態變化通知", "update_trans_rules": "更新實體轉換規則", "update_lan_ctrl_config": "更新局域網控制配置", diff --git a/custom_components/xiaomi_home/vacuum.py b/custom_components/xiaomi_home/vacuum.py index fda2d5a..232e676 100644 --- a/custom_components/xiaomi_home/vacuum.py +++ b/custom_components/xiaomi_home/vacuum.py @@ -120,28 +120,18 @@ class Vacuum(MIoTServiceEntity, StateVacuumEntity): # properties for prop in entity_data.props: if prop.name == 'status': - if ( - not isinstance(prop.value_list, list) - or not prop.value_list - ): + if not prop.value_list: _LOGGER.error( 'invalid status value_list, %s', self.entity_id) continue - self._status_map = { - item['value']: item['description'] - for item in prop.value_list} + self._status_map = prop.value_list.to_map() self._prop_status = prop elif prop.name == 'fan-level': - if ( - not isinstance(prop.value_list, list) - or not prop.value_list - ): + if not prop.value_list: _LOGGER.error( 'invalid fan-level value_list, %s', self.entity_id) continue - self._fan_level_map = { - item['value']: item['description'] - for item in prop.value_list} + self._fan_level_map = prop.value_list.to_map() self._attr_fan_speed_list = list(self._fan_level_map.values()) self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED self._prop_fan_level = prop @@ -202,7 +192,7 @@ class Vacuum(MIoTServiceEntity, StateVacuumEntity): @property def state(self) -> Optional[str]: """Return the current state of the vacuum cleaner.""" - return self.get_map_description( + return self.get_map_value( map_=self._status_map, key=self.get_prop_value(prop=self._prop_status)) @@ -214,6 +204,6 @@ class Vacuum(MIoTServiceEntity, StateVacuumEntity): @property def fan_speed(self) -> Optional[str]: """Return the current fan speed of the vacuum cleaner.""" - return self.get_map_description( + return self.get_map_value( map_=self._fan_level_map, key=self.get_prop_value(prop=self._prop_fan_level)) diff --git a/custom_components/xiaomi_home/water_heater.py b/custom_components/xiaomi_home/water_heater.py index aa7fe67..aba6093 100644 --- a/custom_components/xiaomi_home/water_heater.py +++ b/custom_components/xiaomi_home/water_heater.py @@ -93,7 +93,7 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity): _prop_target_temp: Optional[MIoTSpecProperty] _prop_mode: Optional[MIoTSpecProperty] - _mode_list: Optional[dict[Any, Any]] + _mode_map: Optional[dict[Any, Any]] def __init__( self, miot_device: MIoTDevice, entity_data: MIoTEntityData @@ -106,7 +106,7 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity): self._prop_temp = None self._prop_target_temp = None self._prop_mode = None - self._mode_list = None + self._mode_map = None # properties for prop in entity_data.props: @@ -115,7 +115,7 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity): self._prop_on = prop # temperature if prop.name == 'temperature': - if isinstance(prop.value_range, dict): + if prop.value_range: if ( self._attr_temperature_unit is None and prop.external_unit @@ -128,9 +128,14 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity): self.entity_id) # target-temperature if prop.name == 'target-temperature': - self._attr_min_temp = prop.value_range['min'] - self._attr_max_temp = prop.value_range['max'] - self._attr_precision = prop.value_range['step'] + if not prop.value_range: + _LOGGER.error( + '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_precision = prop.value_range.step if self._attr_temperature_unit is None and prop.external_unit: self._attr_temperature_unit = prop.external_unit self._attr_supported_features |= ( @@ -138,17 +143,12 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity): self._prop_target_temp = prop # mode if prop.name == 'mode': - if ( - not isinstance(prop.value_list, list) - or not prop.value_list - ): + if not prop.value_list: _LOGGER.error( 'mode value_list is None, %s', self.entity_id) continue - self._mode_list = { - item['value']: item['description'] - for item in prop.value_list} - self._attr_operation_list = list(self._mode_list.values()) + self._mode_map = prop.value_list.to_map() + self._attr_operation_list = list(self._mode_map.values()) self._attr_supported_features |= ( WaterHeaterEntityFeature.OPERATION_MODE) self._prop_mode = prop @@ -184,7 +184,9 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity): prop=self._prop_on, value=True, update=False) await self.set_property_async( prop=self._prop_mode, - value=self.__get_mode_value(description=operation_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.""" @@ -207,20 +209,6 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity): return STATE_OFF if not self._prop_mode and self.get_prop_value(prop=self._prop_on): return STATE_ON - return self.__get_mode_description( + return self.get_map_value( + map_=self._mode_map, key=self.get_prop_value(prop=self._prop_mode)) - - def __get_mode_description(self, key: int) -> Optional[str]: - """Convert mode value to description.""" - if self._mode_list is None: - return None - return self._mode_list.get(key, None) - - def __get_mode_value(self, description: str) -> Optional[int]: - """Convert mode description to value.""" - if self._mode_list is None: - return None - for key, value in self._mode_list.items(): - if value == description: - return key - return None diff --git a/test/check_rule_format.py b/test/check_rule_format.py index 3c20afa..18ce034 100644 --- a/test/check_rule_format.py +++ b/test/check_rule_format.py @@ -1,11 +1,14 @@ # -*- coding: utf-8 -*- """Test rule format.""" import json +import logging from os import listdir, path from typing import Optional import pytest import yaml +_LOGGER = logging.getLogger(__name__) + ROOT_PATH: str = path.dirname(path.abspath(__file__)) TRANS_RELATIVE_PATH: str = path.join( ROOT_PATH, '../custom_components/xiaomi_home/translations') @@ -13,13 +16,10 @@ MIOT_I18N_RELATIVE_PATH: str = path.join( ROOT_PATH, '../custom_components/xiaomi_home/miot/i18n') SPEC_BOOL_TRANS_FILE = path.join( ROOT_PATH, - '../custom_components/xiaomi_home/miot/specs/bool_trans.json') -SPEC_MULTI_LANG_FILE = path.join( - ROOT_PATH, - '../custom_components/xiaomi_home/miot/specs/multi_lang.json') + '../custom_components/xiaomi_home/miot/specs/bool_trans.yaml') SPEC_FILTER_FILE = path.join( ROOT_PATH, - '../custom_components/xiaomi_home/miot/specs/spec_filter.json') + '../custom_components/xiaomi_home/miot/specs/spec_filter.yaml') def load_json_file(file_path: str) -> Optional[dict]: @@ -27,10 +27,10 @@ def load_json_file(file_path: str) -> Optional[dict]: with open(file_path, 'r', encoding='utf-8') as file: return json.load(file) except FileNotFoundError: - print(file_path, 'is not found.') + _LOGGER.info('%s is not found.', file_path,) return None except json.JSONDecodeError: - print(file_path, 'is not a valid JSON file.') + _LOGGER.info('%s is not a valid JSON file.', file_path) return None @@ -44,13 +44,19 @@ def load_yaml_file(file_path: str) -> Optional[dict]: with open(file_path, 'r', encoding='utf-8') as file: return yaml.safe_load(file) except FileNotFoundError: - print(file_path, 'is not found.') + _LOGGER.info('%s is not found.', file_path) return None except yaml.YAMLError: - print(file_path, 'is not a valid YAML file.') + _LOGGER.info('%s, is not a valid YAML file.', file_path) return None +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) + + def dict_str_str(d: dict) -> bool: """restricted format: dict[str, str]""" if not isinstance(d, dict): @@ -116,61 +122,59 @@ def bool_trans(d: dict) -> bool: return False default_trans: dict = d['translate'].pop('default') if not default_trans: - print('default trans is empty') + _LOGGER.info('default trans is empty') return False default_keys: set[str] = set(default_trans.keys()) for key, trans in d['translate'].items(): trans_keys: set[str] = set(trans.keys()) if set(trans.keys()) != default_keys: - print('bool trans inconsistent', key, default_keys, trans_keys) + _LOGGER.info( + 'bool trans inconsistent, %s, %s, %s', + key, default_keys, trans_keys) return False return True def compare_dict_structure(dict1: dict, dict2: dict) -> bool: if not isinstance(dict1, dict) or not isinstance(dict2, dict): - print('invalid type') + _LOGGER.info('invalid type') return False if dict1.keys() != dict2.keys(): - print('inconsistent key values, ', dict1.keys(), dict2.keys()) + _LOGGER.info( + 'inconsistent key values, %s, %s', dict1.keys(), dict2.keys()) return False for key in dict1: if isinstance(dict1[key], dict) and isinstance(dict2[key], dict): if not compare_dict_structure(dict1[key], dict2[key]): - print('inconsistent key values, dict, ', key) + _LOGGER.info( + 'inconsistent key values, dict, %s', key) return False elif isinstance(dict1[key], list) and isinstance(dict2[key], list): if not all( isinstance(i, type(j)) for i, j in zip(dict1[key], dict2[key])): - print('inconsistent key values, list, ', key) + _LOGGER.info( + 'inconsistent key values, list, %s', key) return False elif not isinstance(dict1[key], type(dict2[key])): - print('inconsistent key values, type, ', key) + _LOGGER.info( + 'inconsistent key values, type, %s', key) return False return True def sort_bool_trans(file_path: str): - trans_data: dict = load_json_file(file_path=file_path) + trans_data = load_yaml_file(file_path=file_path) + assert isinstance(trans_data, dict), f'{file_path} format error' trans_data['data'] = dict(sorted(trans_data['data'].items())) for key, trans in trans_data['translate'].items(): trans_data['translate'][key] = dict(sorted(trans.items())) return trans_data -def sort_multi_lang(file_path: str): - multi_lang: dict = load_json_file(file_path=file_path) - multi_lang = dict(sorted(multi_lang.items())) - for urn, trans in multi_lang.items(): - multi_lang[urn] = dict(sorted(trans.items())) - for lang, spec in multi_lang[urn].items(): - multi_lang[urn][lang] = dict(sorted(spec.items())) - return multi_lang - - def sort_spec_filter(file_path: str): - filter_data: dict = load_json_file(file_path=file_path) + filter_data = load_yaml_file(file_path=file_path) + assert isinstance(filter_data, dict), f'{file_path} format error' filter_data = dict(sorted(filter_data.items())) for urn, spec in filter_data.items(): filter_data[urn] = dict(sorted(spec.items())) @@ -179,30 +183,26 @@ def sort_spec_filter(file_path: str): @pytest.mark.github def test_bool_trans(): - data: dict = load_json_file(SPEC_BOOL_TRANS_FILE) + data = load_yaml_file(SPEC_BOOL_TRANS_FILE) + assert isinstance(data, dict) assert data, f'load {SPEC_BOOL_TRANS_FILE} failed' assert bool_trans(data), f'{SPEC_BOOL_TRANS_FILE} format error' @pytest.mark.github def test_spec_filter(): - data: dict = load_json_file(SPEC_FILTER_FILE) + data = load_yaml_file(SPEC_FILTER_FILE) + assert isinstance(data, dict) assert data, f'load {SPEC_FILTER_FILE} failed' assert spec_filter(data), f'{SPEC_FILTER_FILE} format error' -@pytest.mark.github -def test_multi_lang(): - data: dict = load_json_file(SPEC_MULTI_LANG_FILE) - assert data, f'load {SPEC_MULTI_LANG_FILE} failed' - assert nested_3_dict_str_str(data), f'{SPEC_MULTI_LANG_FILE} format error' - - @pytest.mark.github def test_miot_i18n(): for file_name in listdir(MIOT_I18N_RELATIVE_PATH): file_path: str = path.join(MIOT_I18N_RELATIVE_PATH, file_name) - data: dict = load_json_file(file_path) + data = load_json_file(file_path) + assert isinstance(data, dict) assert data, f'load {file_path} failed' assert nested_3_dict_str_str(data), f'{file_path} format error' @@ -211,7 +211,8 @@ def test_miot_i18n(): def test_translations(): for file_name in listdir(TRANS_RELATIVE_PATH): file_path: str = path.join(TRANS_RELATIVE_PATH, file_name) - data: dict = load_json_file(file_path) + data = load_json_file(file_path) + assert isinstance(data, dict) assert data, f'load {file_path} failed' assert dict_str_dict(data), f'{file_path} format error' @@ -228,27 +229,30 @@ def test_miot_lang_integrity(): i18n_names: set[str] = set(listdir(MIOT_I18N_RELATIVE_PATH)) assert len(i18n_names) == len(translations_names) assert i18n_names == translations_names - bool_trans_data: set[str] = load_json_file(SPEC_BOOL_TRANS_FILE) + bool_trans_data = load_yaml_file(SPEC_BOOL_TRANS_FILE) + assert isinstance(bool_trans_data, dict) bool_trans_names: set[str] = set( bool_trans_data['translate']['default'].keys()) assert len(bool_trans_names) == len(translations_names) # Check translation files structure - default_dict: dict = load_json_file( + default_dict = load_json_file( path.join(TRANS_RELATIVE_PATH, integration_lang_list[0])) for name in list(integration_lang_list)[1:]: - compare_dict: dict = load_json_file( + compare_dict = load_json_file( path.join(TRANS_RELATIVE_PATH, name)) if not compare_dict_structure(default_dict, compare_dict): - print('compare_dict_structure failed /translations, ', name) + _LOGGER.info( + 'compare_dict_structure failed /translations, %s', name) assert False # Check i18n files structure default_dict = load_json_file( path.join(MIOT_I18N_RELATIVE_PATH, integration_lang_list[0])) for name in list(integration_lang_list)[1:]: - compare_dict: dict = load_json_file( + compare_dict = load_json_file( path.join(MIOT_I18N_RELATIVE_PATH, name)) if not compare_dict_structure(default_dict, compare_dict): - print('compare_dict_structure failed /miot/i18n, ', name) + _LOGGER.info( + 'compare_dict_structure failed /miot/i18n, %s', name) assert False @@ -261,19 +265,13 @@ def test_miot_data_sort(): 'INTEGRATION_LANGUAGES not sorted, correct order\r\n' f'{list(sort_langs.keys())}') assert json.dumps( - load_json_file(file_path=SPEC_BOOL_TRANS_FILE)) == json.dumps( + load_yaml_file(file_path=SPEC_BOOL_TRANS_FILE)) == json.dumps( sort_bool_trans(file_path=SPEC_BOOL_TRANS_FILE)), ( f'{SPEC_BOOL_TRANS_FILE} not sorted, goto project root path' ' and run the following command sorting, ', 'pytest -s -v -m update ./test/check_rule_format.py') assert json.dumps( - load_json_file(file_path=SPEC_MULTI_LANG_FILE)) == json.dumps( - sort_multi_lang(file_path=SPEC_MULTI_LANG_FILE)), ( - f'{SPEC_MULTI_LANG_FILE} not sorted, goto project root path' - ' and run the following command sorting, ', - 'pytest -s -v -m update ./test/check_rule_format.py') - assert json.dumps( - load_json_file(file_path=SPEC_FILTER_FILE)) == json.dumps( + load_yaml_file(file_path=SPEC_FILTER_FILE)) == json.dumps( sort_spec_filter(file_path=SPEC_FILTER_FILE)), ( f'{SPEC_FILTER_FILE} not sorted, goto project root path' ' and run the following command sorting, ', @@ -283,11 +281,8 @@ def test_miot_data_sort(): @pytest.mark.update def test_sort_spec_data(): sort_data: dict = sort_bool_trans(file_path=SPEC_BOOL_TRANS_FILE) - save_json_file(file_path=SPEC_BOOL_TRANS_FILE, data=sort_data) - print(SPEC_BOOL_TRANS_FILE, 'formatted.') - sort_data = sort_multi_lang(file_path=SPEC_MULTI_LANG_FILE) - save_json_file(file_path=SPEC_MULTI_LANG_FILE, data=sort_data) - print(SPEC_MULTI_LANG_FILE, 'formatted.') + save_yaml_file(file_path=SPEC_BOOL_TRANS_FILE, data=sort_data) + _LOGGER.info('%s formatted.', SPEC_BOOL_TRANS_FILE) sort_data = sort_spec_filter(file_path=SPEC_FILTER_FILE) - save_json_file(file_path=SPEC_FILTER_FILE, data=sort_data) - print(SPEC_FILTER_FILE, 'formatted.') + save_yaml_file(file_path=SPEC_FILTER_FILE, data=sort_data) + _LOGGER.info('%s formatted.', SPEC_FILTER_FILE) diff --git a/test/conftest.py b/test/conftest.py index 9263402..9e9160a 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,16 +1,37 @@ # -*- coding: utf-8 -*- """Pytest fixtures.""" +import logging +import random import shutil import pytest from os import path, makedirs +from uuid import uuid4 TEST_ROOT_PATH: str = path.dirname(path.abspath(__file__)) TEST_FILES_PATH: str = path.join(TEST_ROOT_PATH, 'miot') TEST_CACHE_PATH: str = path.join(TEST_ROOT_PATH, 'test_cache') +TEST_OAUTH2_REDIRECT_URL: str = 'http://homeassistant.local:8123' TEST_LANG: str = 'zh-Hans' TEST_UID: str = '123456789' TEST_CLOUD_SERVER: str = 'cn' +DOMAIN_CLOUD_CACHE: str = 'cloud_cache' + +_LOGGER = logging.getLogger(__name__) + + +@pytest.fixture(scope='session', autouse=True) +def set_logger(): + logger = logging.getLogger() + logger.setLevel(logging.INFO) + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.INFO) + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s') + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + _LOGGER.info('set logger, %s', logger) + @pytest.fixture(scope='session', autouse=True) def load_py_file(): @@ -20,10 +41,10 @@ def load_py_file(): 'const.py', 'miot_cloud.py', 'miot_error.py', - 'miot_ev.py', 'miot_i18n.py', 'miot_lan.py', 'miot_mdns.py', + 'miot_mips.py', 'miot_network.py', 'miot_spec.py', 'miot_storage.py'] @@ -35,31 +56,35 @@ def load_py_file(): TEST_ROOT_PATH, '../custom_components/xiaomi_home/miot', file_name), path.join(TEST_FILES_PATH, file_name)) - print('\nloaded test py files, ', file_list) + _LOGGER.info('\nloaded test py files, %s', file_list) # Copy spec files to test folder shutil.copytree( src=path.join( TEST_ROOT_PATH, '../custom_components/xiaomi_home/miot/specs'), dst=path.join(TEST_FILES_PATH, 'specs'), dirs_exist_ok=True) - print('loaded spec test folder, specs') + _LOGGER.info('loaded spec test folder, specs') # Copy lan files to test folder shutil.copytree( src=path.join( TEST_ROOT_PATH, '../custom_components/xiaomi_home/miot/lan'), dst=path.join(TEST_FILES_PATH, 'lan'), dirs_exist_ok=True) - print('loaded lan test folder, lan') + _LOGGER.info('loaded lan test folder, lan') # Copy i18n files to test folder shutil.copytree( src=path.join( TEST_ROOT_PATH, '../custom_components/xiaomi_home/miot/i18n'), dst=path.join(TEST_FILES_PATH, 'i18n'), dirs_exist_ok=True) - print('loaded i18n test folder, i18n') + _LOGGER.info('loaded i18n test folder, i18n') yield + # NOTICE: All test files and data (tokens, device information, etc.) will + # be deleted after the test is completed. For some test cases that + # require caching data, you can comment out the following code. + if path.exists(TEST_FILES_PATH): shutil.rmtree(TEST_FILES_PATH) print('\nremoved test files, ', TEST_FILES_PATH) @@ -80,6 +105,11 @@ def test_cache_path() -> str: return TEST_CACHE_PATH +@pytest.fixture(scope='session') +def test_oauth2_redirect_url() -> str: + return TEST_OAUTH2_REDIRECT_URL + + @pytest.fixture(scope='session') def test_lang() -> str: return TEST_LANG @@ -90,6 +120,53 @@ def test_uid() -> str: return TEST_UID +@pytest.fixture(scope='session') +def test_random_did() -> str: + # Gen random did + return str(random.getrandbits(64)) + + +@pytest.fixture(scope='session') +def test_uuid() -> str: + # Gen uuid + return uuid4().hex + + @pytest.fixture(scope='session') def test_cloud_server() -> str: return TEST_CLOUD_SERVER + + +@pytest.fixture(scope='session') +def test_domain_cloud_cache() -> str: + return DOMAIN_CLOUD_CACHE + + +@pytest.fixture(scope='session') +def test_name_oauth2_info() -> str: + return f'{TEST_CLOUD_SERVER}_oauth2_info' + + +@pytest.fixture(scope='session') +def test_name_uid() -> str: + return f'{TEST_CLOUD_SERVER}_uid' + + +@pytest.fixture(scope='session') +def test_name_uuid() -> str: + return f'{TEST_CLOUD_SERVER}_uuid' + + +@pytest.fixture(scope='session') +def test_name_rd_did() -> str: + return f'{TEST_CLOUD_SERVER}_rd_did' + + +@pytest.fixture(scope='session') +def test_name_homes() -> str: + return f'{TEST_CLOUD_SERVER}_homes' + + +@pytest.fixture(scope='session') +def test_name_devices() -> str: + return f'{TEST_CLOUD_SERVER}_devices' diff --git a/test/test_cloud.py b/test/test_cloud.py new file mode 100755 index 0000000..f1c74b9 --- /dev/null +++ b/test/test_cloud.py @@ -0,0 +1,587 @@ +# -*- coding: utf-8 -*- +"""Unit test for miot_cloud.py.""" +import asyncio +import logging +import time +import webbrowser +import pytest + +# pylint: disable=import-outside-toplevel, unused-argument +_LOGGER = logging.getLogger(__name__) + + +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_oauth_async( + test_cache_path: str, + test_cloud_server: str, + test_oauth2_redirect_url: str, + test_uuid: str, + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_uuid: str +) -> dict: + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTOauthClient + from miot.miot_storage import MIoTStorage + + miot_storage = MIoTStorage(test_cache_path) + local_uuid = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_uuid, type_=str) + uuid = str(local_uuid or test_uuid) + _LOGGER.info('uuid: %s', uuid) + miot_oauth = MIoTOauthClient( + client_id=OAUTH2_CLIENT_ID, + redirect_url=test_oauth2_redirect_url, + cloud_server=test_cloud_server, + uuid=uuid) + + oauth_info = None + load_info = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) + if ( + isinstance(load_info, dict) + and 'access_token' in load_info + and 'expires_ts' in load_info + and load_info['expires_ts'] > int(time.time()) + ): + _LOGGER.info('load oauth info, %s', load_info) + oauth_info = load_info + if oauth_info is None: + # gen oauth url + auth_url: str = miot_oauth.gen_auth_url() + assert isinstance(auth_url, str) + _LOGGER.info('auth url: %s', auth_url) + # get code + webbrowser.open(auth_url) + code: str = input('input code: ') + assert code is not None + # get access_token + res_obj = await miot_oauth.get_access_token_async(code=code) + assert res_obj is not None + oauth_info = res_obj + _LOGGER.info('get_access_token result: %s', res_obj) + rc = await miot_storage.save_async( + test_domain_cloud_cache, test_name_oauth2_info, oauth_info) + assert rc + _LOGGER.info('save oauth info') + rc = await miot_storage.save_async( + test_domain_cloud_cache, test_name_uuid, uuid) + assert rc + _LOGGER.info('save uuid') + + access_token = oauth_info.get('access_token', None) + assert isinstance(access_token, str) + _LOGGER.info('access_token: %s', access_token) + refresh_token = oauth_info.get('refresh_token', None) + assert isinstance(refresh_token, str) + _LOGGER.info('refresh_token: %s', refresh_token) + + await miot_oauth.deinit_async() + return oauth_info + + +@pytest.mark.asyncio +@pytest.mark.dependency(on=['test_miot_oauth_async']) +async def test_miot_oauth_refresh_token( + test_cache_path: str, + test_cloud_server: str, + test_oauth2_redirect_url: str, + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_uuid: str +): + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTOauthClient + from miot.miot_storage import MIoTStorage + + miot_storage = MIoTStorage(test_cache_path) + uuid = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_uuid, type_=str) + assert isinstance(uuid, str) + oauth_info = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) + assert isinstance(oauth_info, dict) + assert 'access_token' in oauth_info + assert 'refresh_token' in oauth_info + assert 'expires_ts' in oauth_info + remaining_time = oauth_info['expires_ts'] - int(time.time()) + _LOGGER.info('token remaining valid time: %ss', remaining_time) + # Refresh token + miot_oauth = MIoTOauthClient( + client_id=OAUTH2_CLIENT_ID, + redirect_url=test_oauth2_redirect_url, + cloud_server=test_cloud_server, + uuid=uuid) + refresh_token = oauth_info.get('refresh_token', None) + assert refresh_token + update_info = await miot_oauth.refresh_access_token_async( + refresh_token=refresh_token) + assert update_info + assert 'access_token' in update_info + assert 'refresh_token' in update_info + assert 'expires_ts' in update_info + remaining_time = update_info['expires_ts'] - int(time.time()) + assert remaining_time > 0 + _LOGGER.info('refresh token, remaining valid time: %ss', remaining_time) + # Save oauth2 info + rc = await miot_storage.save_async( + test_domain_cloud_cache, test_name_oauth2_info, update_info) + assert rc + _LOGGER.info('refresh token success, %s', update_info) + + await miot_oauth.deinit_async() + + +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_cloud_get_nickname_async( + test_cache_path: str, + test_cloud_server: str, + test_domain_cloud_cache: str, + test_name_oauth2_info: str +): + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTStorage + + miot_storage = MIoTStorage(test_cache_path) + oauth_info = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, + access_token=oauth_info['access_token']) + + # Get nickname + user_info = await miot_http.get_user_info_async() + assert isinstance(user_info, dict) and 'miliaoNick' in user_info + nickname = user_info['miliaoNick'] + _LOGGER.info('your nickname: %s', nickname) + + await miot_http.deinit_async() + + +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_cloud_get_uid_async( + test_cache_path: str, + test_cloud_server: str, + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_uid: str +): + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTStorage + + miot_storage = MIoTStorage(test_cache_path) + oauth_info = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, + access_token=oauth_info['access_token']) + + uid = await miot_http.get_uid_async() + assert isinstance(uid, str) + _LOGGER.info('your uid: %s', uid) + # Save uid + rc = await miot_storage.save_async( + domain=test_domain_cloud_cache, name=test_name_uid, data=uid) + assert rc + + await miot_http.deinit_async() + + +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_cloud_get_homeinfos_async( + test_cache_path: str, + test_cloud_server: str, + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_uid: str +): + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTStorage + + miot_storage = MIoTStorage(test_cache_path) + oauth_info = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, + access_token=oauth_info['access_token']) + + # Get homeinfos + homeinfos = await miot_http.get_homeinfos_async() + assert isinstance(homeinfos, dict) + assert 'uid' in homeinfos and isinstance(homeinfos['uid'], str) + assert 'home_list' in homeinfos and isinstance( + homeinfos['home_list'], dict) + assert 'share_home_list' in homeinfos and isinstance( + homeinfos['share_home_list'], dict) + # Get uid + uid = homeinfos.get('uid', '') + # Compare uid with uid in storage + uid2 = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_uid, type_=str) + assert uid == uid2 + _LOGGER.info('your uid: %s', uid) + # Get homes + home_list = homeinfos.get('home_list', {}) + _LOGGER.info('your home_list: ,%s', home_list) + # Get share homes + share_home_list = homeinfos.get('share_home_list', {}) + _LOGGER.info('your share_home_list: %s', share_home_list) + + await miot_http.deinit_async() + + +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_cloud_get_devices_async( + test_cache_path: str, + test_cloud_server: str, + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_uid: str, + test_name_homes: str, + test_name_devices: str +): + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTStorage + + miot_storage = MIoTStorage(test_cache_path) + oauth_info = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, + access_token=oauth_info['access_token']) + + # Get devices + devices = await miot_http.get_devices_async() + assert isinstance(devices, dict) + assert 'uid' in devices and isinstance(devices['uid'], str) + assert 'homes' in devices and isinstance(devices['homes'], dict) + assert 'devices' in devices and isinstance(devices['devices'], dict) + # Compare uid with uid in storage + uid = devices.get('uid', '') + uid2 = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_uid, type_=str) + assert uid == uid2 + _LOGGER.info('your uid: %s', uid) + # Get homes + homes = devices['homes'] + _LOGGER.info('your homes: %s', homes) + # Get devices + devices = devices['devices'] + _LOGGER.info('your devices count: %s', len(devices)) + # Storage homes and devices + rc = await miot_storage.save_async( + domain=test_domain_cloud_cache, name=test_name_homes, data=homes) + assert rc + rc = await miot_storage.save_async( + domain=test_domain_cloud_cache, name=test_name_devices, data=devices) + assert rc + + await miot_http.deinit_async() + + +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_cloud_get_devices_with_dids_async( + test_cache_path: str, + test_cloud_server: str, + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_devices: str +): + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTStorage + + miot_storage = MIoTStorage(test_cache_path) + oauth_info = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, + access_token=oauth_info['access_token']) + + # Load devices + local_devices = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_devices, type_=dict) + assert isinstance(local_devices, dict) + did_list = list(local_devices.keys()) + assert len(did_list) > 0 + # Get device with dids + test_list = did_list[:6] + devices_info = await miot_http.get_devices_with_dids_async( + dids=test_list) + assert isinstance(devices_info, dict) + _LOGGER.info('test did list, %s, %s', len(test_list), test_list) + _LOGGER.info( + 'test result: %s, %s', len(devices_info), list(devices_info.keys())) + + await miot_http.deinit_async() + + +@pytest.mark.asyncio +async def test_miot_cloud_get_cert( + test_cache_path: str, + test_cloud_server: str, + test_random_did: str, + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_uid: str, + test_name_rd_did: str +): + """ + NOTICE: Currently, only certificate acquisition in the CN region is + supported. + """ + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTCert, MIoTStorage + + if test_cloud_server.lower() != 'cn': + _LOGGER.info('only support CN region') + return + + miot_storage = MIoTStorage(test_cache_path) + uid = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_uid, type_=str) + assert isinstance(uid, str) + _LOGGER.info('your uid: %s', uid) + random_did = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_rd_did, type_=str) + if not random_did: + random_did = test_random_did + rc = await miot_storage.save_async( + domain=test_domain_cloud_cache, name=test_name_rd_did, + data=random_did) + assert rc + assert isinstance(random_did, str) + _LOGGER.info('your random_did: %s', random_did) + oauth_info = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) + assert isinstance(oauth_info, dict) + assert 'access_token' in oauth_info + access_token = oauth_info['access_token'] + + # Get certificates + miot_cert = MIoTCert(storage=miot_storage, uid=uid, cloud_server='CN') + assert await miot_cert.verify_ca_cert_async(), 'invalid ca cert' + remaining_time: int = await miot_cert.user_cert_remaining_time_async() + if remaining_time > 0: + _LOGGER.info( + 'user cert is valid, remaining time, %ss', remaining_time) + _LOGGER.info(( + 'if you want to obtain it again, please delete the ' + 'key, csr, and cert files in %s.'), test_cache_path) + return + + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, + client_id=OAUTH2_CLIENT_ID, + access_token=access_token) + + user_key = miot_cert.gen_user_key() + assert isinstance(user_key, str) + _LOGGER.info('user_key str, %s', user_key) + user_csr = miot_cert.gen_user_csr(user_key=user_key, did=random_did) + assert isinstance(user_csr, str) + _LOGGER.info('user_csr str, %s', user_csr) + cert_str = await miot_http.get_central_cert_async(csr=user_csr) + assert isinstance(cert_str, str) + _LOGGER.info('user_cert str, %s', cert_str) + rc = await miot_cert.update_user_key_async(key=user_key) + assert rc + rc = await miot_cert.update_user_cert_async(cert=cert_str) + assert rc + # verify user certificates + remaining_time = await miot_cert.user_cert_remaining_time_async( + cert_data=cert_str.encode('utf-8'), did=random_did) + assert remaining_time > 0 + _LOGGER.info('user cert remaining time, %ss', remaining_time) + + await miot_http.deinit_async() + + +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_cloud_get_prop_async( + test_cache_path: str, + test_cloud_server: str, + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_devices: str +): + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTStorage + + miot_storage = MIoTStorage(test_cache_path) + oauth_info = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, + access_token=oauth_info['access_token']) + + # Load devices + local_devices = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_devices, type_=dict) + assert isinstance(local_devices, dict) + did_list = list(local_devices.keys()) + assert len(did_list) > 0 + # Get prop + test_list = did_list[:6] + for did in test_list: + prop_value = await miot_http.get_prop_async(did=did, siid=2, piid=1) + device_name = local_devices[did]['name'] + _LOGGER.info('%s(%s), prop.2.1: %s', device_name, did, prop_value) + + await miot_http.deinit_async() + + +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_cloud_get_props_async( + test_cache_path: str, + test_cloud_server: str, + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_devices: str +): + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTStorage + + miot_storage = MIoTStorage(test_cache_path) + oauth_info = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, + access_token=oauth_info['access_token']) + + # Load devices + local_devices = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_devices, type_=dict) + assert isinstance(local_devices, dict) + did_list = list(local_devices.keys()) + assert len(did_list) > 0 + # Get props + test_list = did_list[:6] + prop_values = await miot_http.get_props_async(params=[ + {'did': did, 'siid': 2, 'piid': 1} for did in test_list]) + + _LOGGER.info('test did list, %s, %s', len(test_list), test_list) + _LOGGER.info('test result, %s, %s', len(prop_values), prop_values) + + await miot_http.deinit_async() + + +@pytest.mark.skip(reason='skip danger operation') +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_cloud_set_prop_async( + test_cache_path: str, + test_cloud_server: str, + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_devices: str +): + """ + WARNING: This test case will control the actual device and is not enabled + by default. You can uncomment @pytest.mark.skip to enable it. + """ + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTStorage + + miot_storage = MIoTStorage(test_cache_path) + oauth_info = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, + access_token=oauth_info['access_token']) + + # Load devices + local_devices = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_devices, type_=dict) + assert isinstance(local_devices, dict) + assert len(local_devices) > 0 + # Set prop + # Find central hub gateway, control its indicator light switch + # You can replace it with the device you want to control. + test_did = '' + for did, dev in local_devices.items(): + if dev['model'] == 'xiaomi.gateway.hub1': + test_did = did + break + assert test_did != '', 'no central hub gateway found' + result = await miot_http.set_prop_async(params=[{ + 'did': test_did, 'siid': 3, 'piid': 1, 'value': False}]) + _LOGGER.info('test did, %s, prop.3.1=False -> %s', test_did, result) + await asyncio.sleep(1) + result = await miot_http.set_prop_async(params=[{ + 'did': test_did, 'siid': 3, 'piid': 1, 'value': True}]) + _LOGGER.info('test did, %s, prop.3.1=True -> %s', test_did, result) + + await miot_http.deinit_async() + + +@pytest.mark.skip(reason='skip danger operation') +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_cloud_action_async( + test_cache_path: str, + test_cloud_server: str, + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_devices: str +): + """ + WARNING: This test case will control the actual device and is not enabled + by default. You can uncomment @pytest.mark.skip to enable it. + """ + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTStorage + + miot_storage = MIoTStorage(test_cache_path) + oauth_info = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, + access_token=oauth_info['access_token']) + + # Load devices + local_devices = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_devices, type_=dict) + assert isinstance(local_devices, dict) + assert len(local_devices) > 0 + # Action + # Find central hub gateway, trigger its virtual events + # You can replace it with the device you want to control. + test_did = '' + for did, dev in local_devices.items(): + if dev['model'] == 'xiaomi.gateway.hub1': + test_did = did + break + assert test_did != '', 'no central hub gateway found' + result = await miot_http.action_async( + did=test_did, siid=4, aiid=1, + in_list=[{'piid': 1, 'value': 'hello world.'}]) + _LOGGER.info('test did, %s, action.4.1 -> %s', test_did, result) + + await miot_http.deinit_async() diff --git a/test/test_common.py b/test/test_common.py index a6d68bc..18a4736 100644 --- a/test/test_common.py +++ b/test/test_common.py @@ -18,7 +18,7 @@ def test_miot_matcher(): if not matcher.get(topic=f'test/+/{l2}'): matcher[f'test/+/{l2}'] = f'test/+/{l2}' # Match - match_result: list[(str, dict)] = list(matcher.iter_all_nodes()) + match_result: list[str] = list(matcher.iter_all_nodes()) assert len(match_result) == 120 match_result: list[str] = list(matcher.iter_match(topic='test/1/1')) assert len(match_result) == 3 diff --git a/test/test_ev.py b/test/test_ev.py deleted file mode 100644 index 6353fe8..0000000 --- a/test/test_ev.py +++ /dev/null @@ -1,55 +0,0 @@ -# -*- coding: utf-8 -*- -"""Unit test for miot_ev.py.""" -import os -import pytest - -# pylint: disable=import-outside-toplevel, disable=unused-argument - - -@pytest.mark.github -def test_mev_timer_and_fd(): - from miot.miot_ev import MIoTEventLoop, TimeoutHandle - - mev = MIoTEventLoop() - assert mev - event_fd: os.eventfd = os.eventfd(0, os.O_NONBLOCK) - assert event_fd - timer4: TimeoutHandle = None - - def event_handler(event_fd): - value: int = os.eventfd_read(event_fd) - if value == 1: - mev.clear_timeout(timer4) - print('cancel timer4') - elif value == 2: - print('event write twice in a row') - elif value == 3: - mev.set_read_handler(event_fd, None, None) - os.close(event_fd) - event_fd = None - print('close event fd') - - def timer1_handler(event_fd): - os.eventfd_write(event_fd, 1) - - def timer2_handler(event_fd): - os.eventfd_write(event_fd, 1) - os.eventfd_write(event_fd, 1) - - def timer3_handler(event_fd): - os.eventfd_write(event_fd, 3) - - def timer4_handler(event_fd): - raise ValueError('unreachable code') - - mev.set_read_handler( - event_fd, event_handler, event_fd) - - mev.set_timeout(500, timer1_handler, event_fd) - mev.set_timeout(1000, timer2_handler, event_fd) - mev.set_timeout(1500, timer3_handler, event_fd) - timer4 = mev.set_timeout(2000, timer4_handler, event_fd) - - mev.loop_forever() - # Loop will exit when there are no timers or fd handlers. - mev.loop_stop() diff --git a/test/test_lan.py b/test/test_lan.py index a6051c0..a2861cc 100755 --- a/test/test_lan.py +++ b/test/test_lan.py @@ -1,11 +1,14 @@ # -*- coding: utf-8 -*- """Unit test for miot_lan.py.""" +import logging from typing import Any import pytest import asyncio from zeroconf import IPVersion from zeroconf.asyncio import AsyncZeroconf +_LOGGER = logging.getLogger(__name__) + # pylint: disable=import-outside-toplevel, unused-argument @@ -67,7 +70,7 @@ async def test_lan_async(test_devices: dict): miot_network = MIoTNetwork() await miot_network.init_async() - print('miot_network, ', miot_network.network_info) + _LOGGER.info('miot_network, %s', miot_network.network_info) mips_service = MipsService( aiozc=AsyncZeroconf(ip_version=IPVersion.V4Only)) await mips_service.init_async() @@ -81,7 +84,7 @@ async def test_lan_async(test_devices: dict): await miot_lan.vote_for_lan_ctrl_async(key='test', vote=True) async def device_state_change(did: str, state: dict, ctx: Any): - print('device state change, ', did, state) + _LOGGER.info('device state change, %s, %s', did, state) if did != test_did: return if ( @@ -91,10 +94,10 @@ async def test_lan_async(test_devices: dict): # Test sub prop miot_lan.sub_prop( did=did, siid=3, piid=1, handler=lambda msg, ctx: - print(f'sub prop.3.1 msg, {did}={msg}')) + _LOGGER.info('sub prop.3.1 msg, %s=%s', did, msg)) miot_lan.sub_prop( did=did, handler=lambda msg, ctx: - print(f'sub all device msg, {did}={msg}')) + _LOGGER.info('sub all device msg, %s=%s', did, msg)) evt_push_available.set() else: # miot_lan.unsub_prop(did=did, siid=3, piid=1) @@ -102,7 +105,7 @@ async def test_lan_async(test_devices: dict): evt_push_unavailable.set() async def lan_state_change(state: bool): - print('lan state change, ', state) + _LOGGER.info('lan state change, %s', state) if not state: return miot_lan.update_devices(devices={ diff --git a/test/test_mdns.py b/test/test_mdns.py index ddf6a10..a0e148a 100755 --- a/test/test_mdns.py +++ b/test/test_mdns.py @@ -1,28 +1,35 @@ # -*- coding: utf-8 -*- """Unit test for miot_mdns.py.""" +import asyncio +import logging import pytest from zeroconf import IPVersion from zeroconf.asyncio import AsyncZeroconf +_LOGGER = logging.getLogger(__name__) + # pylint: disable=import-outside-toplevel, unused-argument @pytest.mark.asyncio async def test_service_loop_async(): - from miot.miot_mdns import MipsService, MipsServiceData, MipsServiceState + from miot.miot_mdns import MipsService, MipsServiceState async def on_service_state_change( - group_id: str, state: MipsServiceState, data: MipsServiceData): - print( + group_id: str, state: MipsServiceState, data: dict): + _LOGGER.info( 'on_service_state_change, %s, %s, %s', group_id, state, data) async with AsyncZeroconf(ip_version=IPVersion.V4Only) as aiozc: mips_service = MipsService(aiozc) mips_service.sub_service_change('test', '*', on_service_state_change) await mips_service.init_async() + # Wait for service to discover + await asyncio.sleep(3) services_detail = mips_service.get_services() - print('get all service, ', services_detail.keys()) + _LOGGER.info('get all service, %s', list(services_detail.keys())) for name, data in services_detail.items(): - print( - '\tinfo, ', name, data['did'], data['addresses'], data['port']) + _LOGGER.info( + '\tinfo, %s, %s, %s, %s', + name, data['did'], data['addresses'], data['port']) await mips_service.deinit_async() diff --git a/test/test_mips.py b/test/test_mips.py new file mode 100644 index 0000000..d808f22 --- /dev/null +++ b/test/test_mips.py @@ -0,0 +1,264 @@ +# -*- coding: utf-8 -*- +"""Unit test for miot_mips.py. +NOTICE: When running this test case, you need to run test_cloud.py first to +obtain the token and certificate information, and at the same time avoid data +deletion. +""" +import ipaddress +from typing import Any, Tuple +import pytest +import asyncio +import logging + +_LOGGER = logging.getLogger(__name__) + + +# pylint: disable = import-outside-toplevel, unused-argument + +@pytest.mark.parametrize('central_info', [ + ('', 'Gateway did', 'Gateway ip', 8883), +]) +@pytest.mark.asyncio +async def test_mips_local_async( + test_cache_path: str, + test_domain_cloud_cache: str, + test_name_uid: str, + test_name_rd_did: str, + central_info: Tuple[str, str, str, int] +): + """ + NOTICE: + - Mips local is used to connect to the central gateway and is only + supported in the Chinese mainland region. + - Before running this test case, you need to run test_mdns.py first to + obtain the group_id, did, ip, and port of the hub, and then fill in this + information in the parametrize. you can enter multiple central connection + information items for separate tests. + - This test case requires running test_cloud.py first to obtain the + central connection certificate. + - This test case will control the indicator light switch of the central + gateway. + """ + from miot.miot_storage import MIoTStorage, MIoTCert + from miot.miot_mips import MipsLocalClient + + central_group_id: str = central_info[0] + assert isinstance(central_group_id, str) + central_did: str = central_info[1] + assert central_did.isdigit() + central_ip: str = central_info[2] + assert ipaddress.ip_address(central_ip) + central_port: int = central_info[3] + assert isinstance(central_port, int) + + miot_storage = MIoTStorage(test_cache_path) + uid = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_uid, type_=str) + assert isinstance(uid, str) + random_did = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_rd_did, type_=str) + assert isinstance(random_did, str) + miot_cert = MIoTCert(storage=miot_storage, uid=uid, cloud_server='CN') + assert miot_cert.ca_file + assert miot_cert.cert_file + assert miot_cert.key_file + _LOGGER.info( + 'cert info, %s, %s, %s', miot_cert.ca_file, miot_cert.cert_file, + miot_cert.key_file) + + mips_local = MipsLocalClient( + did=random_did, + host=central_ip, + group_id=central_group_id, + ca_file=miot_cert.ca_file, + cert_file=miot_cert.cert_file, + key_file=miot_cert.key_file, + port=central_port, + home_name='mips local test') + mips_local.enable_logger(logger=_LOGGER) + mips_local.enable_mqtt_logger(logger=_LOGGER) + + async def on_mips_state_changed_async(key: str, state: bool): + _LOGGER.info('on mips state changed, %s, %s', key, state) + + async def on_dev_list_changed_async( + mips: MipsLocalClient, did_list: list[str] + ): + _LOGGER.info('dev list changed, %s', did_list) + + def on_prop_changed(payload: dict, ctx: Any): + _LOGGER.info('prop changed, %s=%s', ctx, payload) + + def on_event_occurred(payload: dict, ctx: Any): + _LOGGER.info('event occurred, %s=%s', ctx, payload) + + # Reg mips state + mips_local.sub_mips_state( + key='mips_local', handler=on_mips_state_changed_async) + mips_local.on_dev_list_changed = on_dev_list_changed_async + # Connect + await mips_local.connect_async() + await asyncio.sleep(0.5) + # Get device list + device_list = await mips_local.get_dev_list_async() + assert isinstance(device_list, dict) + _LOGGER.info( + 'get_dev_list, %d, %s', len(device_list), list(device_list.keys())) + # Sub Prop + mips_local.sub_prop( + did=central_did, handler=on_prop_changed, + handler_ctx=f'{central_did}.*') + # Sub Event + mips_local.sub_event( + did=central_did, handler=on_event_occurred, + handler_ctx=f'{central_did}.*') + # Get/set prop + test_siid = 3 + test_piid = 1 + # mips_local.sub_prop( + # did=central_did, siid=test_siid, piid=test_piid, + # handler=on_prop_changed, + # handler_ctx=f'{central_did}.{test_siid}.{test_piid}') + result1 = await mips_local.get_prop_async( + did=central_did, siid=test_siid, piid=test_piid) + assert isinstance(result1, bool) + _LOGGER.info('get prop.%s.%s, value=%s', test_siid, test_piid, result1) + result2 = await mips_local.set_prop_async( + did=central_did, siid=test_siid, piid=test_piid, value=not result1) + _LOGGER.info( + 'set prop.%s.%s=%s, result=%s', + test_siid, test_piid, not result1, result2) + assert isinstance(result2, dict) + result3 = await mips_local.get_prop_async( + did=central_did, siid=test_siid, piid=test_piid) + assert isinstance(result3, bool) + _LOGGER.info('get prop.%s.%s, value=%s', test_siid, test_piid, result3) + # Action + test_siid = 4 + test_aiid = 1 + in_list = [{'piid': 1, 'value': 'hello world.'}] + result4 = await mips_local.action_async( + did=central_did, siid=test_siid, aiid=test_aiid, + in_list=in_list) + assert isinstance(result4, dict) + _LOGGER.info( + 'action.%s.%s=%s, result=%s', test_siid, test_piid, in_list, result4) + # Disconnect + await mips_local.disconnect_async() + await mips_local.deinit_async() + + +@pytest.mark.asyncio +async def test_mips_cloud_async( + test_cache_path: str, + test_name_uuid: str, + test_cloud_server: str, + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_devices: str +): + """ + NOTICE: + - This test case requires running test_cloud.py first to obtain the + central connection certificate. + - This test case will control the indicator light switch of the central + gateway. + """ + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_storage import MIoTStorage + from miot.miot_mips import MipsCloudClient + from miot.miot_cloud import MIoTHttpClient + + miot_storage = MIoTStorage(test_cache_path) + uuid = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_uuid, type_=str) + assert isinstance(uuid, str) + oauth_info = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + access_token = oauth_info['access_token'] + _LOGGER.info('connect info, %s, %s', uuid, access_token) + mips_cloud = MipsCloudClient( + uuid=uuid, + cloud_server=test_cloud_server, + app_id=OAUTH2_CLIENT_ID, + token=access_token) + mips_cloud.enable_logger(logger=_LOGGER) + mips_cloud.enable_mqtt_logger(logger=_LOGGER) + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, + client_id=OAUTH2_CLIENT_ID, + access_token=access_token) + + async def on_mips_state_changed_async(key: str, state: bool): + _LOGGER.info('on mips state changed, %s, %s', key, state) + + def on_prop_changed(payload: dict, ctx: Any): + _LOGGER.info('prop changed, %s=%s', ctx, payload) + + def on_event_occurred(payload: dict, ctx: Any): + _LOGGER.info('event occurred, %s=%s', ctx, payload) + + await mips_cloud.connect_async() + await asyncio.sleep(0.5) + + # Sub mips state + mips_cloud.sub_mips_state( + key='mips_cloud', handler=on_mips_state_changed_async) + # Load devices + local_devices = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_devices, type_=dict) + assert isinstance(local_devices, dict) + central_did = '' + for did, info in local_devices.items(): + if info['model'] != 'xiaomi.gateway.hub1': + continue + central_did = did + break + if central_did: + # Sub Prop + mips_cloud.sub_prop( + did=central_did, handler=on_prop_changed, + handler_ctx=f'{central_did}.*') + # Sub Event + mips_cloud.sub_event( + did=central_did, handler=on_event_occurred, + handler_ctx=f'{central_did}.*') + # Get/set prop + test_siid = 3 + test_piid = 1 + # mips_cloud.sub_prop( + # did=central_did, siid=test_siid, piid=test_piid, + # handler=on_prop_changed, + # handler_ctx=f'{central_did}.{test_siid}.{test_piid}') + result1 = await miot_http.get_prop_async( + did=central_did, siid=test_siid, piid=test_piid) + assert isinstance(result1, bool) + _LOGGER.info('get prop.%s.%s, value=%s', test_siid, test_piid, result1) + result2 = await miot_http.set_prop_async(params=[{ + 'did': central_did, 'siid': test_siid, 'piid': test_piid, + 'value': not result1}]) + _LOGGER.info( + 'set prop.%s.%s=%s, result=%s', + test_siid, test_piid, not result1, result2) + assert isinstance(result2, list) + result3 = await miot_http.get_prop_async( + did=central_did, siid=test_siid, piid=test_piid) + assert isinstance(result3, bool) + _LOGGER.info('get prop.%s.%s, value=%s', test_siid, test_piid, result3) + # Action + test_siid = 4 + test_aiid = 1 + in_list = [{'piid': 1, 'value': 'hello world.'}] + result4 = await miot_http.action_async( + did=central_did, siid=test_siid, aiid=test_aiid, + in_list=in_list) + assert isinstance(result4, dict) + _LOGGER.info( + 'action.%s.%s=%s, result=%s', + test_siid, test_piid, in_list, result4) + await asyncio.sleep(1) + # Disconnect + await mips_cloud.disconnect_async() + await mips_cloud.deinit_async() + await miot_http.deinit_async() diff --git a/test/test_network.py b/test/test_network.py index aa81a4e..f59ddb2 100755 --- a/test/test_network.py +++ b/test/test_network.py @@ -1,8 +1,11 @@ # -*- coding: utf-8 -*- """Unit test for miot_network.py.""" +import logging import pytest import asyncio +_LOGGER = logging.getLogger(__name__) + # pylint: disable=import-outside-toplevel, unused-argument @@ -12,16 +15,16 @@ async def test_network_monitor_loop_async(): miot_net = MIoTNetwork() async def on_network_status_changed(status: bool): - print(f'on_network_status_changed, {status}') + _LOGGER.info('on_network_status_changed, %s', status) miot_net.sub_network_status(key='test', handler=on_network_status_changed) async def on_network_info_changed( status: InterfaceStatus, info: NetworkInfo): - print(f'on_network_info_changed, {status}, {info}') + _LOGGER.info('on_network_info_changed, %s, %s', status, info) miot_net.sub_network_info(key='test', handler=on_network_info_changed) - await miot_net.init_async(3) + await miot_net.init_async() await asyncio.sleep(3) - print(f'net status: {miot_net.network_status}') - print(f'net info: {miot_net.network_info}') + _LOGGER.info('net status: %s', miot_net.network_status) + _LOGGER.info('net info: %s', miot_net.network_info) await miot_net.deinit_async() diff --git a/test/test_spec.py b/test/test_spec.py index 57ccbb6..248e9d8 100755 --- a/test/test_spec.py +++ b/test/test_spec.py @@ -1,11 +1,14 @@ # -*- coding: utf-8 -*- """Unit test for miot_spec.py.""" import json +import logging import random import time from urllib.request import Request, urlopen import pytest +_LOGGER = logging.getLogger(__name__) + # pylint: disable=import-outside-toplevel, unused-argument @@ -79,10 +82,10 @@ async def test_spec_random_parse_async(test_cache_path, test_lang): storage = MIoTStorage(test_cache_path) spec_parser = MIoTSpecParser(lang=test_lang, storage=storage) await spec_parser.init_async() - start_ts: int = time.time()*1000 + start_ts = time.time()*1000 for index in test_urn_index: urn: str = test_urns[int(index)] result = await spec_parser.parse(urn=urn, skip_cache=True) assert result is not None - end_ts: int = time.time()*1000 - print(f'takes time, {test_count}, {end_ts-start_ts}') + end_ts = time.time()*1000 + _LOGGER.info('takes time, %s, %s', test_count, end_ts-start_ts) diff --git a/test/test_storage.py b/test/test_storage.py index 76ec510..ace0c53 100755 --- a/test/test_storage.py +++ b/test/test_storage.py @@ -1,9 +1,12 @@ # -*- coding: utf-8 -*- """Unit test for miot_storage.py.""" import asyncio +import logging from os import path import pytest +_LOGGER = logging.getLogger(__name__) + # pylint: disable=import-outside-toplevel, unused-argument @@ -101,7 +104,7 @@ async def test_multi_task_load_async(test_cache_path): for _ in range(task_count): task_list.append(asyncio.create_task(storage.load_async( domain=test_domain, name=name, type_=dict))) - print(f'\ntask count, {len(task_list)}') + _LOGGER.info('task count, %s', len(task_list)) result: list = await asyncio.gather(*task_list) assert None not in result @@ -178,28 +181,28 @@ async def test_user_config_async( config=config_update, replace=True) assert (config_replace := await storage.load_user_config_async( uid=test_uid, cloud_server=test_cloud_server)) == config_update - print('replace result, ', config_replace) + _LOGGER.info('replace result, %s', config_replace) # Test query query_keys = list(config_base.keys()) - print('query keys, ', query_keys) + _LOGGER.info('query keys, %s', query_keys) query_result = await storage.load_user_config_async( uid=test_uid, cloud_server=test_cloud_server, keys=query_keys) - print('query result 1, ', query_result) + _LOGGER.info('query result 1, %s', query_result) assert await storage.update_user_config_async( uid=test_uid, cloud_server=test_cloud_server, config=config_base, replace=True) query_result = await storage.load_user_config_async( uid=test_uid, cloud_server=test_cloud_server, keys=query_keys) - print('query result 2, ', query_result) + _LOGGER.info('query result 2, %s', query_result) query_result = await storage.load_user_config_async( uid=test_uid, cloud_server=test_cloud_server) - print('query result all, ', query_result) + _LOGGER.info('query result all, %s', query_result) # Remove config assert await storage.update_user_config_async( uid=test_uid, cloud_server=test_cloud_server, config=None) query_result = await storage.load_user_config_async( uid=test_uid, cloud_server=test_cloud_server) - print('remove result, ', query_result) + _LOGGER.info('remove result, %s', query_result) # Remove domain assert await storage.remove_domain_async(domain='miot_config')