From 664787ca5839e2da54883976774a600d7979a3e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E6=88=90?= Date: Fri, 27 Jun 2025 17:52:39 +0800 Subject: [PATCH 01/12] fix: ptx air-conditioner environment temperature (#1210) * fix: ptx air-conditioner temperature #1163 * fix: environment temperature siid and piid --- .../xiaomi_home/miot/specs/spec_add.json | 25 +++++++++++++++++++ .../xiaomi_home/miot/specs/spec_filter.yaml | 3 +++ 2 files changed, 28 insertions(+) diff --git a/custom_components/xiaomi_home/miot/specs/spec_add.json b/custom_components/xiaomi_home/miot/specs/spec_add.json index dac4a9a..64f5113 100644 --- a/custom_components/xiaomi_home/miot/specs/spec_add.json +++ b/custom_components/xiaomi_home/miot/specs/spec_add.json @@ -1,4 +1,29 @@ { + "urn:miot-spec-v2:device:air-conditioner:0000A004:090615-ktf:1": [ + { + "iid": 4, + "type": "urn:miot-spec-v2:service:environment:0000780A:090615-ktf:1", + "description": "Environment", + "properties": [ + { + "iid": 2, + "type": "urn:miot-spec-v2:property:temperature:00000020:090615-ktf:1", + "description": "Temperature", + "format": "float", + "access": [ + "read", + "notify" + ], + "unit": "celsius", + "value-range": [ + -30, + 100, + 1 + ] + } + ] + } + ], "urn:miot-spec-v2:device:airer:0000A00D:hyd-lyjpro:1": [ { "iid": 3, diff --git a/custom_components/xiaomi_home/miot/specs/spec_filter.yaml b/custom_components/xiaomi_home/miot/specs/spec_filter.yaml index 12625ac..c1bca60 100644 --- a/custom_components/xiaomi_home/miot/specs/spec_filter.yaml +++ b/custom_components/xiaomi_home/miot/specs/spec_filter.yaml @@ -1,3 +1,6 @@ +urn:miot-spec-v2:device:air-conditioner:0000A004:090615-ktf: + services: + - '4' urn:miot-spec-v2:device:air-purifier:0000A007:zhimi-ma4: properties: - 9.* From 096b33f3c9a9ba91fd59b49d9360c9349e62946f Mon Sep 17 00:00:00 2001 From: Li Shuzhen Date: Mon, 30 Jun 2025 11:11:36 +0800 Subject: [PATCH 02/12] fix: the operation mode when the device does not have a mode property (#1199) --- custom_components/xiaomi_home/water_heater.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/custom_components/xiaomi_home/water_heater.py b/custom_components/xiaomi_home/water_heater.py index 8830197..e28e8ff 100644 --- a/custom_components/xiaomi_home/water_heater.py +++ b/custom_components/xiaomi_home/water_heater.py @@ -141,12 +141,11 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity): continue self._mode_map = prop.value_list.to_map() self._attr_operation_list = list(self._mode_map.values()) - self._attr_supported_features |= ( - WaterHeaterEntityFeature.OPERATION_MODE) self._prop_mode = prop if not self._attr_operation_list: self._attr_operation_list = [STATE_ON] self._attr_operation_list.append(STATE_OFF) + self._attr_supported_features |= WaterHeaterEntityFeature.OPERATION_MODE async def async_turn_on(self) -> None: """Turn the water heater on.""" @@ -197,5 +196,5 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity): return STATE_OFF if not self._prop_mode and self.get_prop_value(prop=self._prop_on): return STATE_ON - return self.get_map_value(map_=self._mode_map, - key=self.get_prop_value(prop=self._prop_mode)) + return (None if self._prop_mode is None else self.get_map_value( + map_=self._mode_map, key=self.get_prop_value(prop=self._prop_mode))) From fd57e7c565e82a397615a198e445fedabf958392 Mon Sep 17 00:00:00 2001 From: Li Shuzhen Date: Mon, 30 Jun 2025 11:12:18 +0800 Subject: [PATCH 03/12] fix: reconnect delay time (#1200) * fix: reset the reconnect interval when connected (#1175) * feat: set the default reconnect delay time as 10 seconds * fix: get the minimum reconnect interval --- custom_components/xiaomi_home/miot/miot_mips.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/custom_components/xiaomi_home/miot/miot_mips.py b/custom_components/xiaomi_home/miot/miot_mips.py index 4513aef..5cf884d 100644 --- a/custom_components/xiaomi_home/miot/miot_mips.py +++ b/custom_components/xiaomi_home/miot/miot_mips.py @@ -216,7 +216,7 @@ class _MipsClient(ABC): MQTT_INTERVAL_S = 1 MIPS_QOS: int = 2 UINT32_MAX: int = 0xFFFFFFFF - MIPS_RECONNECT_INTERVAL_MIN: float = 30 + MIPS_RECONNECT_INTERVAL_MIN: float = 10 MIPS_RECONNECT_INTERVAL_MAX: float = 600 MIPS_SUB_PATCH: int = 300 MIPS_SUB_INTERVAL: float = 1 @@ -641,6 +641,7 @@ class _MipsClient(ABC): if not self._mqtt.is_connected(): return self.log_info(f'mips connect, {flags}, {rc}, {props}') + self.__reset_reconnect_time() self._mqtt_state = True self._internal_loop.call_soon( self._on_mips_connect, rc, props) @@ -822,7 +823,7 @@ class _MipsClient(ABC): self._internal_loop.stop() def __get_next_reconnect_time(self) -> float: - if self._mips_reconnect_interval == 0: + if self._mips_reconnect_interval < self.MIPS_RECONNECT_INTERVAL_MIN: self._mips_reconnect_interval = self.MIPS_RECONNECT_INTERVAL_MIN else: self._mips_reconnect_interval = min( @@ -830,6 +831,9 @@ class _MipsClient(ABC): self.MIPS_RECONNECT_INTERVAL_MAX) return self._mips_reconnect_interval + def __reset_reconnect_time(self) -> None: + self._mips_reconnect_interval = 0 + class MipsCloudClient(_MipsClient): """MIoT Pub/Sub Cloud Client.""" From 6069eaaba8dbd51ee6159a878d0ff9eb4152a42d Mon Sep 17 00:00:00 2001 From: Li Shuzhen Date: Mon, 30 Jun 2025 11:12:58 +0800 Subject: [PATCH 04/12] feat: exclude unsupported model (#1205) * feat: ignore unsupported models (#933) * fix: remove unnecessary logs --- custom_components/xiaomi_home/miot/const.py | 5 +++++ custom_components/xiaomi_home/miot/miot_cloud.py | 5 +++++ custom_components/xiaomi_home/miot/miot_device.py | 5 ++--- custom_components/xiaomi_home/miot/miot_mips.py | 5 ++++- custom_components/xiaomi_home/miot/specs/spec_filter.yaml | 3 --- 5 files changed, 16 insertions(+), 7 deletions(-) diff --git a/custom_components/xiaomi_home/miot/const.py b/custom_components/xiaomi_home/miot/const.py index 275b297..ba356b8 100644 --- a/custom_components/xiaomi_home/miot/const.py +++ b/custom_components/xiaomi_home/miot/const.py @@ -85,6 +85,11 @@ SUPPORTED_PLATFORMS: list = [ 'water_heater', ] +UNSUPPORTED_MODELS: list = [ + 'chuangmi.ir.v2', + 'xiaomi.router.rd03' +] + DEFAULT_CLOUD_SERVER: str = 'cn' CLOUD_SERVERS: dict = { 'cn': '中国大陆', diff --git a/custom_components/xiaomi_home/miot/miot_cloud.py b/custom_components/xiaomi_home/miot/miot_cloud.py index 0b301e8..91d94b0 100644 --- a/custom_components/xiaomi_home/miot/miot_cloud.py +++ b/custom_components/xiaomi_home/miot/miot_cloud.py @@ -59,6 +59,7 @@ import aiohttp # pylint: disable=relative-beyond-top-level from .common import calc_group_id from .const import ( + UNSUPPORTED_MODELS, DEFAULT_OAUTH2_API_HOST, MIHOME_HTTP_API_TIMEOUT, OAUTH2_AUTH_URL) @@ -573,6 +574,10 @@ class MIoTHttpClient: # were implemented. _LOGGER.info('ignore miwifi.* device, cloud, %s', did) continue + if model in UNSUPPORTED_MODELS: + _LOGGER.info('ignore unsupported model %s, cloud, %s', + model, did) + continue device_infos[did] = { 'did': did, 'uid': device.get('uid', None), diff --git a/custom_components/xiaomi_home/miot/miot_device.py b/custom_components/xiaomi_home/miot/miot_device.py index e3394c9..500734c 100644 --- a/custom_components/xiaomi_home/miot/miot_device.py +++ b/custom_components/xiaomi_home/miot/miot_device.py @@ -1207,10 +1207,9 @@ class MIoTPropertyEntity(Entity): self._attr_available = miot_device.online _LOGGER.info( - 'new miot property entity, %s, %s, %s, %s, %s, %s, %s', + 'new miot property entity, %s, %s, %s, %s, %s', self.miot_device.name, self._attr_name, spec.platform, - spec.device_class, self.entity_id, self._value_range, - self._value_list) + spec.device_class, self.entity_id) @property def device_info(self) -> Optional[DeviceInfo]: diff --git a/custom_components/xiaomi_home/miot/miot_mips.py b/custom_components/xiaomi_home/miot/miot_mips.py index 5cf884d..8eab9ee 100644 --- a/custom_components/xiaomi_home/miot/miot_mips.py +++ b/custom_components/xiaomi_home/miot/miot_mips.py @@ -68,7 +68,7 @@ from paho.mqtt.client import ( # pylint: disable=relative-beyond-top-level from .common import MIoTMatcher -from .const import MIHOME_MQTT_KEEPALIVE +from .const import UNSUPPORTED_MODELS, MIHOME_MQTT_KEEPALIVE from .miot_error import MIoTErrorCode, MIoTMipsError _LOGGER = logging.getLogger(__name__) @@ -1365,6 +1365,9 @@ class MipsLocalClient(_MipsClient): if name is None or urn is None or model is None: self.log_error(f'invalid device info, {did}, {info}') continue + if model in UNSUPPORTED_MODELS: + self.log_info(f'unsupported model, {model}, {did}') + continue device_list[did] = { 'did': did, 'name': name, diff --git a/custom_components/xiaomi_home/miot/specs/spec_filter.yaml b/custom_components/xiaomi_home/miot/specs/spec_filter.yaml index c1bca60..a7116fb 100644 --- a/custom_components/xiaomi_home/miot/specs/spec_filter.yaml +++ b/custom_components/xiaomi_home/miot/specs/spec_filter.yaml @@ -44,9 +44,6 @@ urn:miot-spec-v2:device:motion-sensor:0000A014:xiaomi-pir1: services: - '1' - '5' -urn:miot-spec-v2:device:router:0000A036:xiaomi-rd03: - services: - - '*' urn:miot-spec-v2:device:thermostat:0000A031:tofan-wk01: services: - '2' From 5b1d003bb233549a323fed0fe016af4a631fafba Mon Sep 17 00:00:00 2001 From: Li Shuzhen Date: Mon, 30 Jun 2025 11:27:12 +0800 Subject: [PATCH 05/12] feat: subscribe the BLE device up messages even though the device is offline (#1207) * feat: subscribe the BLE device up messages even though the device is offline (#1170) * fix: set all BLE devices online --- custom_components/xiaomi_home/miot/miot_client.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/custom_components/xiaomi_home/miot/miot_client.py b/custom_components/xiaomi_home/miot/miot_client.py index cdfe110..377822d 100644 --- a/custom_components/xiaomi_home/miot/miot_client.py +++ b/custom_components/xiaomi_home/miot/miot_client.py @@ -1354,6 +1354,11 @@ class MIoTClient: """Update cloud devices. NOTICE: This function will operate the cloud_list """ + # MIoT cloud service may not publish the online state updating message + # for the BLE device. Assume that all BLE devices are online. + for did, info in cloud_list.items(): + if did.startswith('blt.'): + info['online'] = True for did, info in self._device_list_cache.items(): if filter_dids and did not in filter_dids: continue From 9fbbb26d335ad5a86dae94ed445ee125d0c63ae7 Mon Sep 17 00:00:00 2001 From: Li Shuzhen Date: Tue, 8 Jul 2025 13:46:36 +0800 Subject: [PATCH 06/12] fix: translation it.json (#1215) --- custom_components/xiaomi_home/translations/it.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/xiaomi_home/translations/it.json b/custom_components/xiaomi_home/translations/it.json index b384103..20439aa 100644 --- a/custom_components/xiaomi_home/translations/it.json +++ b/custom_components/xiaomi_home/translations/it.json @@ -113,7 +113,7 @@ }, "config_options": { "title": "Opzioni di Configurazione", - "description": "### Ciao, {nick_name}\r\n\r\nID Xiaomi: {uid}\r\nRegione di Login Corrente: {cloud_server}\r\n\r\nSeleziona le opzioni che desideri configurare, poi clicca AVANTI.", + "description": "### Ciao, {nick_name}\r\n\r\nID Xiaomi: {uid}\r\nRegione di Login Corrente: {cloud_server}\r\nID istanza di integrazione: {instance_id}\r\n\r\nSeleziona le opzioni che desideri configurare, poi clicca AVANTI.", "data": { "integration_language": "Lingua dell'Integrazione", "update_user_info": "Aggiorna le informazioni dell'utente", From e5165f34da907b58dd8a9d1f7c4324a7a7073f37 Mon Sep 17 00:00:00 2001 From: ted <44641251+zghnwsq@users.noreply.github.com> Date: Tue, 8 Jul 2025 13:48:17 +0800 Subject: [PATCH 07/12] fix: Fix the HA warning in the logs related to vacuum state setting (#694) --- custom_components/xiaomi_home/vacuum.py | 130 ++++++++++++++++++------ 1 file changed, 99 insertions(+), 31 deletions(-) diff --git a/custom_components/xiaomi_home/vacuum.py b/custom_components/xiaomi_home/vacuum.py index 232e676..9582b6a 100644 --- a/custom_components/xiaomi_home/vacuum.py +++ b/custom_components/xiaomi_home/vacuum.py @@ -47,29 +47,26 @@ Vacuum entities for Xiaomi Home. """ from __future__ import annotations from typing import Any, Optional +import re import logging from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.components.vacuum import ( - StateVacuumEntity, - VacuumEntityFeature -) +from homeassistant.components.vacuum import (StateVacuumEntity, + VacuumEntityFeature) from .miot.const import DOMAIN from .miot.miot_device import MIoTDevice, MIoTServiceEntity, MIoTEntityData -from .miot.miot_spec import ( - MIoTSpecAction, - MIoTSpecProperty) +from .miot.miot_spec import (MIoTSpecAction, 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: device_list: list[MIoTDevice] = hass.data[DOMAIN]['devices'][ config_entry.entry_id] @@ -99,10 +96,12 @@ class Vacuum(MIoTServiceEntity, StateVacuumEntity): _status_map: Optional[dict[int, str]] _fan_level_map: Optional[dict[int, str]] - def __init__( - self, miot_device: MIoTDevice, entity_data: MIoTEntityData - ) -> None: + _device_name: str + + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: super().__init__(miot_device=miot_device, entity_data=entity_data) + self._device_name = miot_device.name self._attr_supported_features = VacuumEntityFeature(0) self._prop_status = None @@ -121,21 +120,21 @@ class Vacuum(MIoTServiceEntity, StateVacuumEntity): for prop in entity_data.props: if prop.name == 'status': if not prop.value_list: - _LOGGER.error( - 'invalid status value_list, %s', self.entity_id) + _LOGGER.error('invalid status value_list, %s', + self.entity_id) continue self._status_map = prop.value_list.to_map() + self._attr_supported_features |= VacuumEntityFeature.STATE self._prop_status = prop elif prop.name == 'fan-level': if not prop.value_list: - _LOGGER.error( - 'invalid fan-level value_list, %s', self.entity_id) + _LOGGER.error('invalid fan-level value_list, %s', + self.entity_id) continue self._fan_level_map = prop.value_list.to_map() self._attr_fan_speed_list = list(self._fan_level_map.values()) self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED self._prop_fan_level = prop - elif prop.name == 'battery-level': self._attr_supported_features |= VacuumEntityFeature.BATTERY self._prop_battery_level = prop @@ -155,16 +154,24 @@ class Vacuum(MIoTServiceEntity, StateVacuumEntity): elif action.name == 'stop-and-gocharge': self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME self._action_stop_and_gocharge = action - elif action.name == 'identify': self._attr_supported_features |= VacuumEntityFeature.LOCATE self._action_identify = action async def async_start(self) -> None: """Start or resume the cleaning task.""" - if self.state.lower() in ['paused', '暂停中']: - await self.action_async(action=self._action_continue_sweep) - return + try: # VacuumActivity is introduced in HA core 2025.1.0 + # pylint: disable=import-outside-toplevel + from homeassistant.components.vacuum import VacuumActivity + if (self.activity + == VacuumActivity.PAUSED) and self._action_continue_sweep: + await self.action_async(action=self._action_continue_sweep) + return + except ImportError: + if self.state and (self.state in {'paused', 'pause' + }) and self._action_continue_sweep: + await self.action_async(action=self._action_continue_sweep) + return await self.action_async(action=self._action_start_sweep) async def async_stop(self, **kwargs: Any) -> None: @@ -179,31 +186,92 @@ class Vacuum(MIoTServiceEntity, StateVacuumEntity): """Set the vacuum cleaner to return to the dock.""" await self.action_async(action=self._action_stop_and_gocharge) - async def async_clean_spot(self, **kwargs: Any) -> None: - """Perform a spot clean-up.""" - async def async_locate(self, **kwargs: Any) -> None: """Locate the vacuum cleaner.""" await self.action_async(action=self._action_identify) async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set fan speed.""" + fan_level_value = self.get_map_key(map_=self._fan_level_map, + value=fan_speed) + await self.set_property_async(prop=self._prop_fan_level, + value=fan_level_value) + + @property + def name(self) -> Optional[str]: + """Name of the vacuum entity.""" + return self._device_name @property def state(self) -> Optional[str]: - """Return the current state of the vacuum cleaner.""" - return self.get_map_value( - map_=self._status_map, - key=self.get_prop_value(prop=self._prop_status)) + """Return the current state of the vacuum cleaner. + + To fix the HA warning below: + Detected that custom integration 'xiaomi_home' is setting state + directly.Entity XXX()should implement the 'activity' property and return + its state using the VacuumActivity enum.This will stop working in + Home Assistant 2026.1. + + Refer to + https://developers.home-assistant.io/blog/2024/12/08/new-vacuum-state-property + + There are only 6 states in VacuumActivity enum. To be compatible with + more constants, try get matching VacuumActivity enum first, return state + string as before if there is no match. In Home Assistant 2026.1, every + state should map to a VacuumActivity enum. + """ + return self.activity + + @property + def activity(self) -> Optional[str]: + """The current vacuum activity.""" + status = self.get_prop_value(prop=self._prop_status) + if status is None: + return None + status_value = self.get_map_value(map_=self._status_map, key=status) + if status_value is None: + return None + try: + # pylint: disable=import-outside-toplevel + from homeassistant.components.vacuum import VacuumActivity + status_value = status_value.lower() + status_str = re.sub(r'[^a-z]', '', status_value) + if status_str in { + 'charging', 'charged', 'chargingcompleted', 'fullcharge', + 'fullpower', 'findchargerpause', 'drying', 'washing', + 'wash', 'inthewash', 'inthedry', 'stationworking', + 'dustcollecting', 'upgrade', 'upgrading', 'updating' + }: + return VacuumActivity.DOCKED + if status_str in {'paused', 'pause'}: + return VacuumActivity.PAUSED + if status_str in { + 'gocharging', 'cleancompletegocharging', 'findchargewash', + 'backtowashmop', 'gowash', 'gowashing', 'summon' + }: + return VacuumActivity.RETURNING + if (status_str.find('sweeping') + != -1) or (status_str.find('mopping') + != -1) or (status_str in { + 'cleaning', 'remoteclean', 'continuesweep', + 'busy', 'building', 'buildingmap', 'mapping' + }): + return VacuumActivity.CLEANING + if status_str in {'error', 'breakcharging', 'gochargebreak'}: + return VacuumActivity.ERROR + return VacuumActivity.IDLE + except ImportError: + return status_value @property def battery_level(self) -> Optional[int]: - """Return the current battery level of the vacuum cleaner.""" + """The current battery level of the vacuum cleaner.""" return self.get_prop_value(prop=self._prop_battery_level) @property def fan_speed(self) -> Optional[str]: - """Return the current fan speed of the vacuum cleaner.""" + """The current fan speed of the vacuum cleaner.""" return self.get_map_value( map_=self._fan_level_map, key=self.get_prop_value(prop=self._prop_fan_level)) From a43447ef6191546d3e25d0a155ffafd9252b16e0 Mon Sep 17 00:00:00 2001 From: Li Shuzhen Date: Wed, 9 Jul 2025 09:14:10 +0800 Subject: [PATCH 08/12] Fix specs (#1236) * fix: ignore bjkcz.curtain.kczble curtain status (#1184) * fix: yutai.plug.fsov8m power consumption (#1229) --- custom_components/xiaomi_home/miot/specs/spec_modify.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/custom_components/xiaomi_home/miot/specs/spec_modify.yaml b/custom_components/xiaomi_home/miot/specs/spec_modify.yaml index b3d2b8f..f656d30 100644 --- a/custom_components/xiaomi_home/miot/specs/spec_modify.yaml +++ b/custom_components/xiaomi_home/miot/specs/spec_modify.yaml @@ -75,6 +75,9 @@ urn:miot-spec-v2:device:bath-heater:0000A028:opple-acmoto:1: urn:miot-spec-v2:device:bath-heater:0000A028:xiaomi-s1:1: prop.4.4: name: fan-level-ventilation +urn:miot-spec-v2:device:curtain:0000A00C:bjkcz-kczble:1:0000D031: + prop.2.2: + name: status-a urn:miot-spec-v2:device:fan:0000A005:dmaker-p33:1: prop.2.2: name: fan-level-a @@ -209,6 +212,9 @@ urn:miot-spec-v2:device:outlet:0000A002:qmi-psv3:1:0000C816: unit: mV prop.3.4: unit: mA +urn:miot-spec-v2:device:outlet:0000A002:yutai-fsov8m:1:0000C816: + prop.4.1: + expr: round(src_value/10000, 2) 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: From b46805b92cf643d9ec8f2f0af6cb6ee323ce544b Mon Sep 17 00:00:00 2001 From: Li Shuzhen Date: Wed, 9 Jul 2025 09:16:55 +0800 Subject: [PATCH 09/12] fix: airer status for cover entity (#1235) * fix: xiaomi.airer.pro3 airer status rising (#1222) * fix: airer status * fix: filter out non alphabetic characters from status descriptions --- custom_components/xiaomi_home/cover.py | 23 ++++++++++++------- .../xiaomi_home/miot/miot_mips.py | 2 +- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/custom_components/xiaomi_home/cover.py b/custom_components/xiaomi_home/cover.py index 0d1817c..1116928 100644 --- a/custom_components/xiaomi_home/cover.py +++ b/custom_components/xiaomi_home/cover.py @@ -46,8 +46,9 @@ off Xiaomi or its affiliates' products. Cover entities for Xiaomi Home. """ from __future__ import annotations -import logging from typing import Any, Optional +import re +import logging from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -97,7 +98,6 @@ class Cover(MIoTServiceEntity, CoverEntity): _prop_status: Optional[MIoTSpecProperty] _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] @@ -122,7 +122,6 @@ class Cover(MIoTServiceEntity, CoverEntity): self._prop_status = 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 @@ -159,13 +158,21 @@ class Cover(MIoTServiceEntity, CoverEntity): self.entity_id) continue for item in prop.value_list.items: - if item.name in {'opening', 'open', 'up'}: + item_str: str = item.name + item_name: str = re.sub(r'[^a-z]', '', item_str) + if item_name in { + 'opening', 'open', 'up', 'uping', 'rise', 'rising' + }: self._prop_status_opening.append(item.value) - elif item.name in {'closing', 'close', 'down', 'dowm'}: + elif item_name in { + 'closing', 'close', 'down', 'dowm', 'falling', + 'dropping', 'downing', 'lower' + }: 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'}: + elif item_name in { + 'stopatlowest', 'stoplowerlimit', 'lowerlimitstop', + 'floor', 'lowerlimit' + }: self._prop_status_closed.append(item.value) self._prop_status = prop elif prop.name == 'current-position': diff --git a/custom_components/xiaomi_home/miot/miot_mips.py b/custom_components/xiaomi_home/miot/miot_mips.py index 8eab9ee..a2aa2c1 100644 --- a/custom_components/xiaomi_home/miot/miot_mips.py +++ b/custom_components/xiaomi_home/miot/miot_mips.py @@ -1178,7 +1178,7 @@ class MipsLocalClient(_MipsClient): or 'piid' not in msg or 'value' not in msg ): - # self.log_error(f'on_prop_msg, recv unknown msg, {payload}') + self.log_info('unknown prop msg, %s', payload) return if handler: self.log_debug('local, on properties_changed, %s', payload) From 9afc62f39a0d683118e44f8236cd588314f38429 Mon Sep 17 00:00:00 2001 From: Li Shuzhen Date: Wed, 9 Jul 2025 09:17:57 +0800 Subject: [PATCH 10/12] docs: modify README (#1237) * docs: modify spec_filter.yaml in README * docs: modify event descriptions in README --- README.md | 48 +++++++++++++++++++++++----------------------- doc/README_zh.md | 50 ++++++++++++++++++++++++------------------------ 2 files changed, 49 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 9d46eaf..c385c94 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,8 @@ In MIoT-Spec-V2 protocol, a product is defined as a device. A device contains se MIoT-Spec-V2 event is transformed to Event entity in Home Assistant. The event's parameters are also passed to entity's `_trigger_event`. +MIoT-Spec-V2 event's arguments field is the list of parameters of the event. The list elements represent the piid of the property in the same service. For example, the [MIoT-Spec-V2](http://poc.miot-spec.srv/miot-spec-v2/instance?type=urn:miot-spec-v2:device:remote-control:0000A021:xiaomi-mcn002:1:0000D057) of the Xiaomi Wireless Double-key Switch contains the siid=2 Switch Sensor service. The eiid=1014 Long Press event of the service is triggered when a button is long pressed. The debug level log will print `Press and hold, attributes: {'Button Type': 1}`. This is an example log that the button type is 1, which means the right button is long pressed. + - Action | in | Entity in Home Assistant | @@ -289,39 +291,37 @@ The value of the event instance name indicates `_attr_device_class` of the Home ### MIoT-Spec-V2 Filter -`spec_filter.json` is used to filter out the MIoT-Spec-V2 instance that will not be converted to Home Assistant entity. +`spec_filter.yaml` is used to filter out the MIoT-Spec-V2 instance that will not be converted to Home Assistant entity. The format of `spec_filter.json` is as follows. -``` -{ - "":{ - "services": list, - "properties": list, - "events": list, - "actions": list, - } -} +```yaml +: + services: list + properties: list + events: list + actions: list ``` -The key of `spec_filter.json` dictionary is the urn excluding the "version" field of the MIoT-Spec-V2 device instance. The firmware of different versions of the same product may be associated with the MIoT-Spec-V2 device instances of different versions. It is required that the MIoT-Spec-V2 instance of a higher version must contain all MIoT-Spec-V2 instances of the lower versions when a vendor defines the MIoT-Spec-V2 of its product on MIoT platform. Thus, the key of `spec_filter.json` does not need to specify the version number of MIoT-Spec-V2 device instance. +The key of `spec_filter.yaml` dictionary is the urn excluding the "version" field of the MIoT-Spec-V2 device instance. The firmware of different versions of the same product may be associated with the MIoT-Spec-V2 device instances of different versions. It is required that the MIoT-Spec-V2 instance of a higher version must contain all MIoT-Spec-V2 instances of the lower versions when a vendor defines the MIoT-Spec-V2 of its product on MIoT platform. Thus, the key of `spec_filter.yaml` does not need to specify the version number of MIoT-Spec-V2 device instance. The value of "services", "properties", "events" or "actions" fields under "device instance" is the instance id (iid) of the service, property, event or action that will be ignored in the conversion process. Wildcard matching is supported. Example: -``` -{ - "urn:miot-spec-v2:device:television:0000A010:xiaomi-rmi1":{ - "services": ["*"] # Filter out all services. It is equivalent to completely ignoring the device with such MIoT-Spec-V2 device instance. - }, - "urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1": { - "services": ["3"], # Filter out the service whose iid=3. - "properties": ["4.*"] # Filter out all properties in the service whose iid=4. - "events": ["4.1"], # Filter out the iid=1 event in the iid=4 service. - "actions": ["4.1"] # Filter out the iid=1 action in the iid=4 service. - } -} +```yaml +urn:miot-spec-v2:device:television:0000A010:xiaomi-rmi1: + services: + - '*' # Filter out all services. It is equivalent to completely ignoring the device with such MIoT-Spec-V2. +urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1: + services: + - '3' # Filter out the siid=3 service. + properties: + - '4.*' # Filter out all properties in the siid=4 service. + events: + - '4.1' # Filter out the eiid=1 event in the siid=4 service. + actions: + - '4.1' # Filter out the aiid=1 action in the siid=4 service. ``` Device information service (urn:miot-spec-v2:service:device-information:00007801) of all devices will never be converted to Home Assistant entity. @@ -376,7 +376,7 @@ Example: } ``` -> If you edit any files in the `custom_components/xiaomi_home/miot/specs` directory (`spec_filter.py`, `spec_modify.json`, `multi_lang.json`, etc.) in your Home Assistant, you need to update the entity conversion rule in the integration's CONFIGURE page to take effect. Method: [Settings > Devices & services > Configured > Xiaomi Home](https://my.home-assistant.io/redirect/integration/?domain=xiaomi_home) > CONFIGURE > Update entity conversion rules +> If you edit any files in the `custom_components/xiaomi_home/miot/specs` directory (`spec_filter.yaml`, `spec_modify.yaml`, `multi_lang.json`, etc.) in your Home Assistant, you need to update the entity conversion rule in the integration's CONFIGURE page to take effect. Method: [Settings > Devices & services > Configured > Xiaomi Home](https://my.home-assistant.io/redirect/integration/?domain=xiaomi_home) > CONFIGURE > Update entity conversion rules ## Documents diff --git a/doc/README_zh.md b/doc/README_zh.md index c3dc93e..2895984 100644 --- a/doc/README_zh.md +++ b/doc/README_zh.md @@ -157,6 +157,8 @@ git checkout v1.0.0 转换后的实体为 Event,事件参数同时传递给实体的 `_trigger_event` 。 +MIoT-Spec-V2 事件的 arguments 字段是事件的参数列表,列表元素代表同服务下属性的 piid 。例如,小米智能无线开关(双开)的 [MIoT-Spec-V2](http://poc.miot-spec.srv/miot-spec-v2/instance?type=urn:miot-spec-v2:device:remote-control:0000A021:xiaomi-mcn002:1:0000D057)的 siid=2 无线开关服务下包含 eiid=1014 长按事件,该事件触发时会携带一个 piid=2 的按键类型属性作为事件参数, debug 等级日志会打印 `长按, attributes: {'按键类型': 1}` (日志示例,按键类型为 1 表示右键触发了长按事件)。 + - 方法(Action) | in(输入参数列表) | 转换后的实体 | @@ -291,39 +293,37 @@ event instance name 下的值表示转换后实体所用的 `_attr_device_class` ### MIoT-Spec-V2 过滤规则 -`spec_filter.json` 用于过滤掉不需要的 MIoT-Spec-V2 实例,过滤掉的实例不会转换成 Home Assistant 实体。 +`spec_filter.yaml` 用于过滤掉不需要的 MIoT-Spec-V2 实例,过滤掉的实例不会转换成 Home Assistant 实体。 -`spec_filter.json`的格式如下: +`spec_filter.yaml`的格式如下: -``` -{ - "":{ - "services": list, - "properties": list, - "events": list, - "actions": list, - } -} +```yaml +: + services: list + properties: list + events: list + actions: list ``` -`spec_filter.json` 的键值为 MIoT-Spec-V2 设备实例的 urn (不含版本号“version”字段)。一个产品的不同版本的固件可能会关联不同版本的 MIoT-Spec-V2 设备实例。 MIoT 平台要求厂商定义产品的 MIoT-Spec-V2 时,高版本的 MIoT-Spec-V2 实例必须包含全部低版本的 MIoT-Spec-V2 实例。因此, `spec_filter.json` 的键值不需要指定设备实例的版本号。 +`spec_filter.yaml` 的键值为 MIoT-Spec-V2 设备实例的 urn (不含版本号“version”字段)。一个产品的不同版本的固件可能会关联不同版本的 MIoT-Spec-V2 设备实例。 MIoT 平台要求厂商定义产品的 MIoT-Spec-V2 时,高版本的 MIoT-Spec-V2 实例必须包含全部低版本的 MIoT-Spec-V2 实例。因此, `spec_filter.yaml` 的键值不需要指定设备实例的版本号。 设备实例下的 services 、 properties 、 events 、 actions 域的值表示需要过滤掉的服务、属性、事件、方法的实例号( iid ,即 instance id )。支持通配符匹配。 示例: -``` -{ - "urn:miot-spec-v2:device:television:0000A010:xiaomi-rmi1":{ - "services": ["*"] # Filter out all services. It is equivalent to completely ignoring the device with such MIoT-Spec-V2 device instance. - }, - "urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1": { - "services": ["3"], # Filter out the service whose iid=3. - "properties": ["4.*"] # Filter out all properties in the service whose iid=4. - "events": ["4.1"], # Filter out the iid=1 event in the iid=4 service. - "actions": ["4.1"] # Filter out the iid=1 action in the iid=4 service. - } -} +```yaml +urn:miot-spec-v2:device:television:0000A010:xiaomi-rmi1: + services: + - '*' # 排除所有服务,相当于排除拥有该 MIoT-Spec-V2 的设备。 +urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1: + services: + - '3' # 排除 siid=3 的服务。 + properties: + - '4.*' # 排除 siid=4 服务的所有属性。 + events: + - '4.1' # 排除 siid=4 服务的 eiid=1 的事件。 + actions: + - '4.1' # 排除 siid=4 服务的 aiid=1 的方法。 ``` 所有设备的设备信息服务( urn:miot-spec-v2:service:device-information:00007801 )均不会生成 Home Assistant 实体。 @@ -378,7 +378,7 @@ siid、piid、eiid、aiid、value 均为十进制三位整数。 } ``` -> 在 Home Assistant 中修改了 `custom_components/xiaomi_home/miot/specs` 路径下的任何文件(`spec_filter.py`、`spec_modify.json`、`multi_lang.json`等),需要在集成配置中更新实体转换规则才能生效。方法:[设置 > 设备与服务 > 已配置 > Xiaomi Home](https://my.home-assistant.io/redirect/integration/?domain=xiaomi_home) > 配置 > 更新实体转换规则 +> 在 Home Assistant 中修改了 `custom_components/xiaomi_home/miot/specs` 路径下的任何文件(`spec_filter.yaml`、`spec_modify.yaml`、`multi_lang.json`等),需要在集成配置中更新实体转换规则才能生效。方法:[设置 > 设备与服务 > 已配置 > Xiaomi Home](https://my.home-assistant.io/redirect/integration/?domain=xiaomi_home) > 配置 > 更新实体转换规则 ## 文档 From 4c2e10038c533fc522a8b0e99d62d7345d96f0e5 Mon Sep 17 00:00:00 2001 From: Li Shuzhen Date: Wed, 9 Jul 2025 09:39:43 +0800 Subject: [PATCH 11/12] docs: update changelog and version to v0.3.4 (#1238) --- CHANGELOG.md | 16 +++++++++++++++- custom_components/xiaomi_home/manifest.json | 2 +- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a18c3b..3fc5f02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,23 @@ # CHANGELOG +## v0.3.4 +### Added +- Exclude the unsupported device models. [#1205](https://github.com/XiaoMi/ha_xiaomi_home/pull/1205) +### Changed +- Subscribe the BLE device upstream messages even though the device is offline. [#1207](https://github.com/XiaoMi/ha_xiaomi_home/pull/1207) +- Record "opening", "closing" and "closed" status of the airer service that occur frequently and do not record "stop" status for the cover entity. [#1235](https://github.com/XiaoMi/ha_xiaomi_home/pull/1235) +- Modify README about spec_filter.yaml and the event attributes. [#1237](https://github.com/XiaoMi/ha_xiaomi_home/pull/1237) +### Fixed +- Fix the reconnect delay time to be reset when the client is connected to the broker. [#1200](https://github.com/XiaoMi/ha_xiaomi_home/pull/1200) +- Fix the HA warning in the logs related to vacuum state setting. [#694](https://github.com/XiaoMi/ha_xiaomi_home/pull/694) +- Fix the operation mode when the device does not have a mode property. [#1199](https://github.com/XiaoMi/ha_xiaomi_home/pull/1199) +- Fix 090615.aircondition.ktf environment temperature. [#1210](https://github.com/XiaoMi/ha_xiaomi_home/pull/1210) +- Fix a missing variable in translation it.json. [#1215](https://github.com/XiaoMi/ha_xiaomi_home/pull/1215) +- Fix yutai.plug.fsov8m power consumption and ignore bjkcz.curtain.kczble curtain status. [#1236](https://github.com/XiaoMi/ha_xiaomi_home/pull/1236) + ## v0.3.3 ### Changed - Change the log level of error "mips unsub internal error, 4, None". [#1135](https://github.com/XiaoMi/ha_xiaomi_home/pull/1135) - Add necessary logs for distinguishing the set_properties command source. [#1160](https://github.com/XiaoMi/ha_xiaomi_home/pull/1160) - ### Fixed - Fix tofan.airrtc.wk01 thermostat and air conditioner service. [#1160](https://github.com/XiaoMi/ha_xiaomi_home/pull/1160) - Fix mrbond.airer.m1t closing status. [#1134](https://github.com/XiaoMi/ha_xiaomi_home/pull/1134) diff --git a/custom_components/xiaomi_home/manifest.json b/custom_components/xiaomi_home/manifest.json index 5c97f6a..54c8708 100644 --- a/custom_components/xiaomi_home/manifest.json +++ b/custom_components/xiaomi_home/manifest.json @@ -25,7 +25,7 @@ "cryptography", "psutil" ], - "version": "v0.3.3", + "version": "v0.3.4", "zeroconf": [ "_miot-central._tcp.local." ] From aebeaf0245d5b003931cdf2a193114bec64ff851 Mon Sep 17 00:00:00 2001 From: Li Shuzhen Date: Wed, 9 Jul 2025 14:02:22 +0800 Subject: [PATCH 12/12] feat: add wifi speaker and television as the media player entity (#706) --- custom_components/xiaomi_home/fan.py | 4 +- custom_components/xiaomi_home/media_player.py | 470 ++++++++++++++++++ custom_components/xiaomi_home/miot/const.py | 1 + .../xiaomi_home/miot/specs/specv2entity.py | 58 +++ 4 files changed, 531 insertions(+), 2 deletions(-) create mode 100644 custom_components/xiaomi_home/media_player.py diff --git a/custom_components/xiaomi_home/fan.py b/custom_components/xiaomi_home/fan.py index fa36035..0743cf3 100644 --- a/custom_components/xiaomi_home/fan.py +++ b/custom_components/xiaomi_home/fan.py @@ -221,7 +221,7 @@ class Fan(MIoTServiceEntity, FanEntity): # preset_mode if preset_mode: await self.set_property_async( - self._prop_mode, + prop=self._prop_mode, value=self.get_map_key( map_=self._mode_map, value=preset_mode)) @@ -258,7 +258,7 @@ class Fan(MIoTServiceEntity, FanEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode.""" await self.set_property_async( - self._prop_mode, + prop=self._prop_mode, value=self.get_map_key( map_=self._mode_map, value=preset_mode)) diff --git a/custom_components/xiaomi_home/media_player.py b/custom_components/xiaomi_home/media_player.py new file mode 100644 index 0000000..f863d92 --- /dev/null +++ b/custom_components/xiaomi_home/media_player.py @@ -0,0 +1,470 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +Media player entities for Xiaomi Home. +""" +from __future__ import annotations +import logging +from typing import Optional + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.components.media_player import (MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerDeviceClass, + MediaPlayerState, MediaType) + +from .miot.const import DOMAIN +from .miot.miot_device import MIoTDevice, MIoTServiceEntity, MIoTEntityData +from .miot.miot_spec import MIoTSpecProperty, MIoTSpecAction + +_LOGGER = logging.getLogger(__name__) + + +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] + + new_entities = [] + for miot_device in device_list: + for data in miot_device.entity_list.get('wifi-speaker', []): + new_entities.append( + WifiSpeaker(miot_device=miot_device, entity_data=data)) + for data in miot_device.entity_list.get('television', []): + new_entities.append( + Television(miot_device=miot_device, entity_data=data)) + + if new_entities: + async_add_entities(new_entities) + + +class FeatureVolumeMute(MIoTServiceEntity, MediaPlayerEntity): + """VOLUME_MUTE feature of the media player entity.""" + _prop_mute: Optional[MIoTSpecProperty] + + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: + """Initialize the feature class.""" + self._prop_mute = None + + super().__init__(miot_device=miot_device, entity_data=entity_data) + # properties + for prop in entity_data.props: + if prop.name == 'mute': + self._attr_supported_features |= ( + MediaPlayerEntityFeature.VOLUME_MUTE) + self._prop_mute = prop + + @property + def is_volume_muted(self) -> Optional[bool]: + """True if volume is currently muted.""" + return self.get_prop_value( + prop=self._prop_mute) if self._prop_mute else None + + async def async_mute_volume(self, mute: bool) -> None: + """Mute the volume.""" + await self.set_property_async(prop=self._prop_mute, value=mute) + + +class FeatureVolumeSet(MIoTServiceEntity, MediaPlayerEntity): + """VOLUME_SET feature of the media player entity.""" + _prop_volume: Optional[MIoTSpecProperty] + _volume_value_min: Optional[float] + _volume_value_max: Optional[float] + _volume_value_range: Optional[float] + + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: + """Initialize the feature class.""" + self._prop_volume = None + self._volume_value_min = None + self._volume_value_max = None + self._volume_value_range = None + + super().__init__(miot_device=miot_device, entity_data=entity_data) + # properties + for prop in entity_data.props: + if prop.name == 'volume': + if not prop.value_range: + _LOGGER.error('invalid volume value_range format, %s', + self.entity_id) + continue + self._volume_value_min = prop.value_range.min_ + self._volume_value_max = prop.value_range.max_ + self._volume_value_range = (prop.value_range.max_ - + prop.value_range.min_) + self._attr_volume_step = (prop.value_range.step / + self._volume_value_range) + self._attr_supported_features |= ( + MediaPlayerEntityFeature.VOLUME_SET | + MediaPlayerEntityFeature.VOLUME_STEP) + self._prop_volume = prop + + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level.""" + value = volume * self._volume_value_range + self._volume_value_min + if value > self._volume_value_max: + value = self._volume_value_max + elif value < self._volume_value_min: + value = self._volume_value_min + await self.set_property_async(prop=self._prop_volume, value=value) + + @property + def volume_level(self) -> Optional[float]: + """The current volume level, range [0, 1].""" + value = self.get_prop_value( + prop=self._prop_volume) if self._prop_volume else None + if value is None: + return None + return (value - self._volume_value_min) / self._volume_value_range + + +class FeaturePlay(MIoTServiceEntity, MediaPlayerEntity): + """PLAY feature of the media player entity.""" + _action_play: Optional[MIoTSpecAction] + + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: + """Initialize the feature class.""" + self._action_play = None + + super().__init__(miot_device=miot_device, entity_data=entity_data) + # actions + for act in entity_data.actions: + if act.name == 'play': + self._attr_supported_features |= (MediaPlayerEntityFeature.PLAY) + self._action_play = act + + async def async_media_play(self) -> None: + """Send play command.""" + await self.action_async(action=self._action_play) + + +class FeaturePause(MIoTServiceEntity, MediaPlayerEntity): + """PAUSE feature of the media player entity.""" + _action_pause: Optional[MIoTSpecAction] + + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: + """Initialize the feature class.""" + self._action_pause = None + + super().__init__(miot_device=miot_device, entity_data=entity_data) + # actions + for act in entity_data.actions: + if act.name == 'pause': + self._attr_supported_features |= ( + MediaPlayerEntityFeature.PAUSE) + self._action_pause = act + + async def async_media_pause(self) -> None: + """Send pause command.""" + await self.action_async(action=self._action_pause) + + +class FeatureStop(MIoTServiceEntity, MediaPlayerEntity): + """STOP feature of the media player entity.""" + _action_stop: Optional[MIoTSpecAction] + + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: + """Initialize the feature class.""" + self._action_stop = None + + super().__init__(miot_device=miot_device, entity_data=entity_data) + # actions + for act in entity_data.actions: + if act.name == 'stop': + self._attr_supported_features |= (MediaPlayerEntityFeature.STOP) + self._action_stop = act + + async def async_media_stop(self) -> None: + """Send stop command.""" + await self.action_async(action=self._action_stop) + + +class FeatureNextTrack(MIoTServiceEntity, MediaPlayerEntity): + """NEXT_TRACK feature of the media player entity.""" + _action_next: Optional[MIoTSpecAction] + + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: + """Initialize the feature class.""" + self._action_next = None + + super().__init__(miot_device=miot_device, entity_data=entity_data) + # actions + for act in entity_data.actions: + if act.name == 'next': + self._attr_supported_features |= ( + MediaPlayerEntityFeature.NEXT_TRACK) + self._action_next = act + + async def async_media_next_track(self) -> None: + """Send next track command.""" + await self.action_async(action=self._action_next) + + +class FeaturePreviousTrack(MIoTServiceEntity, MediaPlayerEntity): + """PREVIOUS_TRACK feature of the media player entity.""" + _action_previous: Optional[MIoTSpecAction] + + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: + """Initialize the feature class.""" + self._action_previous = None + + super().__init__(miot_device=miot_device, entity_data=entity_data) + # actions + for act in entity_data.actions: + if act.name == 'previous': + self._attr_supported_features |= ( + MediaPlayerEntityFeature.PREVIOUS_TRACK) + self._action_previous = act + + async def async_media_previous_track(self) -> None: + """Send previous track command.""" + await self.action_async(action=self._action_previous) + + +class FeatureSoundMode(MIoTServiceEntity, MediaPlayerEntity): + """SELECT_SOUND_MODE feature of the media player entity.""" + _prop_play_loop_mode: Optional[MIoTSpecProperty] + _sound_mode_map: Optional[dict[int, str]] + + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: + """Initialize the feature class.""" + self._prop_play_loop_mode = None + self._sound_mode_map = None + + super().__init__(miot_device=miot_device, entity_data=entity_data) + # properties + for prop in entity_data.props: + if prop.name == 'play-loop-mode': + if not prop.value_list: + _LOGGER.error('invalid play-loop-mode value_list, %s', + self.entity_id) + continue + self._sound_mode_map = prop.value_list.to_map() + self._attr_sound_mode_list = list(self._sound_mode_map.values()) + self._attr_supported_features |= ( + MediaPlayerEntityFeature.SELECT_SOUND_MODE) + self._prop_play_loop_mode = prop + + async def async_select_sound_mode(self, sound_mode: str): + """Switch the sound mode of the entity.""" + await self.set_property_async(prop=self._prop_play_loop_mode, + value=self.get_map_key( + map_=self._sound_mode_map, + value=sound_mode)) + + @property + def sound_mode(self) -> Optional[str]: + """The current sound mode.""" + return (self.get_map_value(map_=self._sound_mode_map, + key=self.get_prop_value( + prop=self._prop_play_loop_mode)) + if self._prop_play_loop_mode else None) + + +class FeatureTurnOn(MIoTServiceEntity, MediaPlayerEntity): + """TURN_ON feature of the media player entity.""" + _action_turn_on: Optional[MIoTSpecAction] + + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: + """Initialize the feature class.""" + self._action_turn_on = None + + super().__init__(miot_device=miot_device, entity_data=entity_data) + # actions + for act in entity_data.actions: + if act.name == 'turn-on': + self._attr_supported_features |= ( + MediaPlayerEntityFeature.TURN_ON) + self._action_turn_on = act + + async def async_turn_on(self) -> None: + """Turn the media player on.""" + await self.action_async(action=self._action_turn_on) + + +class FeatureTurnOff(MIoTServiceEntity, MediaPlayerEntity): + """TURN_OFF feature of the media player entity.""" + _action_turn_off: Optional[MIoTSpecAction] + + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: + """Initialize the feature class.""" + self._action_turn_off = None + + super().__init__(miot_device=miot_device, entity_data=entity_data) + # actions + for act in entity_data.actions: + if act.name == 'turn-off': + self._attr_supported_features |= ( + MediaPlayerEntityFeature.TURN_OFF) + self._action_turn_off = act + + async def async_turn_off(self) -> None: + """Turn the media player off.""" + await self.action_async(action=self._action_turn_off) + + +class FeatureSource(MIoTServiceEntity, MediaPlayerEntity): + """SELECT_SOURCE feature of the media player entity.""" + _prop_input_control: Optional[MIoTSpecProperty] + _input_source_map: Optional[dict[int, str]] + + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: + """Initialize the feature class.""" + self._prop_input_control = None + self._input_source_map = None + + super().__init__(miot_device=miot_device, entity_data=entity_data) + # properties + for prop in entity_data.props: + if prop.name == 'input-control': + if not prop.value_list: + _LOGGER.error('invalid input-control value_list, %s', + self.entity_id) + continue + self._input_source_map = prop.value_list.to_map() + self._attr_source_list = list(self._input_source_map.values()) + self._attr_supported_features |= ( + MediaPlayerEntityFeature.SELECT_SOURCE) + self._prop_input_control = prop + + async def async_select_source(self, source: str) -> None: + """Select input source.""" + await self.set_property_async(prop=self._prop_input_control, + value=self.get_map_key( + map_=self._input_source_map, + value=source)) + + @property + def source(self) -> Optional[str]: + """The current input source.""" + return (self.get_map_value(map_=self._input_source_map, + key=self.get_prop_value( + prop=self._prop_input_control)) + if self._prop_input_control else None) + + +class FeatureState(MIoTServiceEntity, MediaPlayerEntity): + """States feature of the media player entity.""" + _prop_playing_state: Optional[MIoTSpecProperty] + _playing_state_map: Optional[dict[int, str]] + + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: + """Initialize the feature class.""" + self._prop_playing_state = None + self._playing_state_map = None + + super().__init__(miot_device=miot_device, entity_data=entity_data) + # properties + for prop in entity_data.props: + if prop.name == 'playing-state': + if not prop.value_list: + _LOGGER.error('invalid mode value_list, %s', self.entity_id) + continue + self._playing_state_map = {} + for item in prop.value_list.items: + if item.name in {'off'}: + self._playing_state_map[ + item.value] = MediaPlayerState.OFF + elif item.name in {'idle', 'stop', 'stopped'}: + self._playing_state_map[ + item.value] = MediaPlayerState.IDLE + elif item.name in {'playing'}: + self._playing_state_map[ + item.value] = MediaPlayerState.PLAYING + elif item.name in {'pause', 'paused'}: + self._playing_state_map[ + item.value] = MediaPlayerState.PAUSED + self._prop_playing_state = prop + + @property + def state(self) -> Optional[MediaPlayerState]: + """The current state.""" + return (self.get_map_value(map_=self._playing_state_map, + key=self.get_prop_value( + prop=self._prop_playing_state)) + if self._prop_playing_state else MediaPlayerState.ON) + + +class WifiSpeaker(FeatureVolumeSet, FeatureVolumeMute, FeaturePlay, + FeaturePause, FeatureStop, FeatureNextTrack, + FeaturePreviousTrack, FeatureSoundMode, FeatureState): + """WiFi speaker, aka XiaoAI sound speaker.""" + + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: + """Initialize the device.""" + super().__init__(miot_device=miot_device, entity_data=entity_data) + + self._attr_device_class = MediaPlayerDeviceClass.SPEAKER + self._attr_media_content_type = MediaType.MUSIC + + +class Television(FeatureVolumeSet, FeatureVolumeMute, FeaturePlay, FeaturePause, + FeatureStop, FeatureNextTrack, FeaturePreviousTrack, + FeatureSoundMode, FeatureState, FeatureSource, FeatureTurnOn, + FeatureTurnOff): + """Television""" + + def __init__(self, miot_device: MIoTDevice, + entity_data: MIoTEntityData) -> None: + """Initialize the device.""" + super().__init__(miot_device=miot_device, entity_data=entity_data) + + self._attr_device_class = MediaPlayerDeviceClass.TV + self._attr_media_content_type = MediaType.VIDEO diff --git a/custom_components/xiaomi_home/miot/const.py b/custom_components/xiaomi_home/miot/const.py index ba356b8..6c20cf3 100644 --- a/custom_components/xiaomi_home/miot/const.py +++ b/custom_components/xiaomi_home/miot/const.py @@ -75,6 +75,7 @@ SUPPORTED_PLATFORMS: list = [ 'fan', 'humidifier', 'light', + 'media_player', 'notify', 'number', 'select', diff --git a/custom_components/xiaomi_home/miot/specs/specv2entity.py b/custom_components/xiaomi_home/miot/specs/specv2entity.py index 68632dd..2f1bd22 100644 --- a/custom_components/xiaomi_home/miot/specs/specv2entity.py +++ b/custom_components/xiaomi_home/miot/specs/specv2entity.py @@ -321,6 +321,64 @@ SPEC_DEVICE_TRANS_MAP: dict = { 'optional': {}, 'entity': 'electric-blanket' }, + 'speaker': { + 'required': { + 'speaker': { + 'required': { + 'properties': { + 'volume': {'read', 'write'} + } + }, + 'optional': { + 'properties': {'mute'} + } + }, + 'play-control': { + 'required': { + 'actions': {'play'} + }, + 'optional': { + 'properties': {'playing-state'}, + 'actions': {'pause', 'stop', 'next', 'previous'} + } + } + }, + 'optional': {}, + 'entity': 'wifi-speaker' + }, + 'television': { + 'required': { + 'speaker': { + 'required': { + 'properties': { + 'volume': {'read', 'write'} + } + }, + 'optional': { + 'properties': {'mute'} + } + }, + 'television': { + 'required': { + 'actions': {'turn-off'} + }, + 'optional': { + 'properties': {'input-control'}, + 'actions': {'turn-on'} + } + } + }, + 'optional': { + 'play-control': { + 'required': {}, + 'optional': { + 'properties': {'playing-state'}, + 'actions': {'play', 'pause', 'stop', 'next', 'previous'} + } + } + }, + 'entity': 'television' + } } """SPEC_SERVICE_TRANS_MAP