Add SharkIQ room targeting (#89350)
* SharkIQ Dep & Codeowner Update * Update code owners * SharkIQ Room-Targeting Support * Add Tests for New Service * Remove unreachable code * Refine tests to reflect unreachable code changes * Updates based on PR comments * Updates based on PR review comments * Address issues found in PR Review * Update Exception type, add excption message to strings. Do not save room list in state history. * Update message to be more clear that only one faild room is listed * couple more updates based on comments --------- Co-authored-by: jrlambs <jrlambs@gmail.com> Co-authored-by: Robert Resch <robert@resch.dev>pull/114348/head
parent
b90542077c
commit
2511a9a087
|
@ -12,6 +12,7 @@ PLATFORMS = [Platform.VACUUM]
|
|||
DOMAIN = "sharkiq"
|
||||
SHARK = "Shark"
|
||||
UPDATE_INTERVAL = timedelta(seconds=30)
|
||||
SERVICE_CLEAN_ROOM = "clean_room"
|
||||
|
||||
SHARKIQ_REGION_EUROPE = "europe"
|
||||
SHARKIQ_REGION_ELSEWHERE = "elsewhere"
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"services": {
|
||||
"clean_room": "mdi:robot-vacuum"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
clean_room:
|
||||
target:
|
||||
entity:
|
||||
integration: "sharkiq"
|
||||
domain: "vacuum"
|
||||
|
||||
fields:
|
||||
rooms:
|
||||
required: true
|
||||
advanced: false
|
||||
example: "Kitchen"
|
||||
default: ""
|
||||
selector:
|
||||
area:
|
||||
multiple: true
|
|
@ -40,5 +40,22 @@
|
|||
"elsewhere": "Everywhere Else"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"invalid_room": {
|
||||
"message": "The room { room } is unavailable to your vacuum. Make sure all rooms match the Shark App, including capitalization."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"clean_room": {
|
||||
"name": "Clean Room",
|
||||
"description": "Cleans a specific user-defined room or set of rooms.",
|
||||
"fields": {
|
||||
"rooms": {
|
||||
"name": "Rooms",
|
||||
"description": "List of rooms to clean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ from collections.abc import Iterable
|
|||
from typing import Any
|
||||
|
||||
from sharkiq import OperatingModes, PowerModes, Properties, SharkIqVacuum
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.vacuum import (
|
||||
STATE_CLEANING,
|
||||
|
@ -18,11 +19,14 @@ from homeassistant.components.vacuum import (
|
|||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import entity_platform
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, LOGGER, SHARK
|
||||
from .const import DOMAIN, LOGGER, SERVICE_CLEAN_ROOM, SHARK
|
||||
from .update_coordinator import SharkIqUpdateCoordinator
|
||||
|
||||
OPERATING_STATE_MAP = {
|
||||
|
@ -45,7 +49,7 @@ ATTR_ERROR_CODE = "last_error_code"
|
|||
ATTR_ERROR_MSG = "last_error_message"
|
||||
ATTR_LOW_LIGHT = "low_light"
|
||||
ATTR_RECHARGE_RESUME = "recharge_and_resume"
|
||||
ATTR_RSSI = "rssi"
|
||||
ATTR_ROOMS = "rooms"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
@ -64,6 +68,17 @@ async def async_setup_entry(
|
|||
)
|
||||
async_add_entities([SharkVacuumEntity(d, coordinator) for d in devices])
|
||||
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_CLEAN_ROOM,
|
||||
{
|
||||
vol.Required(ATTR_ROOMS): vol.All(
|
||||
cv.ensure_list, vol.Length(min=1), [cv.string]
|
||||
),
|
||||
},
|
||||
"async_clean_room",
|
||||
)
|
||||
|
||||
|
||||
class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuumEntity):
|
||||
"""Shark IQ vacuum entity."""
|
||||
|
@ -81,6 +96,7 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum
|
|||
| VacuumEntityFeature.STOP
|
||||
| VacuumEntityFeature.LOCATE
|
||||
)
|
||||
_unrecorded_attributes = frozenset({ATTR_ROOMS})
|
||||
|
||||
def __init__(
|
||||
self, sharkiq: SharkIqVacuum, coordinator: SharkIqUpdateCoordinator
|
||||
|
@ -136,7 +152,7 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum
|
|||
|
||||
@property
|
||||
def operating_mode(self) -> str | None:
|
||||
"""Operating mode.."""
|
||||
"""Operating mode."""
|
||||
op_mode = self.sharkiq.get_property_value(Properties.OPERATING_MODE)
|
||||
return OPERATING_STATE_MAP.get(op_mode)
|
||||
|
||||
|
@ -192,6 +208,24 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum
|
|||
"""Cause the device to generate a loud chirp."""
|
||||
await self.sharkiq.async_find_device()
|
||||
|
||||
async def async_clean_room(self, rooms: list[str], **kwargs: Any) -> None:
|
||||
"""Clean specific rooms."""
|
||||
rooms_to_clean = []
|
||||
valid_rooms = self.available_rooms or []
|
||||
for room in rooms:
|
||||
if room in valid_rooms:
|
||||
rooms_to_clean.append(room)
|
||||
else:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_room",
|
||||
translation_placeholders={"room": room},
|
||||
)
|
||||
|
||||
LOGGER.debug("Cleaning room(s): %s", rooms_to_clean)
|
||||
await self.sharkiq.async_clean_rooms(rooms_to_clean)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
@property
|
||||
def fan_speed(self) -> str | None:
|
||||
"""Return the current fan speed."""
|
||||
|
@ -225,6 +259,11 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum
|
|||
"""Let us know if the robot is operating in low-light mode."""
|
||||
return self.sharkiq.get_property_value(Properties.LOW_LIGHT_MISSION)
|
||||
|
||||
@property
|
||||
def available_rooms(self) -> list | None:
|
||||
"""Return a list of rooms available to clean."""
|
||||
return self.sharkiq.get_room_list()
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return a dictionary of device state attributes specific to sharkiq."""
|
||||
|
@ -233,5 +272,6 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum
|
|||
ATTR_ERROR_MSG: self.sharkiq.error_text,
|
||||
ATTR_LOW_LIGHT: self.low_light,
|
||||
ATTR_RECHARGE_RESUME: self.recharge_resume,
|
||||
ATTR_ROOMS: self.available_rooms,
|
||||
}
|
||||
return data
|
||||
|
|
|
@ -65,6 +65,11 @@ SHARK_PROPERTIES_DICT = {
|
|||
"read_only": True,
|
||||
"value": "Dummy Firmware 1.0",
|
||||
},
|
||||
"Robot_Room_List": {
|
||||
"base_type": "string",
|
||||
"read_only": True,
|
||||
"value": "Kitchen",
|
||||
},
|
||||
}
|
||||
|
||||
TEST_USERNAME = "test-username"
|
||||
|
|
|
@ -11,7 +11,9 @@ from unittest.mock import patch
|
|||
|
||||
import pytest
|
||||
from sharkiq import AylaApi, SharkIqAuthError, SharkIqNotAuthedError, SharkIqVacuum
|
||||
from voluptuous.error import MultipleInvalid
|
||||
|
||||
from homeassistant import exceptions
|
||||
from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY
|
||||
from homeassistant.components.sharkiq import DOMAIN
|
||||
from homeassistant.components.sharkiq.vacuum import (
|
||||
|
@ -19,7 +21,9 @@ from homeassistant.components.sharkiq.vacuum import (
|
|||
ATTR_ERROR_MSG,
|
||||
ATTR_LOW_LIGHT,
|
||||
ATTR_RECHARGE_RESUME,
|
||||
ATTR_ROOMS,
|
||||
FAN_SPEEDS_MAP,
|
||||
SERVICE_CLEAN_ROOM,
|
||||
)
|
||||
from homeassistant.components.vacuum import (
|
||||
ATTR_BATTERY_LEVEL,
|
||||
|
@ -58,6 +62,7 @@ from .const import (
|
|||
from tests.common import MockConfigEntry
|
||||
|
||||
VAC_ENTITY_ID = f"vacuum.{SHARK_DEVICE_DICT['product_name'].lower()}"
|
||||
ROOM_LIST = ["Kitchen", "Living Room"]
|
||||
EXPECTED_FEATURES = (
|
||||
VacuumEntityFeature.BATTERY
|
||||
| VacuumEntityFeature.FAN_SPEED
|
||||
|
@ -129,6 +134,10 @@ class MockShark(SharkIqVacuum):
|
|||
"""Set a property locally without hitting the API."""
|
||||
self.set_property_value(property_name, value)
|
||||
|
||||
def get_room_list(self):
|
||||
"""Return the list of available rooms without hitting the API."""
|
||||
return ROOM_LIST
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@patch("sharkiq.ayla_api.AylaApi", MockAyla)
|
||||
|
@ -165,6 +174,7 @@ async def test_simple_properties(hass: HomeAssistant) -> None:
|
|||
(ATTR_ERROR_MSG, "Cliff sensor is blocked"),
|
||||
(ATTR_LOW_LIGHT, False),
|
||||
(ATTR_RECHARGE_RESUME, True),
|
||||
(ATTR_ROOMS, ROOM_LIST),
|
||||
],
|
||||
)
|
||||
async def test_initial_attributes(
|
||||
|
@ -223,6 +233,24 @@ async def test_device_properties(
|
|||
assert getattr(device, device_property) == target_value
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("room_list", "exception"),
|
||||
[
|
||||
(["KITCHEN"], exceptions.ServiceValidationError),
|
||||
(["KITCHEN", "MUD_ROOM", "DOG HOUSE"], exceptions.ServiceValidationError),
|
||||
(["Office"], exceptions.ServiceValidationError),
|
||||
([], MultipleInvalid),
|
||||
],
|
||||
)
|
||||
async def test_clean_room_error(
|
||||
hass: HomeAssistant, room_list: list, exception: Exception
|
||||
) -> None:
|
||||
"""Test clean_room errors."""
|
||||
with pytest.raises(exception):
|
||||
data = {ATTR_ENTITY_ID: VAC_ENTITY_ID, ATTR_ROOMS: room_list}
|
||||
await hass.services.async_call(DOMAIN, SERVICE_CLEAN_ROOM, data, blocking=True)
|
||||
|
||||
|
||||
async def test_locate(hass: HomeAssistant) -> None:
|
||||
"""Test that the locate command works."""
|
||||
with patch.object(SharkIqVacuum, "async_find_device") as mock_locate:
|
||||
|
@ -231,6 +259,18 @@ async def test_locate(hass: HomeAssistant) -> None:
|
|||
mock_locate.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("room_list"),
|
||||
[(ROOM_LIST), (["Kitchen"])],
|
||||
)
|
||||
async def test_clean_room(hass: HomeAssistant, room_list: list) -> None:
|
||||
"""Test that the clean_room command works."""
|
||||
with patch.object(SharkIqVacuum, "async_clean_rooms") as mock_clean_room:
|
||||
data = {ATTR_ENTITY_ID: VAC_ENTITY_ID, ATTR_ROOMS: room_list}
|
||||
await hass.services.async_call(DOMAIN, SERVICE_CLEAN_ROOM, data, blocking=True)
|
||||
mock_clean_room.assert_called_once_with(room_list)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "success"),
|
||||
[
|
||||
|
|
Loading…
Reference in New Issue