Compare commits

..

1 Commits

Author SHA1 Message Date
epenet
ed41a6e06a Drop single-use service name constants in bring 2026-02-27 09:20:10 +00:00
16 changed files with 282 additions and 171 deletions

View File

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

View File

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

View File

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

View File

@@ -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."""

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": {

View File

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

View File

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

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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