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
Mark Adkins 2024-03-28 09:19:25 -04:00 committed by GitHub
parent b90542077c
commit 2511a9a087
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 126 additions and 3 deletions

View File

@ -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"

View File

@ -0,0 +1,5 @@
{
"services": {
"clean_room": "mdi:robot-vacuum"
}
}

View File

@ -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

View File

@ -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"
}
}
}
}
}

View File

@ -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

View File

@ -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"

View File

@ -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"),
[