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>
This commit is contained in:
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" DOMAIN = "sharkiq"
SHARK = "Shark" SHARK = "Shark"
UPDATE_INTERVAL = timedelta(seconds=30) UPDATE_INTERVAL = timedelta(seconds=30)
SERVICE_CLEAN_ROOM = "clean_room"
SHARKIQ_REGION_EUROPE = "europe" SHARKIQ_REGION_EUROPE = "europe"
SHARKIQ_REGION_ELSEWHERE = "elsewhere" 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" "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 typing import Any
from sharkiq import OperatingModes, PowerModes, Properties, SharkIqVacuum from sharkiq import OperatingModes, PowerModes, Properties, SharkIqVacuum
import voluptuous as vol
from homeassistant.components.vacuum import ( from homeassistant.components.vacuum import (
STATE_CLEANING, STATE_CLEANING,
@ -18,11 +19,14 @@ from homeassistant.components.vacuum import (
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant 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.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity 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 from .update_coordinator import SharkIqUpdateCoordinator
OPERATING_STATE_MAP = { OPERATING_STATE_MAP = {
@ -45,7 +49,7 @@ ATTR_ERROR_CODE = "last_error_code"
ATTR_ERROR_MSG = "last_error_message" ATTR_ERROR_MSG = "last_error_message"
ATTR_LOW_LIGHT = "low_light" ATTR_LOW_LIGHT = "low_light"
ATTR_RECHARGE_RESUME = "recharge_and_resume" ATTR_RECHARGE_RESUME = "recharge_and_resume"
ATTR_RSSI = "rssi" ATTR_ROOMS = "rooms"
async def async_setup_entry( async def async_setup_entry(
@ -64,6 +68,17 @@ async def async_setup_entry(
) )
async_add_entities([SharkVacuumEntity(d, coordinator) for d in devices]) 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): class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuumEntity):
"""Shark IQ vacuum entity.""" """Shark IQ vacuum entity."""
@ -81,6 +96,7 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum
| VacuumEntityFeature.STOP | VacuumEntityFeature.STOP
| VacuumEntityFeature.LOCATE | VacuumEntityFeature.LOCATE
) )
_unrecorded_attributes = frozenset({ATTR_ROOMS})
def __init__( def __init__(
self, sharkiq: SharkIqVacuum, coordinator: SharkIqUpdateCoordinator self, sharkiq: SharkIqVacuum, coordinator: SharkIqUpdateCoordinator
@ -136,7 +152,7 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum
@property @property
def operating_mode(self) -> str | None: def operating_mode(self) -> str | None:
"""Operating mode..""" """Operating mode."""
op_mode = self.sharkiq.get_property_value(Properties.OPERATING_MODE) op_mode = self.sharkiq.get_property_value(Properties.OPERATING_MODE)
return OPERATING_STATE_MAP.get(op_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.""" """Cause the device to generate a loud chirp."""
await self.sharkiq.async_find_device() 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 @property
def fan_speed(self) -> str | None: def fan_speed(self) -> str | None:
"""Return the current fan speed.""" """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.""" """Let us know if the robot is operating in low-light mode."""
return self.sharkiq.get_property_value(Properties.LOW_LIGHT_MISSION) 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 @property
def extra_state_attributes(self) -> dict[str, Any]: def extra_state_attributes(self) -> dict[str, Any]:
"""Return a dictionary of device state attributes specific to sharkiq.""" """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_ERROR_MSG: self.sharkiq.error_text,
ATTR_LOW_LIGHT: self.low_light, ATTR_LOW_LIGHT: self.low_light,
ATTR_RECHARGE_RESUME: self.recharge_resume, ATTR_RECHARGE_RESUME: self.recharge_resume,
ATTR_ROOMS: self.available_rooms,
} }
return data return data

View File

@ -65,6 +65,11 @@ SHARK_PROPERTIES_DICT = {
"read_only": True, "read_only": True,
"value": "Dummy Firmware 1.0", "value": "Dummy Firmware 1.0",
}, },
"Robot_Room_List": {
"base_type": "string",
"read_only": True,
"value": "Kitchen",
},
} }
TEST_USERNAME = "test-username" TEST_USERNAME = "test-username"

View File

@ -11,7 +11,9 @@ from unittest.mock import patch
import pytest import pytest
from sharkiq import AylaApi, SharkIqAuthError, SharkIqNotAuthedError, SharkIqVacuum 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.homeassistant import SERVICE_UPDATE_ENTITY
from homeassistant.components.sharkiq import DOMAIN from homeassistant.components.sharkiq import DOMAIN
from homeassistant.components.sharkiq.vacuum import ( from homeassistant.components.sharkiq.vacuum import (
@ -19,7 +21,9 @@ from homeassistant.components.sharkiq.vacuum import (
ATTR_ERROR_MSG, ATTR_ERROR_MSG,
ATTR_LOW_LIGHT, ATTR_LOW_LIGHT,
ATTR_RECHARGE_RESUME, ATTR_RECHARGE_RESUME,
ATTR_ROOMS,
FAN_SPEEDS_MAP, FAN_SPEEDS_MAP,
SERVICE_CLEAN_ROOM,
) )
from homeassistant.components.vacuum import ( from homeassistant.components.vacuum import (
ATTR_BATTERY_LEVEL, ATTR_BATTERY_LEVEL,
@ -58,6 +62,7 @@ from .const import (
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
VAC_ENTITY_ID = f"vacuum.{SHARK_DEVICE_DICT['product_name'].lower()}" VAC_ENTITY_ID = f"vacuum.{SHARK_DEVICE_DICT['product_name'].lower()}"
ROOM_LIST = ["Kitchen", "Living Room"]
EXPECTED_FEATURES = ( EXPECTED_FEATURES = (
VacuumEntityFeature.BATTERY VacuumEntityFeature.BATTERY
| VacuumEntityFeature.FAN_SPEED | VacuumEntityFeature.FAN_SPEED
@ -129,6 +134,10 @@ class MockShark(SharkIqVacuum):
"""Set a property locally without hitting the API.""" """Set a property locally without hitting the API."""
self.set_property_value(property_name, value) 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) @pytest.fixture(autouse=True)
@patch("sharkiq.ayla_api.AylaApi", MockAyla) @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_ERROR_MSG, "Cliff sensor is blocked"),
(ATTR_LOW_LIGHT, False), (ATTR_LOW_LIGHT, False),
(ATTR_RECHARGE_RESUME, True), (ATTR_RECHARGE_RESUME, True),
(ATTR_ROOMS, ROOM_LIST),
], ],
) )
async def test_initial_attributes( async def test_initial_attributes(
@ -223,6 +233,24 @@ async def test_device_properties(
assert getattr(device, device_property) == target_value 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: async def test_locate(hass: HomeAssistant) -> None:
"""Test that the locate command works.""" """Test that the locate command works."""
with patch.object(SharkIqVacuum, "async_find_device") as mock_locate: 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() 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( @pytest.mark.parametrize(
("side_effect", "success"), ("side_effect", "success"),
[ [