Skip to content

Commit

Permalink
Add SharkIQ room targeting (#89350)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
3 people committed Mar 28, 2024
1 parent b905420 commit 2511a9a
Show file tree
Hide file tree
Showing 7 changed files with 126 additions and 3 deletions.
1 change: 1 addition & 0 deletions homeassistant/components/sharkiq/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
DOMAIN = "sharkiq"
SHARK = "Shark"
UPDATE_INTERVAL = timedelta(seconds=30)
SERVICE_CLEAN_ROOM = "clean_room"

SHARKIQ_REGION_EUROPE = "europe"
SHARKIQ_REGION_ELSEWHERE = "elsewhere"
Expand Down
5 changes: 5 additions & 0 deletions homeassistant/components/sharkiq/icons.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"services": {
"clean_room": "mdi:robot-vacuum"
}
}
15 changes: 15 additions & 0 deletions homeassistant/components/sharkiq/services.yaml
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions homeassistant/components/sharkiq/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
}
}
46 changes: 43 additions & 3 deletions homeassistant/components/sharkiq/vacuum.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from typing import Any

from sharkiq import OperatingModes, PowerModes, Properties, SharkIqVacuum
import voluptuous as vol

from homeassistant.components.vacuum import (
STATE_CLEANING,
Expand All @@ -18,11 +19,14 @@
)
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 = {
Expand All @@ -45,7 +49,7 @@
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(
Expand All @@ -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."""
Expand All @@ -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
Expand Down Expand Up @@ -136,7 +152,7 @@ def error_message(self) -> str | None:

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

Expand Down Expand Up @@ -192,6 +208,24 @@ async def async_locate(self, **kwargs: Any) -> None:
"""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."""
Expand Down Expand Up @@ -225,6 +259,11 @@ def low_light(self):
"""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."""
Expand All @@ -233,5 +272,6 @@ def extra_state_attributes(self) -> dict[str, Any]:
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
5 changes: 5 additions & 0 deletions tests/components/sharkiq/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@
"read_only": True,
"value": "Dummy Firmware 1.0",
},
"Robot_Room_List": {
"base_type": "string",
"read_only": True,
"value": "Kitchen",
},
}

TEST_USERNAME = "test-username"
Expand Down
40 changes: 40 additions & 0 deletions tests/components/sharkiq/test_vacuum.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,19 @@

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 (
ATTR_ERROR_CODE,
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,
Expand Down Expand Up @@ -58,6 +62,7 @@
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
Expand Down Expand Up @@ -129,6 +134,10 @@ async def async_set_property_value(self, property_name, value):
"""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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand All @@ -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"),
[
Expand Down

0 comments on commit 2511a9a

Please sign in to comment.