Fix VeSync crash on fan levels outside of defined list (#176234)

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Jan Schöppach
2026-07-12 16:32:56 +02:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent 4224d8ff27
commit 75adfde436
3 changed files with 54 additions and 12 deletions
+7 -7
View File
@@ -163,15 +163,15 @@ class VeSyncFanHA(VeSyncBaseEntity[VeSyncFanBase | VeSyncPurifier], FanEntity):
"""Return the currently set speed."""
current_level = self.device.state.fan_level
if (
self.device.state.mode in (VS_FAN_MODE_MANUAL, VS_FAN_MODE_NORMAL)
and current_level is not None
):
if self.device.state.mode in (VS_FAN_MODE_MANUAL, VS_FAN_MODE_NORMAL):
if current_level == 0:
return 0
return ordered_list_item_to_percentage(
self.device.fan_levels, current_level
)
# The device can report an out-of-range level (e.g. -1) when the
# speed is not applicable; treat it as unknown instead of crashing.
if current_level in self.device.fan_levels:
return ordered_list_item_to_percentage(
self.device.fan_levels, current_level
)
return None
@property
+16 -3
View File
@@ -99,9 +99,15 @@ DEVICE_FIXTURES: dict[str, list[tuple[str, str, str]]] = {
def mock_devices_response(
aioclient_mock: AiohttpClientMocker, device_name: str
aioclient_mock: AiohttpClientMocker,
device_name: str,
details_override: dict[str, Any] | None = None,
) -> None:
"""Build a response for the Helpers.call_api method."""
"""Build a response for the Helpers.call_api method.
``details_override`` is merged into the nested ``result`` payload of the
device detail response, allowing tests to simulate specific device states.
"""
device_list = [
device
for device in ALL_DEVICES["result"]["list"]
@@ -126,9 +132,16 @@ def mock_devices_response(
)
for fixture in DEVICE_FIXTURES[device_name]:
detail = load_json_object_fixture(fixture[2], DOMAIN)
if details_override:
assert "result" in detail.get("result", {}), (
f"Fixture {fixture[2]} does not have the expected "
"result.result payload to apply details_override"
)
detail["result"]["result"].update(details_override)
getattr(aioclient_mock, fixture[0])(
f"https://smartapi.vesync.com{fixture[1]}",
json=load_json_object_fixture(fixture[2], DOMAIN),
json=detail,
)
mock_firmware(aioclient_mock)
+31 -2
View File
@@ -6,8 +6,17 @@ from unittest.mock import AsyncMock, patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.fan import ATTR_PRESET_MODE, DOMAIN as FAN_DOMAIN
from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON
from homeassistant.components.fan import (
ATTR_PERCENTAGE,
ATTR_PRESET_MODE,
DOMAIN as FAN_DOMAIN,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_UNAVAILABLE,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -188,6 +197,26 @@ async def test_set_preset_mode(
update_mock.assert_called_once()
async def test_out_of_range_fan_level(
hass: HomeAssistant,
config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that an out-of-range fan level produces an unknown percentage."""
mock_devices_response(
aioclient_mock, "CoreBreeze 432S", details_override={"fanSpeedLevel": -1}
)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_PEDESTAL_FAN)
assert state is not None
assert state.state != STATE_UNAVAILABLE
assert state.attributes[ATTR_PERCENTAGE] is None
@pytest.mark.parametrize(
("action", "api_response", "expectation"),
[