mirror of
https://github.com/home-assistant/core.git
synced 2025-07-21 04:07:08 +00:00
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:
parent
b90542077c
commit
2511a9a087
@ -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"
|
||||||
|
5
homeassistant/components/sharkiq/icons.json
Normal file
5
homeassistant/components/sharkiq/icons.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"services": {
|
||||||
|
"clean_room": "mdi:robot-vacuum"
|
||||||
|
}
|
||||||
|
}
|
15
homeassistant/components/sharkiq/services.yaml
Normal file
15
homeassistant/components/sharkiq/services.yaml
Normal 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
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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"
|
||||||
|
@ -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"),
|
||||||
[
|
[
|
||||||
|
Loading…
x
Reference in New Issue
Block a user