Add lawnmower entity (#93623)

Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
pull/98744/head
Michael Arthur 2023-08-21 22:19:55 +12:00 committed by GitHub
parent 538de6d1f3
commit 82b3ced4f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 686 additions and 2 deletions

View File

@ -30,6 +30,7 @@ base_platforms: &base_platforms
- homeassistant/components/humidifier/**
- homeassistant/components/image/**
- homeassistant/components/image_processing/**
- homeassistant/components/lawn_mower/**
- homeassistant/components/light/**
- homeassistant/components/lock/**
- homeassistant/components/media_player/**

View File

@ -194,6 +194,7 @@ homeassistant.components.lacrosse.*
homeassistant.components.lacrosse_view.*
homeassistant.components.lametric.*
homeassistant.components.laundrify.*
homeassistant.components.lawn_mower.*
homeassistant.components.lcn.*
homeassistant.components.ld2410_ble.*
homeassistant.components.lidarr.*

View File

@ -673,6 +673,8 @@ build.json @home-assistant/supervisor
/tests/components/launch_library/ @ludeeus @DurgNomis-drol
/homeassistant/components/laundrify/ @xLarry
/tests/components/laundrify/ @xLarry
/homeassistant/components/lawn_mower/ @home-assistant/core
/tests/components/lawn_mower/ @home-assistant/core
/homeassistant/components/lcn/ @alengwenus
/tests/components/lcn/ @alengwenus
/homeassistant/components/ld2410_ble/ @930913

View File

@ -27,9 +27,10 @@ DOMAIN = "kitchen_sink"
COMPONENTS_WITH_DEMO_PLATFORM = [
Platform.SENSOR,
Platform.LOCK,
Platform.IMAGE,
Platform.LAWN_MOWER,
Platform.LOCK,
Platform.SENSOR,
Platform.WEATHER,
]

View File

@ -0,0 +1,100 @@
"""Demo platform that has a couple fake lawn mowers."""
from __future__ import annotations
from homeassistant.components.lawn_mower import (
LawnMowerActivity,
LawnMowerEntity,
LawnMowerEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Demo lawn mowers."""
async_add_entities(
[
DemoLawnMower(
"kitchen_sink_mower_001",
"Mower can mow",
LawnMowerActivity.DOCKED,
LawnMowerEntityFeature.START_MOWING,
),
DemoLawnMower(
"kitchen_sink_mower_002",
"Mower can dock",
LawnMowerActivity.MOWING,
LawnMowerEntityFeature.DOCK | LawnMowerEntityFeature.START_MOWING,
),
DemoLawnMower(
"kitchen_sink_mower_003",
"Mower can pause",
LawnMowerActivity.DOCKED,
LawnMowerEntityFeature.PAUSE | LawnMowerEntityFeature.START_MOWING,
),
DemoLawnMower(
"kitchen_sink_mower_004",
"Mower can do all",
LawnMowerActivity.DOCKED,
LawnMowerEntityFeature.DOCK
| LawnMowerEntityFeature.PAUSE
| LawnMowerEntityFeature.START_MOWING,
),
DemoLawnMower(
"kitchen_sink_mower_005",
"Mower is paused",
LawnMowerActivity.PAUSED,
LawnMowerEntityFeature.DOCK
| LawnMowerEntityFeature.PAUSE
| LawnMowerEntityFeature.START_MOWING,
),
]
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Everything but the Kitchen Sink config entry."""
await async_setup_platform(hass, {}, async_add_entities)
class DemoLawnMower(LawnMowerEntity):
"""Representation of a Demo lawn mower."""
def __init__(
self,
unique_id: str,
name: str,
activity: LawnMowerActivity,
features: LawnMowerEntityFeature = LawnMowerEntityFeature(0),
) -> None:
"""Initialize the lawn mower."""
self._attr_name = name
self._attr_unique_id = unique_id
self._attr_supported_features = features
self._attr_activity = activity
async def async_start_mowing(self) -> None:
"""Start mowing."""
self._attr_activity = LawnMowerActivity.MOWING
self.async_write_ha_state()
async def async_dock(self) -> None:
"""Start docking."""
self._attr_activity = LawnMowerActivity.DOCKED
self.async_write_ha_state()
async def async_pause(self) -> None:
"""Pause mower."""
self._attr_activity = LawnMowerActivity.PAUSED
self.async_write_ha_state()

View File

@ -0,0 +1,120 @@
"""The lawn mower integration."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import final
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_validation import ( # noqa: F401
PLATFORM_SCHEMA,
PLATFORM_SCHEMA_BASE,
)
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
from .const import (
DOMAIN,
SERVICE_DOCK,
SERVICE_PAUSE,
SERVICE_START_MOWING,
LawnMowerActivity,
LawnMowerEntityFeature,
)
SCAN_INTERVAL = timedelta(seconds=60)
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the lawn_mower component."""
component = hass.data[DOMAIN] = EntityComponent[LawnMowerEntity](
_LOGGER, DOMAIN, hass, SCAN_INTERVAL
)
await component.async_setup(config)
component.async_register_entity_service(
SERVICE_START_MOWING,
{},
"async_start_mowing",
[LawnMowerEntityFeature.START_MOWING],
)
component.async_register_entity_service(
SERVICE_PAUSE, {}, "async_pause", [LawnMowerEntityFeature.PAUSE]
)
component.async_register_entity_service(
SERVICE_DOCK, {}, "async_dock", [LawnMowerEntityFeature.DOCK]
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up lawn mower devices."""
component: EntityComponent[LawnMowerEntity] = hass.data[DOMAIN]
return await component.async_setup_entry(entry)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
component: EntityComponent[LawnMowerEntity] = hass.data[DOMAIN]
return await component.async_unload_entry(entry)
@dataclass
class LawnMowerEntityEntityDescription(EntityDescription):
"""A class that describes lawn mower entities."""
class LawnMowerEntity(Entity):
"""Base class for lawn mower entities."""
entity_description: LawnMowerEntityEntityDescription
_attr_activity: LawnMowerActivity | None = None
_attr_supported_features: LawnMowerEntityFeature = LawnMowerEntityFeature(0)
@final
@property
def state(self) -> str | None:
"""Return the current state."""
if (activity := self.activity) is None:
return None
return str(activity)
@property
def activity(self) -> LawnMowerActivity | None:
"""Return the current lawn mower activity."""
return self._attr_activity
@property
def supported_features(self) -> LawnMowerEntityFeature:
"""Flag lawn mower features that are supported."""
return self._attr_supported_features
def start_mowing(self) -> None:
"""Start or resume mowing."""
raise NotImplementedError()
async def async_start_mowing(self) -> None:
"""Start or resume mowing."""
await self.hass.async_add_executor_job(self.start_mowing)
def dock(self) -> None:
"""Dock the mower."""
raise NotImplementedError()
async def async_dock(self) -> None:
"""Dock the mower."""
await self.hass.async_add_executor_job(self.dock)
def pause(self) -> None:
"""Pause the lawn mower."""
raise NotImplementedError()
async def async_pause(self) -> None:
"""Pause the lawn mower."""
await self.hass.async_add_executor_job(self.pause)

View File

@ -0,0 +1,33 @@
"""Constants for the lawn mower integration."""
from enum import IntFlag, StrEnum
class LawnMowerActivity(StrEnum):
"""Activity state of lawn mower devices."""
ERROR = "error"
"""Device is in error state, needs assistance."""
PAUSED = "paused"
"""Paused during activity."""
MOWING = "mowing"
"""Device is mowing."""
DOCKED = "docked"
"""Device is docked."""
class LawnMowerEntityFeature(IntFlag):
"""Supported features of the lawn mower entity."""
START_MOWING = 1
PAUSE = 2
DOCK = 4
DOMAIN = "lawn_mower"
SERVICE_START_MOWING = "start_mowing"
SERVICE_PAUSE = "pause"
SERVICE_DOCK = "dock"

View File

@ -0,0 +1,8 @@
{
"domain": "lawn_mower",
"name": "Lawn Mower",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/lawn_mower",
"integration_type": "entity",
"quality_scale": "internal"
}

View File

@ -0,0 +1,22 @@
# Describes the format for available lawn_mower services
start_mowing:
target:
entity:
domain: lawn_mower
supported_features:
- lawn_mower.LawnMowerEntityFeature.START_MOWING
dock:
target:
entity:
domain: lawn_mower
supported_features:
- lawn_mower.LawnMowerEntityFeature.DOCK
pause:
target:
entity:
domain: lawn_mower
supported_features:
- lawn_mower.LawnMowerEntityFeature.PAUSE

View File

@ -0,0 +1,28 @@
{
"title": "Lawn mower",
"entity_component": {
"_": {
"name": "[%key:component::lawn_mower::title%]",
"state": {
"error": "Error",
"paused": "Paused",
"mowing": "Mowing",
"docked": "Docked"
}
}
},
"services": {
"start_mowing": {
"name": "Start mowing",
"description": "Starts the mowing task."
},
"dock": {
"name": "Return to dock",
"description": "Stops the mowing task and returns to the dock."
},
"pause": {
"name": "Pause",
"description": "Pauses the mowing task."
}
}
}

View File

@ -39,6 +39,7 @@ class Platform(StrEnum):
HUMIDIFIER = "humidifier"
IMAGE = "image"
IMAGE_PROCESSING = "image_processing"
LAWN_MOWER = "lawn_mower"
LIGHT = "light"
LOCK = "lock"
MAILBOX = "mailbox"

View File

@ -92,6 +92,7 @@ def _entity_features() -> dict[str, type[IntFlag]]:
from homeassistant.components.cover import CoverEntityFeature
from homeassistant.components.fan import FanEntityFeature
from homeassistant.components.humidifier import HumidifierEntityFeature
from homeassistant.components.lawn_mower import LawnMowerEntityFeature
from homeassistant.components.light import LightEntityFeature
from homeassistant.components.lock import LockEntityFeature
from homeassistant.components.media_player import MediaPlayerEntityFeature
@ -110,6 +111,7 @@ def _entity_features() -> dict[str, type[IntFlag]]:
"CoverEntityFeature": CoverEntityFeature,
"FanEntityFeature": FanEntityFeature,
"HumidifierEntityFeature": HumidifierEntityFeature,
"LawnMowerEntityFeature": LawnMowerEntityFeature,
"LightEntityFeature": LightEntityFeature,
"LockEntityFeature": LockEntityFeature,
"MediaPlayerEntityFeature": MediaPlayerEntityFeature,

View File

@ -1702,6 +1702,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.lawn_mower.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.lcn.*]
check_untyped_defs = true
disallow_incomplete_defs = true

View File

@ -0,0 +1,60 @@
# serializer version: 1
# name: test_states
set({
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mower can do all',
'supported_features': <LawnMowerEntityFeature: 7>,
}),
'context': <ANY>,
'entity_id': 'lawn_mower.mower_can_do_all',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': 'docked',
}),
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mower can dock',
'supported_features': <LawnMowerEntityFeature: 5>,
}),
'context': <ANY>,
'entity_id': 'lawn_mower.mower_can_dock',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': 'mowing',
}),
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mower can mow',
'supported_features': <LawnMowerEntityFeature: 1>,
}),
'context': <ANY>,
'entity_id': 'lawn_mower.mower_can_mow',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': 'docked',
}),
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mower can pause',
'supported_features': <LawnMowerEntityFeature: 3>,
}),
'context': <ANY>,
'entity_id': 'lawn_mower.mower_can_pause',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': 'docked',
}),
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mower is paused',
'supported_features': <LawnMowerEntityFeature: 7>,
}),
'context': <ANY>,
'entity_id': 'lawn_mower.mower_is_paused',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': 'paused',
}),
})
# ---

View File

@ -0,0 +1,116 @@
"""The tests for the kitchen_sink lawn mower platform."""
from unittest.mock import patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.kitchen_sink import DOMAIN
from homeassistant.components.lawn_mower import (
DOMAIN as LAWN_MOWER_DOMAIN,
SERVICE_DOCK,
SERVICE_PAUSE,
SERVICE_START_MOWING,
LawnMowerActivity,
)
from homeassistant.const import ATTR_ENTITY_ID, EVENT_STATE_CHANGED, Platform
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import async_capture_events, async_mock_service
MOWER_SERVICE_ENTITY = "lawn_mower.mower_can_dock"
@pytest.fixture
async def lawn_mower_only() -> None:
"""Enable only the lawn mower platform."""
with patch(
"homeassistant.components.kitchen_sink.COMPONENTS_WITH_DEMO_PLATFORM",
[Platform.LAWN_MOWER],
):
yield
@pytest.fixture(autouse=True)
async def setup_comp(hass: HomeAssistant, lawn_mower_only):
"""Set up demo component."""
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await hass.async_block_till_done()
async def test_states(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None:
"""Test the expected lawn mower entities are added."""
states = hass.states.async_all()
assert set(states) == snapshot
@pytest.mark.parametrize(
("entity", "service_call", "activity", "next_activity"),
[
(
"lawn_mower.mower_can_mow",
SERVICE_START_MOWING,
LawnMowerActivity.DOCKED,
LawnMowerActivity.MOWING,
),
(
"lawn_mower.mower_can_pause",
SERVICE_PAUSE,
LawnMowerActivity.DOCKED,
LawnMowerActivity.PAUSED,
),
(
"lawn_mower.mower_is_paused",
SERVICE_START_MOWING,
LawnMowerActivity.PAUSED,
LawnMowerActivity.MOWING,
),
(
"lawn_mower.mower_can_dock",
SERVICE_DOCK,
LawnMowerActivity.MOWING,
LawnMowerActivity.DOCKED,
),
],
)
async def test_mower(
hass: HomeAssistant,
entity: str,
service_call: str,
activity: LawnMowerActivity,
next_activity: LawnMowerActivity,
) -> None:
"""Test the activity states of a lawn mower."""
state = hass.states.get(entity)
assert state.state == str(activity.value)
await hass.async_block_till_done()
state_changes = async_capture_events(hass, EVENT_STATE_CHANGED)
await hass.services.async_call(
LAWN_MOWER_DOMAIN, service_call, {ATTR_ENTITY_ID: entity}, blocking=False
)
await hass.async_block_till_done()
assert state_changes[0].data["entity_id"] == entity
assert state_changes[0].data["new_state"].state == str(next_activity.value)
@pytest.mark.parametrize(
"service_call",
[
SERVICE_DOCK,
SERVICE_START_MOWING,
SERVICE_PAUSE,
],
)
async def test_service_calls_mocked(hass: HomeAssistant, service_call) -> None:
"""Test the services of a lawn mower."""
calls = async_mock_service(hass, LAWN_MOWER_DOMAIN, service_call)
await hass.services.async_call(
LAWN_MOWER_DOMAIN,
service_call,
{ATTR_ENTITY_ID: MOWER_SERVICE_ENTITY},
blocking=True,
)
assert len(calls) == 1

View File

@ -0,0 +1 @@
"""Tests for the lawn mower integration."""

View File

@ -0,0 +1,178 @@
"""The tests for the lawn mower integration."""
from collections.abc import Generator
from unittest.mock import MagicMock
import pytest
from homeassistant.components.lawn_mower import (
DOMAIN as LAWN_MOWER_DOMAIN,
LawnMowerActivity,
LawnMowerEntity,
LawnMowerEntityFeature,
)
from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from tests.common import (
MockConfigEntry,
MockModule,
MockPlatform,
mock_config_flow,
mock_integration,
mock_platform,
)
TEST_DOMAIN = "test"
class MockFlow(ConfigFlow):
"""Test flow."""
class MockLawnMowerEntity(LawnMowerEntity):
"""Mock lawn mower device to use in tests."""
def __init__(
self,
unique_id: str = "mock_lawn_mower",
name: str = "Lawn Mower",
features: LawnMowerEntityFeature = LawnMowerEntityFeature(0),
) -> None:
"""Initialize the lawn mower."""
self._attr_name = name
self._attr_unique_id = unique_id
self._attr_supported_features = features
def start_mowing(self) -> None:
"""Start mowing."""
self._attr_activity = LawnMowerActivity.MOWING
@pytest.fixture(autouse=True)
def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]:
"""Mock config flow."""
mock_platform(hass, f"{TEST_DOMAIN}.config_flow")
with mock_config_flow(TEST_DOMAIN, MockFlow):
yield
async def test_lawn_mower_setup(hass: HomeAssistant) -> None:
"""Test setup and tear down of lawn mower platform and entity."""
async def async_setup_entry_init(
hass: HomeAssistant, config_entry: ConfigEntry
) -> bool:
"""Set up test config entry."""
await hass.config_entries.async_forward_entry_setup(
config_entry, Platform.LAWN_MOWER
)
return True
async def async_unload_entry_init(
hass: HomeAssistant, config_entry: ConfigEntry
) -> bool:
"""Unload up test config entry."""
await hass.config_entries.async_unload_platforms(
config_entry, [Platform.LAWN_MOWER]
)
return True
mock_platform(hass, f"{TEST_DOMAIN}.config_flow")
mock_integration(
hass,
MockModule(
TEST_DOMAIN,
async_setup_entry=async_setup_entry_init,
async_unload_entry=async_unload_entry_init,
),
)
entity1 = MockLawnMowerEntity()
entity1.entity_id = "lawn_mower.mock_lawn_mower"
async def async_setup_entry_platform(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up test platform via config entry."""
async_add_entities([entity1])
mock_platform(
hass,
f"{TEST_DOMAIN}.{LAWN_MOWER_DOMAIN}",
MockPlatform(async_setup_entry=async_setup_entry_platform),
)
config_entry = MockConfigEntry(domain=TEST_DOMAIN)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state == ConfigEntryState.LOADED
assert hass.states.get(entity1.entity_id)
assert await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state == ConfigEntryState.NOT_LOADED
entity_state = hass.states.get(entity1.entity_id)
assert entity_state
assert entity_state.state == STATE_UNAVAILABLE
async def test_sync_start_mowing(hass: HomeAssistant) -> None:
"""Test if async mowing calls sync mowing."""
lawn_mower = MockLawnMowerEntity()
lawn_mower.hass = hass
lawn_mower.start_mowing = MagicMock()
await lawn_mower.async_start_mowing()
assert lawn_mower.start_mowing.called
async def test_sync_dock(hass: HomeAssistant) -> None:
"""Test if async dock calls sync dock."""
lawn_mower = MockLawnMowerEntity()
lawn_mower.hass = hass
lawn_mower.dock = MagicMock()
await lawn_mower.async_dock()
assert lawn_mower.dock.called
async def test_sync_pause(hass: HomeAssistant) -> None:
"""Test if async pause calls sync pause."""
lawn_mower = MockLawnMowerEntity()
lawn_mower.hass = hass
lawn_mower.pause = MagicMock()
await lawn_mower.async_pause()
assert lawn_mower.pause.called
async def test_lawn_mower_default(hass: HomeAssistant) -> None:
"""Test lawn mower entity with defaults."""
lawn_mower = MockLawnMowerEntity()
lawn_mower.hass = hass
assert lawn_mower.state is None
async def test_lawn_mower_state(hass: HomeAssistant) -> None:
"""Test lawn mower entity returns state."""
lawn_mower = MockLawnMowerEntity(
"lawn_mower_1", "Test lawn mower", LawnMowerActivity.MOWING
)
lawn_mower.hass = hass
lawn_mower.start_mowing()
assert lawn_mower.state == str(LawnMowerActivity.MOWING)