Skip to content

Commit ea651c4

Browse files
authored
Overhaul Roborock integration to use new devices based API (home-assistant#154837)
1 parent ff40ce4 commit ea651c4

31 files changed

+2531
-3534
lines changed

homeassistant/components/roborock/__init__.py

Lines changed: 70 additions & 163 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,14 @@
99
from typing import Any
1010

1111
from roborock import (
12-
HomeDataRoom,
1312
RoborockException,
1413
RoborockInvalidCredentials,
1514
RoborockInvalidUserAgreement,
1615
RoborockNoUserAgreement,
1716
)
18-
from roborock.data import DeviceData, HomeDataDevice, HomeDataProduct, UserData
19-
from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1
20-
from roborock.version_a01_apis import RoborockMqttClientA01
21-
from roborock.web_api import RoborockApiClient
17+
from roborock.data import UserData
18+
from roborock.devices.device import RoborockDevice
19+
from roborock.devices.device_manager import UserParams, create_device_manager
2220

2321
from homeassistant.const import CONF_USERNAME, EVENT_HOMEASSISTANT_STOP
2422
from homeassistant.core import HomeAssistant
@@ -32,8 +30,10 @@
3230
RoborockCoordinators,
3331
RoborockDataUpdateCoordinator,
3432
RoborockDataUpdateCoordinatorA01,
33+
RoborockWashingMachineUpdateCoordinator,
34+
RoborockWetDryVacUpdateCoordinator,
3535
)
36-
from .roborock_storage import async_remove_map_storage
36+
from .roborock_storage import CacheStore, async_remove_map_storage
3737

3838
SCAN_INTERVAL = timedelta(seconds=30)
3939

@@ -44,14 +44,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
4444
"""Set up roborock from a config entry."""
4545

4646
user_data = UserData.from_dict(entry.data[CONF_USER_DATA])
47-
api_client = RoborockApiClient(
48-
entry.data[CONF_USERNAME],
49-
entry.data[CONF_BASE_URL],
50-
session=async_get_clientsession(hass),
47+
user_params = UserParams(
48+
username=entry.data[CONF_USERNAME],
49+
user_data=user_data,
50+
base_url=entry.data[CONF_BASE_URL],
5151
)
52-
_LOGGER.debug("Getting home data")
52+
cache = CacheStore(hass, entry.entry_id)
5353
try:
54-
home_data = await api_client.get_home_data_v3(user_data)
54+
device_manager = await create_device_manager(
55+
user_params,
56+
cache=cache,
57+
session=async_get_clientsession(hass),
58+
)
5559
except RoborockInvalidCredentials as err:
5660
raise ConfigEntryAuthFailed(
5761
"Invalid credentials",
@@ -75,29 +79,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
7579
translation_domain=DOMAIN,
7680
translation_key="home_data_fail",
7781
) from err
82+
devices = await device_manager.get_devices()
83+
_LOGGER.debug("Device manager found %d devices", len(devices))
84+
for device in devices:
85+
entry.async_on_unload(device.close)
7886

79-
_LOGGER.debug("Got home data %s", home_data)
80-
all_devices: list[HomeDataDevice] = home_data.devices + home_data.received_devices
81-
device_map: dict[str, HomeDataDevice] = {
82-
device.duid: device for device in all_devices
83-
}
84-
product_info: dict[str, HomeDataProduct] = {
85-
product.id: product for product in home_data.products
86-
}
87-
# Get a Coordinator if the device is available or if we have connected to the device before
8887
coordinators = await asyncio.gather(
89-
*build_setup_functions(
90-
hass,
91-
entry,
92-
device_map,
93-
user_data,
94-
product_info,
95-
home_data.rooms,
96-
api_client,
97-
),
88+
*build_setup_functions(hass, entry, devices, user_data),
9889
return_exceptions=True,
9990
)
100-
# Valid coordinators are those where we had networking cached or we could get networking
10191
v1_coords = [
10292
coord
10393
for coord in coordinators
@@ -115,17 +105,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
115105
translation_key="no_coordinators",
116106
)
117107
valid_coordinators = RoborockCoordinators(v1_coords, a01_coords)
118-
await asyncio.gather(
119-
*(coord.refresh_coordinator_map() for coord in valid_coordinators.v1)
120-
)
121108

122109
async def on_stop(_: Any) -> None:
123110
_LOGGER.debug("Shutting down roborock")
124111
await asyncio.gather(
125112
*(
126113
coordinator.async_shutdown()
127114
for coordinator in valid_coordinators.values()
128-
)
115+
),
116+
cache.flush(),
129117
)
130118

131119
entry.async_on_unload(
@@ -138,6 +126,17 @@ async def on_stop(_: Any) -> None:
138126

139127
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
140128

129+
_remove_stale_devices(hass, entry, devices)
130+
131+
return True
132+
133+
134+
def _remove_stale_devices(
135+
hass: HomeAssistant,
136+
entry: RoborockConfigEntry,
137+
devices: list[RoborockDevice],
138+
) -> None:
139+
device_map: dict[str, RoborockDevice] = {device.duid: device for device in devices}
141140
device_registry = dr.async_get(hass)
142141
device_entries = dr.async_entries_for_config_entry(
143142
device_registry, config_entry_id=entry.entry_id
@@ -159,8 +158,6 @@ async def on_stop(_: Any) -> None:
159158
remove_config_entry_id=entry.entry_id,
160159
)
161160

162-
return True
163-
164161

165162
async def async_migrate_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool:
166163
"""Migrate old configuration entries to the new format."""
@@ -190,11 +187,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -
190187
def build_setup_functions(
191188
hass: HomeAssistant,
192189
entry: RoborockConfigEntry,
193-
device_map: dict[str, HomeDataDevice],
190+
devices: list[RoborockDevice],
194191
user_data: UserData,
195-
product_info: dict[str, HomeDataProduct],
196-
home_data_rooms: list[HomeDataRoom],
197-
api_client: RoborockApiClient,
198192
) -> list[
199193
Coroutine[
200194
Any,
@@ -203,134 +197,45 @@ def build_setup_functions(
203197
]
204198
]:
205199
"""Create a list of setup functions that can later be called asynchronously."""
206-
return [
207-
setup_device(
208-
hass,
209-
entry,
210-
user_data,
211-
device,
212-
product_info[device.product_id],
213-
home_data_rooms,
214-
api_client,
215-
)
216-
for device in device_map.values()
217-
]
218-
200+
coordinators: list[
201+
RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01
202+
] = []
203+
for device in devices:
204+
_LOGGER.debug("Creating device %s: %s", device.name, device)
205+
if device.v1_properties is not None:
206+
coordinators.append(
207+
RoborockDataUpdateCoordinator(hass, entry, device, device.v1_properties)
208+
)
209+
elif device.dyad is not None:
210+
coordinators.append(
211+
RoborockWetDryVacUpdateCoordinator(hass, entry, device, device.dyad)
212+
)
213+
elif device.zeo is not None:
214+
coordinators.append(
215+
RoborockWashingMachineUpdateCoordinator(hass, entry, device, device.zeo)
216+
)
217+
else:
218+
_LOGGER.warning(
219+
"Not adding device %s because its protocol version %s or category %s is not supported",
220+
device.duid,
221+
device.device_info.pv,
222+
device.product.category.name,
223+
)
219224

220-
async def setup_device(
221-
hass: HomeAssistant,
222-
entry: RoborockConfigEntry,
223-
user_data: UserData,
224-
device: HomeDataDevice,
225-
product_info: HomeDataProduct,
226-
home_data_rooms: list[HomeDataRoom],
227-
api_client: RoborockApiClient,
228-
) -> RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 | None:
229-
"""Set up a coordinator for a given device."""
230-
if device.pv == "1.0":
231-
return await setup_device_v1(
232-
hass, entry, user_data, device, product_info, home_data_rooms, api_client
233-
)
234-
if device.pv == "A01":
235-
return await setup_device_a01(hass, entry, user_data, device, product_info)
236-
_LOGGER.warning(
237-
"Not adding device %s because its protocol version %s or category %s is not supported",
238-
device.duid,
239-
device.pv,
240-
product_info.category.name,
241-
)
242-
return None
225+
return [setup_coordinator(coordinator) for coordinator in coordinators]
243226

244227

245-
async def setup_device_v1(
246-
hass: HomeAssistant,
247-
entry: RoborockConfigEntry,
248-
user_data: UserData,
249-
device: HomeDataDevice,
250-
product_info: HomeDataProduct,
251-
home_data_rooms: list[HomeDataRoom],
252-
api_client: RoborockApiClient,
253-
) -> RoborockDataUpdateCoordinator | None:
254-
"""Set up a device Coordinator."""
255-
mqtt_client = await hass.async_add_executor_job(
256-
RoborockMqttClientV1, user_data, DeviceData(device, product_info.model)
257-
)
258-
try:
259-
await mqtt_client.async_connect()
260-
networking = await mqtt_client.get_networking()
261-
if networking is None:
262-
# If the api does not return an error but does return None for
263-
# get_networking - then we need to go through cache checking.
264-
raise RoborockException("Networking request returned None.") # noqa: TRY301
265-
except RoborockException as err:
266-
_LOGGER.warning(
267-
"Not setting up %s because we could not get the network information of the device. "
268-
"Please confirm it is online and the Roborock servers can communicate with it",
269-
device.name,
270-
)
271-
_LOGGER.debug(err)
272-
await mqtt_client.async_release()
273-
raise
274-
coordinator = RoborockDataUpdateCoordinator(
275-
hass,
276-
entry,
277-
device,
278-
networking,
279-
product_info,
280-
mqtt_client,
281-
home_data_rooms,
282-
api_client,
283-
user_data,
284-
)
228+
async def setup_coordinator(
229+
coordinator: RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01,
230+
) -> RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 | None:
231+
"""Set up a single coordinator."""
285232
try:
286233
await coordinator.async_config_entry_first_refresh()
287-
except ConfigEntryNotReady as ex:
234+
except ConfigEntryNotReady:
288235
await coordinator.async_shutdown()
289-
if isinstance(coordinator.api, RoborockMqttClientV1):
290-
_LOGGER.warning(
291-
"Not setting up %s because the we failed to get data for the first time using the online client. "
292-
"Please ensure your Home Assistant instance can communicate with this device. "
293-
"You may need to open firewall instances on your Home Assistant network and on your Vacuum's network",
294-
device.name,
295-
)
296-
# Most of the time if we fail to connect using the mqtt client, the problem is due to firewall,
297-
# but in case if it isn't, the error can be included in debug logs for the user to grab.
298-
if coordinator.last_exception:
299-
_LOGGER.debug(coordinator.last_exception)
300-
raise coordinator.last_exception from ex
301-
elif coordinator.last_exception:
302-
# If this is reached, we have verified that we can communicate with the Vacuum locally,
303-
# so if there is an error here - it is not a communication issue but some other problem
304-
extra_error = f"Please create an issue with the following error included: {coordinator.last_exception}"
305-
_LOGGER.warning(
306-
"Not setting up %s because the coordinator failed to get data for the first time using the "
307-
"offline client %s",
308-
device.name,
309-
extra_error,
310-
)
311-
raise coordinator.last_exception from ex
312-
return coordinator
313-
314-
315-
async def setup_device_a01(
316-
hass: HomeAssistant,
317-
entry: RoborockConfigEntry,
318-
user_data: UserData,
319-
device: HomeDataDevice,
320-
product_info: HomeDataProduct,
321-
) -> RoborockDataUpdateCoordinatorA01 | None:
322-
"""Set up a A01 protocol device."""
323-
mqtt_client = await hass.async_add_executor_job(
324-
RoborockMqttClientA01,
325-
user_data,
326-
DeviceData(device, product_info.model),
327-
product_info.category,
328-
)
329-
coord = RoborockDataUpdateCoordinatorA01(
330-
hass, entry, device, product_info, mqtt_client
331-
)
332-
await coord.async_config_entry_first_refresh()
333-
return coord
236+
raise
237+
else:
238+
return coordinator
334239

335240

336241
async def async_unload_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool:
@@ -341,3 +246,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
341246
async def async_remove_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> None:
342247
"""Handle removal of an entry."""
343248
await async_remove_map_storage(hass, entry.entry_id)
249+
store = CacheStore(hass, entry.entry_id)
250+
await store.async_remove()

homeassistant/components/roborock/binary_sensor.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from dataclasses import dataclass
77

88
from roborock.data import RoborockStateCode
9-
from roborock.roborock_typing import DeviceProp
109

1110
from homeassistant.components.binary_sensor import (
1211
BinarySensorDeviceClass,
@@ -19,6 +18,7 @@
1918

2019
from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator
2120
from .entity import RoborockCoordinatedEntityV1
21+
from .models import DeviceState
2222

2323
PARALLEL_UPDATES = 0
2424

@@ -27,9 +27,11 @@
2727
class RoborockBinarySensorDescription(BinarySensorEntityDescription):
2828
"""A class that describes Roborock binary sensors."""
2929

30-
value_fn: Callable[[DeviceProp], bool | int | None]
31-
# If it is a dock entity
30+
value_fn: Callable[[DeviceState], bool | int | None]
31+
"""A function that extracts the sensor value from DeviceState."""
32+
3233
is_dock_entity: bool = False
34+
"""Whether this sensor is for the dock."""
3335

3436

3537
BINARY_SENSOR_DESCRIPTIONS = [
@@ -92,7 +94,7 @@ async def async_setup_entry(
9294
)
9395
for coordinator in config_entry.runtime_data.v1
9496
for description in BINARY_SENSOR_DESCRIPTIONS
95-
if description.value_fn(coordinator.roborock_device_info.props) is not None
97+
if description.value_fn(coordinator.data) is not None
9698
)
9799

98100

@@ -117,8 +119,4 @@ def __init__(
117119
@property
118120
def is_on(self) -> bool:
119121
"""Return the value reported by the sensor."""
120-
return bool(
121-
self.entity_description.value_fn(
122-
self.coordinator.roborock_device_info.props
123-
)
124-
)
122+
return bool(self.entity_description.value_fn(self.coordinator.data))

0 commit comments

Comments
 (0)