diff --git a/homeassistant/components/sharkiq/const.py b/homeassistant/components/sharkiq/const.py index 8d5d4708e0e29a..f328e6453ccf01 100644 --- a/homeassistant/components/sharkiq/const.py +++ b/homeassistant/components/sharkiq/const.py @@ -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" diff --git a/homeassistant/components/sharkiq/icons.json b/homeassistant/components/sharkiq/icons.json new file mode 100644 index 00000000000000..13fd58ce66d4d7 --- /dev/null +++ b/homeassistant/components/sharkiq/icons.json @@ -0,0 +1,5 @@ +{ + "services": { + "clean_room": "mdi:robot-vacuum" + } +} diff --git a/homeassistant/components/sharkiq/services.yaml b/homeassistant/components/sharkiq/services.yaml new file mode 100644 index 00000000000000..7f82ed407024cf --- /dev/null +++ b/homeassistant/components/sharkiq/services.yaml @@ -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 diff --git a/homeassistant/components/sharkiq/strings.json b/homeassistant/components/sharkiq/strings.json index 23f949be4cc667..c16483329754ed 100644 --- a/homeassistant/components/sharkiq/strings.json +++ b/homeassistant/components/sharkiq/strings.json @@ -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" + } + } + } } } diff --git a/homeassistant/components/sharkiq/vacuum.py b/homeassistant/components/sharkiq/vacuum.py index 658d446b9cb9be..6647b79c892b40 100644 --- a/homeassistant/components/sharkiq/vacuum.py +++ b/homeassistant/components/sharkiq/vacuum.py @@ -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, @@ -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 = { @@ -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( @@ -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 @@ 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) @@ -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.""" @@ -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.""" @@ -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 diff --git a/tests/components/sharkiq/const.py b/tests/components/sharkiq/const.py index b4f9d72dafddb7..e8d920e7763cfe 100644 --- a/tests/components/sharkiq/const.py +++ b/tests/components/sharkiq/const.py @@ -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" diff --git a/tests/components/sharkiq/test_vacuum.py b/tests/components/sharkiq/test_vacuum.py index 4a1671a616fcc0..c72ad1a8c361c0 100644 --- a/tests/components/sharkiq/test_vacuum.py +++ b/tests/components/sharkiq/test_vacuum.py @@ -11,7 +11,9 @@ 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 @@ 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 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 @@ 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) @@ -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"), [