Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions custom_components/xiaomi_miot/core/device_customizes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2621,6 +2621,25 @@
{'interval': 999, 'props': 'clean_record'},
],
},
'xiaomi.vacuum.d103cn': {
'interval_seconds': 120,
'chunk_coordinators': [
{'interval': 11, 'props': 'status', 'notify': True},
{'interval': 21, 'props': 'mode,sweep_mop_type'},
{'interval': 31, 'props': 'charging_state,vacuum_position'},
{'interval': 41, 'props': 'cleaning_area,cleaning_time'},
{'interval': 61, 'props': 'mop_status,battery_level,clean_times'},
{'interval': 150, 'props': 'auto_*,*_detection'},
{'interval': 200, 'props': 'carpet_*,water_*'},
{'interval': 250, 'props': 'map_*'},
{'interval': 300, 'props': 'filter_l*,mop_l*,dust_bag_l*,brush_l*,detergent_l*'},
{'interval': 999, 'props': 'clean_record,map_3d_info,room_information,room_ids'},
],
'button_actions': 'start_sweep,stop_sweeping,continue_sweep,start_dust_arrest,'
'start_mop_wash,start_dry,stop_dry,start_eject,identify,'
'reset_mop_life,reset_brush_life,reset_filter_life,reset_dust_bag_life',
'sensor_properties': 'cleaning_area,cleaning_time,dry_left_time',
},
'xiaomi.vacuum.*': {
'interval_seconds': 90,
'chunk_coordinators': [
Expand Down
224 changes: 223 additions & 1 deletion custom_components/xiaomi_miot/vacuum.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Support for Xiaomi vacuums."""
import logging
import asyncio
import json
from datetime import timedelta

from homeassistant.components.vacuum import ( # noqa: F401
Expand Down Expand Up @@ -49,7 +50,9 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
for srv in spec.get_services(ENTITY_DOMAIN, 'mopping_machine'):
if not srv.get_property('status'):
continue
if model in MIOT_LOCAL_MODELS:
if model in ['xiaomi.vacuum.d103cn']:
entities.append(MiotXiaomiVacuumEntity(config, srv))
elif model in MIOT_LOCAL_MODELS:
entities.append(MiotVacuumEntity(config, srv))
elif 'roborock.' in model or 'rockrobo.' in model:
entities.append(MiotRoborockVacuumEntity(config, srv))
Expand Down Expand Up @@ -399,3 +402,222 @@ async def async_clean_zones(self, zones, repeats=1):
async def async_clean_point(self, point):
await self.async_miio_command('set_uploadmap', [0])
return await self.async_miio_command('set_pointclean', [1, *point])


class MiotXiaomiVacuumEntity(MiotVacuumEntity):
def __init__(self, config: dict, miot_service: MiotService):
super().__init__(config, miot_service)
self._act_room_sweep = miot_service.get_action('start_room_sweep', 'start_room_clean')
if self._act_room_sweep:
self._supported_features |= VacuumEntityFeature.SEND_COMMAND

async def async_added_to_hass(self):
await super().async_added_to_hass()
if not self._act_room_sweep:
return
await self._setup_room_buttons()

async def _setup_room_buttons(self):
rooms = await self._get_rooms()
if not rooms:
return
add_buttons = self.device.entry.adders.get('button')
if not add_buttons:
return
from .button import ButtonSubEntity
entities = []
for rid, rname in rooms:
sub = f'room_{rid}'
entity = ButtonSubEntity(self, sub, option={
'name': f'{self.device_name} {rname}',
'async_press_action': self.async_start_room_sweep,
'press_kwargs': {'room_id': rid},
'state_attrs': {'room_id': rid, 'room_name': rname},
})
self._subs[sub] = entity
entities.append(entity)
if entities:
add_buttons(entities, update_before_add=False)
self.logger.info('Room buttons added: %s', rooms)
Comment on lines +420 to +441

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Calling add_buttons inside the loop for each room is inefficient as it triggers multiple update cycles in Home Assistant. It is highly recommended to batch all the new button entities into a list and call add_buttons once after the loop.

    async def _setup_room_buttons(self):
        rooms = await self._get_rooms()
        if not rooms:
            return
        add_buttons = self.device.entry.adders.get('button')
        if not add_buttons:
            return
        from .button import ButtonSubEntity
        new_buttons = []
        for rid, rname in rooms:
            sub = f'room_{rid}'
            button = ButtonSubEntity(self, sub, option={
                'name': f'{self.device_name} {rname}',
                'async_press_action': self.async_start_room_sweep,
                'press_kwargs': {'room_id': rid},
                'state_attrs': {'room_id': rid, 'room_name': rname},
            })
            self._subs[sub] = button
            new_buttons.append(button)
        if new_buttons:
            add_buttons(new_buttons, update_before_add=False)
        self.logger.info('Room buttons added: %s', rooms)


async def _get_rooms(self):
rooms = await self._get_device_rooms()
if not rooms:
rooms = await self._get_cloud_rooms()
if not rooms:
return None
seen = set()
deduped = []
for rid, name in rooms:
entry = self._normalize_room_entry(rid, name)
if not entry or entry[0] in seen:
continue
seen.add(entry[0])
deduped.append(entry)
if not deduped:
return None
self._state_attrs['room_mapping'] = deduped
return deduped

async def _get_device_rooms(self):
# Try to read room data from device props
props = self.device.props or {}
for key in ('room_information', 'vacuum_extend.room_information',
'room_ids', 'vacuum.room_ids',
'vacuum_room_ids', 'clean_room_ids'):
val = props.get(key)
if val:
rooms = self._parse_room_data(val)
if rooms:
return rooms
# Try to fetch room_information directly from device
prop = self._miot_service.get_property('room_information')
if prop:
try:
mapping = {prop.full_name: {'siid': self._miot_service.iid, 'piid': prop.iid}}
vals = await self.device.async_get_properties(mapping, update_entity=False)
if vals:
val = vals.get(prop.full_name)
if val:
rooms = self._parse_room_data(val)
if rooms:
return rooms
except Exception:
pass
# Try miio get_room_mapping command (like Roborock)
if self.miot_device:
try:
result = await self.miot_device.async_send('get_room_mapping')
if result and result != 'unknown_method':
return await self._process_room_mapping(result)
except Exception:
pass
return None

async def _get_cloud_rooms(self):
if not self.xiaomi_cloud:
return None
homes = await self.xiaomi_cloud.async_get_homerooms() or []
if not homes:
return None
# Find the home that this device belongs to
device_did = self.device.did
target_home_id = None
if device_did:
devices = await self.xiaomi_cloud.async_get_devices_by_key('did') or {}
dev = devices.get(device_did)
if dev:
target_home_id = dev.get('home_id')
rooms = []
for home in homes:
if target_home_id is not None and home.get('id') != target_home_id:
continue
for room in home.get('roomlist', []):
if not isinstance(room, dict):
continue
entry = self._normalize_room_entry(room.get('id'), room.get('name'))
if entry:
rooms.append(entry)
return rooms or None

@staticmethod
def _normalize_room_id(rid):
if rid is None:
return None
rid = str(rid).strip()
if not rid or rid == 'None':
return None
return rid

@classmethod
def _normalize_room_name(cls, name, rid):
rid = cls._normalize_room_id(rid) or str(rid)
if name is None:
return f'Room {rid}'
name = str(name).strip()
if not name or name == 'None':
return f'Room {rid}'
return name

@classmethod
def _normalize_room_entry(cls, rid, name=None):
rid = cls._normalize_room_id(rid)
if not rid:
return None
return rid, cls._normalize_room_name(name, rid)

@classmethod
def _parse_room_data(cls, data):
if not data:
return None
if isinstance(data, str):
try:
data = json.loads(data)
except (json.JSONDecodeError, TypeError):
return None
rooms = []
if isinstance(data, list):
for item in data:
if item is None:
continue
if isinstance(item, (list, tuple)):
if not item:
continue
entry = cls._normalize_room_entry(
item[0], item[1] if len(item) > 1 else None,
)
elif isinstance(item, dict):
rid = item.get('id')
if rid is None:
rid = item.get('ID')
entry = cls._normalize_room_entry(
rid, item.get('name') or item.get('Name'),
)
else:
entry = cls._normalize_room_entry(item)
if entry:
rooms.append(entry)
elif isinstance(data, dict):
for rid, name in data.items():
entry = cls._normalize_room_entry(rid, name)
if entry:
rooms.append(entry)
return rooms or None
Comment on lines +523 to +585

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When parsing room data from dictionaries, if both id and ID keys are missing, rid will resolve to the string 'None'. This can lead to invalid room buttons and errors when trying to sweep. Additionally, we should guard against None values in lists and dictionary keys to ensure robust parsing.

    @staticmethod
    def _parse_room_data(data):
        if not data:
            return None
        if isinstance(data, str):
            try:
                data = json.loads(data)
            except (json.JSONDecodeError, TypeError):
                return None
        rooms = []
        if isinstance(data, list):
            for item in data:
                if isinstance(item, (list, tuple)):
                    if item and item[0] is not None:
                        rid = str(item[0])
                        name = str(item[1]) if len(item) > 1 and item[1] is not None else f'Room {rid}'
                        rooms.append((rid, name))
                elif isinstance(item, dict):
                    rid = item.get('id') or item.get('ID')
                    if rid is not None:
                        rid = str(rid)
                        name = str(item.get('name') or item.get('Name') or f'Room {rid}')
                        rooms.append((rid, name))
                else:
                    if item is not None:
                        rid = str(item)
                        rooms.append((rid, f'Room {rid}'))
        elif isinstance(data, dict):
            for rid, name in data.items():
                if rid is not None:
                    rid = str(rid)
                    rooms.append((rid, str(name) if name else f'Room {rid}'))
        return rooms or None


async def _process_room_mapping(self, data):
if not data or not isinstance(data, list):
return None
cloud_rooms = {}
if self.xiaomi_cloud:
try:
homes = await self.xiaomi_cloud.async_get_homerooms() or []
for home in homes:
for room in home.get('roomlist', []):
if not isinstance(room, dict):
continue
cloud_id = room.get('id')
if cloud_id is not None:
cloud_rooms[cloud_id] = room.get('name')
except Exception:
pass
rooms = []
for item in data:
if item is None or not isinstance(item, (list, tuple)) or len(item) < 2:
continue
seg_id = self._normalize_room_id(item[0])
if not seg_id:
continue
name = None
if len(item) > 2 and item[2] is not None:
name = item[2]
elif item[1] is not None:
name = cloud_rooms.get(item[1])
rooms.append((seg_id, self._normalize_room_name(name, seg_id)))
return rooms or None
Comment on lines +587 to +616

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Improve robustness when processing room mapping by explicitly checking for None values in the list items and fallback names. This prevents potential string representation of 'None' from being used as room names or IDs.

    async def _process_room_mapping(self, data):
        if not data or not isinstance(data, list):
            return None
        cloud_rooms = {}
        if self.xiaomi_cloud:
            try:
                homes = await self.xiaomi_cloud.async_get_homerooms() or []
                for home in homes:
                    for room in home.get('roomlist', []):
                        r_id = room.get('id')
                        r_name = room.get('name')
                        if r_id is not None and r_name is not None:
                            cloud_rooms[r_id] = r_name
            except Exception:
                pass
        rooms = []
        for item in data:
            if isinstance(item, (list, tuple)) and len(item) >= 2:
                if item[0] is not None:
                    seg_id = str(item[0])
                    cloud_id = item[1]
                    name = None
                    if len(item) > 2 and item[2] is not None:
                        name = str(item[2])
                    if not name and cloud_id is not None:
                        name = cloud_rooms.get(cloud_id)
                    if not name:
                        name = f'Room {seg_id}'
                    rooms.append((seg_id, name))
        return rooms or None


async def async_start_room_sweep(self, room_id, **kwargs):
room_id = self._normalize_room_id(room_id)
if not self._act_room_sweep or not room_id:
return False
params = self._act_room_sweep.in_params([str(room_id)])
return await self.async_call_action(self._act_room_sweep, params=params)