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 GitHub
parent 9a90e1e410
commit c834944ee7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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: async def _update_v2_schedules(self) -> None:
for zone in self.tcs.zones: 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: 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] async def _async_update_data(self) -> EvoLocStatusResponseT: # type: ignore[override]
"""Fetch the latest state of an entire TCC Location. """Fetch the latest state of an entire TCC Location.

View File

@ -6,6 +6,7 @@ import logging
from typing import Any from typing import Any
import evohomeasync2 as evo import evohomeasync2 as evo
from evohomeasync2.schemas.typedefs import DayOfWeekDhwT
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
@ -102,7 +103,7 @@ class EvoChild(EvoEntity):
self._evo_tcs = evo_device.tcs self._evo_tcs = evo_device.tcs
self._schedule: dict[str, Any] | None = None self._schedule: list[DayOfWeekDhwT] | None = None
self._setpoints: dict[str, Any] = {} self._setpoints: dict[str, Any] = {}
@property @property
@ -123,6 +124,9 @@ class EvoChild(EvoEntity):
Only Zones & DHW controllers (but not the TCS) can have schedules. 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 this_sp_dtm, this_sp_val = self._evo_device.this_switchpoint
next_sp_dtm, next_sp_val = self._evo_device.next_switchpoint next_sp_dtm, next_sp_val = self._evo_device.next_switchpoint
@ -152,10 +156,10 @@ class EvoChild(EvoEntity):
self._evo_device, self._evo_device,
err, err,
) )
self._schedule = {} self._schedule = []
return return
else: else:
self._schedule = schedule or {} # mypy hint self._schedule = schedule # type: ignore[assignment]
_LOGGER.debug("Schedule['%s'] = %s", self.name, schedule) _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) 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.""" """Load JSON for the schedule of a domesticHotWater zone."""
try: 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: except FileNotFoundError:
return load_json_object_fixture("default/schedule_dhw.json", DOMAIN) 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.""" """Load JSON for the schedule of a temperatureZone zone."""
try: 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: except FileNotFoundError:
return load_json_object_fixture("default/schedule_zone.json", DOMAIN) 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: elif "schedule" in url:
if url.startswith("domesticHotWater"): # /v2/domesticHotWater/{id}/schedule 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 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}") pytest.fail(f"Unexpected request: {HTTPMethod.GET} {url}")

View File

@ -15,8 +15,9 @@ TEST_INSTALLS: Final = (
"default", # evohome: multi-zone, with DHW "default", # evohome: multi-zone, with DHW
"h032585", # VisionProWifi: no preset modes for TCS, zoneId=systemId "h032585", # VisionProWifi: no preset modes for TCS, zoneId=systemId
"h099625", # RoundThermostat "h099625", # RoundThermostat
"h139906", # zone with null schedule
"sys_004", # RoundModulation "sys_004", # RoundModulation
) )
# "botched", # as default: but with activeFaults, ghost zones & unknown types # "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] # name: test_ctl_set_hvac_mode[minimal]
list([ list([
tuple( tuple(
@ -70,6 +80,13 @@
), ),
]) ])
# --- # ---
# name: test_ctl_turn_off[h139906]
list([
tuple(
<SystemMode.HEATING_OFF: 'HeatingOff'>,
),
])
# ---
# name: test_ctl_turn_off[minimal] # name: test_ctl_turn_off[minimal]
list([ list([
tuple( tuple(
@ -105,6 +122,13 @@
), ),
]) ])
# --- # ---
# name: test_ctl_turn_on[h139906]
list([
tuple(
<SystemMode.AUTO: 'Auto'>,
),
])
# ---
# name: test_ctl_turn_on[minimal] # name: test_ctl_turn_on[minimal]
list([ list([
tuple( tuple(
@ -1118,6 +1142,136 @@
'state': 'heat', '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] # name: test_setup_platform[minimal][climate.main_room-state]
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
@ -1312,6 +1466,13 @@
), ),
]) ])
# --- # ---
# name: test_zone_set_hvac_mode[h139906]
list([
tuple(
5.0,
),
])
# ---
# name: test_zone_set_hvac_mode[minimal] # name: test_zone_set_hvac_mode[minimal]
list([ list([
tuple( 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] # name: test_zone_set_preset_mode[minimal]
list([ list([
tuple( tuple(
@ -1412,6 +1586,13 @@
}), }),
]) ])
# --- # ---
# name: test_zone_set_temperature[h139906]
list([
dict({
'until': None,
}),
])
# ---
# name: test_zone_set_temperature[minimal] # name: test_zone_set_temperature[minimal]
list([ list([
dict({ dict({
@ -1447,6 +1628,13 @@
), ),
]) ])
# --- # ---
# name: test_zone_turn_off[h139906]
list([
tuple(
5.0,
),
])
# ---
# name: test_zone_turn_off[minimal] # name: test_zone_turn_off[minimal]
list([ list([
tuple( tuple(

View File

@ -11,6 +11,9 @@
# name: test_setup[h099625] # name: test_setup[h099625]
dict_keys(['refresh_system', 'set_system_mode', 'clear_zone_override', 'set_zone_override']) 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] # name: test_setup[minimal]
dict_keys(['refresh_system', 'reset_system', 'set_system_mode', 'clear_zone_override', 'set_zone_override']) dict_keys(['refresh_system', 'reset_system', 'set_system_mode', 'clear_zone_override', 'set_zone_override'])
# --- # ---

View File

@ -1,4 +1,14 @@
# serializer version: 1 # 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] # name: test_set_operation_mode[default]
list([ list([
dict({ dict({

View File

@ -33,7 +33,7 @@ from .const import TEST_INSTALLS_WITH_DHW
DHW_ENTITY_ID = "water_heater.domestic_hot_water" 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( async def test_setup_platform(
hass: HomeAssistant, hass: HomeAssistant,
config: dict[str, str], config: dict[str, str],