mirror of
https://github.com/home-assistant/core.git
synced 2025-07-27 07:07:28 +00:00
Add a service to get maps for Roborock (#111478)
* add service to get rooms * fix linting * add snapshot * change map id
This commit is contained in:
parent
9cbed10372
commit
5ef42078a3
@ -8,7 +8,7 @@ from datetime import timedelta
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from roborock import RoborockException, RoborockInvalidCredentials
|
from roborock import HomeDataRoom, RoborockException, RoborockInvalidCredentials
|
||||||
from roborock.cloud_api import RoborockMqttClient
|
from roborock.cloud_api import RoborockMqttClient
|
||||||
from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, UserData
|
from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, UserData
|
||||||
from roborock.web_api import RoborockApiClient
|
from roborock.web_api import RoborockApiClient
|
||||||
@ -28,6 +28,7 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up roborock from a config entry."""
|
"""Set up roborock from a config entry."""
|
||||||
|
|
||||||
_LOGGER.debug("Integration async setup entry: %s", entry.as_dict())
|
_LOGGER.debug("Integration async setup entry: %s", entry.as_dict())
|
||||||
|
|
||||||
user_data = UserData.from_dict(entry.data[CONF_USER_DATA])
|
user_data = UserData.from_dict(entry.data[CONF_USER_DATA])
|
||||||
@ -56,7 +57,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
}
|
}
|
||||||
# Get a Coordinator if the device is available or if we have connected to the device before
|
# Get a Coordinator if the device is available or if we have connected to the device before
|
||||||
coordinators = await asyncio.gather(
|
coordinators = await asyncio.gather(
|
||||||
*build_setup_functions(hass, device_map, user_data, product_info),
|
*build_setup_functions(
|
||||||
|
hass, device_map, user_data, product_info, home_data.rooms
|
||||||
|
),
|
||||||
return_exceptions=True,
|
return_exceptions=True,
|
||||||
)
|
)
|
||||||
# Valid coordinators are those where we had networking cached or we could get networking
|
# Valid coordinators are those where we had networking cached or we could get networking
|
||||||
@ -85,10 +88,13 @@ def build_setup_functions(
|
|||||||
device_map: dict[str, HomeDataDevice],
|
device_map: dict[str, HomeDataDevice],
|
||||||
user_data: UserData,
|
user_data: UserData,
|
||||||
product_info: dict[str, HomeDataProduct],
|
product_info: dict[str, HomeDataProduct],
|
||||||
|
home_data_rooms: list[HomeDataRoom],
|
||||||
) -> list[Coroutine[Any, Any, RoborockDataUpdateCoordinator | None]]:
|
) -> list[Coroutine[Any, Any, RoborockDataUpdateCoordinator | None]]:
|
||||||
"""Create a list of setup functions that can later be called asynchronously."""
|
"""Create a list of setup functions that can later be called asynchronously."""
|
||||||
return [
|
return [
|
||||||
setup_device(hass, user_data, device, product_info[device.product_id])
|
setup_device(
|
||||||
|
hass, user_data, device, product_info[device.product_id], home_data_rooms
|
||||||
|
)
|
||||||
for device in device_map.values()
|
for device in device_map.values()
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -98,6 +104,7 @@ async def setup_device(
|
|||||||
user_data: UserData,
|
user_data: UserData,
|
||||||
device: HomeDataDevice,
|
device: HomeDataDevice,
|
||||||
product_info: HomeDataProduct,
|
product_info: HomeDataProduct,
|
||||||
|
home_data_rooms: list[HomeDataRoom],
|
||||||
) -> RoborockDataUpdateCoordinator | None:
|
) -> RoborockDataUpdateCoordinator | None:
|
||||||
"""Set up a device Coordinator."""
|
"""Set up a device Coordinator."""
|
||||||
mqtt_client = RoborockMqttClient(user_data, DeviceData(device, product_info.name))
|
mqtt_client = RoborockMqttClient(user_data, DeviceData(device, product_info.name))
|
||||||
@ -117,7 +124,7 @@ async def setup_device(
|
|||||||
await mqtt_client.async_release()
|
await mqtt_client.async_release()
|
||||||
raise
|
raise
|
||||||
coordinator = RoborockDataUpdateCoordinator(
|
coordinator = RoborockDataUpdateCoordinator(
|
||||||
hass, device, networking, product_info, mqtt_client
|
hass, device, networking, product_info, mqtt_client, home_data_rooms
|
||||||
)
|
)
|
||||||
# Verify we can communicate locally - if we can't, switch to cloud api
|
# Verify we can communicate locally - if we can't, switch to cloud api
|
||||||
await coordinator.verify_api()
|
await coordinator.verify_api()
|
||||||
|
@ -30,3 +30,5 @@ IMAGE_DRAWABLES: list[Drawable] = [
|
|||||||
IMAGE_CACHE_INTERVAL = 90
|
IMAGE_CACHE_INTERVAL = 90
|
||||||
|
|
||||||
MAP_SLEEP = 3
|
MAP_SLEEP = 3
|
||||||
|
|
||||||
|
GET_MAPS_SERVICE_NAME = "get_maps"
|
||||||
|
@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from roborock import HomeDataRoom
|
||||||
from roborock.cloud_api import RoborockMqttClient
|
from roborock.cloud_api import RoborockMqttClient
|
||||||
from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, NetworkInfo
|
from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, NetworkInfo
|
||||||
from roborock.exceptions import RoborockException
|
from roborock.exceptions import RoborockException
|
||||||
@ -18,7 +20,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
|||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .models import RoborockHassDeviceInfo
|
from .models import RoborockHassDeviceInfo, RoborockMapInfo
|
||||||
|
|
||||||
SCAN_INTERVAL = timedelta(seconds=30)
|
SCAN_INTERVAL = timedelta(seconds=30)
|
||||||
|
|
||||||
@ -35,6 +37,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
|
|||||||
device_networking: NetworkInfo,
|
device_networking: NetworkInfo,
|
||||||
product_info: HomeDataProduct,
|
product_info: HomeDataProduct,
|
||||||
cloud_api: RoborockMqttClient,
|
cloud_api: RoborockMqttClient,
|
||||||
|
home_data_rooms: list[HomeDataRoom],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize."""
|
"""Initialize."""
|
||||||
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL)
|
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL)
|
||||||
@ -61,7 +64,8 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
|
|||||||
if mac := self.roborock_device_info.network_info.mac:
|
if mac := self.roborock_device_info.network_info.mac:
|
||||||
self.device_info[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, mac)}
|
self.device_info[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, mac)}
|
||||||
# Maps from map flag to map name
|
# Maps from map flag to map name
|
||||||
self.maps: dict[int, str] = {}
|
self.maps: dict[int, RoborockMapInfo] = {}
|
||||||
|
self._home_data_rooms = {str(room.id): room.name for room in home_data_rooms}
|
||||||
|
|
||||||
async def verify_api(self) -> None:
|
async def verify_api(self) -> None:
|
||||||
"""Verify that the api is reachable. If it is not, switch clients."""
|
"""Verify that the api is reachable. If it is not, switch clients."""
|
||||||
@ -95,7 +99,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
|
|||||||
async def _async_update_data(self) -> DeviceProp:
|
async def _async_update_data(self) -> DeviceProp:
|
||||||
"""Update data via library."""
|
"""Update data via library."""
|
||||||
try:
|
try:
|
||||||
await self._update_device_prop()
|
await asyncio.gather(*(self._update_device_prop(), self.get_rooms()))
|
||||||
self._set_current_map()
|
self._set_current_map()
|
||||||
except RoborockException as ex:
|
except RoborockException as ex:
|
||||||
raise UpdateFailed(ex) from ex
|
raise UpdateFailed(ex) from ex
|
||||||
@ -117,4 +121,19 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
|
|||||||
maps = await self.api.get_multi_maps_list()
|
maps = await self.api.get_multi_maps_list()
|
||||||
if maps and maps.map_info:
|
if maps and maps.map_info:
|
||||||
for roborock_map in maps.map_info:
|
for roborock_map in maps.map_info:
|
||||||
self.maps[roborock_map.mapFlag] = roborock_map.name
|
self.maps[roborock_map.mapFlag] = RoborockMapInfo(
|
||||||
|
flag=roborock_map.mapFlag, name=roborock_map.name, rooms={}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_rooms(self) -> None:
|
||||||
|
"""Get all of the rooms for the current map."""
|
||||||
|
# The api is only able to access rooms for the currently selected map
|
||||||
|
# So it is important this is only called when you have the map you care
|
||||||
|
# about selected.
|
||||||
|
if self.current_map in self.maps:
|
||||||
|
iot_rooms = await self.api.get_room_mapping()
|
||||||
|
if iot_rooms is not None:
|
||||||
|
for room in iot_rooms:
|
||||||
|
self.maps[self.current_map].rooms[room.segment_id] = (
|
||||||
|
self._home_data_rooms.get(room.iot_id, "Unknown")
|
||||||
|
)
|
||||||
|
@ -105,5 +105,8 @@
|
|||||||
"default": "mdi:power-plug-off"
|
"default": "mdi:power-plug-off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"get_maps": "mdi:floor-plan"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -130,23 +130,27 @@ async def create_coordinator_maps(
|
|||||||
maps_info = sorted(
|
maps_info = sorted(
|
||||||
coord.maps.items(), key=lambda data: data[0] == cur_map, reverse=True
|
coord.maps.items(), key=lambda data: data[0] == cur_map, reverse=True
|
||||||
)
|
)
|
||||||
for map_flag, map_name in maps_info:
|
for map_flag, map_info in maps_info:
|
||||||
# Load the map - so we can access it with get_map_v1
|
# Load the map - so we can access it with get_map_v1
|
||||||
if map_flag != cur_map:
|
if map_flag != cur_map:
|
||||||
# Only change the map and sleep if we have multiple maps.
|
# Only change the map and sleep if we have multiple maps.
|
||||||
await coord.api.send_command(RoborockCommand.LOAD_MULTI_MAP, [map_flag])
|
await coord.api.send_command(RoborockCommand.LOAD_MULTI_MAP, [map_flag])
|
||||||
|
coord.current_map = map_flag
|
||||||
# We cannot get the map until the roborock servers fully process the
|
# We cannot get the map until the roborock servers fully process the
|
||||||
# map change.
|
# map change.
|
||||||
await asyncio.sleep(MAP_SLEEP)
|
await asyncio.sleep(MAP_SLEEP)
|
||||||
# Get the map data
|
# Get the map data
|
||||||
api_data: bytes = await coord.cloud_api.get_map_v1()
|
map_update = await asyncio.gather(
|
||||||
|
*[coord.cloud_api.get_map_v1(), coord.get_rooms()]
|
||||||
|
)
|
||||||
|
api_data: bytes = map_update[0]
|
||||||
entities.append(
|
entities.append(
|
||||||
RoborockMap(
|
RoborockMap(
|
||||||
f"{slugify(coord.roborock_device_info.device.duid)}_map_{map_name}",
|
f"{slugify(coord.roborock_device_info.device.duid)}_map_{map_info.name}",
|
||||||
coord,
|
coord,
|
||||||
map_flag,
|
map_flag,
|
||||||
api_data,
|
api_data,
|
||||||
map_name,
|
map_info.name,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if len(coord.maps) != 1:
|
if len(coord.maps) != 1:
|
||||||
@ -154,4 +158,5 @@ async def create_coordinator_maps(
|
|||||||
# does not change the end user's app.
|
# does not change the end user's app.
|
||||||
# Only needs to happen when we changed maps above.
|
# Only needs to happen when we changed maps above.
|
||||||
await coord.cloud_api.send_command(RoborockCommand.LOAD_MULTI_MAP, [cur_map])
|
await coord.cloud_api.send_command(RoborockCommand.LOAD_MULTI_MAP, [cur_map])
|
||||||
|
coord.current_map = cur_map
|
||||||
return entities
|
return entities
|
||||||
|
@ -24,3 +24,12 @@ class RoborockHassDeviceInfo:
|
|||||||
"product": self.product.as_dict(),
|
"product": self.product.as_dict(),
|
||||||
"props": self.props.as_dict(),
|
"props": self.props.as_dict(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RoborockMapInfo:
|
||||||
|
"""A model to describe all information about a map we may want."""
|
||||||
|
|
||||||
|
flag: int
|
||||||
|
name: str
|
||||||
|
rooms: dict[int, str]
|
||||||
|
4
homeassistant/components/roborock/services.yaml
Normal file
4
homeassistant/components/roborock/services.yaml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
get_maps:
|
||||||
|
target:
|
||||||
|
entity:
|
||||||
|
domain: vacuum
|
@ -293,5 +293,11 @@
|
|||||||
"no_coordinators": {
|
"no_coordinators": {
|
||||||
"message": "No devices were able to successfully setup"
|
"message": "No devices were able to successfully setup"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"get_maps": {
|
||||||
|
"name": "Get maps",
|
||||||
|
"description": "Get the map and room information of your device."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
"""Support for Roborock vacuum class."""
|
"""Support for Roborock vacuum class."""
|
||||||
|
|
||||||
|
from dataclasses import asdict
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from roborock.code_mappings import RoborockStateCode
|
from roborock.code_mappings import RoborockStateCode
|
||||||
@ -17,11 +18,12 @@ from homeassistant.components.vacuum import (
|
|||||||
VacuumEntityFeature,
|
VacuumEntityFeature,
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse
|
||||||
|
from homeassistant.helpers import entity_platform
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.util import slugify
|
from homeassistant.util import slugify
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN, GET_MAPS_SERVICE_NAME
|
||||||
from .coordinator import RoborockDataUpdateCoordinator
|
from .coordinator import RoborockDataUpdateCoordinator
|
||||||
from .device import RoborockCoordinatedEntity
|
from .device import RoborockCoordinatedEntity
|
||||||
|
|
||||||
@ -66,6 +68,15 @@ async def async_setup_entry(
|
|||||||
for device_id, coordinator in coordinators.items()
|
for device_id, coordinator in coordinators.items()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
platform = entity_platform.async_get_current_platform()
|
||||||
|
|
||||||
|
platform.async_register_entity_service(
|
||||||
|
GET_MAPS_SERVICE_NAME,
|
||||||
|
{},
|
||||||
|
RoborockVacuum.get_maps.__name__,
|
||||||
|
supports_response=SupportsResponse.ONLY,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity):
|
class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity):
|
||||||
"""General Representation of a Roborock vacuum."""
|
"""General Representation of a Roborock vacuum."""
|
||||||
@ -164,3 +175,7 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity):
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Send a command to a vacuum cleaner."""
|
"""Send a command to a vacuum cleaner."""
|
||||||
await self.send(command, params)
|
await self.send(command, params)
|
||||||
|
|
||||||
|
async def get_maps(self) -> ServiceResponse:
|
||||||
|
"""Get map information such as map id and room ids."""
|
||||||
|
return {"maps": [asdict(map) for map in self.coordinator.maps.values()]}
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from roborock import RoomMapping
|
||||||
|
|
||||||
from homeassistant.components.roborock.const import (
|
from homeassistant.components.roborock.const import (
|
||||||
CONF_BASE_URL,
|
CONF_BASE_URL,
|
||||||
@ -74,6 +75,22 @@ def bypass_api_fixture() -> None:
|
|||||||
"homeassistant.components.roborock.image.MAP_SLEEP",
|
"homeassistant.components.roborock.image.MAP_SLEEP",
|
||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.roborock.coordinator.RoborockLocalClient.get_room_mapping",
|
||||||
|
return_value=[
|
||||||
|
RoomMapping(16, "2362048"),
|
||||||
|
RoomMapping(17, "2362044"),
|
||||||
|
RoomMapping(18, "2362041"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.roborock.coordinator.RoborockMqttClient.get_room_mapping",
|
||||||
|
return_value=[
|
||||||
|
RoomMapping(16, "2362048"),
|
||||||
|
RoomMapping(17, "2362044"),
|
||||||
|
RoomMapping(18, "2362041"),
|
||||||
|
],
|
||||||
|
),
|
||||||
):
|
):
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
27
tests/components/roborock/snapshots/test_vacuum.ambr
Normal file
27
tests/components/roborock/snapshots/test_vacuum.ambr
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# serializer version: 1
|
||||||
|
# name: test_get_maps
|
||||||
|
dict({
|
||||||
|
'vacuum.roborock_s7_maxv': dict({
|
||||||
|
'maps': list([
|
||||||
|
dict({
|
||||||
|
'flag': 0,
|
||||||
|
'name': 'Upstairs',
|
||||||
|
'rooms': dict({
|
||||||
|
16: 'Example room 1',
|
||||||
|
17: 'Example room 2',
|
||||||
|
18: 'Example room 3',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
dict({
|
||||||
|
'flag': 1,
|
||||||
|
'name': 'Downstairs',
|
||||||
|
'rooms': dict({
|
||||||
|
16: 'Example room 1',
|
||||||
|
17: 'Example room 2',
|
||||||
|
18: 'Example room 3',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
# ---
|
@ -7,8 +7,10 @@ from unittest.mock import patch
|
|||||||
import pytest
|
import pytest
|
||||||
from roborock import RoborockException
|
from roborock import RoborockException
|
||||||
from roborock.roborock_typing import RoborockCommand
|
from roborock.roborock_typing import RoborockCommand
|
||||||
|
from syrupy.assertion import SnapshotAssertion
|
||||||
|
|
||||||
from homeassistant.components.roborock import DOMAIN
|
from homeassistant.components.roborock import DOMAIN
|
||||||
|
from homeassistant.components.roborock.const import GET_MAPS_SERVICE_NAME
|
||||||
from homeassistant.components.vacuum import (
|
from homeassistant.components.vacuum import (
|
||||||
SERVICE_CLEAN_SPOT,
|
SERVICE_CLEAN_SPOT,
|
||||||
SERVICE_LOCATE,
|
SERVICE_LOCATE,
|
||||||
@ -154,3 +156,20 @@ async def test_failed_user_command(
|
|||||||
data,
|
data,
|
||||||
blocking=True,
|
blocking=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_maps(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
bypass_api_fixture,
|
||||||
|
setup_entry: MockConfigEntry,
|
||||||
|
snapshot: SnapshotAssertion,
|
||||||
|
) -> None:
|
||||||
|
"""Test that the service for maps correctly outputs rooms with the right name."""
|
||||||
|
response = await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
GET_MAPS_SERVICE_NAME,
|
||||||
|
{ATTR_ENTITY_ID: ENTITY_ID},
|
||||||
|
blocking=True,
|
||||||
|
return_response=True,
|
||||||
|
)
|
||||||
|
assert response == snapshot
|
||||||
|
Loading…
x
Reference in New Issue
Block a user