mirror of
https://github.com/home-assistant/core.git
synced 2026-02-27 12:31:32 +01:00
Compare commits
1 Commits
dev
...
epenet/202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed41a6e06a |
@@ -29,9 +29,6 @@ ATTR_NOTIFICATION_TYPE = "message"
|
||||
ATTR_REACTION = "reaction"
|
||||
ATTR_RECEIVER = "publicUserUuid"
|
||||
|
||||
SERVICE_PUSH_NOTIFICATION = "send_message"
|
||||
SERVICE_ACTIVITY_STREAM_REACTION = "send_reaction"
|
||||
|
||||
SERVICE_ACTIVITY_STREAM_REACTION_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
|
||||
@@ -96,7 +93,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_ACTIVITY_STREAM_REACTION,
|
||||
"send_reaction",
|
||||
async_send_activity_stream_reaction,
|
||||
SERVICE_ACTIVITY_STREAM_REACTION_SCHEMA,
|
||||
)
|
||||
@@ -104,7 +101,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_PUSH_NOTIFICATION,
|
||||
"send_message",
|
||||
entity_domain=TODO_DOMAIN,
|
||||
schema={
|
||||
vol.Required(ATTR_NOTIFICATION_TYPE): vol.All(
|
||||
|
||||
@@ -31,6 +31,10 @@ ATTR_FRIDAY_SLOTS = "friday_slots"
|
||||
ATTR_SATURDAY_SLOTS = "saturday_slots"
|
||||
ATTR_SUNDAY_SLOTS = "sunday_slots"
|
||||
|
||||
# Service names
|
||||
SERVICE_SET_HOT_WATER_SCHEDULE = "set_hot_water_schedule"
|
||||
SERVICE_SYNC_TIME = "sync_time"
|
||||
|
||||
|
||||
# Schema for a single time slot
|
||||
_SLOT_SCHEMA = vol.Schema(
|
||||
@@ -256,14 +260,14 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register the BSB-LAN services."""
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"set_hot_water_schedule",
|
||||
SERVICE_SET_HOT_WATER_SCHEDULE,
|
||||
set_hot_water_schedule,
|
||||
schema=SERVICE_SET_HOT_WATER_SCHEDULE_SCHEMA,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"sync_time",
|
||||
SERVICE_SYNC_TIME,
|
||||
async_sync_time,
|
||||
schema=SYNC_TIME_SCHEMA,
|
||||
)
|
||||
|
||||
@@ -36,12 +36,12 @@ from homeassistant.const import (
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import ATTR_DURATION, ATTR_PERIOD, DOMAIN, EVOHOME_DATA, EvoService
|
||||
from .const import ATTR_DURATION, ATTR_PERIOD, ATTR_SETPOINT, EVOHOME_DATA, EvoService
|
||||
from .coordinator import EvoDataUpdateCoordinator
|
||||
from .entity import EvoChild, EvoEntity
|
||||
|
||||
@@ -132,24 +132,6 @@ class EvoClimateEntity(EvoEntity, ClimateEntity):
|
||||
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
|
||||
async def async_clear_zone_override(self) -> None:
|
||||
"""Clear the zone override; only supported by zones."""
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="zone_only_service",
|
||||
translation_placeholders={"service": EvoService.CLEAR_ZONE_OVERRIDE},
|
||||
)
|
||||
|
||||
async def async_set_zone_override(
|
||||
self, setpoint: float, duration: timedelta | None = None
|
||||
) -> None:
|
||||
"""Set the zone override; only supported by zones."""
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="zone_only_service",
|
||||
translation_placeholders={"service": EvoService.SET_ZONE_OVERRIDE},
|
||||
)
|
||||
|
||||
|
||||
class EvoZone(EvoChild, EvoClimateEntity):
|
||||
"""Base for any evohome-compatible heating zone."""
|
||||
@@ -188,22 +170,22 @@ class EvoZone(EvoChild, EvoClimateEntity):
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
)
|
||||
|
||||
async def async_clear_zone_override(self) -> None:
|
||||
"""Clear the zone's override, if any."""
|
||||
await self.coordinator.call_client_api(self._evo_device.reset())
|
||||
async def async_zone_svc_request(self, service: str, data: dict[str, Any]) -> None:
|
||||
"""Process a service request (setpoint override) for a zone."""
|
||||
if service == EvoService.CLEAR_ZONE_OVERRIDE:
|
||||
await self.coordinator.call_client_api(self._evo_device.reset())
|
||||
return
|
||||
|
||||
async def async_set_zone_override(
|
||||
self, setpoint: float, duration: timedelta | None = None
|
||||
) -> None:
|
||||
"""Set the zone's override (mode/setpoint)."""
|
||||
temperature = max(min(setpoint, self.max_temp), self.min_temp)
|
||||
# otherwise it is EvoService.SET_ZONE_OVERRIDE
|
||||
temperature = max(min(data[ATTR_SETPOINT], self.max_temp), self.min_temp)
|
||||
|
||||
if duration is not None:
|
||||
if ATTR_DURATION in data:
|
||||
duration: timedelta = data[ATTR_DURATION]
|
||||
if duration.total_seconds() == 0:
|
||||
await self._update_schedule()
|
||||
until = self.setpoints.get("next_sp_from")
|
||||
else:
|
||||
until = dt_util.now() + duration
|
||||
until = dt_util.now() + data[ATTR_DURATION]
|
||||
else:
|
||||
until = None # indefinitely
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.core import callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN, EvoService
|
||||
from .coordinator import EvoDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -47,12 +47,22 @@ class EvoEntity(CoordinatorEntity[EvoDataUpdateCoordinator]):
|
||||
raise NotImplementedError
|
||||
if payload["unique_id"] != self._attr_unique_id:
|
||||
return
|
||||
if payload["service"] in (
|
||||
EvoService.SET_ZONE_OVERRIDE,
|
||||
EvoService.CLEAR_ZONE_OVERRIDE,
|
||||
):
|
||||
await self.async_zone_svc_request(payload["service"], payload["data"])
|
||||
return
|
||||
await self.async_tcs_svc_request(payload["service"], payload["data"])
|
||||
|
||||
async def async_tcs_svc_request(self, service: str, data: dict[str, Any]) -> None:
|
||||
"""Process a service request (system mode) for a controller."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_zone_svc_request(self, service: str, data: dict[str, Any]) -> None:
|
||||
"""Process a service request (setpoint override) for a zone."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> Mapping[str, Any]:
|
||||
"""Return the evohome-specific state attributes."""
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any, Final
|
||||
from typing import Final
|
||||
|
||||
from evohomeasync2.const import SZ_CAN_BE_TEMPORARY, SZ_SYSTEM_MODE, SZ_TIMING_MODE
|
||||
from evohomeasync2.schemas.const import (
|
||||
@@ -13,10 +13,9 @@ from evohomeasync2.schemas.const import (
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
|
||||
from homeassistant.const import ATTR_MODE
|
||||
from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.service import verify_domain_control
|
||||
|
||||
@@ -26,38 +25,21 @@ from .coordinator import EvoDataUpdateCoordinator
|
||||
# system mode schemas are built dynamically when the services are registered
|
||||
# because supported modes can vary for edge-case systems
|
||||
|
||||
# Zone service schemas (registered as entity services)
|
||||
CLEAR_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {}
|
||||
SET_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {
|
||||
vol.Required(ATTR_SETPOINT): vol.All(
|
||||
vol.Coerce(float), vol.Range(min=4.0, max=35.0)
|
||||
),
|
||||
vol.Optional(ATTR_DURATION): vol.All(
|
||||
cv.time_period,
|
||||
vol.Range(min=timedelta(days=0), max=timedelta(days=1)),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _register_zone_entity_services(hass: HomeAssistant) -> None:
|
||||
"""Register entity-level services for zones."""
|
||||
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
EvoService.CLEAR_ZONE_OVERRIDE,
|
||||
entity_domain=CLIMATE_DOMAIN,
|
||||
schema=CLEAR_ZONE_OVERRIDE_SCHEMA,
|
||||
func="async_clear_zone_override",
|
||||
)
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
EvoService.SET_ZONE_OVERRIDE,
|
||||
entity_domain=CLIMATE_DOMAIN,
|
||||
schema=SET_ZONE_OVERRIDE_SCHEMA,
|
||||
func="async_set_zone_override",
|
||||
)
|
||||
CLEAR_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema(
|
||||
{vol.Required(ATTR_ENTITY_ID): cv.entity_id}
|
||||
)
|
||||
SET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
|
||||
vol.Required(ATTR_SETPOINT): vol.All(
|
||||
vol.Coerce(float), vol.Range(min=4.0, max=35.0)
|
||||
),
|
||||
vol.Optional(ATTR_DURATION): vol.All(
|
||||
cv.time_period,
|
||||
vol.Range(min=timedelta(days=0), max=timedelta(days=1)),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
@@ -69,6 +51,8 @@ def setup_service_functions(
|
||||
Not all Honeywell TCC-compatible systems support all operating modes. In addition,
|
||||
each mode will require any of four distinct service schemas. This has to be
|
||||
enumerated before registering the appropriate handlers.
|
||||
|
||||
It appears that all TCC-compatible systems support the same three zones modes.
|
||||
"""
|
||||
|
||||
@verify_domain_control(DOMAIN)
|
||||
@@ -88,6 +72,28 @@ def setup_service_functions(
|
||||
}
|
||||
async_dispatcher_send(hass, DOMAIN, payload)
|
||||
|
||||
@verify_domain_control(DOMAIN)
|
||||
async def set_zone_override(call: ServiceCall) -> None:
|
||||
"""Set the zone override (setpoint)."""
|
||||
entity_id = call.data[ATTR_ENTITY_ID]
|
||||
|
||||
registry = er.async_get(hass)
|
||||
registry_entry = registry.async_get(entity_id)
|
||||
|
||||
if registry_entry is None or registry_entry.platform != DOMAIN:
|
||||
raise ValueError(f"'{entity_id}' is not a known {DOMAIN} entity")
|
||||
|
||||
if registry_entry.domain != "climate":
|
||||
raise ValueError(f"'{entity_id}' is not an {DOMAIN} controller/zone")
|
||||
|
||||
payload = {
|
||||
"unique_id": registry_entry.unique_id,
|
||||
"service": call.service,
|
||||
"data": call.data,
|
||||
}
|
||||
|
||||
async_dispatcher_send(hass, DOMAIN, payload)
|
||||
|
||||
assert coordinator.tcs is not None # mypy
|
||||
|
||||
hass.services.async_register(DOMAIN, EvoService.REFRESH_SYSTEM, force_refresh)
|
||||
@@ -150,4 +156,16 @@ def setup_service_functions(
|
||||
schema=vol.Schema(vol.Any(*system_mode_schemas)),
|
||||
)
|
||||
|
||||
_register_zone_entity_services(hass)
|
||||
# The zone modes are consistent across all systems and use the same schema
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
EvoService.CLEAR_ZONE_OVERRIDE,
|
||||
set_zone_override,
|
||||
schema=CLEAR_ZONE_OVERRIDE_SCHEMA,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
EvoService.SET_ZONE_OVERRIDE,
|
||||
set_zone_override,
|
||||
schema=SET_ZONE_OVERRIDE_SCHEMA,
|
||||
)
|
||||
|
||||
@@ -28,11 +28,14 @@ reset_system:
|
||||
refresh_system:
|
||||
|
||||
set_zone_override:
|
||||
target:
|
||||
entity:
|
||||
integration: evohome
|
||||
domain: climate
|
||||
fields:
|
||||
entity_id:
|
||||
required: true
|
||||
example: climate.bathroom
|
||||
selector:
|
||||
entity:
|
||||
integration: evohome
|
||||
domain: climate
|
||||
setpoint:
|
||||
required: true
|
||||
selector:
|
||||
@@ -46,7 +49,10 @@ set_zone_override:
|
||||
object:
|
||||
|
||||
clear_zone_override:
|
||||
target:
|
||||
entity:
|
||||
integration: evohome
|
||||
domain: climate
|
||||
fields:
|
||||
entity_id:
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
integration: evohome
|
||||
domain: climate
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"exceptions": {
|
||||
"zone_only_service": {
|
||||
"message": "Only zones support the `{service}` service"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"clear_zone_override": {
|
||||
"description": "Sets a zone to follow its schedule.",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "[%key:component::evohome::services::set_zone_override::fields::entity_id::description%]",
|
||||
"name": "[%key:component::evohome::services::set_zone_override::fields::entity_id::name%]"
|
||||
}
|
||||
},
|
||||
"name": "Clear zone override"
|
||||
},
|
||||
"refresh_system": {
|
||||
@@ -42,6 +43,10 @@
|
||||
"description": "The zone will revert to its schedule after this time. If 0 the change is until the next scheduled setpoint.",
|
||||
"name": "Duration"
|
||||
},
|
||||
"entity_id": {
|
||||
"description": "The entity ID of the Evohome zone.",
|
||||
"name": "Entity"
|
||||
},
|
||||
"setpoint": {
|
||||
"description": "The temperature to be used instead of the scheduled setpoint.",
|
||||
"name": "Setpoint"
|
||||
|
||||
@@ -236,6 +236,12 @@ class StateVacuumEntity(
|
||||
if self.__vacuum_legacy_battery_icon:
|
||||
self._report_deprecated_battery_properties("battery_icon")
|
||||
|
||||
@callback
|
||||
def async_write_ha_state(self) -> None:
|
||||
"""Write the state to the state machine."""
|
||||
super().async_write_ha_state()
|
||||
self._async_check_segments_issues()
|
||||
|
||||
@callback
|
||||
def async_registry_entry_updated(self) -> None:
|
||||
"""Run when the entity registry entry has been updated."""
|
||||
@@ -508,6 +514,43 @@ class StateVacuumEntity(
|
||||
return
|
||||
|
||||
options: Mapping[str, Any] = self.registry_entry.options.get(DOMAIN, {})
|
||||
should_have_not_configured_issue = (
|
||||
VacuumEntityFeature.CLEAN_AREA in self.supported_features
|
||||
and options.get("area_mapping") is None
|
||||
)
|
||||
|
||||
if (
|
||||
should_have_not_configured_issue
|
||||
and not self._segments_not_configured_issue_created
|
||||
):
|
||||
issue_id = (
|
||||
f"{ISSUE_SEGMENTS_MAPPING_NOT_CONFIGURED}_{self.registry_entry.id}"
|
||||
)
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
issue_id,
|
||||
data={
|
||||
"entry_id": self.registry_entry.id,
|
||||
"entity_id": self.entity_id,
|
||||
},
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key=ISSUE_SEGMENTS_MAPPING_NOT_CONFIGURED,
|
||||
translation_placeholders={
|
||||
"entity_id": self.entity_id,
|
||||
},
|
||||
)
|
||||
self._segments_not_configured_issue_created = True
|
||||
elif (
|
||||
not should_have_not_configured_issue
|
||||
and self._segments_not_configured_issue_created
|
||||
):
|
||||
issue_id = (
|
||||
f"{ISSUE_SEGMENTS_MAPPING_NOT_CONFIGURED}_{self.registry_entry.id}"
|
||||
)
|
||||
ir.async_delete_issue(self.hass, DOMAIN, issue_id)
|
||||
self._segments_not_configured_issue_created = False
|
||||
|
||||
if self._segments_changed_last_seen is not None and (
|
||||
VacuumEntityFeature.CLEAN_AREA not in self.supported_features
|
||||
|
||||
@@ -93,6 +93,10 @@
|
||||
"segments_changed": {
|
||||
"description": "",
|
||||
"title": "Vacuum segments have changed for {entity_id}"
|
||||
},
|
||||
"segments_mapping_not_configured": {
|
||||
"description": "",
|
||||
"title": "Vacuum segment mapping not configured for {entity_id}"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
|
||||
@@ -32,11 +32,8 @@ def write_utf8_file_atomic(
|
||||
Using this function frequently will significantly
|
||||
negatively impact performance.
|
||||
"""
|
||||
encoding = "utf-8" if "b" not in mode else None
|
||||
try:
|
||||
with AtomicWriter( # type: ignore[call-arg] # atomicwrites-stubs is outdated, encoding is a valid kwarg
|
||||
filename, mode=mode, overwrite=True, encoding=encoding
|
||||
).open() as fdesc:
|
||||
with AtomicWriter(filename, mode=mode, overwrite=True).open() as fdesc:
|
||||
if not private:
|
||||
os.fchmod(fdesc.fileno(), 0o644)
|
||||
fdesc.write(utf8_data)
|
||||
|
||||
@@ -9,7 +9,6 @@ from homeassistant.components.bring.const import DOMAIN
|
||||
from homeassistant.components.bring.services import (
|
||||
ATTR_ITEM_NAME,
|
||||
ATTR_NOTIFICATION_TYPE,
|
||||
SERVICE_PUSH_NOTIFICATION,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
@@ -34,7 +33,7 @@ async def test_send_notification(
|
||||
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_PUSH_NOTIFICATION,
|
||||
"send_message",
|
||||
service_data={
|
||||
ATTR_NOTIFICATION_TYPE: "GOING_SHOPPING",
|
||||
},
|
||||
@@ -65,7 +64,7 @@ async def test_send_notification_exception(
|
||||
with pytest.raises(HomeAssistantError) as err:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_PUSH_NOTIFICATION,
|
||||
"send_message",
|
||||
service_data={
|
||||
ATTR_NOTIFICATION_TYPE: "GOING_SHOPPING",
|
||||
},
|
||||
@@ -91,7 +90,7 @@ async def test_send_notification_service_validation_error(
|
||||
with pytest.raises(ServiceValidationError) as err:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_PUSH_NOTIFICATION,
|
||||
"send_message",
|
||||
service_data={ATTR_NOTIFICATION_TYPE: "URGENT_MESSAGE", ATTR_ITEM_NAME: ""},
|
||||
target={ATTR_ENTITY_ID: "todo.einkauf"},
|
||||
blocking=True,
|
||||
|
||||
@@ -12,10 +12,7 @@ from bring_api import (
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.bring.const import DOMAIN
|
||||
from homeassistant.components.bring.services import (
|
||||
ATTR_REACTION,
|
||||
SERVICE_ACTIVITY_STREAM_REACTION,
|
||||
)
|
||||
from homeassistant.components.bring.services import ATTR_REACTION
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -51,7 +48,7 @@ async def test_send_reaction(
|
||||
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_ACTIVITY_STREAM_REACTION,
|
||||
"send_reaction",
|
||||
service_data={
|
||||
ATTR_ENTITY_ID: "event.einkauf_activities",
|
||||
ATTR_REACTION: reaction,
|
||||
@@ -85,7 +82,7 @@ async def test_send_reaction_exception(
|
||||
with pytest.raises(HomeAssistantError) as err:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_ACTIVITY_STREAM_REACTION,
|
||||
"send_reaction",
|
||||
service_data={
|
||||
ATTR_ENTITY_ID: "event.einkauf_activities",
|
||||
ATTR_REACTION: "heart",
|
||||
@@ -113,7 +110,7 @@ async def test_send_reaction_config_entry_not_loaded(
|
||||
with pytest.raises(ServiceValidationError) as err:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_ACTIVITY_STREAM_REACTION,
|
||||
"send_reaction",
|
||||
service_data={
|
||||
ATTR_ENTITY_ID: "event.einkauf_activities",
|
||||
ATTR_REACTION: "heart",
|
||||
@@ -144,7 +141,7 @@ async def test_send_reaction_unknown_entity(
|
||||
with pytest.raises(ServiceValidationError) as err:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_ACTIVITY_STREAM_REACTION,
|
||||
"send_reaction",
|
||||
service_data={
|
||||
ATTR_ENTITY_ID: "event.einkauf_activities",
|
||||
ATTR_REACTION: "heart",
|
||||
@@ -177,7 +174,7 @@ async def test_send_reaction_not_found(
|
||||
with pytest.raises(HomeAssistantError) as err:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_ACTIVITY_STREAM_REACTION,
|
||||
"send_reaction",
|
||||
service_data={
|
||||
ATTR_ENTITY_ID: "event.einkauf_activities",
|
||||
ATTR_REACTION: "heart",
|
||||
|
||||
@@ -10,6 +10,10 @@ import pytest
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.bsblan.const import DOMAIN
|
||||
from homeassistant.components.bsblan.services import (
|
||||
SERVICE_SET_HOT_WATER_SCHEDULE,
|
||||
async_setup_services,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
@@ -130,7 +134,7 @@ async def test_set_hot_water_schedule(
|
||||
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"set_hot_water_schedule",
|
||||
SERVICE_SET_HOT_WATER_SCHEDULE,
|
||||
service_call_data,
|
||||
blocking=True,
|
||||
)
|
||||
@@ -159,7 +163,7 @@ async def test_invalid_device_id(
|
||||
with pytest.raises(ServiceValidationError) as exc_info:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"set_hot_water_schedule",
|
||||
SERVICE_SET_HOT_WATER_SCHEDULE,
|
||||
{
|
||||
"device_id": "invalid_device_id",
|
||||
"monday_slots": [
|
||||
@@ -172,12 +176,11 @@ async def test_invalid_device_id(
|
||||
assert exc_info.value.translation_key == "invalid_device_id"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("setup_integration")
|
||||
@pytest.mark.parametrize(
|
||||
("service_name", "service_data"),
|
||||
[
|
||||
(
|
||||
"set_hot_water_schedule",
|
||||
SERVICE_SET_HOT_WATER_SCHEDULE,
|
||||
{"monday_slots": [{"start_time": time(6, 0), "end_time": time(8, 0)}]},
|
||||
),
|
||||
("sync_time", {}),
|
||||
@@ -202,6 +205,9 @@ async def test_no_config_entry_for_device(
|
||||
name="Other Device",
|
||||
)
|
||||
|
||||
# Register the bsblan service without setting up any bsblan config entry
|
||||
async_setup_services(hass)
|
||||
|
||||
with pytest.raises(ServiceValidationError) as exc_info:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
@@ -216,15 +222,26 @@ async def test_no_config_entry_for_device(
|
||||
async def test_config_entry_not_loaded(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
device_entry: dr.DeviceEntry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test error when config entry is not loaded."""
|
||||
await hass.config_entries.async_unload(mock_config_entry.entry_id)
|
||||
# Add the config entry but don't set it up (so it stays in NOT_LOADED state)
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
# Create the device manually since setup won't run
|
||||
device_entry = device_registry.async_get_or_create(
|
||||
config_entry_id=mock_config_entry.entry_id,
|
||||
identifiers={(DOMAIN, TEST_DEVICE_MAC)},
|
||||
name="BSB-LAN Device",
|
||||
)
|
||||
|
||||
# Register the service
|
||||
async_setup_services(hass)
|
||||
|
||||
with pytest.raises(ServiceValidationError) as exc_info:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"set_hot_water_schedule",
|
||||
SERVICE_SET_HOT_WATER_SCHEDULE,
|
||||
{
|
||||
"device_id": device_entry.id,
|
||||
"monday_slots": [
|
||||
@@ -249,7 +266,7 @@ async def test_api_error(
|
||||
with pytest.raises(HomeAssistantError) as exc_info:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"set_hot_water_schedule",
|
||||
SERVICE_SET_HOT_WATER_SCHEDULE,
|
||||
{
|
||||
"device_id": device_entry.id,
|
||||
"monday_slots": [
|
||||
@@ -285,7 +302,7 @@ async def test_time_validation_errors(
|
||||
with pytest.raises(ServiceValidationError) as exc_info:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"set_hot_water_schedule",
|
||||
SERVICE_SET_HOT_WATER_SCHEDULE,
|
||||
{
|
||||
"device_id": device_entry.id,
|
||||
"monday_slots": [
|
||||
@@ -308,7 +325,7 @@ async def test_unprovided_days_are_none(
|
||||
# Only provide Monday and Tuesday, leave other days unprovided
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"set_hot_water_schedule",
|
||||
SERVICE_SET_HOT_WATER_SCHEDULE,
|
||||
{
|
||||
"device_id": device_entry.id,
|
||||
"monday_slots": [
|
||||
@@ -352,7 +369,7 @@ async def test_string_time_formats(
|
||||
# Test with string time formats
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"set_hot_water_schedule",
|
||||
SERVICE_SET_HOT_WATER_SCHEDULE,
|
||||
{
|
||||
"device_id": device_entry.id,
|
||||
"monday_slots": [
|
||||
@@ -389,7 +406,7 @@ async def test_non_standard_time_types(
|
||||
with pytest.raises(vol.MultipleInvalid):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"set_hot_water_schedule",
|
||||
SERVICE_SET_HOT_WATER_SCHEDULE,
|
||||
{
|
||||
"device_id": device_entry.id,
|
||||
"monday_slots": [
|
||||
@@ -407,7 +424,7 @@ async def test_async_setup_services(
|
||||
) -> None:
|
||||
"""Test service registration."""
|
||||
# Verify service doesn't exist initially
|
||||
assert not hass.services.has_service(DOMAIN, "set_hot_water_schedule")
|
||||
assert not hass.services.has_service(DOMAIN, SERVICE_SET_HOT_WATER_SCHEDULE)
|
||||
|
||||
# Set up the integration
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
@@ -415,7 +432,7 @@ async def test_async_setup_services(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify service is now registered
|
||||
assert hass.services.has_service(DOMAIN, "set_hot_water_schedule")
|
||||
assert hass.services.has_service(DOMAIN, SERVICE_SET_HOT_WATER_SCHEDULE)
|
||||
|
||||
|
||||
async def test_sync_time_service(
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
from evohomeasync2 import EvohomeClient
|
||||
@@ -19,11 +18,10 @@ from homeassistant.components.evohome.const import (
|
||||
)
|
||||
from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
|
||||
|
||||
@pytest.mark.parametrize("install", ["default"])
|
||||
async def test_refresh_system(
|
||||
async def test_service_refresh_system(
|
||||
hass: HomeAssistant,
|
||||
evohome: EvohomeClient,
|
||||
) -> None:
|
||||
@@ -42,7 +40,7 @@ async def test_refresh_system(
|
||||
|
||||
|
||||
@pytest.mark.parametrize("install", ["default"])
|
||||
async def test_reset_system(
|
||||
async def test_service_reset_system(
|
||||
hass: HomeAssistant,
|
||||
ctl_id: str,
|
||||
) -> None:
|
||||
@@ -61,7 +59,7 @@ async def test_reset_system(
|
||||
|
||||
|
||||
@pytest.mark.parametrize("install", ["default"])
|
||||
async def test_set_system_mode(
|
||||
async def test_ctl_set_system_mode(
|
||||
hass: HomeAssistant,
|
||||
ctl_id: str,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
@@ -117,7 +115,7 @@ async def test_set_system_mode(
|
||||
|
||||
|
||||
@pytest.mark.parametrize("install", ["default"])
|
||||
async def test_clear_zone_override(
|
||||
async def test_zone_clear_zone_override(
|
||||
hass: HomeAssistant,
|
||||
zone_id: str,
|
||||
) -> None:
|
||||
@@ -128,8 +126,9 @@ async def test_clear_zone_override(
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
EvoService.CLEAR_ZONE_OVERRIDE,
|
||||
{},
|
||||
target={ATTR_ENTITY_ID: zone_id},
|
||||
{
|
||||
ATTR_ENTITY_ID: zone_id,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
@@ -137,7 +136,7 @@ async def test_clear_zone_override(
|
||||
|
||||
|
||||
@pytest.mark.parametrize("install", ["default"])
|
||||
async def test_set_zone_override(
|
||||
async def test_zone_set_zone_override(
|
||||
hass: HomeAssistant,
|
||||
zone_id: str,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
@@ -152,9 +151,9 @@ async def test_set_zone_override(
|
||||
DOMAIN,
|
||||
EvoService.SET_ZONE_OVERRIDE,
|
||||
{
|
||||
ATTR_ENTITY_ID: zone_id,
|
||||
ATTR_SETPOINT: 19.5,
|
||||
},
|
||||
target={ATTR_ENTITY_ID: zone_id},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
@@ -166,41 +165,13 @@ async def test_set_zone_override(
|
||||
DOMAIN,
|
||||
EvoService.SET_ZONE_OVERRIDE,
|
||||
{
|
||||
ATTR_ENTITY_ID: zone_id,
|
||||
ATTR_SETPOINT: 19.5,
|
||||
ATTR_DURATION: {"minutes": 135},
|
||||
},
|
||||
target={ATTR_ENTITY_ID: zone_id},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_fcn.assert_awaited_once_with(
|
||||
19.5, until=datetime(2024, 7, 10, 14, 15, tzinfo=UTC)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("install", ["default"])
|
||||
@pytest.mark.parametrize(
|
||||
("service", "service_data"),
|
||||
[
|
||||
(EvoService.CLEAR_ZONE_OVERRIDE, {}),
|
||||
(EvoService.SET_ZONE_OVERRIDE, {ATTR_SETPOINT: 19.5}),
|
||||
],
|
||||
)
|
||||
async def test_zone_services_with_ctl_id(
|
||||
hass: HomeAssistant,
|
||||
ctl_id: str,
|
||||
service: EvoService,
|
||||
service_data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test calling zone-only services with a non-zone entity_id fail."""
|
||||
|
||||
with pytest.raises(ServiceValidationError) as excinfo:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
service,
|
||||
service_data,
|
||||
target={ATTR_ENTITY_ID: ctl_id},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert excinfo.value.translation_key == "zone_only_service"
|
||||
|
||||
@@ -487,6 +487,80 @@ async def test_segments_changed_issue(
|
||||
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("config_flow_fixture")
|
||||
@pytest.mark.parametrize("area_mapping", [{"area_1": ["seg_1"]}, {}])
|
||||
async def test_segments_mapping_not_configured_issue(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
area_mapping: dict[str, list[str]],
|
||||
) -> None:
|
||||
"""Test segments_mapping_not_configured issue."""
|
||||
mock_vacuum = MockVacuumWithCleanArea(name="Testing", entity_id="vacuum.testing")
|
||||
|
||||
config_entry = MockConfigEntry(domain="test")
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
mock_integration(
|
||||
hass,
|
||||
MockModule(
|
||||
"test",
|
||||
async_setup_entry=help_async_setup_entry_init,
|
||||
async_unload_entry=help_async_unload_entry,
|
||||
),
|
||||
)
|
||||
setup_test_component_platform(hass, DOMAIN, [mock_vacuum], from_config_entry=True)
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_entry = entity_registry.async_get(mock_vacuum.entity_id)
|
||||
|
||||
issue_id = f"segments_mapping_not_configured_{entity_entry.id}"
|
||||
issue = ir.async_get(hass).async_get_issue(DOMAIN, issue_id)
|
||||
assert issue is not None
|
||||
assert issue.severity == ir.IssueSeverity.WARNING
|
||||
assert issue.translation_key == "segments_mapping_not_configured"
|
||||
|
||||
entity_registry.async_update_entity_options(
|
||||
mock_vacuum.entity_id,
|
||||
DOMAIN,
|
||||
{
|
||||
"area_mapping": area_mapping,
|
||||
"last_seen_segments": [asdict(segment) for segment in mock_vacuum.segments],
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("config_flow_fixture")
|
||||
async def test_no_segments_mapping_issue_without_clean_area(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test no repair issue is created when CLEAN_AREA is not supported."""
|
||||
mock_vacuum = MockVacuum(name="Testing", entity_id="vacuum.testing")
|
||||
|
||||
config_entry = MockConfigEntry(domain="test")
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
mock_integration(
|
||||
hass,
|
||||
MockModule(
|
||||
"test",
|
||||
async_setup_entry=help_async_setup_entry_init,
|
||||
async_unload_entry=help_async_unload_entry,
|
||||
),
|
||||
)
|
||||
setup_test_component_platform(hass, DOMAIN, [mock_vacuum], from_config_entry=True)
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
issues = ir.async_get(hass).issues
|
||||
assert not any(
|
||||
issue_id[1].startswith("segments_mapping_not_configured") for issue_id in issues
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("is_built_in", "log_warnings"), [(True, 0), (False, 3)])
|
||||
async def test_vacuum_log_deprecated_battery_using_properties(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -83,19 +83,6 @@ def test_write_utf8_file_fails_at_rename_and_remove(
|
||||
assert "File replacement cleanup failed" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("func", [write_utf8_file, write_utf8_file_atomic])
|
||||
def test_write_utf8_file_with_non_ascii_content(tmp_path: Path, func) -> None:
|
||||
"""Test files with non-ASCII content can be written even when locale is ASCII."""
|
||||
test_file = tmp_path / "test.json"
|
||||
non_ascii_data = '{"name":"自动化","emoji":"🏠"}'
|
||||
|
||||
with patch("locale.getpreferredencoding", return_value="ascii"):
|
||||
func(test_file, non_ascii_data, False)
|
||||
|
||||
file_text = test_file.read_text(encoding="utf-8")
|
||||
assert file_text == non_ascii_data
|
||||
|
||||
|
||||
def test_write_utf8_file_atomic_fails(tmpdir: py.path.local) -> None:
|
||||
"""Test OSError from write_utf8_file_atomic is rethrown as WriteError."""
|
||||
test_dir = tmpdir.mkdir("files")
|
||||
|
||||
Reference in New Issue
Block a user