diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 173ee77..1fca160 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -1,7 +1,7 @@ name: Bug Report / 报告问题 description: Create a report to help us improve. / 报告问题以帮助我们改进 body: - - type: input + - type: textarea attributes: label: Describe the Bug / 描述问题 description: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 899e3b8..65d5922 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,107 @@ # CHANGELOG +## v0.3.2 +> Xiaomi Home has been added to the Home Assistant Community Store (HACS) as a default since May 8, 2025. +### Added +- Modify MIoT-Spec-V2 property format by spec_modify.yaml. [#1111](https://github.com/XiaoMi/ha_xiaomi_home/pull/1111) +### Changed +- Update the instructions of Xiaomi Home integration installation from HACS. [#102](https://github.com/XiaoMi/ha_xiaomi_home/pull/102) [#1088](https://github.com/XiaoMi/ha_xiaomi_home/pull/1088) +- Add an alongside switch entity for zimi.waterheater.h03 and xiaomi.waterheater.yms2. [#1115](https://github.com/XiaoMi/ha_xiaomi_home/pull/1115) +### Fixed +- Fix Chinese encoding in LAN Control. [#1114](https://github.com/XiaoMi/ha_xiaomi_home/pull/1114) +- Fix the MIoT-Spec-V2 of lxzn.switch.jcbcsm power consumption, voltage and current, shhf.light.sfla10 fan direction, zhimi.fan.za4 fan-level, zhimi.fan.sa1 fan-level. [#1110](https://github.com/XiaoMi/ha_xiaomi_home/pull/1110) +- Revise the Chinese descriptions of loock.lock.t2pv1 door state value-list. [#1110](https://github.com/XiaoMi/ha_xiaomi_home/pull/1110) + +## v0.3.1 +### Changed +- Setting the fan speed level when the fan is off will turning the fan on first. [#1031](https://github.com/XiaoMi/ha_xiaomi_home/pull/1031) +### Fixed +- Fix update device list error when there is no shared devices. [#1024](https://github.com/XiaoMi/ha_xiaomi_home/pull/1024) +- Fix the humidifier get_prop_value error when the property is None. [#1035](https://github.com/XiaoMi/ha_xiaomi_home/pull/1035) +- Fix the MIoT-Spec-V2 of zhimi.fan.v3 fan-level, cuco.plug.cp1md voltage and current, zimi.plug.zncz01 electric-power, giot.plug.v8icm power-consumption unit, yunmi.kettle.r3 tds unit, and dmaker.fan.p5 fan-level. [#1037](https://github.com/XiaoMi/ha_xiaomi_home/pull/1037) + +## v0.3.0 +注意:v0.3.0 变更了部分实体 unique_id 的生成规则,如果勾选 xiaomi_home > 配置 > 更新实体转换规则,会导致部分实体已配置的自动化失效。如果想要避免重新配置大量自动化,可使用这个[补丁](https://github.com/XiaoMi/ha_xiaomi_home/pull/972)。 + +CAUTION: v0.3.0 changes the unique_id of some entities. If you check the option `xiaomi_home > CONFIGURE > Update entity conversion rules`, it may cause the automation settings for these entities to fail. To avoid having to reconfigure a large number of automation settings, you can use this [patch](https://github.com/XiaoMi/ha_xiaomi_home/pull/972). +### Added +- Import the devices in the shared homes and the separated shared devices. [#1021](https://github.com/XiaoMi/ha_xiaomi_home/pull/1021) +- Support _attr_hvac_action of the climate entity. [#956](https://github.com/XiaoMi/ha_xiaomi_home/pull/956) +- Add custom defined MIoT-Spec-V2 instance via spec_add.json. [#953](https://github.com/XiaoMi/ha_xiaomi_home/pull/953) +### Fixed +- Ignore 'Event loop is closed' when unsub a closed event loop. [#991](https://github.com/XiaoMi/ha_xiaomi_home/pull/991) +- Fix contact-state for linp.magnet.m1 and loock.safe.v1. [#977](https://github.com/XiaoMi/ha_xiaomi_home/pull/977) +- Fix the mode initialization error of aupu.bhf_light.s368m. [#955](https://github.com/XiaoMi/ha_xiaomi_home/pull/955) +- Fix the MIoT-Spec-V2 of lumi.gateway.mcn001, qmi.plug.psv3, lumi.motion.acn001, izq.sensor_occupy.24, linp.sensor_occupy.hb01 and yunmi.waterpuri.s20. [#949](https://github.com/XiaoMi/ha_xiaomi_home/pull/949) + +## v0.2.4 +### Added +- Convert the submersion-state, the contact-state and the occupancy-status property to the binary_sensor entity. [#905](https://github.com/XiaoMi/ha_xiaomi_home/pull/905) +### Changed +- suittc.airrtc.wk168 mode descriptions are set to strings of numbers from 1 to 16. [#921](https://github.com/XiaoMi/ha_xiaomi_home/pull/921) +- Do not set _attr_suggested_display_precision when the spec.expr is set in spec_modify.yaml [#929](https://github.com/XiaoMi/ha_xiaomi_home/pull/929) +- Set "unknown event msg" log to info level. +### Fixed +- hhcc.plantmonitor.v1 soil moisture and soil ec icon and unit. [#927](https://github.com/XiaoMi/ha_xiaomi_home/pull/27) +- cuco.plug.cp2 voltage and power value ratio. +- cgllc.airmonitor.s1 unit ppb. +- roswan.waterpuri.lte01 tds unit. +- lumi.relay.c2acn01 power consumption unit +- xiaomi.bhf_light.s1 fan level of ventilation. + +## v0.2.3 +### Changed +- Specify the service name and the property name during the climate entity's on/off feature initialization. [#899](https://github.com/XiaoMi/ha_xiaomi_home/pull/899) +- Remove the useless total-battery property from `SPEC_PROP_TRANS_MAP`. +### Fixed +- Fix the hvac mode setting error when changing the preset mode of the ptc-bath-heater. +- Fix the ambiguous descriptions of yeelink.bhf_light.v10 ptc-bath-heater mode value-list. +- Fix the power consumption value of chuangmi.plug.212a01. [#910](https://github.com/XiaoMi/ha_xiaomi_home/pull/910) + +## v0.2.2 +This version has modified the conversion rules of the climate entity, which will have effect on the devices with the ptc-bath-heater, the air-conditioner and the air-fresh service. After updating, you need to restart Home Assistant and check `xiaomi_home > CONFIGURE > +Update entity conversion rules > NEXT` to reload the integration. + +这个版本修改了浴霸、空调、新风机的实体转换规则,更新之后需要重启 Home Assistant,并且勾选 `xiaomi_home > 配置 > 更新实体转换规则 > 下一步` 重新加载集成。 +### Added +- Add conversion rules for the air-conditioner service and the air-fresh service. [#879](https://github.com/XiaoMi/ha_xiaomi_home/pull/879) +### Changed +- Convert the mode of the ptc bath heater to the preset mode of the climate entity. [#874](https://github.com/XiaoMi/ha_xiaomi_home/pull/874) +- Use Home Assistant default icon when device_class is set. [#855](https://github.com/XiaoMi/ha_xiaomi_home/pull/855) +### Fixed +- Fix xiaomi.aircondition.m9 humidity-range unit. [#878](https://github.com/XiaoMi/ha_xiaomi_home/pull/878) +- Fix MIoT-Spec-V2 conflicts of xiaomi.fan.p5 and mike.bhf_light.2. [#866](https://github.com/XiaoMi/ha_xiaomi_home/pull/866) + +## v0.2.1 +### Added +- Add the preset mode for the thermostat. [#833](https://github.com/XiaoMi/ha_xiaomi_home/pull/833) +### Changed +- Change paho-mqtt version to adapt Home Assistant 2025.03. [#839](https://github.com/XiaoMi/ha_xiaomi_home/pull/839) +- Revert to use multi_lang.json. [#834](https://github.com/XiaoMi/ha_xiaomi_home/pull/834) +### Fixed +- Fix the opening and the closing status of linp.wopener.wd1lb. [#826](https://github.com/XiaoMi/ha_xiaomi_home/pull/826) +- Fix the format type of the wind-reverse property. [#810](https://github.com/XiaoMi/ha_xiaomi_home/pull/810) +- Fix the fan-level property without value-list but with value-range. [#808](https://github.com/XiaoMi/ha_xiaomi_home/pull/808) + +## v0.2.0 +This version has modified some default units of sensors. After updating, it may cause Home Assistant to pop up some compatibility warnings. You can re-add the integration to resolve it. + +这个版本修改了一些传感器默认单位,更新后会导致 Home Assistant 弹出一些兼容性提示,您可以重新添加集成解决。 + +### Added +- Add prop trans rule for surge-power. [#595](https://github.com/XiaoMi/ha_xiaomi_home/pull/595) +- Support modify spec and value conversion. [#663](https://github.com/XiaoMi/ha_xiaomi_home/pull/663) +- Support the electric blanket. [#781](https://github.com/XiaoMi/ha_xiaomi_home/pull/781) +- Add device with motor-control service as cover entity. [#688](https://github.com/XiaoMi/ha_xiaomi_home/pull/688) +### Changed +- Update README file. [#681](https://github.com/XiaoMi/ha_xiaomi_home/pull/681) [#747](https://github.com/XiaoMi/ha_xiaomi_home/pull/747) +- Update CONTRIBUTING.md. [#681](https://github.com/XiaoMi/ha_xiaomi_home/pull/681) +- Refactor climate.py. [#614](https://github.com/XiaoMi/ha_xiaomi_home/pull/614) +### Fixed +- Fix variable name or comment errors & fix test_lan error. [#678](https://github.com/XiaoMi/ha_xiaomi_home/pull/678) [#690](https://github.com/XiaoMi/ha_xiaomi_home/pull/690) +- Fix water heater error & some type error. [#684](https://github.com/XiaoMi/ha_xiaomi_home/pull/684) +- Fix fan level with value-list & fan reverse [#689](https://github.com/XiaoMi/ha_xiaomi_home/pull/689) +- Fix sensor display precision [#708](https://github.com/XiaoMi/ha_xiaomi_home/pull/708) +- Fix event:motion-detected without arguments [#712](https://github.com/XiaoMi/ha_xiaomi_home/pull/712) ## v0.1.5b2 ### Added @@ -91,10 +194,10 @@ This version will cause some Xiaomi routers that do not support access (#564) to ### Changed ### Fixed - Fix humidifier trans rule. https://github.com/XiaoMi/ha_xiaomi_home/issues/59 -- Fix get homeinfo error. https://github.com/XiaoMi/ha_xiaomi_home/issues/22 +- Fix get homeinfo error. https://github.com/XiaoMi/ha_xiaomi_home/issues/22 - Fix air-conditioner switch on. https://github.com/XiaoMi/ha_xiaomi_home/issues/37 https://github.com/XiaoMi/ha_xiaomi_home/issues/16 -- Fix invalid cover status. https://github.com/XiaoMi/ha_xiaomi_home/issues/11 https://github.com/XiaoMi/ha_xiaomi_home/issues/85 -- Water heater entity add STATE_OFF. https://github.com/XiaoMi/ha_xiaomi_home/issues/105 https://github.com/XiaoMi/ha_xiaomi_home/issues/17 +- Fix invalid cover status. https://github.com/XiaoMi/ha_xiaomi_home/issues/11 https://github.com/XiaoMi/ha_xiaomi_home/issues/85 +- Water heater entity add STATE_OFF. https://github.com/XiaoMi/ha_xiaomi_home/issues/105 https://github.com/XiaoMi/ha_xiaomi_home/issues/17 ## v0.1.0 ### Added diff --git a/README.md b/README.md index e6539e7..22f8e6e 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,11 @@ git checkout v1.0.0 ### Method 2: [HACS](https://hacs.xyz/) -HACS > Overflow Menu > Custom repositories > Repository: https://github.com/XiaoMi/ha_xiaomi_home.git & Category or Type: Integration > ADD > Xiaomi Home in New or Available for download section of HACS > DOWNLOAD +One-click installation from HACS: -> Xiaomi Home has not been added to the HACS store as a default yet. It's coming soon. +[![Open your Home Assistant instance and open the Xiaomi Home integration inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=XiaoMi&repository=ha_xiaomi_home&category=integration) + +Or, HACS > In the search box, type **Xiaomi Home** > Click **Xiaomi Home**, getting into the detail page > DOWNLOAD ### Method 3: Manually installation via [Samba](https://github.com/home-assistant/addons/tree/master/samba) / [FTPS](https://github.com/hassio-addons/addon-ftp) @@ -47,7 +49,7 @@ Download and copy `custom_components/xiaomi_home` folder to `config/custom_compo [Settings > Devices & services > ADD INTEGRATION](https://my.home-assistant.io/redirect/brand/?brand=xiaomi_home) > Search `Xiaomi Home` > NEXT > Click here to login > Sign in with Xiaomi account -[![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=xiaomi_home) +[![Open your Home Assistant instance and start setting up a new Xiaomi Home integration instance.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=xiaomi_home) ### Add MIoT Devices @@ -59,7 +61,7 @@ After a Xiaomi account login and its user configuration are completed, you can c Method: [Settings > Devices & services > Configured > Xiaomi Home](https://my.home-assistant.io/redirect/integration/?domain=xiaomi_home) > ADD HUB > NEXT > Click here to login > Sign in with Xiaomi account -[![Open your Home Assistant instance and show an integration.](https://my.home-assistant.io/badges/integration.svg)](https://my.home-assistant.io/redirect/integration/?domain=xiaomi_home) +[![Open your Home Assistant instance and show Xiaomi Home integration.](https://my.home-assistant.io/badges/integration.svg)](https://my.home-assistant.io/redirect/integration/?domain=xiaomi_home) ### Update Configurations diff --git a/custom_components/xiaomi_home/__init__.py b/custom_components/xiaomi_home/__init__.py index fb1b5c7..b26cf0a 100644 --- a/custom_components/xiaomi_home/__init__.py +++ b/custom_components/xiaomi_home/__init__.py @@ -156,7 +156,8 @@ async def async_setup_entry( device.entity_list[platform].remove(entity) entity_id = device.gen_service_entity_id( ha_domain=platform, - siid=entity.spec.iid) # type: ignore + siid=entity.spec.iid, + description=entity.spec.description) if er.async_get(entity_id_or_uuid=entity_id): er.async_remove(entity_id=entity_id) if platform in device.prop_list: diff --git a/custom_components/xiaomi_home/binary_sensor.py b/custom_components/xiaomi_home/binary_sensor.py index aca45d8..8019104 100644 --- a/custom_components/xiaomi_home/binary_sensor.py +++ b/custom_components/xiaomi_home/binary_sensor.py @@ -89,4 +89,8 @@ class BinarySensor(MIoTPropertyEntity, BinarySensorEntity): @property def is_on(self) -> bool: """On/Off state. True if the binary sensor is on, False otherwise.""" + if self.spec.name == 'contact-state': + return self._value is False + elif self.spec.name == 'occupancy-status': + return bool(self._value) return self._value is True diff --git a/custom_components/xiaomi_home/climate.py b/custom_components/xiaomi_home/climate.py index 88140ab..5259de5 100644 --- a/custom_components/xiaomi_home/climate.py +++ b/custom_components/xiaomi_home/climate.py @@ -51,18 +51,11 @@ from typing import Any, Optional from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.const import UnitOfTemperature from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.components.climate import ( - SWING_ON, - SWING_OFF, - SWING_BOTH, - SWING_VERTICAL, - SWING_HORIZONTAL, - ATTR_TEMPERATURE, - HVACMode, - ClimateEntity, - ClimateEntityFeature -) + FAN_ON, FAN_OFF, SWING_OFF, SWING_BOTH, SWING_VERTICAL, SWING_HORIZONTAL, + ATTR_TEMPERATURE, HVACMode, HVACAction, ClimateEntity, ClimateEntityFeature) from .miot.const import DOMAIN from .miot.miot_device import MIoTDevice, MIoTServiceEntity, MIoTEntityData @@ -71,11 +64,8 @@ from .miot.miot_spec import MIoTSpecProperty _LOGGER = logging.getLogger(__name__) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback) -> None: """Set up a config entry.""" device_list: list[MIoTDevice] = hass.data[DOMAIN]['devices'][ config_entry.entry_id] @@ -88,77 +78,436 @@ async def async_setup_entry( for data in miot_device.entity_list.get('heater', []): new_entities.append( 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)) + for data in miot_device.entity_list.get('thermostat', []): + new_entities.append( + Thermostat(miot_device=miot_device, entity_data=data)) + for data in miot_device.entity_list.get('electric-blanket', []): + new_entities.append( + ElectricBlanket(miot_device=miot_device, entity_data=data)) if new_entities: async_add_entities(new_entities) -class AirConditioner(MIoTServiceEntity, ClimateEntity): - """Air conditioner entities for Xiaomi Home.""" - # service: air-conditioner +class FeatureOnOff(MIoTServiceEntity, ClimateEntity): + """TURN_ON and TURN_OFF feature of the climate entity.""" _prop_on: Optional[MIoTSpecProperty] - _prop_mode: Optional[MIoTSpecProperty] + + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: + """Initialize the feature class.""" + self._prop_on = None + + super().__init__(miot_device=miot_device, entity_data=entity_data) + + def _init_on_off(self, service_name: str, prop_name: str) -> None: + """Initialize the on_off feature.""" + for prop in self.entity_data.props: + if prop.name == prop_name and prop.service.name == service_name: + if prop.format_ != bool: + _LOGGER.error('wrong format %s %s, %s', service_name, + prop_name, self.entity_id) + continue + self._attr_supported_features |= ClimateEntityFeature.TURN_ON + self._attr_supported_features |= ClimateEntityFeature.TURN_OFF + self._prop_on = prop + break + + async def async_turn_on(self) -> None: + """Turn on.""" + await self.set_property_async(prop=self._prop_on, value=True) + + async def async_turn_off(self) -> None: + """Turn off.""" + await self.set_property_async(prop=self._prop_on, value=False) + + +class FeatureTargetTemperature(MIoTServiceEntity, ClimateEntity): + """TARGET_TEMPERATURE feature of the climate entity.""" _prop_target_temp: Optional[MIoTSpecProperty] - _prop_target_humi: Optional[MIoTSpecProperty] - # service: fan-control + + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: + """Initialize the feature class.""" + self._prop_target_temp = None + self._attr_temperature_unit = None + + super().__init__(miot_device=miot_device, entity_data=entity_data) + # properties + for prop in entity_data.props: + if prop.name == 'target-temperature': + 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_target_temperature_step = prop.value_range.step + self._attr_temperature_unit = prop.external_unit + self._attr_supported_features |= ( + ClimateEntityFeature.TARGET_TEMPERATURE) + self._prop_target_temp = prop + break + # temperature_unit is required by the climate entity + if not self._attr_temperature_unit: + self._attr_temperature_unit = UnitOfTemperature.CELSIUS + + async def async_set_temperature(self, **kwargs): + """Set the target temperature.""" + if ATTR_TEMPERATURE in kwargs: + temp = kwargs[ATTR_TEMPERATURE] + if temp > self._attr_max_temp: + temp = self._attr_max_temp + elif temp < self._attr_min_temp: + temp = self._attr_min_temp + + await self.set_property_async(prop=self._prop_target_temp, + value=temp) + + @property + def target_temperature(self) -> Optional[float]: + """The current target temperature.""" + return (self.get_prop_value( + prop=self._prop_target_temp) 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]] + + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: + """Initialize the feature class.""" + self._prop_mode = None + self._mode_map = None + + super().__init__(miot_device=miot_device, entity_data=entity_data) + + def _init_preset_modes(self, service_name: str, prop_name: str) -> None: + """Initialize the preset modes.""" + for prop in self.entity_data.props: + if prop.name == prop_name and prop.service.name == service_name: + if not prop.value_list: + _LOGGER.error('invalid %s %s value_list, %s', service_name, + prop_name, self.entity_id) + continue + self._mode_map = prop.value_list.to_map() + self._attr_preset_modes = prop.value_list.descriptions + self._attr_supported_features |= ( + ClimateEntityFeature.PRESET_MODE) + self._prop_mode = prop + break + + 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_key( + map_=self._mode_map, + value=preset_mode)) + + @property + def preset_mode(self) -> Optional[str]: + """The current preset mode.""" + return (self.get_map_value( + map_=self._mode_map, key=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]] + + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: + """Initialize the feature class.""" + self._prop_fan_on = None + self._prop_fan_level = None + self._fan_mode_map = None + self._attr_fan_modes = None + + super().__init__(miot_device=miot_device, entity_data=entity_data) + # properties + for prop in entity_data.props: + if (prop.name == 'fan-level' and + (prop.service.name == 'fan-control' or + prop.service.name == 'thermostat')): + if not prop.value_list: + _LOGGER.error('invalid fan-level value_list, %s', + self.entity_id) + continue + 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': + self._prop_fan_on = prop + self._attr_supported_features |= ClimateEntityFeature.FAN_MODE + + if self._prop_fan_on: + if self._attr_fan_modes is None: + self._attr_fan_modes = [FAN_ON, FAN_OFF] + else: + self._attr_fan_modes.append(FAN_OFF) + + async def async_set_fan_mode(self, fan_mode): + """Set the target fan mode.""" + if fan_mode == FAN_OFF: + await self.set_property_async(prop=self._prop_fan_on, value=False) + return + if fan_mode == FAN_ON: + await self.set_property_async(prop=self._prop_fan_on, value=True) + return + mode_value = self.get_map_key(map_=self._fan_mode_map, value=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}') + + @property + def fan_mode(self) -> Optional[str]: + """The current fan mode.""" + if self._prop_fan_level is None and self._prop_fan_on is None: + 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_value( + map_=self._fan_mode_map, + key=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] - # service: environment - _prop_env_temp: Optional[MIoTSpecProperty] - _prop_env_humi: Optional[MIoTSpecProperty] - # service: air-condition-outlet-matching + + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: + """Initialize the feature class.""" + self._prop_horizontal_swing = None + self._prop_vertical_swing = None + + super().__init__(miot_device=miot_device, entity_data=entity_data) + # properties + swing_modes = [] + for prop in entity_data.props: + if prop.name == 'horizontal-swing': + swing_modes.append(SWING_HORIZONTAL) + self._prop_horizontal_swing = prop + elif prop.name == 'vertical-swing': + swing_modes.append(SWING_VERTICAL) + self._prop_vertical_swing = prop + # swing modes + if SWING_HORIZONTAL in swing_modes and SWING_VERTICAL in swing_modes: + swing_modes.append(SWING_BOTH) + if swing_modes: + swing_modes.insert(0, SWING_OFF) + self._attr_supported_features |= ClimateEntityFeature.SWING_MODE + self._attr_swing_modes = swing_modes + + async def async_set_swing_mode(self, swing_mode): + """Set the target swing operation.""" + if swing_mode == SWING_BOTH: + await self.set_property_async(prop=self._prop_horizontal_swing, + value=True) + await self.set_property_async(prop=self._prop_vertical_swing, + value=True) + elif swing_mode == SWING_HORIZONTAL: + await self.set_property_async(prop=self._prop_horizontal_swing, + value=True) + elif swing_mode == SWING_VERTICAL: + await self.set_property_async(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) + if self._prop_vertical_swing: + await self.set_property_async(prop=self._prop_vertical_swing, + value=False) + else: + raise RuntimeError( + f'unknown swing_mode, {swing_mode}, {self.entity_id}') + + @property + def swing_mode(self) -> Optional[str]: + """The current swing mode of the fan.""" + if (self._prop_horizontal_swing is None and + self._prop_vertical_swing is None): + return None + horizontal: bool = (self.get_prop_value( + prop=self._prop_horizontal_swing) + 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 horizontal and vertical: + return SWING_BOTH + elif horizontal: + return SWING_HORIZONTAL + elif vertical: + return SWING_VERTICAL + else: + return SWING_OFF + + +class FeatureTemperature(MIoTServiceEntity, ClimateEntity): + """Temperature of the climate entity.""" + _prop_env_temperature: Optional[MIoTSpecProperty] + + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: + """Initialize the feature class.""" + self._prop_env_temperature = None + + super().__init__(miot_device=miot_device, entity_data=entity_data) + # properties + for prop in entity_data.props: + if prop.name == 'temperature': + self._prop_env_temperature = prop + break + + @property + def current_temperature(self) -> Optional[float]: + """The current environment temperature.""" + return (self.get_prop_value(prop=self._prop_env_temperature) + if self._prop_env_temperature else None) + + +class FeatureHumidity(MIoTServiceEntity, ClimateEntity): + """Humidity of the climate entity.""" + _prop_env_humidity: Optional[MIoTSpecProperty] + + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: + """Initialize the feature class.""" + self._prop_env_humidity = None + + super().__init__(miot_device=miot_device, entity_data=entity_data) + # properties + for prop in entity_data.props: + if prop.name == 'relative-humidity': + self._prop_env_humidity = prop + break + + @property + def current_humidity(self) -> Optional[float]: + """The current environment humidity.""" + return (self.get_prop_value( + prop=self._prop_env_humidity) 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__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: + """Initialize the feature class.""" + self._prop_target_humidity = None + + super().__init__(miot_device=miot_device, entity_data=entity_data) + # properties + for prop in entity_data.props: + if prop.name == 'target-humidity': + 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_supported_features |= ( + ClimateEntityFeature.TARGET_HUMIDITY) + self._prop_target_humidity = prop + break + + async def async_set_humidity(self, humidity): + """Set the target humidity.""" + if humidity > self._attr_max_humidity: + humidity = self._attr_max_humidity + elif humidity < self._attr_min_humidity: + humidity = self._attr_min_humidity + await self.set_property_async(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) + + +class Heater(FeatureOnOff, FeatureTargetTemperature, FeatureTemperature, + FeatureHumidity, FeaturePresetMode): + """Heater""" + + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: + """Initialize the heater.""" + super().__init__(miot_device=miot_device, entity_data=entity_data) + + self._attr_icon = 'mdi:radiator' + # hvac modes + self._attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] + # on/off + self._init_on_off('heater', 'on') + # preset modes + self._init_preset_modes('heater', 'heat-level') + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the target hvac mode.""" + await self.set_property_async( + prop=self._prop_on, + 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) + + @property + def hvac_action(self) -> Optional[HVACAction]: + """The current hvac action.""" + if self.hvac_mode == HVACMode.HEAT: + return HVACAction.HEATING + return HVACAction.OFF + + +class AirConditioner(FeatureOnOff, FeatureTargetTemperature, + FeatureTargetHumidity, FeatureTemperature, FeatureHumidity, + FeatureFanMode, FeatureSwingMode): + """Air conditioner""" + _prop_mode: Optional[MIoTSpecProperty] + _hvac_mode_map: Optional[dict[int, HVACMode]] _prop_ac_state: Optional[MIoTSpecProperty] _value_ac_state: Optional[dict[str, int]] - _hvac_mode_map: Optional[dict[int, HVACMode]] - _fan_mode_map: Optional[dict[int, str]] - - def __init__( - self, miot_device: MIoTDevice, entity_data: MIoTEntityData - ) -> None: - """Initialize the Air conditioner.""" - super().__init__(miot_device=miot_device, entity_data=entity_data) - self._attr_icon = 'mdi:air-conditioner' - self._attr_supported_features = ClimateEntityFeature(0) - self._attr_swing_mode = None - self._attr_swing_modes = [] - - self._prop_on = None + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: + """Initialize the air conditioner.""" self._prop_mode = None - self._prop_target_temp = None - self._prop_target_humi = None - self._prop_fan_on = None - self._prop_fan_level = None - self._prop_horizontal_swing = None - self._prop_vertical_swing = None - self._prop_env_temp = None - self._prop_env_humi = None + self._hvac_mode_map = None self._prop_ac_state = None self._value_ac_state = None - self._hvac_mode_map = None - self._fan_mode_map = None - # properties + super().__init__(miot_device=miot_device, entity_data=entity_data) + self._attr_icon = 'mdi:air-conditioner' + # on/off + self._init_on_off('air-conditioner', 'on') + # hvac modes + self._attr_hvac_modes = None for prop in entity_data.props: - if prop.name == 'on': - if prop.service.name == 'air-conditioner': - self._attr_supported_features |= ( - ClimateEntityFeature.TURN_ON) - self._attr_supported_features |= ( - ClimateEntityFeature.TURN_OFF) - self._prop_on = prop - elif prop.service.name == 'fan-control': - self._attr_swing_modes.append(SWING_ON) - self._prop_fan_on = prop - else: - _LOGGER.error( - 'unknown on property, %s', self.entity_id) - elif prop.name == 'mode': + if prop.name == 'mode' and prop.service.name == 'air-conditioner': if not prop.value_list: - _LOGGER.error( - 'invalid mode value_list, %s', self.entity_id) + _LOGGER.error('invalid mode value_list, %s', self.entity_id) continue self._hvac_mode_map = {} for item in prop.value_list.items: @@ -176,239 +525,71 @@ class AirConditioner(MIoTServiceEntity, ClimateEntity): 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 == 'target-temperature': - 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_target_temperature_step = prop.value_range.step - self._attr_temperature_unit = prop.external_unit - self._attr_supported_features |= ( - ClimateEntityFeature.TARGET_TEMPERATURE) - self._prop_target_temp = prop - elif prop.name == 'target-humidity': - 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_supported_features |= ( - ClimateEntityFeature.TARGET_HUMIDITY) - self._prop_target_humi = prop - elif prop.name == 'fan-level': - if not prop.value_list: - _LOGGER.error( - 'invalid fan-level value_list, %s', self.entity_id) - continue - self._fan_mode_map = prop.value_list.to_map() - self._attr_fan_modes = list(self._fan_mode_map.values()) - self._attr_supported_features |= ClimateEntityFeature.FAN_MODE - self._prop_fan_level = prop - elif prop.name == 'horizontal-swing': - self._attr_swing_modes.append(SWING_HORIZONTAL) - self._prop_horizontal_swing = prop - elif prop.name == 'vertical-swing': - self._attr_swing_modes.append(SWING_VERTICAL) - self._prop_vertical_swing = prop - elif prop.name == 'temperature': - self._prop_env_temp = prop - elif prop.name == 'relative-humidity': - self._prop_env_humi = 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) + self.sub_prop_changed(prop=prop, + handler=self.__ac_state_changed) - # hvac modes - if HVACMode.OFF not in self._attr_hvac_modes: + if self._attr_hvac_modes is None: + self._attr_hvac_modes = [HVACMode.OFF] + elif HVACMode.OFF not in self._attr_hvac_modes: self._attr_hvac_modes.append(HVACMode.OFF) - # swing modes - if ( - SWING_HORIZONTAL in self._attr_swing_modes - and SWING_VERTICAL in self._attr_swing_modes - ): - self._attr_swing_modes.append(SWING_BOTH) - if self._attr_swing_modes: - self._attr_swing_modes.insert(0, SWING_OFF) - self._attr_supported_features |= ClimateEntityFeature.SWING_MODE - - async def async_turn_on(self) -> None: - """Turn the entity on.""" - await self.set_property_async(prop=self._prop_on, value=True) - - async def async_turn_off(self) -> None: - """Turn the entity off.""" - await self.set_property_async(prop=self._prop_on, value=False) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set new target hvac mode.""" - # set air-conditioner off + """Set the target hvac mode.""" + # set the device off if hvac_mode == HVACMode.OFF: - if not await self.set_property_async( - prop=self._prop_on, value=False): - raise RuntimeError( - f'set climate prop.on failed, {hvac_mode}, ' - f'{self.entity_id}') + if not await self.set_property_async(prop=self._prop_on, + value=False): + raise RuntimeError(f'set climate prop.on failed, {hvac_mode}, ' + f'{self.entity_id}') return - # set air-conditioner on + # set the device on if self.get_prop_value(prop=self._prop_on) is False: - await self.set_property_async( - prop=self._prop_on, value=True, write_ha_state=False) + await self.set_property_async(prop=self._prop_on, + value=True, + write_ha_state=False) # set mode - mode_value = self.get_map_key( - map_=self._hvac_mode_map, value=hvac_mode) - if ( - not mode_value or - not await self.set_property_async( - prop=self._prop_mode, value=mode_value) - ): + if self._prop_mode is None: + return + mode_value = self.get_map_key(map_=self._hvac_mode_map, value=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}') - async def async_set_temperature(self, **kwargs): - """Set new target temperature.""" - if ATTR_TEMPERATURE in kwargs: - temp = kwargs[ATTR_TEMPERATURE] - if temp > self.max_temp: - temp = self.max_temp - elif temp < self.min_temp: - temp = self.min_temp - - await self.set_property_async( - prop=self._prop_target_temp, value=temp) - - async def async_set_humidity(self, humidity): - """Set new target humidity.""" - if humidity > self.max_humidity: - humidity = self.max_humidity - elif humidity < self.min_humidity: - humidity = self.min_humidity - await self.set_property_async( - prop=self._prop_target_humi, value=humidity) - - async def async_set_swing_mode(self, swing_mode): - """Set new target swing operation.""" - if swing_mode == SWING_BOTH: - await self.set_property_async( - prop=self._prop_horizontal_swing, value=True, - write_ha_state=False) - await self.set_property_async( - prop=self._prop_vertical_swing, value=True) - elif swing_mode == SWING_HORIZONTAL: - await self.set_property_async( - prop=self._prop_horizontal_swing, value=True) - elif swing_mode == SWING_VERTICAL: - await self.set_property_async( - prop=self._prop_vertical_swing, value=True) - elif swing_mode == SWING_ON: - await self.set_property_async( - prop=self._prop_fan_on, value=True) - elif swing_mode == SWING_OFF: - if self._prop_fan_on: - await self.set_property_async( - prop=self._prop_fan_on, value=False, - write_ha_state=False) - if self._prop_horizontal_swing: - await self.set_property_async( - prop=self._prop_horizontal_swing, value=False, - write_ha_state=False) - if self._prop_vertical_swing: - await self.set_property_async( - prop=self._prop_vertical_swing, value=False, - write_ha_state=False) - self.async_write_ha_state() - else: - raise RuntimeError( - f'unknown swing_mode, {swing_mode}, {self.entity_id}') - - async def async_set_fan_mode(self, fan_mode): - """Set new target fan mode.""" - mode_value = self.get_map_key( - map_=self._fan_mode_map, value=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}') - - @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 - - @property - def target_humidity(self) -> Optional[int]: - """Return the target humidity.""" - return self.get_prop_value( - prop=self._prop_target_humi) if self._prop_target_humi else None - - @property - def current_temperature(self) -> Optional[float]: - """Return the current temperature.""" - return self.get_prop_value( - prop=self._prop_env_temp) if self._prop_env_temp else None - - @property - def current_humidity(self) -> Optional[int]: - """Return the current humidity.""" - return self.get_prop_value( - prop=self._prop_env_humi) if self._prop_env_humi else None - @property def hvac_mode(self) -> Optional[HVACMode]: - """Return the hvac mode. e.g., heat, cool mode.""" + """The current hvac mode.""" if self.get_prop_value(prop=self._prop_on) is False: return HVACMode.OFF - return self.get_map_value( - map_=self._hvac_mode_map, - key=self.get_prop_value(prop=self._prop_mode)) + return (self.get_map_value(map_=self._hvac_mode_map, + key=self.get_prop_value( + prop=self._prop_mode)) + if self._prop_mode else None) @property - def fan_mode(self) -> Optional[str]: - """Return the fan mode. - - Requires ClimateEntityFeature.FAN_MODE. - """ - return self.get_map_value( - map_=self._fan_mode_map, - key=self.get_prop_value(prop=self._prop_fan_level)) - - @property - def swing_mode(self) -> Optional[str]: - """Return the swing mode. - - Requires ClimateEntityFeature.SWING_MODE. - """ - horizontal = ( - self.get_prop_value(prop=self._prop_horizontal_swing)) - vertical = ( - self.get_prop_value(prop=self._prop_vertical_swing)) - if horizontal and vertical: - return SWING_BOTH - if horizontal: - return SWING_HORIZONTAL - if vertical: - return SWING_VERTICAL - if self._prop_fan_on: - if self.get_prop_value(prop=self._prop_fan_on): - return SWING_ON - else: - return SWING_OFF - return None + def hvac_action(self) -> Optional[HVACAction]: + """The current hvac action.""" + if self.hvac_mode is None: + return None + if self.hvac_mode == HVACMode.OFF: + return HVACAction.OFF + if self.hvac_mode == HVACMode.FAN_ONLY: + return HVACAction.FAN + if self.hvac_mode == HVACMode.COOL: + return HVACAction.COOLING + if self.hvac_mode == HVACMode.HEAT: + return HVACAction.HEATING + if self.hvac_mode == HVACMode.DRY: + return HVACAction.DRYING + return HVACAction.IDLE def __ac_state_changed(self, prop: MIoTSpecProperty, value: Any) -> None: del prop if not isinstance(value, str): - _LOGGER.error( - 'ac_status value format error, %s', value) + _LOGGER.error('ac_status value format error, %s', value) return v_ac_state = {} v_split = value.split('_') @@ -422,8 +603,7 @@ class AirConditioner(MIoTServiceEntity, ClimateEntity): _LOGGER.error('ac_status value error, %s', item) # P: status. 0: on, 1: off if 'P' in v_ac_state and self._prop_on: - self.set_prop_value(prop=self._prop_on, - value=v_ac_state['P'] == 0) + self.set_prop_value(prop=self._prop_on, value=v_ac_state['P'] == 0) # M: model. 0: cool, 1: heat, 2: auto, 3: fan, 4: dry if 'M' in v_ac_state and self._prop_mode: mode: Optional[HVACMode] = { @@ -431,12 +611,12 @@ class AirConditioner(MIoTServiceEntity, ClimateEntity): 1: HVACMode.HEAT, 2: HVACMode.AUTO, 3: HVACMode.FAN_ONLY, - 4: HVACMode.DRY + 4: HVACMode.DRY, }.get(v_ac_state['M'], None) if mode: - self.set_prop_value( - prop=self._prop_mode, value=self.get_map_key( - map_=self._hvac_mode_map, value=mode)) + self.set_prop_value(prop=self._prop_mode, + value=self.get_map_key( + map_=self._hvac_mode_map, value=mode)) # T: target temperature if 'T' in v_ac_state and self._prop_target_temp: self.set_prop_value(prop=self._prop_target_temp, @@ -446,162 +626,138 @@ class AirConditioner(MIoTServiceEntity, ClimateEntity): self.set_prop_value(prop=self._prop_fan_level, value=v_ac_state['S']) # D: swing mode. 0: on, 1: off - if ( - 'D' in v_ac_state - and self._attr_swing_modes - and len(self._attr_swing_modes) == 2 - ): - if ( - SWING_HORIZONTAL in self._attr_swing_modes - and self._prop_horizontal_swing - ): - self.set_prop_value( - 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) - if self._value_ac_state: - self._value_ac_state.update(v_ac_state) - _LOGGER.debug( - 'ac_state update, %s', self._value_ac_state) + if ('D' in v_ac_state and self._attr_swing_modes and + len(self._attr_swing_modes) == 2): + if (SWING_HORIZONTAL in self._attr_swing_modes and + self._prop_horizontal_swing): + self.set_prop_value(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) + + self._value_ac_state.update(v_ac_state) + _LOGGER.debug('ac_state update, %s', self._value_ac_state) -class Heater(MIoTServiceEntity, ClimateEntity): - """Heater entities for Xiaomi Home.""" - # service: heater - _prop_on: Optional[MIoTSpecProperty] +class PtcBathHeater(FeatureTargetTemperature, FeatureTemperature, + FeatureFanMode, FeatureSwingMode, FeaturePresetMode): + """Ptc bath heater""" _prop_mode: Optional[MIoTSpecProperty] - _prop_target_temp: Optional[MIoTSpecProperty] - _prop_heat_level: Optional[MIoTSpecProperty] - # service: environment - _prop_env_temp: Optional[MIoTSpecProperty] - _prop_env_humi: Optional[MIoTSpecProperty] + _hvac_mode_map: Optional[dict[int, HVACMode]] - _heat_level_map: Optional[dict[int, str]] - - def __init__( - self, miot_device: MIoTDevice, entity_data: MIoTEntityData - ) -> None: - """Initialize the Heater.""" - super().__init__(miot_device=miot_device, entity_data=entity_data) - self._attr_icon = 'mdi:air-conditioner' - self._attr_supported_features = ClimateEntityFeature(0) - self._attr_preset_modes = [] - - self._prop_on = None + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: + """Initialize the ptc bath heater.""" self._prop_mode = None - self._prop_target_temp = None - self._prop_heat_level = None - self._prop_env_temp = None - self._prop_env_humi = None - self._heat_level_map = None - - # properties - for prop in entity_data.props: - if prop.name == 'on': - self._attr_supported_features |= ( - ClimateEntityFeature.TURN_ON) - self._attr_supported_features |= ( - ClimateEntityFeature.TURN_OFF) - self._prop_on = prop - elif prop.name == 'target-temperature': - 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_target_temperature_step = prop.value_range.step - self._attr_temperature_unit = prop.external_unit - self._attr_supported_features |= ( - ClimateEntityFeature.TARGET_TEMPERATURE) - self._prop_target_temp = prop - elif prop.name == 'heat-level': - if not prop.value_list: - _LOGGER.error( - 'invalid heat-level value_list, %s', self.entity_id) - continue - self._heat_level_map = prop.value_list.to_map() - self._attr_preset_modes = list(self._heat_level_map.values()) - self._attr_supported_features |= ( - ClimateEntityFeature.PRESET_MODE) - self._prop_heat_level = prop - elif prop.name == 'temperature': - self._prop_env_temp = prop - elif prop.name == 'relative-humidity': - self._prop_env_humi = prop + self._hvac_mode_map = None + super().__init__(miot_device=miot_device, entity_data=entity_data) + self._attr_icon = 'mdi:hvac' # hvac modes - self._attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] - - async def async_turn_on(self) -> None: - """Turn the entity on.""" - await self.set_property_async(prop=self._prop_on, value=True) - - async def async_turn_off(self) -> None: - """Turn the entity off.""" - await self.set_property_async(prop=self._prop_on, value=False) + for prop in entity_data.props: + if prop.name == 'mode' and prop.service.name == 'ptc-bath-heater': + 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.items: + if item.name in {'off', 'idle'}: + self._hvac_mode_map[item.value] = HVACMode.OFF + break + if self._hvac_mode_map: + self._attr_hvac_modes = [HVACMode.AUTO, HVACMode.OFF] + else: + _LOGGER.error('no idle mode, %s', self.entity_id) + # preset modes + self._init_preset_modes('ptc-bath-heater', 'mode') async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set new target hvac mode.""" - await self.set_property_async( - prop=self._prop_on, value=False - if hvac_mode == HVACMode.OFF else True) - - async def async_set_temperature(self, **kwargs): - """Set new target temperature.""" - if ATTR_TEMPERATURE in kwargs: - temp = kwargs[ATTR_TEMPERATURE] - if temp > self.max_temp: - temp = self.max_temp - elif temp < self.min_temp: - temp = self.min_temp - - await self.set_property_async( - prop=self._prop_target_temp, value=temp) - - async def async_set_preset_mode(self, preset_mode: str) -> None: - """Set the preset mode.""" - await self.set_property_async( - self._prop_heat_level, - value=self.get_map_key( - map_=self._heat_level_map, value=preset_mode)) - - @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 - - @property - def current_temperature(self) -> Optional[float]: - """Return the current temperature.""" - return self.get_prop_value( - prop=self._prop_env_temp) if self._prop_env_temp else None - - @property - def current_humidity(self) -> Optional[int]: - """Return the current humidity.""" - return self.get_prop_value( - prop=self._prop_env_humi) if self._prop_env_humi else None + """Set the target hvac mode.""" + if self._prop_mode is None or hvac_mode != HVACMode.OFF: + return + mode_value = self.get_map_key(map_=self._hvac_mode_map, value=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 ptc-bath-heater {hvac_mode} failed, {self.entity_id}') @property def hvac_mode(self) -> Optional[HVACMode]: - """Return the hvac mode.""" - return ( - HVACMode.HEAT if self.get_prop_value(prop=self._prop_on) - else HVACMode.OFF) + """The current hvac mode.""" + if self._prop_mode is None: + return None + current_mode = self.get_prop_value(prop=self._prop_mode) + if current_mode is None: + return None + mode_value = self.get_map_value(map_=self._hvac_mode_map, + key=current_mode) + return HVACMode.OFF if mode_value == HVACMode.OFF else HVACMode.AUTO + + +class Thermostat(FeatureOnOff, FeatureTargetTemperature, FeatureTemperature, + FeatureHumidity, FeatureFanMode, FeaturePresetMode): + """Thermostat""" + + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: + """Initialize the thermostat.""" + super().__init__(miot_device=miot_device, entity_data=entity_data) + + self._attr_icon = 'mdi:thermostat' + # hvac modes + self._attr_hvac_modes = [HVACMode.AUTO, HVACMode.OFF] + # on/off + self._init_on_off('thermostat', 'on') + # preset modes + self._init_preset_modes('thermostat', 'mode') + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the target hvac mode.""" + await self.set_property_async( + prop=self._prop_on, + value=False if hvac_mode == HVACMode.OFF else True) @property - def preset_mode(self) -> Optional[str]: - return ( - self.get_map_value( - map_=self._heat_level_map, - key=self.get_prop_value(prop=self._prop_heat_level)) - if self._prop_heat_level else None) + def hvac_mode(self) -> Optional[HVACMode]: + """The current hvac mode.""" + return (HVACMode.AUTO if self.get_prop_value( + prop=self._prop_on) else HVACMode.OFF) + + +class ElectricBlanket(FeatureOnOff, FeatureTargetTemperature, + FeatureTemperature, FeaturePresetMode): + """Electric blanket""" + + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: + """Initialize the electric blanket.""" + super().__init__(miot_device=miot_device, entity_data=entity_data) + + self._attr_icon = 'mdi:rug' + # hvac modes + self._attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] + # on/off + self._init_on_off('electric-blanket', 'on') + # preset modes + self._init_preset_modes('electric-blanket', 'mode') + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the target hvac mode.""" + await self.set_property_async( + prop=self._prop_on, + 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) + + @property + def hvac_action(self) -> Optional[HVACAction]: + """The current hvac action.""" + if self.hvac_mode == HVACMode.OFF: + return HVACAction.OFF + return HVACAction.HEATING diff --git a/custom_components/xiaomi_home/config_flow.py b/custom_components/xiaomi_home/config_flow.py index 7c0d20a..9d89a06 100644 --- a/custom_components/xiaomi_home/config_flow.py +++ b/custom_components/xiaomi_home/config_flow.py @@ -565,27 +565,32 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): home_list = {} tip_devices = self._miot_i18n.translate(key='config.other.devices') # home list - for home_id, home_info in self._cc_home_info[ - 'homes']['home_list'].items(): - # i18n - tip_central = '' - group_id = home_info.get('group_id', None) - dev_list = { - device['did']: device - for device in list(self._cc_home_info['devices'].values()) - if device.get('home_id', None) == home_id} - if ( - mips_list - and group_id in mips_list - and mips_list[group_id].get('did', None) in dev_list - ): + for device_source in ['home_list','share_home_list', + 'separated_shared_list']: + if device_source not in self._cc_home_info['homes']: + continue + for home_id, home_info in self._cc_home_info[ + 'homes'][device_source].items(): # i18n - tip_central = self._miot_i18n.translate( - key='config.other.found_central_gateway') - home_info['central_did'] = mips_list[group_id].get('did', None) - home_list[home_id] = ( - f'{home_info["home_name"]} ' - f'[ {len(dev_list)} {tip_devices} {tip_central} ]') + tip_central = '' + group_id = home_info.get('group_id', None) + dev_list = { + device['did']: device + for device in list(self._cc_home_info['devices'].values()) + if device.get('home_id', None) == home_id} + if ( + mips_list + and group_id in mips_list + and mips_list[group_id].get('did', None) in dev_list + ): + # i18n + tip_central = self._miot_i18n.translate( + key='config.other.found_central_gateway') + home_info['central_did'] = mips_list[group_id].get( + 'did', None) + home_list[home_id] = ( + f'{home_info["home_name"]} ' + f'[ {len(dev_list)} {tip_devices} {tip_central} ]') self._cc_home_list_show = dict(sorted(home_list.items())) @@ -660,10 +665,14 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if not home_selected: return await self.__show_homes_select_form( 'no_family_selected') - for home_id, home_info in self._cc_home_info[ - 'homes']['home_list'].items(): - if home_id in home_selected: - self._home_selected[home_id] = home_info + for device_source in ['home_list','share_home_list', + 'separated_shared_list']: + if device_source not in self._cc_home_info['homes']: + continue + for home_id, home_info in self._cc_home_info[ + 'homes'][device_source].items(): + if home_id in home_selected: + self._home_selected[home_id] = home_info self._area_name_rule = user_input.get( 'area_name_rule', self._area_name_rule) # Storage device list @@ -1420,27 +1429,31 @@ class OptionsFlowHandler(config_entries.OptionsFlow): home_list = {} tip_devices = self._miot_i18n.translate(key='config.other.devices') # home list - for home_id, home_info in self._cc_home_info[ - 'homes']['home_list'].items(): - # i18n - tip_central = '' - group_id = home_info.get('group_id', None) - did_list = { - device['did']: device for device in list( - self._cc_home_info['devices'].values()) - if device.get('home_id', None) == home_id} - if ( - group_id in mips_list - and mips_list[group_id].get('did', None) in did_list - ): + for device_source in ['home_list','share_home_list', + 'separated_shared_list']: + if device_source not in self._cc_home_info['homes']: + continue + for home_id, home_info in self._cc_home_info[ + 'homes'][device_source].items(): # i18n - tip_central = self._miot_i18n.translate( - key='config.other.found_central_gateway') - home_info['central_did'] = mips_list[group_id].get( - 'did', None) - home_list[home_id] = ( - f'{home_info["home_name"]} ' - f'[ {len(did_list)} {tip_devices} {tip_central} ]') + tip_central = '' + group_id = home_info.get('group_id', None) + did_list = { + device['did']: device for device in list( + self._cc_home_info['devices'].values()) + if device.get('home_id', None) == home_id} + if ( + group_id in mips_list + and mips_list[group_id].get('did', None) in did_list + ): + # i18n + tip_central = self._miot_i18n.translate( + key='config.other.found_central_gateway') + home_info['central_did'] = mips_list[group_id].get( + 'did', None) + home_list[home_id] = ( + f'{home_info["home_name"]} ' + f'[ {len(did_list)} {tip_devices} {tip_central} ]') # Remove deleted item self._home_selected_list = [ home_id for home_id in self._home_selected_list @@ -1460,10 +1473,14 @@ class OptionsFlowHandler(config_entries.OptionsFlow): return await self.__show_homes_select_form('no_family_selected') self._ctrl_mode = user_input.get('ctrl_mode', self._ctrl_mode) self._home_selected = {} - for home_id, home_info in self._cc_home_info[ - 'homes']['home_list'].items(): - if home_id in self._home_selected_list: - self._home_selected[home_id] = home_info + for device_source in ['home_list','share_home_list', + 'separated_shared_list']: + if device_source not in self._cc_home_info['homes']: + continue + for home_id, home_info in self._cc_home_info[ + 'homes'][device_source].items(): + if home_id in self._home_selected_list: + self._home_selected[home_id] = home_info # Get device list device_list: dict = { did: dev_info diff --git a/custom_components/xiaomi_home/cover.py b/custom_components/xiaomi_home/cover.py index f2ebaeb..08398e6 100644 --- a/custom_components/xiaomi_home/cover.py +++ b/custom_components/xiaomi_home/cover.py @@ -47,30 +47,24 @@ Cover entities for Xiaomi Home. """ from __future__ import annotations import logging -from typing import Optional +from typing import Any, Optional from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.components.cover import ( - ATTR_POSITION, - CoverEntity, - CoverEntityFeature, - CoverDeviceClass -) +from homeassistant.components.cover import (ATTR_POSITION, CoverEntity, + CoverEntityFeature, + CoverDeviceClass) from .miot.miot_spec import MIoTSpecProperty -from .miot.miot_device import MIoTDevice, MIoTEntityData, MIoTServiceEntity +from .miot.miot_device import MIoTDevice, MIoTEntityData, MIoTServiceEntity from .miot.const import DOMAIN _LOGGER = logging.getLogger(__name__) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback) -> None: """Set up a config entry.""" device_list: list[MIoTDevice] = hass.data[DOMAIN]['devices'][ config_entry.entry_id] @@ -82,8 +76,12 @@ async def async_setup_entry( data.spec.device_class = CoverDeviceClass.CURTAIN elif data.spec.name == 'window-opener': data.spec.device_class = CoverDeviceClass.WINDOW - new_entities.append( - Cover(miot_device=miot_device, entity_data=data)) + elif data.spec.name == 'motor-controller': + data.spec.device_class = CoverDeviceClass.SHUTTER + elif data.spec.name == 'airer': + data.spec.device_class = CoverDeviceClass.BLIND + new_entities.append(Cover(miot_device=miot_device, + entity_data=data)) if new_entities: async_add_entities(new_entities) @@ -97,18 +95,20 @@ class Cover(MIoTServiceEntity, CoverEntity): _prop_motor_value_close: Optional[int] _prop_motor_value_pause: Optional[int] _prop_status: Optional[MIoTSpecProperty] - _prop_status_opening: Optional[int] - _prop_status_closing: Optional[int] - _prop_status_stop: Optional[int] + _prop_status_opening: Optional[list[int]] + _prop_status_closing: Optional[list[int]] + _prop_status_stop: Optional[list[int]] + _prop_status_closed: Optional[list[int]] _prop_current_position: Optional[MIoTSpecProperty] _prop_target_position: Optional[MIoTSpecProperty] _prop_position_value_min: Optional[int] _prop_position_value_max: Optional[int] _prop_position_value_range: Optional[int] + _prop_pos_closing: bool + _prop_pos_opening: bool - def __init__( - self, miot_device: MIoTDevice, entity_data: MIoTEntityData - ) -> None: + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: """Initialize the Cover.""" super().__init__(miot_device=miot_device, entity_data=entity_data) self._attr_device_class = entity_data.spec.device_class @@ -120,50 +120,64 @@ class Cover(MIoTServiceEntity, CoverEntity): self._prop_motor_value_close = None self._prop_motor_value_pause = None self._prop_status = None - self._prop_status_opening = None - self._prop_status_closing = None - self._prop_status_stop = None + self._prop_status_opening = [] + self._prop_status_closing = [] + self._prop_status_stop = [] + self._prop_status_closed = [] self._prop_current_position = None self._prop_target_position = None self._prop_position_value_min = None self._prop_position_value_max = None self._prop_position_value_range = None + self._prop_pos_closing = False + self._prop_pos_opening = False # properties for prop in entity_data.props: if prop.name == 'motor-control': if not prop.value_list: - _LOGGER.error( - 'motor-control value_list is None, %s', self.entity_id) + _LOGGER.error('motor-control value_list is None, %s', + self.entity_id) continue for item in prop.value_list.items: - if item.name in {'open'}: + if item.name in {'open', 'up'}: self._attr_supported_features |= ( CoverEntityFeature.OPEN) self._prop_motor_value_open = item.value - elif item.name in {'close'}: + elif item.name in {'close', 'down'}: self._attr_supported_features |= ( CoverEntityFeature.CLOSE) self._prop_motor_value_close = item.value - elif item.name in {'pause'}: + elif item.name in {'pause', 'stop'}: self._attr_supported_features |= ( CoverEntityFeature.STOP) self._prop_motor_value_pause = item.value self._prop_motor_control = prop elif prop.name == 'status': if not prop.value_list: - _LOGGER.error( - 'status value_list is None, %s', self.entity_id) + _LOGGER.error('status value_list is None, %s', + self.entity_id) continue 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 + if item.name in {'opening', 'open', 'up'}: + self._prop_status_opening.append(item.value) + elif item.name in {'closing', 'close', 'down'}: + self._prop_status_closing.append(item.value) + elif item.name in {'stop', 'stopped', 'pause'}: + self._prop_status_stop.append(item.value) + elif item.name in {'closed'}: + self._prop_status_closed.append(item.value) self._prop_status = prop elif prop.name == 'current-position': + if not prop.value_range: + _LOGGER.error( + 'invalid current-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_range = (prop.value_range.max_ - + prop.value_range.min_) self._prop_current_position = prop elif prop.name == 'target-position': if not prop.value_range: @@ -173,35 +187,65 @@ class Cover(MIoTServiceEntity, CoverEntity): continue 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) + self._prop_position_value_range = (prop.value_range.max_ - + prop.value_range.min_) self._attr_supported_features |= CoverEntityFeature.SET_POSITION self._prop_target_position = prop + # For the device that has the current position property but no status + # property, the current position property will be used to determine the + # opening and the closing status. + if (self._prop_status is None) and (self._prop_current_position + is not None): + self.sub_prop_changed(self._prop_current_position, + self._position_changed_handler) + + def _position_changed_handler(self, prop: MIoTSpecProperty, + ctx: Any) -> None: + self._prop_pos_closing = False + self._prop_pos_opening = False + self.async_write_ha_state() async def async_open_cover(self, **kwargs) -> None: """Open the cover.""" - await self.set_property_async( - self._prop_motor_control, self._prop_motor_value_open) + current = None if (self._prop_current_position + is None) else self.get_prop_value( + prop=self._prop_current_position) + if (current is not None) and (current < self._prop_position_value_max): + self._prop_pos_opening = True + self._prop_pos_closing = False + await self.set_property_async(self._prop_motor_control, + self._prop_motor_value_open) async def async_close_cover(self, **kwargs) -> None: """Close the cover.""" - await self.set_property_async( - self._prop_motor_control, self._prop_motor_value_close) + current = None if (self._prop_current_position + is None) else self.get_prop_value( + prop=self._prop_current_position) + if (current is not None) and (current > self._prop_position_value_min): + self._prop_pos_opening = False + self._prop_pos_closing = True + await self.set_property_async(self._prop_motor_control, + self._prop_motor_value_close) async def async_stop_cover(self, **kwargs) -> None: """Stop the cover.""" - await self.set_property_async( - self._prop_motor_control, self._prop_motor_value_pause) + self._prop_pos_opening = False + self._prop_pos_closing = False + await self.set_property_async(self._prop_motor_control, + self._prop_motor_value_pause) async def async_set_cover_position(self, **kwargs) -> None: """Set the position of the cover.""" pos = kwargs.get(ATTR_POSITION, None) if pos is None: return None - pos = round(pos*self._prop_position_value_range/100) - await self.set_property_async( - prop=self._prop_target_position, value=pos) + current = self.current_cover_position + if current is not None: + self._prop_pos_opening = pos > current + self._prop_pos_closing = pos < current + pos = round(pos * self._prop_position_value_range / 100) + await self.set_property_async(prop=self._prop_target_position, + value=pos) @property def current_cover_position(self) -> Optional[int]: @@ -209,28 +253,47 @@ class Cover(MIoTServiceEntity, CoverEntity): 0: the cover is closed, 100: the cover is fully opened, None: unknown. """ + if self._prop_current_position is None: + # Assume that the current position is the same as the target + # position when the current position is not defined in the device's + # MIoT-Spec-V2. + if self._prop_target_position is None: + return None + self._prop_pos_opening = False + self._prop_pos_closing = False + return self.get_prop_value(prop=self._prop_target_position) pos = self.get_prop_value(prop=self._prop_current_position) - if pos is None: - return None - return round(pos*100/self._prop_position_value_range) + return None if pos is None else round(pos * 100 / + self._prop_position_value_range) @property def is_opening(self) -> Optional[bool]: """Return if the cover is opening.""" - if self._prop_status is None: - return None - return self.get_prop_value( - prop=self._prop_status) == self._prop_status_opening + if self._prop_status and self._prop_status_opening: + return (self.get_prop_value(prop=self._prop_status) + in self._prop_status_opening) + # The status has higher priority when determining whether the cover + # is opening. + return self._prop_pos_opening @property def is_closing(self) -> Optional[bool]: """Return if the cover is closing.""" - if self._prop_status is None: - return None - return self.get_prop_value( - prop=self._prop_status) == self._prop_status_closing + if self._prop_status and self._prop_status_closing: + return (self.get_prop_value(prop=self._prop_status) + in self._prop_status_closing) + # The status has higher priority when determining whether the cover + # is closing. + return self._prop_pos_closing @property def is_closed(self) -> Optional[bool]: """Return if the cover is closed.""" - return self.get_prop_value(prop=self._prop_current_position) == 0 + if self.current_cover_position is not None: + return self.current_cover_position == 0 + # The current position is prior to the status when determining + # whether the cover is closed. + if self._prop_status and self._prop_status_closed: + return (self.get_prop_value(prop=self._prop_status) + in self._prop_status_closed) + return None diff --git a/custom_components/xiaomi_home/event.py b/custom_components/xiaomi_home/event.py index 85fbf33..27720c8 100644 --- a/custom_components/xiaomi_home/event.py +++ b/custom_components/xiaomi_home/event.py @@ -46,6 +46,7 @@ off Xiaomi or its affiliates' products. Event entities for Xiaomi Home. """ from __future__ import annotations +import logging from typing import Any from homeassistant.config_entries import ConfigEntry @@ -57,6 +58,8 @@ from .miot.miot_spec import MIoTSpecEvent from .miot.miot_device import MIoTDevice, MIoTEventEntity from .miot.const import DOMAIN +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, @@ -89,4 +92,5 @@ class Event(MIoTEventEntity, EventEntity): self, name: str, arguments: dict[str, Any] | None = None ) -> None: """An event is occurred.""" + _LOGGER.debug('%s, attributes: %s', name, str(arguments)) 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 962c789..0743cf3 100644 --- a/custom_components/xiaomi_home/fan.py +++ b/custom_components/xiaomi_home/fan.py @@ -172,7 +172,7 @@ class Fan(MIoTServiceEntity, FanEntity): self._attr_supported_features |= FanEntityFeature.OSCILLATE self._prop_horizontal_swing = prop elif prop.name == 'wind-reverse': - if prop.format_ == 'bool': + if prop.format_ == bool: self._prop_wind_reverse_forward = False self._prop_wind_reverse_reverse = True elif prop.value_list: @@ -186,7 +186,7 @@ class Fan(MIoTServiceEntity, FanEntity): or self._prop_wind_reverse_reverse is None ): # NOTICE: Value may be 0 or False - _LOGGER.info( + _LOGGER.error( 'invalid wind-reverse, %s', self.entity_id) continue self._attr_supported_features |= FanEntityFeature.DIRECTION @@ -236,6 +236,9 @@ class Fan(MIoTServiceEntity, FanEntity): async def async_set_percentage(self, percentage: int) -> None: """Set the percentage of the fan speed.""" if percentage > 0: + if not self.is_on: + # If the fan is off, turn it on. + await self.set_property_async(prop=self._prop_on, value=True) if self._speed_names: await self.set_property_async( prop=self._prop_fan_level, @@ -249,9 +252,6 @@ class Fan(MIoTServiceEntity, FanEntity): value=int(percentage_to_ranged_value( low_high_range=(self._speed_min, self._speed_max), percentage=percentage))) - if not self.is_on: - # If the fan is off, turn it on. - await self.set_property_async(prop=self._prop_on, value=True) else: await self.set_property_async(prop=self._prop_on, value=False) diff --git a/custom_components/xiaomi_home/humidifier.py b/custom_components/xiaomi_home/humidifier.py index 1bcd5c8..ef9cc23 100644 --- a/custom_components/xiaomi_home/humidifier.py +++ b/custom_components/xiaomi_home/humidifier.py @@ -52,23 +52,22 @@ from typing import Any, Optional from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.components.humidifier import ( - HumidifierEntity, - HumidifierDeviceClass, - HumidifierEntityFeature -) +from homeassistant.components.humidifier import (HumidifierEntity, + HumidifierDeviceClass, + HumidifierEntityFeature, + HumidifierAction) from .miot.miot_spec import MIoTSpecProperty -from .miot.miot_device import MIoTDevice, MIoTEntityData, MIoTServiceEntity +from .miot.miot_device import MIoTDevice, MIoTEntityData, MIoTServiceEntity from .miot.const import DOMAIN _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up a config entry.""" device_list: list[MIoTDevice] = hass.data[DOMAIN]['devices'][ @@ -82,8 +81,8 @@ async def async_setup_entry( Humidifier(miot_device=miot_device, entity_data=data)) for data in miot_device.entity_list.get('dehumidifier', []): data.device_class = HumidifierDeviceClass.DEHUMIDIFIER - new_entities.append(Humidifier( - miot_device=miot_device, entity_data=data)) + new_entities.append( + Humidifier(miot_device=miot_device, entity_data=data)) if new_entities: async_add_entities(new_entities) @@ -99,9 +98,8 @@ class Humidifier(MIoTServiceEntity, HumidifierEntity): _mode_map: dict[Any, Any] - def __init__( - self, miot_device: MIoTDevice, entity_data: MIoTEntityData - ) -> None: + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: """Initialize the Humidifier.""" super().__init__(miot_device=miot_device, entity_data=entity_data) self._attr_device_class = entity_data.device_class @@ -130,12 +128,10 @@ class Humidifier(MIoTServiceEntity, HumidifierEntity): # mode elif prop.name == 'mode': if not prop.value_list: - _LOGGER.error( - 'mode value_list is None, %s', self.entity_id) + _LOGGER.error('mode value_list is None, %s', self.entity_id) continue self._mode_map = prop.value_list.to_map() - self._attr_available_modes = list( - self._mode_map.values()) + self._attr_available_modes = list(self._mode_map.values()) self._attr_supported_features |= HumidifierEntityFeature.MODES self._prop_mode = prop # relative-humidity @@ -152,33 +148,45 @@ class Humidifier(MIoTServiceEntity, HumidifierEntity): async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" - await self.set_property_async( - prop=self._prop_target_humidity, value=humidity) + if self._prop_target_humidity is None: + return + await self.set_property_async(prop=self._prop_target_humidity, + value=humidity) 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_map_key(map_=self._mode_map, value=mode)) + await self.set_property_async(prop=self._prop_mode, + value=self.get_map_key( + map_=self._mode_map, value=mode)) @property def is_on(self) -> Optional[bool]: """Return if the humidifier is on.""" return self.get_prop_value(prop=self._prop_on) + @property + def action(self) -> Optional[HumidifierAction]: + """Return the current status of the device.""" + if not self.is_on: + return HumidifierAction.OFF + if self._attr_device_class == HumidifierDeviceClass.HUMIDIFIER: + return HumidifierAction.HUMIDIFYING + return HumidifierAction.DRYING + @property def current_humidity(self) -> Optional[int]: """Return the current humidity.""" - return self.get_prop_value(prop=self._prop_humidity) + return (self.get_prop_value( + prop=self._prop_humidity) if self._prop_humidity else None) @property def target_humidity(self) -> Optional[int]: """Return the target humidity.""" - return self.get_prop_value(prop=self._prop_target_humidity) + return (self.get_prop_value(prop=self._prop_target_humidity) + if self._prop_target_humidity else None) @property def mode(self) -> Optional[str]: """Return the current preset mode.""" - return self.get_map_value( - map_=self._mode_map, - key=self.get_prop_value(prop=self._prop_mode)) + return self.get_map_value(map_=self._mode_map, + key=self.get_prop_value(prop=self._prop_mode)) diff --git a/custom_components/xiaomi_home/light.py b/custom_components/xiaomi_home/light.py index ef9fed2..26ed208 100644 --- a/custom_components/xiaomi_home/light.py +++ b/custom_components/xiaomi_home/light.py @@ -179,7 +179,7 @@ class Light(MIoTServiceEntity, LightEntity): ) / prop.value_range.step) > self._VALUE_RANGE_MODE_COUNT_MAX ): - _LOGGER.info( + _LOGGER.error( 'too many mode values, %s, %s, %s', self.entity_id, prop.name, prop.value_range) else: diff --git a/custom_components/xiaomi_home/manifest.json b/custom_components/xiaomi_home/manifest.json index ca5d71e..9d3c97e 100644 --- a/custom_components/xiaomi_home/manifest.json +++ b/custom_components/xiaomi_home/manifest.json @@ -20,13 +20,13 @@ ], "requirements": [ "construct>=2.10.56", - "paho-mqtt<2.0.0", + "paho-mqtt", "numpy", "cryptography", "psutil" ], - "version": "v0.1.5b2", + "version": "v0.3.2", "zeroconf": [ "_miot-central._tcp.local." ] -} \ No newline at end of file +} diff --git a/custom_components/xiaomi_home/miot/miot_client.py b/custom_components/xiaomi_home/miot/miot_client.py index 8f89f8d..58f506d 100644 --- a/custom_components/xiaomi_home/miot/miot_client.py +++ b/custom_components/xiaomi_home/miot/miot_client.py @@ -646,7 +646,8 @@ class MIoTClient: result = await self._miot_lan.set_prop_async( did=did, siid=siid, piid=piid, value=value) _LOGGER.debug( - 'lan set prop, %s, %s, %s -> %s', did, siid, piid, result) + 'lan set prop, %s.%d.%d, %s -> %s', + did, siid, piid, value, result) rc = (result or {}).get( 'code', MIoTErrorCode.CODE_MIPS_INVALID_RESULT.value) if rc in [0, 1]: @@ -879,16 +880,7 @@ class MIoTClient: 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) + self.__unsub_from(sub_from, did) # Storage await self._storage.save_async( domain='miot_devices', @@ -936,6 +928,39 @@ class MIoTClient: delay_sec, lambda: self._main_loop.create_task( self.refresh_user_cert_async())) + @final + def __unsub_from(self, sub_from: str, did: str) -> None: + mips: Any = None + if sub_from == 'cloud': + mips = self._mips_cloud + elif sub_from == 'lan': + mips = self._miot_lan + elif sub_from in self._mips_local: + mips = self._mips_local[sub_from] + if mips is not None: + try: + mips.unsub_prop(did=did) + mips.unsub_event(did=did) + except RuntimeError as e: + if 'Event loop is closed' in str(e): + # Ignore unsub exception when loop is closed + pass + else: + raise + + @final + def __sub_from(self, sub_from: str, did: str) -> None: + mips = None + if sub_from == 'cloud': + mips = self._mips_cloud + elif sub_from == 'lan': + mips = self._miot_lan + elif sub_from in self._mips_local: + mips = self._mips_local[sub_from] + if mips is not None: + mips.sub_prop(did=did, handler=self.__on_prop_msg) + mips.sub_event(did=did, handler=self.__on_event_msg) + @final def __update_device_msg_sub(self, did: str) -> None: if did not in self._device_list_cache: @@ -967,27 +992,9 @@ class MIoTClient: return # Unsub old if from_old: - if from_old == 'cloud': - self._mips_cloud.unsub_prop(did=did) - self._mips_cloud.unsub_event(did=did) - elif from_old == 'lan': - self._miot_lan.unsub_prop(did=did) - self._miot_lan.unsub_event(did=did) - elif from_old in self._mips_local: - mips = self._mips_local[from_old] - mips.unsub_prop(did=did) - mips.unsub_event(did=did) + self.__unsub_from(from_old, did) # Sub new - if from_new == 'cloud': - self._mips_cloud.sub_prop(did=did, handler=self.__on_prop_msg) - self._mips_cloud.sub_event(did=did, handler=self.__on_event_msg) - elif from_new == 'lan': - self._miot_lan.sub_prop(did=did, handler=self.__on_prop_msg) - self._miot_lan.sub_event(did=did, handler=self.__on_event_msg) - elif from_new in self._mips_local: - mips = self._mips_local[from_new] - mips.sub_prop(did=did, handler=self.__on_prop_msg) - mips.sub_event(did=did, handler=self.__on_event_msg) + self.__sub_from(from_new, did) self._sub_source_list[did] = from_new _LOGGER.info( 'device sub changed, %s, from %s to %s', did, from_old, from_new) diff --git a/custom_components/xiaomi_home/miot/miot_cloud.py b/custom_components/xiaomi_home/miot/miot_cloud.py index 1911d82..0b301e8 100644 --- a/custom_components/xiaomi_home/miot/miot_cloud.py +++ b/custom_components/xiaomi_home/miot/miot_cloud.py @@ -444,6 +444,17 @@ class MIoTHttpClient: return home_list + async def get_separated_shared_devices_async(self) -> dict[str, dict]: + separated_shared_devices: dict = {} + device_list: dict[str, dict] = await self.__get_device_list_page_async( + dids=[], start_did=None) + for did, value in device_list.items(): + if value['owner'] is not None and ('userid' in value['owner']) and ( + 'nickname' in value['owner'] + ): + separated_shared_devices.setdefault(did, value['owner']) + return separated_shared_devices + async def get_homeinfos_async(self) -> dict: res_obj = await self.__mihome_api_post_async( url_path='/app/v2/homeroom/gethome', @@ -499,19 +510,22 @@ class MIoTHttpClient: ): more_list = await self.__get_dev_room_page_async( max_id=res_obj['result']['max_id']) - for home_id, info in more_list.items(): - if home_id not in home_infos['homelist']: - _LOGGER.info('unknown home, %s, %s', home_id, info) - continue - home_infos['homelist'][home_id]['dids'].extend(info['dids']) - for room_id, info in info['room_info'].items(): - home_infos['homelist'][home_id]['room_info'].setdefault( - room_id, { - 'room_id': room_id, - 'room_name': '', - 'dids': []}) - home_infos['homelist'][home_id]['room_info'][ - room_id]['dids'].extend(info['dids']) + for device_source in ['homelist', 'share_home_list']: + for home_id, info in more_list.items(): + if home_id not in home_infos[device_source]: + _LOGGER.info('unknown home, %s, %s', home_id, info) + continue + home_infos[device_source][home_id]['dids'].extend( + info['dids']) + for room_id, info in info['room_info'].items(): + home_infos[device_source][home_id][ + 'room_info'].setdefault( + room_id, { + 'room_id': room_id, + 'room_name': '', + 'dids': []}) + home_infos[device_source][home_id]['room_info'][ + room_id]['dids'].extend(info['dids']) return { 'uid': uid, @@ -651,6 +665,25 @@ class MIoTHttpClient: 'room_name': room_name, 'group_id': group_id } for did in room_info.get('dids', [])}) + separated_shared_devices: dict = ( + await self.get_separated_shared_devices_async()) + if separated_shared_devices: + homes.setdefault('separated_shared_list', {}) + for did, owner in separated_shared_devices.items(): + owner_id = str(owner['userid']) + homes['separated_shared_list'].setdefault(owner_id,{ + 'home_name': owner['nickname'], + 'uid': owner_id, + 'group_id': 'NotSupport', + 'room_info': {'shared_device': 'shared_device'} + }) + devices.update({did: { + 'home_id': owner_id, + 'home_name': owner['nickname'], + 'room_id': 'shared_device', + 'room_name': 'shared_device', + 'group_id': 'NotSupport' + }}) dids = sorted(list(devices.keys())) results = await self.get_devices_with_dids_async(dids=dids) if results is None: diff --git a/custom_components/xiaomi_home/miot/miot_device.py b/custom_components/xiaomi_home/miot/miot_device.py index 1f3f186..e3394c9 100644 --- a/custom_components/xiaomi_home/miot/miot_device.py +++ b/custom_components/xiaomi_home/miot/miot_device.py @@ -345,10 +345,11 @@ class MIoTDevice: f'{ha_domain}.{self._model_strs[0][:9]}_{self.did_tag}_' f'{self._model_strs[-1][:20]}') - def gen_service_entity_id(self, ha_domain: str, siid: int) -> str: + def gen_service_entity_id(self, ha_domain: str, siid: int, + description: str) -> str: return ( f'{ha_domain}.{self._model_strs[0][:9]}_{self.did_tag}_' - f'{self._model_strs[-1][:20]}_s_{siid}') + f'{self._model_strs[-1][:20]}_s_{siid}_{description}') def gen_prop_entity_id( self, ha_domain: str, spec_name: str, siid: int, piid: int @@ -549,6 +550,10 @@ class MIoTDevice: # Optional actions # Optional events miot_service.platform = platform + # entity_category + if entity_category := SPEC_SERVICE_TRANS_MAP[service_name].get( + 'entity_category', None): + miot_service.entity_category = entity_category return entity_data def parse_miot_property_entity(self, miot_prop: MIoTSpecProperty) -> bool: @@ -587,13 +592,8 @@ class MIoTDevice: # Priority: spec_modify.unit > unit_convert > specv2entity.unit miot_prop.external_unit = SPEC_PROP_TRANS_MAP['properties'][ prop_name]['unit_of_measurement'] - if ( - not miot_prop.icon - and 'icon' in SPEC_PROP_TRANS_MAP['properties'][prop_name] - ): - # Priority: spec_modify.icon > icon_convert > specv2entity.icon - miot_prop.icon = SPEC_PROP_TRANS_MAP['properties'][prop_name][ - 'icon'] + # Priority: default.icon when device_class is set > spec_modify.icon + # > icon_convert miot_prop.platform = platform return True @@ -779,8 +779,10 @@ class MIoTDevice: # pylint: disable=import-outside-toplevel from homeassistant.const import UnitOfConductivity # type: ignore unit_map['μS/cm'] = UnitOfConductivity.MICROSIEMENS_PER_CM + unit_map['mWh'] = UnitOfEnergy.MILLIWATT_HOUR except Exception: # pylint: disable=broad-except unit_map['μS/cm'] = 'μS/cm' + unit_map['mWh'] = 'mWh' return unit_map.get(spec_unit, None) @@ -895,10 +897,12 @@ class MIoTServiceEntity(Entity): self._attr_name = f' {self.entity_data.spec.description_trans}' elif isinstance(self.entity_data.spec, MIoTSpecService): self.entity_id = miot_device.gen_service_entity_id( - DOMAIN, siid=self.entity_data.spec.iid) + DOMAIN, siid=self.entity_data.spec.iid, + description=self.entity_data.spec.description) self._attr_name = ( f'{"* "if self.entity_data.spec.proprietary else " "}' f'{self.entity_data.spec.description_trans}') + self._attr_entity_category = entity_data.spec.entity_category # Set entity attr self._attr_unique_id = self.entity_id self._attr_should_poll = False diff --git a/custom_components/xiaomi_home/miot/miot_lan.py b/custom_components/xiaomi_home/miot/miot_lan.py index fd9ff47..5c56a55 100644 --- a/custom_components/xiaomi_home/miot/miot_lan.py +++ b/custom_components/xiaomi_home/miot/miot_lan.py @@ -226,7 +226,7 @@ class _MIoTLanDevice: def gen_packet( self, out_buffer: bytearray, clear_data: dict, did: str, offset: int ) -> int: - clear_bytes = json.dumps(clear_data).encode('utf-8') + clear_bytes = json.dumps(clear_data, ensure_ascii=False).encode('utf-8') padder = padding.PKCS7(algorithms.AES128.block_size).padder() padded_data = padder.update(clear_bytes) + padder.finalize() if len(padded_data) + self.OT_HEADER_LEN > len(out_buffer): diff --git a/custom_components/xiaomi_home/miot/miot_mips.py b/custom_components/xiaomi_home/miot/miot_mips.py index 2187488..f1a4534 100644 --- a/custom_components/xiaomi_home/miot/miot_mips.py +++ b/custom_components/xiaomi_home/miot/miot_mips.py @@ -1213,10 +1213,13 @@ class MipsLocalClient(_MipsClient): or 'did' not in msg or 'siid' not in msg or 'eiid' not in msg - or 'arguments' not in msg + # or 'arguments' not in msg ): - # self.log_error(f'on_event_msg, recv unknown msg, {payload}') + self.log_info('unknown event msg, %s', payload) return + if 'arguments' not in msg: + self.log_info('wrong event msg, %s', payload) + msg['arguments'] = [] if handler: self.log_debug('local, on event_occurred, %s', payload) handler(msg, ctx) diff --git a/custom_components/xiaomi_home/miot/miot_spec.py b/custom_components/xiaomi_home/miot/miot_spec.py index eaede61..9cabdcb 100644 --- a/custom_components/xiaomi_home/miot/miot_spec.py +++ b/custom_components/xiaomi_home/miot/miot_spec.py @@ -53,10 +53,9 @@ 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 .common import MIoTHttp, load_yaml_file, load_json_file from .miot_error import MIoTSpecError from .miot_storage import MIoTStorage @@ -78,11 +77,8 @@ class MIoTSpecValueRange: raise MIoTSpecError('invalid value range format') 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 - ): + 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'] @@ -96,11 +92,7 @@ class MIoTSpecValueRange: self.step = value_range[2] def dump(self) -> dict: - return { - 'min': self.min_, - 'max': self.max_, - 'step': self.step - } + return {'min': self.min_, 'max': self.max_, 'step': self.step} def __str__(self) -> str: return f'[{self.min_}, {self.max_}, {self.step}' @@ -128,11 +120,8 @@ class MIoTSpecValueListItem: @staticmethod def from_spec(item: dict) -> 'MIoTSpecValueListItem': - if ( - 'name' not in item - or 'value' not in item - or 'description' not in item - ): + 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 = { @@ -240,15 +229,10 @@ class _SpecStdLib: self._spec_std_lib = 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 - or 'services' not in std_lib - or 'properties' not in std_lib - or 'events' not in std_lib - or 'actions' not in std_lib - or 'values' not in std_lib - ): + if (not isinstance(std_lib, dict) or 'devices' not in std_lib or + 'services' not in std_lib or 'properties' not in std_lib or + 'events' not in std_lib or 'actions' not in std_lib or + 'values' not in std_lib): return self._devices = std_lib['devices'] self._services = std_lib['services'] @@ -261,48 +245,42 @@ class _SpecStdLib: if not self._devices or key not in self._devices: return None if self._lang not in self._devices[key]: - return self._devices[key].get( - DEFAULT_INTEGRATION_LANGUAGE, None) + return self._devices[key].get(DEFAULT_INTEGRATION_LANGUAGE, None) return self._devices[key][self._lang] def service_translate(self, key: str) -> Optional[str]: if not self._services or key not in self._services: return None if self._lang not in self._services[key]: - return self._services[key].get( - DEFAULT_INTEGRATION_LANGUAGE, None) + return self._services[key].get(DEFAULT_INTEGRATION_LANGUAGE, None) return self._services[key][self._lang] def property_translate(self, key: str) -> Optional[str]: if not self._properties or key not in self._properties: return None if self._lang not in self._properties[key]: - return self._properties[key].get( - DEFAULT_INTEGRATION_LANGUAGE, None) + return self._properties[key].get(DEFAULT_INTEGRATION_LANGUAGE, None) return self._properties[key][self._lang] def event_translate(self, key: str) -> Optional[str]: if not self._events or key not in self._events: return None if self._lang not in self._events[key]: - return self._events[key].get( - DEFAULT_INTEGRATION_LANGUAGE, None) + return self._events[key].get(DEFAULT_INTEGRATION_LANGUAGE, None) return self._events[key][self._lang] def action_translate(self, key: str) -> Optional[str]: if not self._actions or key not in self._actions: return None if self._lang not in self._actions[key]: - return self._actions[key].get( - DEFAULT_INTEGRATION_LANGUAGE, None) + return self._actions[key].get(DEFAULT_INTEGRATION_LANGUAGE, None) return self._actions[key][self._lang] def value_translate(self, key: str) -> Optional[str]: if not self._values or key not in self._values: return None if self._lang not in self._values[key]: - return self._values[key].get( - DEFAULT_INTEGRATION_LANGUAGE, None) + return self._values[key].get(DEFAULT_INTEGRATION_LANGUAGE, None) return self._values[key][self._lang] def dump(self) -> dict[str, dict[str, dict[str, str]]]: @@ -329,10 +307,12 @@ class _SpecStdLib: tasks: list = [] # Get std lib for name in [ - 'device', 'service', 'property', 'event', 'action']: - tasks.append(self.__get_template_list( - 'https://miot-spec.org/miot-spec-v2/template/list/' - + name)) + 'device', 'service', 'property', 'event', 'action' + ]: + tasks.append( + self.__get_template_list( + 'https://miot-spec.org/miot-spec-v2/template/list/' + + name)) tasks.append(self.__get_property_value()) # Async request results = await asyncio.gather(*tasks) @@ -349,11 +329,13 @@ class _SpecStdLib: # Get external std lib, Power by LM tasks.clear() for name in [ - 'device', 'service', 'property', 'event', 'action', - 'property_value']: - tasks.append(MIoTHttp.get_json_async( - 'https://cdn.cnbj1.fds.api.mi-img.com/res-conf/' - f'xiaomi-home/std_ex_{name}.json')) + 'device', 'service', 'property', 'event', 'action', + 'property_value' + ]: + 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) if results[0]: for key, value in results[0].items(): @@ -402,12 +384,11 @@ class _SpecStdLib: else: std_libs['values'][key] = value else: - _LOGGER.error( - 'get external std lib failed, values') + _LOGGER.error('get external std lib failed, values') return std_libs except Exception as err: # pylint: disable=broad-exception-caught - _LOGGER.error( - 'update spec std lib error, retry, %d, %s', index, err) + _LOGGER.error('update spec std lib error, retry, %d, %s', index, + err) return None async def __get_property_value(self) -> dict: @@ -418,20 +399,15 @@ class _SpecStdLib: raise MIoTSpecError('get property value failed') result = {} for item in reply['result']: - if ( - not isinstance(item, dict) - or 'normalization' not in item - or 'description' not in item - or 'proName' not in item - or 'urn' not in item - ): + if (not isinstance(item, dict) or 'normalization' not in item or + 'description' not in item or 'proName' not in item or + 'urn' not in item): continue result[ - f'{item["urn"]}|{item["proName"]}|{item["normalization"]}' - ] = { - 'zh-Hans': item['description'], - 'en': item['normalization'] - } + f'{item["urn"]}|{item["proName"]}|{item["normalization"]}'] = { + 'zh-Hans': item['description'], + 'en': item['normalization'] + } return result async def __get_template_list(self, url: str) -> dict: @@ -440,11 +416,8 @@ class _SpecStdLib: raise MIoTSpecError(f'get service failed, {url}') result: dict = {} for item in reply['result']: - if ( - not isinstance(item, dict) - or 'type' not in item - or 'description' not in item - ): + if (not isinstance(item, dict) or 'type' not in item or + 'description' not in item): continue if 'zh_cn' in item['description']: item['description']['zh-Hans'] = item['description'].pop( @@ -465,7 +438,7 @@ class _MIoTSpecBase: iid: int type_: str description: str - description_trans: str + description_trans: Optional[str] proprietary: bool need_filter: bool name: str @@ -476,6 +449,7 @@ class _MIoTSpecBase: device_class: Any state_class: Any external_unit: Any + entity_category: Optional[str] spec_id: int @@ -494,6 +468,7 @@ class _MIoTSpecBase: self.device_class = None self.state_class = None self.external_unit = None + self.entity_category = None self.spec_id = hash(f'{self.type_}.{self.iid}') @@ -521,18 +496,16 @@ class MIoTSpecProperty(_MIoTSpecBase): 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, - expr: Optional[str] = None - ) -> None: + 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, + expr: Optional[str] = None) -> None: super().__init__(spec=spec) self.service = service self.format_ = format_ @@ -540,11 +513,10 @@ class MIoTSpecProperty(_MIoTSpecBase): self.unit = unit self.value_range = value_range self.value_list = value_list - self.precision = precision or 1 + self.precision = precision if precision is not None else 1 self.expr = expr - self.spec_id = hash( - f'p.{self.name}.{self.service.iid}.{self.iid}') + self.spec_id = hash(f'p.{self.name}.{self.service.iid}.{self.iid}') @property def format_(self) -> Type: @@ -556,8 +528,8 @@ class MIoTSpecProperty(_MIoTSpecBase): 'string': str, 'str': str, 'bool': bool, - 'float': float}.get( - value, int) + 'float': float + }.get(value, int) @property def access(self) -> list: @@ -595,17 +567,17 @@ class MIoTSpecProperty(_MIoTSpecBase): 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 + 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: + def value_list(self, value: Union[list[dict], MIoTSpecValueList, + None]) -> None: if not value: self._value_list = None return @@ -621,16 +593,15 @@ class MIoTSpecProperty(_MIoTSpecBase): # pylint: disable=eval-used return eval(self.expr, {'src_value': src_value}) except Exception as err: # pylint: disable=broad-exception-caught - _LOGGER.error( - 'eval expression error, %s, %s, %s, %s', - self.iid, src_value, self.expr, err) + _LOGGER.error('eval expression error, %s, %s, %s, %s', self.iid, + src_value, self.expr, err) return src_value def value_format(self, value: Any) -> Any: if value is None: return None if self.format_ == int: - return int(value) + return int(round(value)) if self.format_ == float: return round(value, self.precision) if self.format_ == bool: @@ -649,8 +620,8 @@ class MIoTSpecProperty(_MIoTSpecBase): 'format': self.format_.__name__, 'access': self._access, 'unit': self.unit, - 'value_range': ( - self._value_range.dump() if self._value_range else None), + '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, 'expr': self.expr, @@ -663,16 +634,15 @@ class MIoTSpecEvent(_MIoTSpecBase): argument: list[MIoTSpecProperty] service: 'MIoTSpecService' - def __init__( - self, spec: dict, service: 'MIoTSpecService', - argument: Optional[list[MIoTSpecProperty]] = None - ) -> None: + 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}') + self.spec_id = hash(f'e.{self.name}.{self.service.iid}.{self.iid}') def dump(self) -> dict: return { @@ -693,18 +663,17 @@ class MIoTSpecAction(_MIoTSpecBase): out: list[MIoTSpecProperty] service: 'MIoTSpecService' - def __init__( - self, spec: dict, service: 'MIoTSpecService', - in_: Optional[list[MIoTSpecProperty]] = None, - out: Optional[list[MIoTSpecProperty]] = None - ) -> None: + 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}') + self.spec_id = hash(f'a.{self.name}.{self.service.iid}.{self.iid}') def dump(self) -> dict: return { @@ -761,9 +730,8 @@ class MIoTSpecInstance: device_class: Any icon: str - def __init__( - self, urn: str, name: str, description: str, description_trans: str - ) -> None: + def __init__(self, urn: str, name: str, description: str, + description_trans: str) -> None: self.urn = urn self.name = name self.description = description @@ -780,20 +748,19 @@ class MIoTSpecInstance: 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), - expr=prop.get('expr', None)) + 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), + expr=prop.get('expr', None)) spec_service.properties.append(spec_prop) for event in service['events']: - spec_event = MIoTSpecEvent( - spec=event, service=spec_service) + spec_event = MIoTSpecEvent(spec=event, service=spec_service) arg_list: list[MIoTSpecProperty] = [] for piid in event['argument']: for prop in spec_service.properties: @@ -803,8 +770,9 @@ class MIoTSpecInstance: 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']) + 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: @@ -837,6 +805,7 @@ class _MIoTSpecMultiLang: """MIoT SPEC multi lang class.""" # pylint: disable=broad-exception-caught _DOMAIN: str = 'miot_specs_multi_lang' + _MULTI_LANG_FILE = 'specs/multi_lang.json' _lang: str _storage: MIoTStorage _main_loop: asyncio.AbstractEventLoop @@ -844,11 +813,10 @@ class _MIoTSpecMultiLang: _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: + 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() @@ -882,23 +850,37 @@ class _MIoTSpecMultiLang: _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_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) + # Revert: load multi_lang.json + try: + trans_local_json = 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)) + urn_strs: list[str] = urn.split(':') + urn_key: str = ':'.join(urn_strs[:6]) + if (isinstance(trans_local_json, dict) and + urn_key in trans_local_json and + self._lang in trans_local_json[urn_key]): + trans_cache.update(trans_local_json[urn_key][self._lang]) + trans_local = trans_local_json[urn_key] + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.error('multi lang, load json file error, %s', err) + # Revert end # 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_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() == '': @@ -913,13 +895,10 @@ class _MIoTSpecMultiLang: 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 + 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 + f'v:{int(strs[1])}:{int(strs[3])}:{int(strs[5])}'] = value self._custom_cache[urn] = trans_data self._current_data = trans_data @@ -933,11 +912,8 @@ class _MIoTSpecMultiLang: 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) - ): + 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'] @@ -952,9 +928,9 @@ class _SpecBoolTranslation: _data: Optional[dict[str, list]] _data_default: Optional[list[dict]] - def __init__( - self, lang: str, loop: Optional[asyncio.AbstractEventLoop] = None - ) -> None: + 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 @@ -968,47 +944,46 @@ class _SpecBoolTranslation: 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)) + 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) - ): + 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)) + 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']} - ] + 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)) + 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']} - ] + self._data[urn] = [{ + 'value': True, + 'description': trans_data['true'] + }, { + 'value': False, + 'description': trans_data['false'] + }] async def deinit_async(self) -> None: self._data = None @@ -1049,9 +1024,8 @@ class _SpecFilter: 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)) + 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 @@ -1082,13 +1056,9 @@ class _SpecFilter: 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']) - ): + if (self._cache and 'services' in self._cache and + (str(siid) in self._cache['services'] or + '*' in self._cache['services'])): return True return False @@ -1096,44 +1066,82 @@ class _SpecFilter: 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']) - ): + 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'] - ) - ): + 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']) - ): + 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 _SpecAdd: + """MIoT-Spec-V2 add for entity conversion.""" + _SPEC_ADD_FILE = 'specs/spec_add.json' + _main_loop: asyncio.AbstractEventLoop + _data: Optional[dict] + _selected: Optional[dict] + + def __init__(self, + loop: Optional[asyncio.AbstractEventLoop] = None) -> None: + self._main_loop = loop or asyncio.get_running_loop() + self._data = None + + async def init_async(self) -> None: + if isinstance(self._data, dict): + return + add_data = None + self._data = {} + self._selected = None + try: + add_data = await self._main_loop.run_in_executor( + None, load_json_file, + os.path.join(os.path.dirname(os.path.abspath(__file__)), + self._SPEC_ADD_FILE)) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.error('spec add, load file error, %s', err) + return + if not isinstance(add_data, dict): + _LOGGER.error('spec add, invalid spec add content') + return + for key, value in add_data.items(): + if not isinstance(key, str) or not isinstance(value, (list, str)): + _LOGGER.error('spec add, invalid spec modify data') + return + + self._data = add_data + + async def deinit_async(self) -> None: + self._data = None + self._selected = None + + async def set_spec_async(self, urn: str) -> None: + if not self._data: + return + self._selected = self._data.get(urn, None) + if isinstance(self._selected, str): + return await self.set_spec_async(urn=self._selected) + + def get_service_add(self) -> Optional[list[dict]]: + return self._selected + + class _SpecModify: """MIoT-Spec-V2 modify for entity conversion.""" _SPEC_MODIFY_FILE = 'specs/spec_modify.yaml' @@ -1141,9 +1149,8 @@ class _SpecModify: _data: Optional[dict] _selected: Optional[dict] - def __init__( - self, loop: Optional[asyncio.AbstractEventLoop] = None - ) -> None: + def __init__(self, + loop: Optional[asyncio.AbstractEventLoop] = None) -> None: self._main_loop = loop or asyncio.get_running_loop() self._data = None @@ -1156,9 +1163,8 @@ class _SpecModify: try: modify_data = await self._main_loop.run_in_executor( None, load_yaml_file, - os.path.join( - os.path.dirname(os.path.abspath(__file__)), - self._SPEC_MODIFY_FILE)) + os.path.join(os.path.dirname(os.path.abspath(__file__)), + self._SPEC_MODIFY_FILE)) except Exception as err: # pylint: disable=broad-exception-caught _LOGGER.error('spec modify, load file error, %s', err) return @@ -1183,9 +1189,15 @@ class _SpecModify: if isinstance(self._selected, str): return await self.set_spec_async(urn=self._selected) + def get_prop_name(self, siid: int, piid: int) -> Optional[str]: + return self.__get_prop_item(siid=siid, piid=piid, key='name') + def get_prop_unit(self, siid: int, piid: int) -> Optional[str]: return self.__get_prop_item(siid=siid, piid=piid, key='unit') + def get_prop_format(self, siid: int, piid: int) -> Optional[str]: + return self.__get_prop_item(siid=siid, piid=piid, key='format') + def get_prop_expr(self, siid: int, piid: int) -> Optional[str]: return self.__get_prop_item(siid=siid, piid=piid, key='expr') @@ -1198,6 +1210,22 @@ class _SpecModify: return None return access + def get_prop_value_range(self, siid: int, piid: int) -> Optional[list]: + value_range = self.__get_prop_item(siid=siid, + piid=piid, + key='value-range') + if not isinstance(value_range, list): + return None + return value_range + + def get_prop_value_list(self, siid: int, piid: int) -> Optional[list]: + value_list = self.__get_prop_item(siid=siid, + piid=piid, + key='value-list') + if not isinstance(value_list, list): + return None + return value_list + def __get_prop_item(self, siid: int, piid: int, key: str) -> Optional[str]: if not self._selected: return None @@ -1220,24 +1248,26 @@ class MIoTSpecParser: _multi_lang: _MIoTSpecMultiLang _bool_trans: _SpecBoolTranslation _spec_filter: _SpecFilter + _spec_add: _SpecAdd _spec_modify: _SpecModify _init_done: bool - def __init__( - self, lang: Optional[str], - storage: MIoTStorage, - loop: Optional[asyncio.AbstractEventLoop] = None - ) -> None: + 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._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._spec_add = _SpecAdd(loop=self._main_loop) self._spec_modify = _SpecModify(loop=self._main_loop) self._init_done = False @@ -1247,32 +1277,31 @@ class MIoTSpecParser: return await self._bool_trans.init_async() await self._spec_filter.init_async() + await self._spec_add.init_async() await self._spec_modify.init_async() - std_lib_cache = await self._storage.load_async( - domain=self._DOMAIN, name='spec_std_lib', type_=dict) - if ( - 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 - ): + 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']) + _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()) - } - ): + 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: @@ -1287,10 +1316,13 @@ class MIoTSpecParser: # self._std_lib.deinit() await self._bool_trans.deinit_async() await self._spec_filter.deinit_async() + await self._spec_add.deinit_async() await self._spec_modify.deinit_async() async def parse( - self, urn: str, skip_cache: bool = False, + self, + urn: str, + skip_cache: bool = False, ) -> Optional[MIoTSpecInstance]: """MUST await init first !!!""" if not skip_cache: @@ -1303,8 +1335,7 @@ class MIoTSpecParser: 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) + _LOGGER.error('parse error, retry, %d, %s, %s', index, urn, err) return None async def refresh_async(self, urn_list: list[str]) -> int: @@ -1313,20 +1344,23 @@ class MIoTSpecParser: 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()) - } - ): + 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] + 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 @@ -1334,16 +1368,16 @@ class MIoTSpecParser: 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 + 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) + 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( @@ -1354,12 +1388,8 @@ class MIoTSpecParser: _LOGGER.debug('parse urn, %s', urn) # Load spec instance instance = await self.__get_instance(urn=urn) - if ( - not isinstance(instance, dict) - or 'type' not in instance - or 'description' not in instance - or 'services' not in instance - ): + if (not isinstance(instance, dict) or 'type' not in instance or + 'description' not in instance or 'services' not in instance): raise MIoTSpecError(f'invalid urn instance, {urn}') urn_strs: list[str] = urn.split(':') urn_key: str = ':'.join(urn_strs[:6]) @@ -1367,23 +1397,38 @@ class MIoTSpecParser: await self._multi_lang.set_spec_async(urn=urn) # Set spec filter await self._spec_filter.set_spec_spec(urn_key=urn_key) + # Set spec add + await self._spec_add.set_spec_async(urn=urn) # Set spec modify await self._spec_modify.set_spec_async(urn=urn) # Parse device type spec_instance: MIoTSpecInstance = MIoTSpecInstance( - urn=urn, name=urn_strs[3], + urn=urn, + name=urn_strs[3], description=instance['description'], description_trans=( - self._std_lib.device_translate(key=':'.join(urn_strs[:5])) - or instance['description'] - or urn_strs[3])) + self._std_lib.device_translate(key=':'.join(urn_strs[:5])) or + instance['description'] or urn_strs[3])) + urn_service_instance = instance.get('services', []) + # set spec instance in spec_add.json as not being filtered. + custom_service_instance = self._spec_add.get_service_add() + if custom_service_instance: + for service in custom_service_instance: + service['need_filter'] = False + if 'properties' in service: + for prop in service['properties']: + prop['need_filter'] = False + if 'actions' in service: + for action in service['actions']: + action['need_filter'] = False + if 'events' in service: + for event in service['events']: + event['need_filter'] = False + urn_service_instance.append(service) # Parse services - for service in instance.get('services', []): - if ( - 'iid' not in service - or 'type' not in service - or 'description' not in service - ): + for service in urn_service_instance: + if ('iid' not in service or 'type' not in service or + 'description' not in service): _LOGGER.error('invalid service, %s, %s', urn, service) continue type_strs: list[str] = service['type'].split(':') @@ -1394,24 +1439,21 @@ class MIoTSpecParser: spec_service.name = type_strs[3] # Filter spec service spec_service.need_filter = self._spec_filter.filter_service( - siid=service['iid']) + siid=service['iid']) if ( + 'need_filter' not in service) else service['need_filter'] + if spec_service.need_filter: + continue if type_strs[1] != 'miot-spec-v2': spec_service.proprietary = True spec_service.description_trans = ( - 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 - ) + 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) # Parse service property for property_ in service.get('properties', []): - if ( - 'iid' not in property_ - or 'type' not in property_ - or 'description' not in property_ - or 'format' not in property_ - or 'access' not in property_ - ): + if ('iid' not in property_ or 'type' not in property_ or + 'description' not in property_ or + 'format' not in property_ or 'access' not in property_): continue p_type_strs: list[str] = property_['type'].split(':') # Handle special property.unit @@ -1425,46 +1467,51 @@ class MIoTSpecParser: spec_prop.name = p_type_strs[3] # Filter spec property spec_prop.need_filter = ( - spec_service.need_filter - or self._spec_filter.filter_property( - siid=service['iid'], piid=property_['iid'])) + spec_service.need_filter or + (self._spec_filter.filter_property(siid=service['iid'], + piid=property_['iid']) + if 'need_filter' not in property_ else + property_['need_filter'])) + if spec_prop.need_filter: + continue if p_type_strs[1] != 'miot-spec-v2': spec_prop.proprietary = spec_service.proprietary or True spec_prop.description_trans = ( 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 = property_['value-range'] - elif 'value-list' in property_: - v_list: list[dict] = property_['value-list'] + 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) + # Modify value-list before translation + v_list: list[dict] = self._spec_modify.get_prop_value_list( + siid=service['iid'], piid=property_['iid']) + if (v_list is None) and ('value-list' in property_): + v_list = property_['value-list'] + if v_list is not None: for index, v in enumerate(v_list): if v['description'].strip() == '': v['description'] = f'v_{v["value"]}' v['name'] = v['description'] - v['description'] = ( - self._multi_lang.translate( - f'v:{service["iid"]}:{property_["iid"]}:' - f'{index}') - or self._std_lib.value_translate( + v['description'] = (self._multi_lang.translate( + f'v:{service["iid"]}:{property_["iid"]}:' + f'{index}') or self._std_lib.value_translate( key=f'{type_strs[:5]}|{p_type_strs[3]}|' - f'{v["description"]}') - or v['name']) + f'{v["description"]}') or v['name']) spec_prop.value_list = MIoTSpecValueList.from_spec(v_list) + if 'value-range' in property_: + spec_prop.value_range = property_['value-range'] elif property_['format'] == 'bool': v_tag = ':'.join(p_type_strs[:5]) - v_descriptions = ( - await self._bool_trans.translate_async(urn=v_tag)) + 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 # Prop modify spec_prop.unit = self._spec_modify.get_prop_unit( - siid=service['iid'], piid=property_['iid'] - ) or spec_prop.unit + siid=service['iid'], + piid=property_['iid']) or spec_prop.unit spec_prop.expr = self._spec_modify.get_prop_expr( siid=service['iid'], piid=property_['iid']) spec_prop.icon = self._spec_modify.get_prop_icon( @@ -1474,34 +1521,42 @@ class MIoTSpecParser: siid=service['iid'], piid=property_['iid']) if custom_access: spec_prop.access = custom_access + custom_format = self._spec_modify.get_prop_format( + siid=service['iid'], piid=property_['iid']) + if custom_format: + spec_prop.format_ = custom_format + custom_range = self._spec_modify.get_prop_value_range( + siid=service['iid'], piid=property_['iid']) + if custom_range: + spec_prop.value_range = custom_range + custom_name = self._spec_modify.get_prop_name( + siid=service['iid'], piid=property_['iid']) + if custom_name: + spec_prop.name = custom_name # Parse service event for event in service.get('events', []): - if ( - 'iid' not in event - or 'type' not in event - or 'description' not in event - or 'arguments' not in event - ): + if ('iid' not in event or 'type' not in event or + 'description' not in event or 'arguments' not in event): continue e_type_strs: list[str] = event['type'].split(':') - spec_event: MIoTSpecEvent = MIoTSpecEvent( - spec=event, service=spec_service) + spec_event: MIoTSpecEvent = MIoTSpecEvent(spec=event, + service=spec_service) spec_event.name = e_type_strs[3] # Filter spec event spec_event.need_filter = ( - spec_service.need_filter - or self._spec_filter.filter_event( - siid=service['iid'], eiid=event['iid'])) + spec_service.need_filter or + (self._spec_filter.filter_event(siid=service['iid'], + eiid=event['iid']) + if 'need_filter' not in event else event['need_filter'])) + if spec_event.need_filter: + continue if e_type_strs[1] != 'miot-spec-v2': spec_event.proprietary = spec_service.proprietary or True spec_event.description_trans = ( 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'] - or spec_event.name - ) + f'e:{service["iid"]}:{event["iid"]}') or + self._std_lib.event_translate(key=':'.join(e_type_strs[:5])) + or event['description'] or spec_event.name) arg_list: list[MIoTSpecProperty] = [] for piid in event['arguments']: for prop in spec_service.properties: @@ -1512,12 +1567,8 @@ class MIoTSpecParser: spec_service.events.append(spec_event) # Parse service action for action in service.get('actions', []): - if ( - 'iid' not in action - or 'type' not in action - or 'description' not in action - or 'in' not in action - ): + if ('iid' not in action or 'type' not in action or + 'description' not in action or 'in' not in action): continue a_type_strs: list[str] = action['type'].split(':') spec_action: MIoTSpecAction = MIoTSpecAction( @@ -1525,19 +1576,20 @@ class MIoTSpecParser: spec_action.name = a_type_strs[3] # Filter spec action spec_action.need_filter = ( - spec_service.need_filter - or self._spec_filter.filter_action( - siid=service['iid'], aiid=action['iid'])) + spec_service.need_filter or + (self._spec_filter.filter_action(siid=service['iid'], + aiid=action['iid']) + if 'need_filter' not in action else action['need_filter'])) + if spec_action.need_filter: + continue if a_type_strs[1] != 'miot-spec-v2': spec_action.proprietary = spec_service.proprietary or True spec_action.description_trans = ( 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'] - or spec_action.name - ) + f'a:{service["iid"]}:{action["iid"]}') or + self._std_lib.action_translate( + key=':'.join(a_type_strs[:5])) or + action['description'] or spec_action.name) in_list: list[MIoTSpecProperty] = [] for piid in action['in']: for prop in spec_service.properties: diff --git a/custom_components/xiaomi_home/miot/specs/multi_lang.json b/custom_components/xiaomi_home/miot/specs/multi_lang.json new file mode 100644 index 0000000..b2e7979 --- /dev/null +++ b/custom_components/xiaomi_home/miot/specs/multi_lang.json @@ -0,0 +1,198 @@ +{ + "urn:miot-spec-v2:device:bath-heater:0000A028:yeelink-v10": { + "en": { + "service:003:property:001:valuelist:000": "Idle", + "service:003:property:001:valuelist:001": "Dry" + } + }, + "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:lock:0000A038:loock-t2pv1": { + "zh-Hans": { + "service:003:property:1021:valuelist:000": "已上锁", + "service:003:property:1021:valuelist:001": "已上锁(童锁)", + "service:003:property:1021:valuelist:002": "已上锁(反锁)", + "service:003:property:1021:valuelist:003": "已上锁(反锁+童锁)", + "service:003:property:1021:valuelist:004": "已开锁", + "service:003:property:1021:valuelist:008": "门未关(门超时未关)", + "service:003:property:1021:valuelist:012": "门虚掩" + } + }, + "urn:miot-spec-v2:device:plant-monitor:0000A030:hhcc-v1": { + "en": { + "service:002:property:001": "Soil Moisture" + }, + "zh-Hans": { + "service:002:property:001": "土壤湿度", + "service:002:property:003": "光照强度" + } + }, + "urn:miot-spec-v2:device:switch:0000A003:lumi-acn040": { + "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": "右键确认" + } + } +} diff --git a/custom_components/xiaomi_home/miot/specs/spec_add.json b/custom_components/xiaomi_home/miot/specs/spec_add.json new file mode 100644 index 0000000..3ead8d0 --- /dev/null +++ b/custom_components/xiaomi_home/miot/specs/spec_add.json @@ -0,0 +1,62 @@ +{ + "urn:miot-spec-v2:device:airer:0000A00D:hyd-lyjpro:1": [ + { + "iid": 3, + "type": "urn:miot-spec-v2:service:light:00007802:hyd-lyjpro:1", + "description": "Moon Light", + "properties": [ + { + "iid": 2, + "type": "urn:miot-spec-v2:property:on:00000006:hyd-lyjpro:1", + "description": "Switch Status", + "format": "bool", + "access": [ + "read", + "write", + "notify" + ] + } + ] + } + ], + "urn:miot-spec-v2:device:water-heater:0000A02A:xiaomi-yms2:1": [ + { + "iid": 2, + "type": "urn:miot-spec-v2:service:switch:0000780C:xiaomi-yms2:1", + "description": "Switch", + "properties": [ + { + "iid": 6, + "type": "urn:miot-spec-v2:property:on:00000006:xiaomi-yms2:1", + "description": "Switch Status", + "format": "bool", + "access": [ + "read", + "write", + "notify" + ] + } + ] + } + ], + "urn:miot-spec-v2:device:water-heater:0000A02A:zimi-h03:1": [ + { + "iid": 2, + "type": "urn:miot-spec-v2:service:switch:0000780C:zimi-h03:1", + "description": "Switch", + "properties": [ + { + "iid": 6, + "type": "urn:miot-spec-v2:property:on:00000006:zimi-h03:1", + "description": "Switch Status", + "format": "bool", + "access": [ + "read", + "write", + "notify" + ] + } + ] + } + ] +} \ 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 index 2102e48..77f302c 100644 --- a/custom_components/xiaomi_home/miot/specs/spec_filter.yaml +++ b/custom_components/xiaomi_home/miot/specs/spec_filter.yaml @@ -5,6 +5,9 @@ urn:miot-spec-v2:device:air-purifier:0000A007:zhimi-ma4: - 15.* services: - '10' +urn:miot-spec-v2:device:airer:0000A00D:hyd-lyjpro: + properties: + - '3.2' urn:miot-spec-v2:device:curtain:0000A00C:lumi-hmcn01: properties: - '5.1' diff --git a/custom_components/xiaomi_home/miot/specs/spec_modify.yaml b/custom_components/xiaomi_home/miot/specs/spec_modify.yaml index 0a0950d..3bf9c3b 100644 --- a/custom_components/xiaomi_home/miot/specs/spec_modify.yaml +++ b/custom_components/xiaomi_home/miot/specs/spec_modify.yaml @@ -1,3 +1,85 @@ +urn:miot-spec-v2:device:air-condition-outlet:0000A045:lumi-mcn04:1: + prop.3.4: + format: uint8 +urn:miot-spec-v2:device:air-conditioner:0000A004:xiaomi-m9:1: urn:miot-spec-v2:device:air-conditioner:0000A004:xiaomi-m9:6 +urn:miot-spec-v2:device:air-conditioner:0000A004:xiaomi-m9:2: urn:miot-spec-v2:device:air-conditioner:0000A004:xiaomi-m9:6 +urn:miot-spec-v2:device:air-conditioner:0000A004:xiaomi-m9:3: urn:miot-spec-v2:device:air-conditioner:0000A004:xiaomi-m9:6 +urn:miot-spec-v2:device:air-conditioner:0000A004:xiaomi-m9:4: urn:miot-spec-v2:device:air-conditioner:0000A004:xiaomi-m9:6 +urn:miot-spec-v2:device:air-conditioner:0000A004:xiaomi-m9:5: urn:miot-spec-v2:device:air-conditioner:0000A004:xiaomi-m9:6 +urn:miot-spec-v2:device:air-conditioner:0000A004:xiaomi-m9:6: + prop.10.6: + unit: none +urn:miot-spec-v2:device:air-monitor:0000A008:cgllc-s1:1: + prop.2.5: + name: voc-density +urn:miot-spec-v2:device:airer:0000A00D:hyd-lyjpro:1: + prop.2.3: + name: current-position-a + prop.2.8: + name: target-position-a + prop.2.9: + name: target-position-b +urn:miot-spec-v2:device:airer:0000A00D:hyd-znlyj5:1: + prop.2.3: + value-range: + - 0 + - 1 + - 1 +urn:miot-spec-v2:device:airer:0000A00D:hyd-znlyj5:2: urn:miot-spec-v2:device:airer:0000A00D:hyd-znlyj5:1 +urn:miot-spec-v2:device:airer:0000A00D:mrbond-m33a:1: + prop.2.3: + name: current-position-a + prop.2.11: + name: current-position-b +urn:miot-spec-v2:device:bath-heater:0000A028:mike-2:1: + prop.3.1: + name: mode-a + prop.3.11: + name: mode-b + prop.3.12: + name: mode-c +urn:miot-spec-v2:device:bath-heater:0000A028:opple-acmoto:1: + prop.5.2: + value-list: + - value: 1 + description: low + - value: 128 + description: medium + - value: 255 + description: high +urn:miot-spec-v2:device:bath-heater:0000A028:xiaomi-s1:1: + prop.4.4: + name: fan-level-ventilation +urn:miot-spec-v2:device:fan:0000A005:dmaker-p5:1: + prop.2.4: + name: fan-level-a +urn:miot-spec-v2:device:fan:0000A005:xiaomi-p51:1: + prop.2.2: + name: fan-level-a +urn:miot-spec-v2:device:fan:0000A005:zhimi-sa1:3: + prop.2.2: + name: fan-level-a +urn:miot-spec-v2:device:fan:0000A005:zhimi-v3:3: + prop.2.2: + name: fan-level-a +urn:miot-spec-v2:device:fan:0000A005:zhimi-za4:3: + prop.2.2: + name: fan-level-a +urn:miot-spec-v2:device:gateway:0000A019:lumi-mcn001:1: + prop.2.1: + access: + - read + - notify + prop.2.2: + icon: mdi:ip + prop.2.3: + access: + - read + - notify + prop.2.5: + access: + - read + - notify urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1:1: prop.2.1: name: access-mode @@ -14,25 +96,86 @@ urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1:1: - notify urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1:2: urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1:1 urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1:3: urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1:1 -urn:miot-spec-v2:device:outlet:0000A002:chuangmi-212a01:1: +urn:miot-spec-v2:device:kettle:0000A009:yunmi-r3:1: + prop.3.1: + unit: ppm +urn:miot-spec-v2:device:light:0000A001:shhf-sfla10:1: + prop.8.9: + name: wind-reverse +urn:miot-spec-v2:device:light:0000A001:shhf-sfla12:1: + prop.8.11: + name: on-a +urn:miot-spec-v2:device:magnet-sensor:0000A016:linp-m1:1: + prop.2.1004: + name: contact-state + value-list: + - value: 0 + description: open + - value: 1 + description: closed + expr: src_value!=1 +urn:miot-spec-v2:device:motion-sensor:0000A014:lumi-acn001:1: + prop.3.2: + access: + - read + - notify + unit: mV +urn:miot-spec-v2:device:occupancy-sensor:0000A0BF:izq-24:2:0000C824: + prop.2.6: + unit: cm +urn:miot-spec-v2:device:occupancy-sensor:0000A0BF:linp-hb01:2:0000C824: + prop.3.3: + unit: m +urn:miot-spec-v2:device:outlet:0000A002:chuangmi-212a01:1: urn:miot-spec-v2:device:outlet:0000A002:chuangmi-212a01:3 +urn:miot-spec-v2:device:outlet:0000A002:chuangmi-212a01:2: urn:miot-spec-v2:device:outlet:0000A002:chuangmi-212a01:3 +urn:miot-spec-v2:device:outlet:0000A002:chuangmi-212a01:3: prop.5.1: - name: power-consumption - expr: round(src_value/1000, 3) -urn:miot-spec-v2:device:outlet:0000A002:chuangmi-212a01:2: urn:miot-spec-v2:device:outlet:0000A002:chuangmi-212a01:1 -urn:miot-spec-v2:device:outlet:0000A002:chuangmi-212a01:3: urn:miot-spec-v2:device:outlet:0000A002:chuangmi-212a01:1 + expr: round(src_value*6/1000000, 3) urn:miot-spec-v2:device:outlet:0000A002:cuco-cp1md:1: prop.2.2: name: power-consumption expr: round(src_value/1000, 3) + prop.2.3: + expr: round(src_value/10, 1) + prop.2.4: + unit: mA +urn:miot-spec-v2:device:outlet:0000A002:cuco-cp2:1: urn:miot-spec-v2:device:outlet:0000A002:cuco-cp2:2 +urn:miot-spec-v2:device:outlet:0000A002:cuco-cp2:2: + prop.2.3: + expr: round(src_value/10, 1) + prop.2.4: + unit: mA + prop.3.2: + expr: round(src_value/10, 1) urn:miot-spec-v2:device:outlet:0000A002:cuco-v3:1: prop.11.1: name: power-consumption expr: round(src_value/100, 2) urn:miot-spec-v2:device:outlet:0000A002:cuco-v3:2: urn:miot-spec-v2:device:outlet:0000A002:cuco-v3:1 +urn:miot-spec-v2:device:outlet:0000A002:giot-v8icm:1:0000C816: + prop.4.1: + unit: mWh +urn:miot-spec-v2:device:outlet:0000A002:qmi-psv3:1:0000C816: + prop.3.3: + unit: mV + prop.3.4: + unit: mA +urn:miot-spec-v2:device:outlet:0000A002:zimi-zncz01:1:0000C816: urn:miot-spec-v2:device:outlet:0000A002:zimi-zncz01:2:0000C816 urn:miot-spec-v2:device:outlet:0000A002:zimi-zncz01:2:0000C816: prop.3.1: name: electric-power expr: round(src_value/100, 2) +urn:miot-spec-v2:device:plant-monitor:0000A030:hhcc-v1:1: + prop.2.1: + name: soil-moisture + icon: mdi:watering-can + prop.2.2: + name: soil-ec + icon: mdi:sprout-outline + unit: μS/cm +urn:miot-spec-v2:device:relay:0000A03D:lumi-c2acn01:1: + prop.4.1: + unit: kWh urn:miot-spec-v2:device:router:0000A036:xiaomi-rd08:1: prop.2.1: name: download-speed @@ -42,3 +185,59 @@ urn:miot-spec-v2:device:router:0000A036:xiaomi-rd08:1: name: upload-speed icon: mdi:upload unit: B/s +urn:miot-spec-v2:device:safe-box:0000A042:loock-v1:1: + prop.5.1: + name: contact-state + expr: src_value!=1 +urn:miot-spec-v2:device:switch:0000A003:lxzn-cbcsmj:1:0000D00D: + prop.3.1: + expr: round(src_value/100, 2) + prop.3.2: + expr: round(src_value/1000, 2) + prop.3.3: + expr: round(src_value/10, 1) +urn:miot-spec-v2:device:thermostat:0000A031:suittc-wk168:1: + prop.2.3: + value-list: + - value: 1 + description: '1' + - value: 2 + description: '2' + - value: 3 + description: '3' + - value: 4 + description: '4' + - value: 5 + description: '5' + - value: 6 + description: '6' + - value: 7 + description: '7' + - value: 8 + description: '8' + - value: 9 + description: '9' + - value: 10 + description: '10' + - value: 11 + description: '11' + - value: 12 + description: '12' + - value: 13 + description: '13' + - value: 14 + description: '14' + - value: 15 + description: '15' + - value: 16 + description: '16' +urn:miot-spec-v2:device:water-purifier:0000A013:roswan-lte01:1:0000D05A: + prop.4.1: + unit: ppm + prop.4.2: + unit: ppm +urn:miot-spec-v2:device:water-purifier:0000A013:yunmi-s20:1: + prop.4.1: + unit: ppm + prop.4.2: + unit: ppm diff --git a/custom_components/xiaomi_home/miot/specs/specv2entity.py b/custom_components/xiaomi_home/miot/specs/specv2entity.py index 57d54ed..2f1bd22 100644 --- a/custom_components/xiaomi_home/miot/specs/specv2entity.py +++ b/custom_components/xiaomi_home/miot/specs/specv2entity.py @@ -48,9 +48,11 @@ Conversion rules of MIoT-Spec-V2 instance to Home Assistant entity. from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.sensor import SensorStateClass from homeassistant.components.event import EventDeviceClass +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + EntityCategory, LIGHT_LUX, UnitOfEnergy, UnitOfPower, @@ -137,7 +139,7 @@ SPEC_DEVICE_TRANS_MAP: dict = { 'optional': { 'properties': {'mode', 'target-humidity'} } - }, + } }, 'optional': { 'environment': { @@ -163,8 +165,7 @@ SPEC_DEVICE_TRANS_MAP: dict = { 'continue-sweep', 'stop-and-gocharge' } - }, - + } } }, 'optional': { @@ -177,9 +178,9 @@ SPEC_DEVICE_TRANS_MAP: dict = { 'required': { 'properties': { 'battery-level': {'read'} - }, + } } - }, + } }, 'entity': 'vacuum' }, @@ -195,7 +196,7 @@ SPEC_DEVICE_TRANS_MAP: dict = { }, 'optional': { 'properties': {'target-humidity'} - }, + } } }, 'optional': { @@ -224,6 +225,31 @@ SPEC_DEVICE_TRANS_MAP: dict = { 'entity': 'air-conditioner' }, 'air-condition-outlet': 'air-conditioner', + 'thermostat': { + 'required': { + 'thermostat': { + 'required': { + 'properties': { + 'on': {'read', 'write'} + } + }, + 'optional': { + 'properties': { + 'target-temperature', 'mode', 'fan-level', + 'temperature'} + } + } + }, + 'optional': { + 'environment': { + 'required': {}, + 'optional': { + 'properties': {'temperature', 'relative-humidity'} + } + } + }, + 'entity': 'thermostat' + }, 'heater': { 'required': { 'heater': { @@ -234,7 +260,7 @@ SPEC_DEVICE_TRANS_MAP: dict = { }, 'optional': { 'properties': {'target-temperature', 'heat-level'} - }, + } } }, 'optional': { @@ -243,10 +269,58 @@ SPEC_DEVICE_TRANS_MAP: dict = { 'optional': { 'properties': {'temperature', 'relative-humidity'} } - }, + } }, 'entity': 'heater' }, + 'bath-heater': { + 'required': { + 'ptc-bath-heater': { + 'required': { + 'properties': { + 'mode':{'read', 'write'} + } + }, + 'optional': { + 'properties': {'target-temperature', 'temperature'} + } + } + }, + 'optional': { + 'fan-control': { + 'required': {}, + 'optional': { + 'properties': { + 'on', 'fan-level', 'horizontal-swing', 'vertical-swing' + } + } + }, + 'environment': { + 'required': {}, + 'optional': { + 'properties': {'temperature'} + } + } + }, + 'entity': 'bath-heater', + }, + 'electric-blanket': { + 'required': { + 'electric-blanket': { + 'required': { + 'properties': { + 'on': {'read', 'write'}, + 'target-temperature': {'read', 'write'} + } + }, + 'optional': { + 'properties': {'mode', 'temperature'} + } + } + }, + 'optional': {}, + 'entity': 'electric-blanket' + }, 'speaker': { 'required': { 'speaker': { @@ -322,7 +396,8 @@ SPEC_DEVICE_TRANS_MAP: dict = { 'events': set, 'actions': set }, - 'entity': str + 'entity': str, + 'entity_category'?: str } } """ @@ -340,10 +415,23 @@ SPEC_SERVICE_TRANS_MAP: dict = { }, 'entity': 'light' }, - 'indicator-light': 'light', 'ambient-light': 'light', 'night-light': 'light', 'white-light': 'light', + 'indicator-light': { + 'required': { + 'properties': { + 'on': {'read', 'write'} + } + }, + 'optional': { + 'properties': { + 'mode', 'brightness', + } + }, + 'entity': 'light', + 'entity_category': EntityCategory.CONFIG + }, 'fan': { 'required': { 'properties': { @@ -358,6 +446,8 @@ SPEC_SERVICE_TRANS_MAP: dict = { }, 'fan-control': 'fan', 'ceiling-fan': 'fan', + 'air-fresh': 'fan', + 'air-purifier': 'fan', 'water-heater': { 'required': { 'properties': { @@ -365,7 +455,7 @@ SPEC_SERVICE_TRANS_MAP: dict = { } }, 'optional': { - 'properties': {'on', 'temperature', 'target-temperature', 'mode'} + 'properties': {'temperature', 'target-temperature', 'mode'} }, 'entity': 'water_heater' }, @@ -377,12 +467,27 @@ SPEC_SERVICE_TRANS_MAP: dict = { }, 'optional': { 'properties': { - 'motor-control', 'status', 'current-position', 'target-position' + 'status', 'current-position', 'target-position' } }, 'entity': 'cover' }, - 'window-opener': 'curtain' + 'window-opener': 'curtain', + 'motor-controller': 'curtain', + 'airer': 'curtain', + 'air-conditioner': { + 'required': { + 'properties': { + 'on': {'read', 'write'}, + 'mode': {'read', 'write'}, + 'target-temperature': {'read', 'write'} + } + }, + 'optional': { + 'properties': {'target-humidity'} + }, + 'entity': 'air-conditioner' + } } """SPEC_PROP_TRANS_MAP @@ -409,12 +514,28 @@ SPEC_PROP_TRANS_MAP: dict = { 'format': {'int', 'float'}, 'access': {'read'} }, + 'binary_sensor': { + 'format': {'bool', 'int'}, + 'access': {'read'} + }, 'switch': { 'format': {'bool'}, 'access': {'read', 'write'} } }, 'properties': { + 'submersion-state': { + 'device_class': BinarySensorDeviceClass.MOISTURE, + 'entity': 'binary_sensor' + }, + 'contact-state': { + 'device_class': BinarySensorDeviceClass.DOOR, + 'entity': 'binary_sensor' + }, + 'occupancy-status': { + 'device_class': BinarySensorDeviceClass.OCCUPANCY, + 'entity': 'binary_sensor', + }, 'temperature': { 'device_class': SensorDeviceClass.TEMPERATURE, 'entity': 'sensor', @@ -461,7 +582,11 @@ SPEC_PROP_TRANS_MAP: dict = { 'entity': 'sensor', 'state_class': SensorStateClass.MEASUREMENT }, - 'voc-density': 'tvoc-density', + 'voc-density': { + 'device_class': SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, + 'entity': 'sensor', + 'state_class': SensorStateClass.MEASUREMENT + }, 'battery-level': { 'device_class': SensorDeviceClass.BATTERY, 'entity': 'sensor', @@ -515,12 +640,6 @@ SPEC_PROP_TRANS_MAP: dict = { 'entity': 'sensor', 'state_class': SensorStateClass.MEASUREMENT, 'unit_of_measurement': UnitOfPower.WATT - }, - 'total-battery': { - 'device_class': SensorDeviceClass.ENERGY, - 'entity': 'sensor', - 'state_class': SensorStateClass.TOTAL_INCREASING, - 'unit_of_measurement': UnitOfEnergy.KILO_WATT_HOUR } } } diff --git a/custom_components/xiaomi_home/number.py b/custom_components/xiaomi_home/number.py index 29bd6b7..1a38592 100644 --- a/custom_components/xiaomi_home/number.py +++ b/custom_components/xiaomi_home/number.py @@ -88,7 +88,7 @@ class Number(MIoTPropertyEntity, NumberEntity): if self.spec.external_unit: self._attr_native_unit_of_measurement = self.spec.external_unit # Set icon - if self.spec.icon: + if self.spec.icon and not self.device_class: self._attr_icon = self.spec.icon # Set value range if self._value_range: diff --git a/custom_components/xiaomi_home/sensor.py b/custom_components/xiaomi_home/sensor.py index 68e2d2d..88cb063 100644 --- a/custom_components/xiaomi_home/sensor.py +++ b/custom_components/xiaomi_home/sensor.py @@ -110,13 +110,13 @@ class Sensor(MIoTPropertyEntity, SensorEntity): self._attr_native_unit_of_measurement = list( unit_sets)[0] if unit_sets else None # Set suggested precision - if spec.format_ in {int, float}: + if spec.format_ in {int, float} and spec.expr is None: self._attr_suggested_display_precision = spec.precision # Set state_class if spec.state_class: self._attr_state_class = spec.state_class # Set icon - if spec.icon: + if spec.icon and not self.device_class: self._attr_icon = spec.icon @property diff --git a/custom_components/xiaomi_home/water_heater.py b/custom_components/xiaomi_home/water_heater.py index 3c255ec..8830197 100644 --- a/custom_components/xiaomi_home/water_heater.py +++ b/custom_components/xiaomi_home/water_heater.py @@ -52,25 +52,22 @@ from typing import Any, Optional from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.components.water_heater import ( - STATE_ON, - STATE_OFF, - ATTR_TEMPERATURE, - WaterHeaterEntity, - WaterHeaterEntityFeature -) +from homeassistant.components.water_heater import (STATE_ON, STATE_OFF, + ATTR_TEMPERATURE, + WaterHeaterEntity, + WaterHeaterEntityFeature) from .miot.const import DOMAIN -from .miot.miot_device import MIoTDevice, MIoTEntityData, MIoTServiceEntity +from .miot.miot_device import MIoTDevice, MIoTEntityData, MIoTServiceEntity from .miot.miot_spec import MIoTSpecProperty _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up a config entry.""" device_list: list[MIoTDevice] = hass.data[DOMAIN]['devices'][ @@ -79,8 +76,8 @@ async def async_setup_entry( new_entities = [] for miot_device in device_list: for data in miot_device.entity_list.get('water_heater', []): - new_entities.append(WaterHeater( - miot_device=miot_device, entity_data=data)) + new_entities.append( + WaterHeater(miot_device=miot_device, entity_data=data)) if new_entities: async_add_entities(new_entities) @@ -95,12 +92,11 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity): _mode_map: Optional[dict[Any, Any]] - def __init__( - self, miot_device: MIoTDevice, entity_data: MIoTEntityData - ) -> None: + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: """Initialize the Water heater.""" super().__init__(miot_device=miot_device, entity_data=entity_data) - self._attr_temperature_unit = None # type: ignore + self._attr_temperature_unit = None self._attr_supported_features = WaterHeaterEntityFeature(0) self._prop_on = None self._prop_temp = None @@ -117,14 +113,11 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity): # temperature if prop.name == 'temperature': if not prop.value_range: - _LOGGER.error( - 'invalid temperature value_range format, %s', - self.entity_id) + _LOGGER.error('invalid temperature value_range format, %s', + self.entity_id) continue if prop.external_unit: self._attr_temperature_unit = prop.external_unit - self._attr_min_temp = prop.value_range.min_ - self._attr_max_temp = prop.value_range.max_ self._prop_temp = prop # target-temperature if prop.name == 'target-temperature': @@ -133,9 +126,9 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity): 'invalid target-temperature value_range format, %s', self.entity_id) continue - self._attr_target_temperature_low = prop.value_range.min_ - self._attr_target_temperature_high = prop.value_range.max_ - self._attr_precision = prop.value_range.step + 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 if self._attr_temperature_unit is None and prop.external_unit: self._attr_temperature_unit = prop.external_unit self._attr_supported_features |= ( @@ -144,8 +137,7 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity): # mode if prop.name == 'mode': if not prop.value_list: - _LOGGER.error( - 'mode value_list is None, %s', self.entity_id) + _LOGGER.error('mode value_list is None, %s', self.entity_id) continue self._mode_map = prop.value_list.to_map() self._attr_operation_list = list(self._mode_map.values()) @@ -165,16 +157,12 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity): await self.set_property_async(prop=self._prop_on, value=False) async def async_set_temperature(self, **kwargs: Any) -> None: - """Set the temperature the water heater should heat water to.""" - if not self._prop_target_temp: - return - await self.set_property_async( - prop=self._prop_target_temp, value=kwargs[ATTR_TEMPERATURE]) + """Set the target temperature.""" + await self.set_property_async(prop=self._prop_target_temp, + value=kwargs[ATTR_TEMPERATURE]) async def async_set_operation_mode(self, operation_mode: str) -> None: - """Set the operation mode of the water heater. - Must be in the operation_list. - """ + """Set the operation mode of the water heater.""" if operation_mode == STATE_OFF: await self.set_property_async(prop=self._prop_on, value=False) return @@ -182,32 +170,32 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity): await self.set_property_async(prop=self._prop_on, value=True) return if self.get_prop_value(prop=self._prop_on) is False: - await self.set_property_async( - prop=self._prop_on, value=True, write_ha_state=False) - await self.set_property_async( - prop=self._prop_mode, - value=self.get_map_key( - map_=self._mode_map, value=operation_mode)) + await self.set_property_async(prop=self._prop_on, + value=True, + write_ha_state=False) + await self.set_property_async(prop=self._prop_mode, + value=self.get_map_key( + map_=self._mode_map, + value=operation_mode)) @property def current_temperature(self) -> Optional[float]: - """Return the current temperature.""" - return self.get_prop_value(prop=self._prop_temp) + """The current temperature.""" + return (None if self._prop_temp is None else self.get_prop_value( + prop=self._prop_temp)) @property def target_temperature(self) -> Optional[float]: - """Return the target temperature.""" - if not self._prop_target_temp: - return None - return self.get_prop_value(prop=self._prop_target_temp) + """The target temperature.""" + return (None if self._prop_target_temp is None else self.get_prop_value( + prop=self._prop_target_temp)) @property def current_operation(self) -> Optional[str]: - """Return the current mode.""" + """The current mode.""" if self.get_prop_value(prop=self._prop_on) is False: return STATE_OFF if not self._prop_mode and self.get_prop_value(prop=self._prop_on): return STATE_ON - return self.get_map_value( - map_=self._mode_map, - key=self.get_prop_value(prop=self._prop_mode)) + return self.get_map_value(map_=self._mode_map, + key=self.get_prop_value(prop=self._prop_mode)) diff --git a/doc/README_zh.md b/doc/README_zh.md index 438771f..b33ece3 100644 --- a/doc/README_zh.md +++ b/doc/README_zh.md @@ -33,9 +33,11 @@ git checkout v1.0.0 ### 方法 2: [HACS](https://hacs.xyz/) -HACS > 右上角三个点 > Custom repositories > Repository: https://github.com/XiaoMi/ha_xiaomi_home.git & Category or Type: Integration > ADD > 点击 HACS 的 New 或 Available for download 分类下的 Xiaomi Home ,进入集成详情页 > DOWNLOAD +一键从 HACS 安装米家集成: -> 米家集成暂未添加到 HACS 商店,敬请期待。 +[![打开您的 Home Assistant 实例并打开 Home Assistant 社区商店内的米家集成。](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=XiaoMi&repository=ha_xiaomi_home&category=integration) + +或者,HACS > 在搜索框中输入 **Xiaomi Home** > 点击 **Xiaomi Home** ,进入集成详情页 > DOWNLOAD ### 方法 3:通过 [Samba](https://github.com/home-assistant/addons/tree/master/samba) 或 [FTPS](https://github.com/hassio-addons/addon-ftp) 手动安装 @@ -47,7 +49,7 @@ HACS > 右上角三个点 > Custom repositories > Repository: https://github.com [设置 > 设备与服务 > 添加集成](https://my.home-assistant.io/redirect/brand/?brand=xiaomi_home) > 搜索“`Xiaomi Home`” > 下一步 > 请点击此处进行登录 > 使用小米账号登录 -[![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=xiaomi_home) +[![打开您的 Home Assistant 实例并开始配置一个新的米家集成实例。](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=xiaomi_home) ### 添加 MIoT 设备 @@ -59,7 +61,7 @@ HACS > 右上角三个点 > Custom repositories > Repository: https://github.com 方法:[设置 > 设备与服务 > 已配置 > Xiaomi Home](https://my.home-assistant.io/redirect/integration/?domain=xiaomi_home) > 添加中枢 > 下一步 > 请点击此处进行登录 > 使用小米账号登录 -[![Open your Home Assistant instance and show an integration.](https://my.home-assistant.io/badges/integration.svg)](https://my.home-assistant.io/redirect/integration/?domain=xiaomi_home) +[![打开您的 Home Assistant 实例并显示米家集成。](https://my.home-assistant.io/badges/integration.svg)](https://my.home-assistant.io/redirect/integration/?domain=xiaomi_home) ### 修改配置项 @@ -353,7 +355,7 @@ instance code 为 MIoT-Spec-V2 实例代码,格式如下: ``` service: # 服务 service::property: # 属性 -service::property::valuelist: # 属性取值列表的值 +service::property::valuelist: # 属性取值列表的索引值 service::event: # 事件 service::action: # 方法 ``` @@ -376,7 +378,7 @@ siid、piid、eiid、aiid、value 均为十进制三位整数。 } ``` -> 在 Home Assistant 中修改了 `custom_components/xiaomi_home/translations/` 路径下的 `specv2entity.py`、`spec_filter.json`、`multi_lang.json` 文件的内容,需要在集成配置中更新实体转换规则才能生效。方法:[设置 > 设备与服务 > 已配置 > Xiaomi Home](https://my.home-assistant.io/redirect/integration/?domain=xiaomi_home) > 配置 > 更新实体转换规则 +> 在 Home Assistant 中修改了 `custom_components/xiaomi_home/miot/specs` 路径下的 `specv2entity.py`、`spec_filter.json`、`multi_lang.json` 文件的内容,需要在集成配置中更新实体转换规则才能生效。方法:[设置 > 设备与服务 > 已配置 > Xiaomi Home](https://my.home-assistant.io/redirect/integration/?domain=xiaomi_home) > 配置 > 更新实体转换规则 ## 文档 diff --git a/test/check_rule_format.py b/test/check_rule_format.py index 79fcc43..d4bf956 100644 --- a/test/check_rule_format.py +++ b/test/check_rule_format.py @@ -15,14 +15,15 @@ TRANS_RELATIVE_PATH: str = path.join( 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.yaml') + ROOT_PATH, '../custom_components/xiaomi_home/miot/specs/bool_trans.yaml') SPEC_FILTER_FILE = path.join( - ROOT_PATH, - '../custom_components/xiaomi_home/miot/specs/spec_filter.yaml') + ROOT_PATH, '../custom_components/xiaomi_home/miot/specs/spec_filter.yaml') +SPEC_MULTI_LANG_FILE = path.join( + ROOT_PATH, '../custom_components/xiaomi_home/miot/specs/multi_lang.json') +SPEC_ADD_FILE = path.join( + ROOT_PATH, '../custom_components/xiaomi_home/miot/specs/spec_add.json') SPEC_MODIFY_FILE = path.join( - ROOT_PATH, - '../custom_components/xiaomi_home/miot/specs/spec_modify.yaml') + ROOT_PATH, '../custom_components/xiaomi_home/miot/specs/spec_modify.yaml') def load_json_file(file_path: str) -> Optional[dict]: @@ -30,7 +31,7 @@ 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: - _LOGGER.info('%s is not found.', file_path,) + _LOGGER.info('%s is not found.', file_path) return None except json.JSONDecodeError: _LOGGER.info('%s is not a valid JSON file.', file_path) @@ -39,7 +40,7 @@ def load_json_file(file_path: str) -> Optional[dict]: def save_json_file(file_path: str, data: dict) -> None: with open(file_path, 'w', encoding='utf-8') as file: - json.dump(data, file, ensure_ascii=False, indent=4) + json.dump(data, file, ensure_ascii=False, indent=2) def load_yaml_file(file_path: str) -> Optional[dict]: @@ -56,9 +57,12 @@ def load_yaml_file(file_path: str) -> Optional[dict]: def save_yaml_file(file_path: str, data: dict) -> None: with open(file_path, 'w', encoding='utf-8') as file: - yaml.safe_dump( - data, file, default_flow_style=False, - allow_unicode=True, indent=2, sort_keys=False) + yaml.safe_dump(data, + file, + default_flow_style=False, + allow_unicode=True, + indent=2, + sort_keys=False) def dict_str_str(d: dict) -> bool: @@ -132,13 +136,120 @@ def bool_trans(d: dict) -> bool: for key, trans in d['translate'].items(): trans_keys: set[str] = set(trans.keys()) if set(trans.keys()) != default_keys: - _LOGGER.info( - 'bool trans inconsistent, %s, %s, %s', - key, default_keys, trans_keys) + _LOGGER.info('bool trans inconsistent, %s, %s, %s', key, + default_keys, trans_keys) return False return True +def multi_lang(data: dict) -> bool: + """dict[str, dict[str, dict[str, str]]]""" + for key in data.keys(): + if key.count(':') != 5: + return False + return nested_3_dict_str_str(data) + + +def spec_add(data: dict) -> bool: + """dict[str, list[dict[str, int| str | list]]]""" + if not isinstance(data, dict): + return False + for urn, content in data.items(): + if not isinstance(urn, str) or not isinstance(content, (list, str)): + return False + if isinstance(content, str): + continue + for service in content: + if ('iid' not in service) or ('type' not in service) or ( + 'description' + not in service) or (('properties' not in service) and + ('actions' not in service) and + ('events' not in service)): + return False + type_strs: list[str] = service['type'].split(':') + if type_strs[1] != 'miot-spec-v2': + return False + if 'properties' in service: + if not isinstance(service['properties'], list): + return False + for prop in service['properties']: + if ('iid' not in prop) or ('type' not in prop) or ( + 'description' not in prop) or ( + 'format' not in prop) or ('access' not in prop): + return False + if not isinstance(prop['iid'], int) or not isinstance( + prop['type'], str) or not isinstance( + prop['description'], str) or not isinstance( + prop['format'], str) or not isinstance( + prop['access'], list): + return False + type_strs = prop['type'].split(':') + if type_strs[1] != 'miot-spec-v2': + return False + for access in prop['access']: + if access not in ['read', 'write', 'notify']: + return False + if 'value-range' in prop: + if not isinstance(prop['value-range'], list): + return False + for value in prop['value-range']: + if not isinstance(value, (int, float)): + return False + if 'value-list' in prop: + if not isinstance(prop['value-list'], list): + return False + for item in prop['value-list']: + if 'value' not in item or 'description' not in item: + return False + if not isinstance(item['value'], + int) or not isinstance( + item['description'], str): + return False + if 'actions' in service: + if not isinstance(service['actions'], list): + return False + for action in service['actions']: + if ('iid' not in action) or ('type' not in action) or ( + 'description' not in action) or ( + 'in' not in action) or ('out' not in action): + return False + if not isinstance(action['iid'], int) or not isinstance( + action['type'], str) or not isinstance( + action['description'], str) or not isinstance( + action['in'], list) or not isinstance( + action['out'], list): + return False + type_strs = action['type'].split(':') + if type_strs[1] != 'miot-spec-v2': + return False + for param in action['in']: + if not isinstance(param, int): + return False + for param in action['out']: + if not isinstance(param, int): + return False + if 'events' in service: + if not isinstance(service['events'], list): + return False + for event in service['events']: + if ('iid' not in event) or ('type' not in event) or ( + 'description' not in event) or ('arguments' + not in event): + return False + if not isinstance(event['iid'], int) or not isinstance( + event['type'], str) or not isinstance( + event['description'], str) or not isinstance( + event['arguments'], list): + return False + type_strs = event['type'].split(':') + if type_strs[1] != 'miot-spec-v2': + return False + for param in event['arguments']: + if not isinstance(param, int): + return False + return True + + def spec_modify(data: dict) -> bool: """dict[str, str | dict[str, dict]]""" if not isinstance(data, dict): @@ -159,25 +270,22 @@ def compare_dict_structure(dict1: dict, dict2: dict) -> bool: _LOGGER.info('invalid type') return False if dict1.keys() != dict2.keys(): - _LOGGER.info( - 'inconsistent key values, %s, %s', 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]): - _LOGGER.info( - 'inconsistent key values, dict, %s', 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])): - _LOGGER.info( - 'inconsistent key values, list, %s', key) + _LOGGER.info('inconsistent key values, list, %s', key) return False elif not isinstance(dict1[key], type(dict2[key])): - _LOGGER.info( - 'inconsistent key values, type, %s', key) + _LOGGER.info('inconsistent key values, type, %s', key) return False return True @@ -200,6 +308,16 @@ def sort_spec_filter(file_path: str): return filter_data +def sort_spec_add(file_path: str): + filter_data = load_json_file(file_path=file_path) + assert isinstance(filter_data, dict), f'{file_path} format error' + return dict(sorted(filter_data.items())) + + +def sort_multi_lang(file_path: str): + return sort_spec_add(file_path) + + def sort_spec_modify(file_path: str): filter_data = load_yaml_file(file_path=file_path) assert isinstance(filter_data, dict), f'{file_path} format error' @@ -222,6 +340,22 @@ def test_spec_filter(): assert spec_filter(data), f'{SPEC_FILTER_FILE} format error' +@pytest.mark.github +def test_multi_lang(): + data = load_json_file(SPEC_MULTI_LANG_FILE) + assert isinstance(data, dict) + assert data, f'load {SPEC_MULTI_LANG_FILE} failed' + assert multi_lang(data), f'{SPEC_MULTI_LANG_FILE} format error' + + +@pytest.mark.github +def test_spec_add(): + data = load_json_file(SPEC_ADD_FILE) + assert isinstance(data, dict) + assert data, f'load {SPEC_ADD_FILE} failed' + assert spec_add(data), f'{SPEC_ADD_FILE} format error' + + @pytest.mark.github def test_spec_modify(): data = load_yaml_file(SPEC_MODIFY_FILE) @@ -255,7 +389,8 @@ def test_miot_lang_integrity(): # pylint: disable=import-outside-toplevel from miot.const import INTEGRATION_LANGUAGES integration_lang_list: list[str] = [ - f'{key}.json' for key in list(INTEGRATION_LANGUAGES.keys())] + f'{key}.json' for key in list(INTEGRATION_LANGUAGES.keys()) + ] translations_names: set[str] = set(listdir(TRANS_RELATIVE_PATH)) assert len(translations_names) == len(integration_lang_list) assert translations_names == set(integration_lang_list) @@ -271,21 +406,18 @@ def test_miot_lang_integrity(): default_dict = load_json_file( path.join(TRANS_RELATIVE_PATH, integration_lang_list[0])) for name in list(integration_lang_list)[1:]: - compare_dict = load_json_file( - path.join(TRANS_RELATIVE_PATH, name)) + compare_dict = load_json_file(path.join(TRANS_RELATIVE_PATH, name)) if not compare_dict_structure(default_dict, compare_dict): - _LOGGER.info( - 'compare_dict_structure failed /translations, %s', 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 = load_json_file( - path.join(MIOT_I18N_RELATIVE_PATH, name)) + compare_dict = load_json_file(path.join(MIOT_I18N_RELATIVE_PATH, name)) if not compare_dict_structure(default_dict, compare_dict): - _LOGGER.info( - 'compare_dict_structure failed /miot/i18n, %s', name) + _LOGGER.info('compare_dict_structure failed /miot/i18n, %s', name) assert False @@ -303,12 +435,27 @@ def test_miot_data_sort(): 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_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, ', + 'pytest -s -v -m update ./test/check_rule_format.py') assert 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' + 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_ADD_FILE)) == json.dumps( + sort_spec_add(file_path=SPEC_ADD_FILE)), ( + f'{SPEC_ADD_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_yaml_file(file_path=SPEC_MODIFY_FILE)) == json.dumps( + sort_spec_modify(file_path=SPEC_MODIFY_FILE)), ( + f'{SPEC_MODIFY_FILE} not sorted, goto project root path' + ' and run the following command sorting, ', + 'pytest -s -v -m update ./test/check_rule_format.py') @pytest.mark.update @@ -319,6 +466,11 @@ def test_sort_spec_data(): sort_data = sort_spec_filter(file_path=SPEC_FILTER_FILE) save_yaml_file(file_path=SPEC_FILTER_FILE, data=sort_data) _LOGGER.info('%s formatted.', SPEC_FILTER_FILE) + sort_data = sort_multi_lang(file_path=SPEC_MULTI_LANG_FILE) + save_json_file(file_path=SPEC_MULTI_LANG_FILE, data=sort_data) + sort_data = sort_spec_add(file_path=SPEC_ADD_FILE) + save_json_file(file_path=SPEC_ADD_FILE, data=sort_data) + _LOGGER.info('%s formatted.', SPEC_ADD_FILE) sort_data = sort_spec_modify(file_path=SPEC_MODIFY_FILE) save_yaml_file(file_path=SPEC_MODIFY_FILE, data=sort_data) _LOGGER.info('%s formatted.', SPEC_MODIFY_FILE)