Fix evohome to gracefully handle null schedules (#140036)

* extend tests to catch null schedules

* add fixture with null schedule

* remove null schedules for now

* fic the typing for _schedule attr (is list, not dict)

* add valid schedule to fixture

* update ssetpoints only if there is a schedule

* snapshot to match last change

* refactor: dont update switchpoints if no schedule

* add in warnings for null schedules

* add fixture for DHW without schedule
This commit is contained in:
David Bonnes 2025-03-07 12:04:04 +00:00 committed by Franck Nijhof
parent e7ea0e435e
commit 8bcd135f3d
No known key found for this signature in database
GPG Key ID: D62583BA8AB11CA3
13 changed files with 553 additions and 13 deletions

View File

@ -207,10 +207,18 @@ class EvoDataUpdateCoordinator(DataUpdateCoordinator):
async def _update_v2_schedules(self) -> None:
for zone in self.tcs.zones:
await zone.get_schedule()
try:
await zone.get_schedule()
except ec2.InvalidScheduleError as err:
self.logger.warning(
"Zone '%s' has an invalid/missing schedule: %r", zone.name, err
)
if dhw := self.tcs.hotwater:
await dhw.get_schedule()
try:
await dhw.get_schedule()
except ec2.InvalidScheduleError as err:
self.logger.warning("DHW has an invalid/missing schedule: %r", err)
async def _async_update_data(self) -> EvoLocStatusResponseT: # type: ignore[override]
"""Fetch the latest state of an entire TCC Location.

View File

@ -6,6 +6,7 @@ import logging
from typing import Any
import evohomeasync2 as evo
from evohomeasync2.schemas.typedefs import DayOfWeekDhwT
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@ -102,7 +103,7 @@ class EvoChild(EvoEntity):
self._evo_tcs = evo_device.tcs
self._schedule: dict[str, Any] | None = None
self._schedule: list[DayOfWeekDhwT] | None = None
self._setpoints: dict[str, Any] = {}
@property
@ -123,6 +124,9 @@ class EvoChild(EvoEntity):
Only Zones & DHW controllers (but not the TCS) can have schedules.
"""
if not self._schedule:
return self._setpoints
this_sp_dtm, this_sp_val = self._evo_device.this_switchpoint
next_sp_dtm, next_sp_val = self._evo_device.next_switchpoint
@ -152,10 +156,10 @@ class EvoChild(EvoEntity):
self._evo_device,
err,
)
self._schedule = {}
self._schedule = []
return
else:
self._schedule = schedule or {} # mypy hint
self._schedule = schedule # type: ignore[assignment]
_LOGGER.debug("Schedule['%s'] = %s", self.name, schedule)

View File

@ -48,18 +48,18 @@ def location_status_fixture(install: str, loc_id: str | None = None) -> JsonObje
return load_json_object_fixture(f"{install}/status_{loc_id}.json", DOMAIN)
def dhw_schedule_fixture(install: str) -> JsonObjectType:
def dhw_schedule_fixture(install: str, dhw_id: str | None = None) -> JsonObjectType:
"""Load JSON for the schedule of a domesticHotWater zone."""
try:
return load_json_object_fixture(f"{install}/schedule_dhw.json", DOMAIN)
return load_json_object_fixture(f"{install}/schedule_{dhw_id}.json", DOMAIN)
except FileNotFoundError:
return load_json_object_fixture("default/schedule_dhw.json", DOMAIN)
def zone_schedule_fixture(install: str) -> JsonObjectType:
def zone_schedule_fixture(install: str, zon_id: str | None = None) -> JsonObjectType:
"""Load JSON for the schedule of a temperatureZone zone."""
try:
return load_json_object_fixture(f"{install}/schedule_zone.json", DOMAIN)
return load_json_object_fixture(f"{install}/schedule_{zon_id}.json", DOMAIN)
except FileNotFoundError:
return load_json_object_fixture("default/schedule_zone.json", DOMAIN)
@ -120,9 +120,9 @@ def mock_make_request(install: str) -> Callable:
elif "schedule" in url:
if url.startswith("domesticHotWater"): # /v2/domesticHotWater/{id}/schedule
return dhw_schedule_fixture(install)
return dhw_schedule_fixture(install, url[16:23])
if url.startswith("temperatureZone"): # /v2/temperatureZone/{id}/schedule
return zone_schedule_fixture(install)
return zone_schedule_fixture(install, url[16:23])
pytest.fail(f"Unexpected request: {HTTPMethod.GET} {url}")

View File

@ -15,8 +15,9 @@ TEST_INSTALLS: Final = (
"default", # evohome: multi-zone, with DHW
"h032585", # VisionProWifi: no preset modes for TCS, zoneId=systemId
"h099625", # RoundThermostat
"h139906", # zone with null schedule
"sys_004", # RoundModulation
)
# "botched", # as default: but with activeFaults, ghost zones & unknown types
TEST_INSTALLS_WITH_DHW: Final = ("default",)
TEST_INSTALLS_WITH_DHW: Final = ("default", "botched")

View File

@ -0,0 +1,3 @@
{
"dailySchedules": []
}

View File

@ -0,0 +1,3 @@
{
"dailySchedules": []
}

View File

@ -0,0 +1,143 @@
{
"dailySchedules": [
{
"dayOfWeek": "Monday",
"switchpoints": [
{
"heatSetpoint": 22.0,
"timeOfDay": "05:30:00"
},
{
"heatSetpoint": 20.0,
"timeOfDay": "08:00:00"
},
{
"heatSetpoint": 22.5,
"timeOfDay": "16:00:00"
},
{
"heatSetpoint": 15.0,
"timeOfDay": "23:00:00"
}
]
},
{
"dayOfWeek": "Tuesday",
"switchpoints": [
{
"heatSetpoint": 22.0,
"timeOfDay": "05:30:00"
},
{
"heatSetpoint": 20.0,
"timeOfDay": "08:00:00"
},
{
"heatSetpoint": 22.5,
"timeOfDay": "16:00:00"
},
{
"heatSetpoint": 15.0,
"timeOfDay": "23:00:00"
}
]
},
{
"dayOfWeek": "Wednesday",
"switchpoints": [
{
"heatSetpoint": 22.0,
"timeOfDay": "05:30:00"
},
{
"heatSetpoint": 20.0,
"timeOfDay": "08:00:00"
},
{
"heatSetpoint": 22.5,
"timeOfDay": "12:00:00"
},
{
"heatSetpoint": 15.0,
"timeOfDay": "23:00:00"
}
]
},
{
"dayOfWeek": "Thursday",
"switchpoints": [
{
"heatSetpoint": 22.0,
"timeOfDay": "05:30:00"
},
{
"heatSetpoint": 20.0,
"timeOfDay": "08:00:00"
},
{
"heatSetpoint": 22.5,
"timeOfDay": "16:00:00"
},
{
"heatSetpoint": 15.0,
"timeOfDay": "23:00:00"
}
]
},
{
"dayOfWeek": "Friday",
"switchpoints": [
{
"heatSetpoint": 22.0,
"timeOfDay": "05:30:00"
},
{
"heatSetpoint": 20.0,
"timeOfDay": "08:00:00"
},
{
"heatSetpoint": 22.5,
"timeOfDay": "16:00:00"
},
{
"heatSetpoint": 15.0,
"timeOfDay": "23:00:00"
}
]
},
{
"dayOfWeek": "Saturday",
"switchpoints": [
{
"heatSetpoint": 22.0,
"timeOfDay": "07:00:00"
},
{
"heatSetpoint": 22.5,
"timeOfDay": "16:00:00"
},
{
"heatSetpoint": 15.0,
"timeOfDay": "23:00:00"
}
]
},
{
"dayOfWeek": "Sunday",
"switchpoints": [
{
"heatSetpoint": 22.0,
"timeOfDay": "07:30:00"
},
{
"heatSetpoint": 22.5,
"timeOfDay": "16:00:00"
},
{
"heatSetpoint": 15.0,
"timeOfDay": "23:00:00"
}
]
}
]
}

View File

@ -0,0 +1,52 @@
{
"locationId": "2727366",
"gateways": [
{
"gatewayId": "2513794",
"temperatureControlSystems": [
{
"systemId": "3454856",
"zones": [
{
"zoneId": "3454854",
"temperatureStatus": {
"temperature": 22.0,
"isAvailable": true
},
"activeFaults": [
{
"faultType": "TempZoneSensorCommunicationLost",
"since": "2025-02-06T11:20:29"
}
],
"setpointStatus": {
"targetHeatTemperature": 5.0,
"setpointMode": "FollowSchedule"
},
"name": "Thermostat"
},
{
"zoneId": "3454855",
"temperatureStatus": {
"temperature": 22.0,
"isAvailable": true
},
"activeFaults": [],
"setpointStatus": {
"targetHeatTemperature": 20.0,
"setpointMode": "FollowSchedule"
},
"name": "Thermostat 2"
}
],
"activeFaults": [],
"systemModeStatus": {
"mode": "Auto",
"isPermanent": true
}
}
],
"activeFaults": []
}
]
}

View File

@ -0,0 +1,125 @@
[
{
"locationInfo": {
"locationId": "2727366",
"name": "Vr**********",
"streetAddress": "********** *",
"city": "*********",
"country": "Netherlands",
"postcode": "******",
"locationType": "Residential",
"useDaylightSaveSwitching": true,
"timeZone": {
"timeZoneId": "WEuropeStandardTime",
"displayName": "(UTC+01:00) Amsterdam, Berlijn, Bern, Rome, Stockholm, Wenen",
"offsetMinutes": 60,
"currentOffsetMinutes": 60,
"supportsDaylightSaving": true
},
"locationOwner": {
"userId": "2276512",
"username": "nobody@nowhere.com",
"firstname": "Gl***",
"lastname": "de*****"
}
},
"gateways": [
{
"gatewayInfo": {
"gatewayId": "2513794",
"mac": "************",
"crc": "****",
"isWiFi": false
},
"temperatureControlSystems": [
{
"systemId": "3454856",
"modelType": "EvoTouch",
"zones": [
{
"zoneId": "3454854",
"modelType": "HeatingZone",
"setpointCapabilities": {
"maxHeatSetpoint": 35.0,
"minHeatSetpoint": 5.0,
"valueResolution": 0.5,
"canControlHeat": true,
"canControlCool": false,
"allowedSetpointModes": [
"PermanentOverride",
"FollowSchedule",
"TemporaryOverride"
],
"maxDuration": "1.00:00:00",
"timingResolution": "00:10:00"
},
"scheduleCapabilities": {
"maxSwitchpointsPerDay": 6,
"minSwitchpointsPerDay": 1,
"timingResolution": "00:10:00",
"setpointValueResolution": 0.5
},
"name": "Thermostat",
"zoneType": "ZoneTemperatureControl"
},
{
"zoneId": "3454855",
"modelType": "RoundWireless",
"setpointCapabilities": {
"maxHeatSetpoint": 35.0,
"minHeatSetpoint": 5.0,
"valueResolution": 0.5,
"canControlHeat": true,
"canControlCool": false,
"allowedSetpointModes": [
"PermanentOverride",
"FollowSchedule",
"TemporaryOverride"
],
"maxDuration": "1.00:00:00",
"timingResolution": "00:10:00"
},
"scheduleCapabilities": {
"maxSwitchpointsPerDay": 6,
"minSwitchpointsPerDay": 0,
"timingResolution": "00:10:00",
"setpointValueResolution": 0.5
},
"name": "Thermostat 2",
"zoneType": "Thermostat"
}
],
"allowedSystemModes": [
{
"systemMode": "Auto",
"canBePermanent": true,
"canBeTemporary": false
},
{
"systemMode": "AutoWithEco",
"canBePermanent": true,
"canBeTemporary": true,
"maxDuration": "1.00:00:00",
"timingResolution": "01:00:00",
"timingMode": "Duration"
},
{
"systemMode": "Away",
"canBePermanent": true,
"canBeTemporary": true,
"maxDuration": "99.00:00:00",
"timingResolution": "1.00:00:00",
"timingMode": "Period"
},
{
"systemMode": "HeatingOff",
"canBePermanent": true,
"canBeTemporary": false
}
]
}
]
}
]
}
]

View File

@ -29,6 +29,16 @@
),
])
# ---
# name: test_ctl_set_hvac_mode[h139906]
list([
tuple(
<SystemMode.HEATING_OFF: 'HeatingOff'>,
),
tuple(
<SystemMode.AUTO: 'Auto'>,
),
])
# ---
# name: test_ctl_set_hvac_mode[minimal]
list([
tuple(
@ -70,6 +80,13 @@
),
])
# ---
# name: test_ctl_turn_off[h139906]
list([
tuple(
<SystemMode.HEATING_OFF: 'HeatingOff'>,
),
])
# ---
# name: test_ctl_turn_off[minimal]
list([
tuple(
@ -105,6 +122,13 @@
),
])
# ---
# name: test_ctl_turn_on[h139906]
list([
tuple(
<SystemMode.AUTO: 'Auto'>,
),
])
# ---
# name: test_ctl_turn_on[minimal]
list([
tuple(
@ -1118,6 +1142,136 @@
'state': 'heat',
})
# ---
# name: test_setup_platform[h139906][climate.thermostat-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 22.0,
'friendly_name': 'Thermostat',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'none',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
dict({
'fault_type': 'TempZoneSensorCommunicationLost',
'since': '2025-02-06T11:20:29+01:00',
}),
),
'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 5.0,
}),
'setpoints': dict({
}),
'temperature_status': dict({
'is_available': True,
'temperature': 22.0,
}),
'zone_id': '3454854',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 5.0,
}),
'context': <ANY>,
'entity_id': 'climate.thermostat',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_setup_platform[h139906][climate.thermostat_2-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 22.0,
'friendly_name': 'Thermostat 2',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'none',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 20.0,
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Berlin')),
'next_sp_temp': 15.0,
'this_sp_from': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Berlin')),
'this_sp_temp': 22.5,
}),
'temperature_status': dict({
'is_available': True,
'temperature': 22.0,
}),
'zone_id': '3454855',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 20.0,
}),
'context': <ANY>,
'entity_id': 'climate.thermostat_2',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_setup_platform[h139906][climate.vr-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 22.0,
'friendly_name': 'Vr**********',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'icon': 'mdi:thermostat',
'max_temp': 35,
'min_temp': 7,
'preset_mode': None,
'preset_modes': list([
'eco',
'away',
]),
'status': dict({
'activeSystemFaults': tuple(
),
'system_id': '3454856',
'system_mode_status': dict({
'is_permanent': True,
'mode': 'Auto',
}),
}),
'supported_features': <ClimateEntityFeature: 400>,
}),
'context': <ANY>,
'entity_id': 'climate.vr',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_setup_platform[minimal][climate.main_room-state]
StateSnapshot({
'attributes': ReadOnlyDict({
@ -1312,6 +1466,13 @@
),
])
# ---
# name: test_zone_set_hvac_mode[h139906]
list([
tuple(
5.0,
),
])
# ---
# name: test_zone_set_hvac_mode[minimal]
list([
tuple(
@ -1365,6 +1526,19 @@
}),
])
# ---
# name: test_zone_set_preset_mode[h139906]
list([
tuple(
5.0,
),
tuple(
5.0,
),
dict({
'until': None,
}),
])
# ---
# name: test_zone_set_preset_mode[minimal]
list([
tuple(
@ -1412,6 +1586,13 @@
}),
])
# ---
# name: test_zone_set_temperature[h139906]
list([
dict({
'until': None,
}),
])
# ---
# name: test_zone_set_temperature[minimal]
list([
dict({
@ -1447,6 +1628,13 @@
),
])
# ---
# name: test_zone_turn_off[h139906]
list([
tuple(
5.0,
),
])
# ---
# name: test_zone_turn_off[minimal]
list([
tuple(

View File

@ -11,6 +11,9 @@
# name: test_setup[h099625]
dict_keys(['refresh_system', 'set_system_mode', 'clear_zone_override', 'set_zone_override'])
# ---
# name: test_setup[h139906]
dict_keys(['refresh_system', 'set_system_mode', 'clear_zone_override', 'set_zone_override'])
# ---
# name: test_setup[minimal]
dict_keys(['refresh_system', 'reset_system', 'set_system_mode', 'clear_zone_override', 'set_zone_override'])
# ---

View File

@ -1,4 +1,14 @@
# serializer version: 1
# name: test_set_operation_mode[botched]
list([
dict({
'until': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc),
}),
dict({
'until': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc),
}),
])
# ---
# name: test_set_operation_mode[default]
list([
dict({

View File

@ -33,7 +33,7 @@ from .const import TEST_INSTALLS_WITH_DHW
DHW_ENTITY_ID = "water_heater.domestic_hot_water"
@pytest.mark.parametrize("install", [*TEST_INSTALLS_WITH_DHW, "botched"])
@pytest.mark.parametrize("install", TEST_INSTALLS_WITH_DHW)
async def test_setup_platform(
hass: HomeAssistant,
config: dict[str, str],