Fix Matter fan and light dividing by zero on out-of-spec device ranges (#182645)

This commit is contained in:
Franck Nijhof
2026-09-19 09:51:17 +02:00
committed by GitHub
parent 76920130f8
commit f31b3e2567
4 changed files with 74 additions and 11 deletions
+5 -1
View File
@@ -270,9 +270,13 @@ class MatterFan(MatterEntity, FanEntity):
# does not leave a stale speed_count / percentage_step.
self._attr_speed_count = 100
if feature_map & FanControlFeature.kMultiSpeed:
self._attr_speed_count = int(
speed_max = int(
self.get_matter_attribute_value(clusters.FanControl.Attributes.SpeedMax)
)
# the step size divides by the speed count, so a device reporting
# no speeds keeps the default
if speed_max > 0:
self._attr_speed_count = speed_max
if feature_map & FanControlFeature.kRocking:
# NOTE: the Matter model allows that a device can have multiple/different
# rock directions while HA doesn't allow this in the entity model.
+15 -10
View File
@@ -84,6 +84,19 @@ TRANSITION_BLOCKLIST = (
)
def _level_range(level_control: clusters.LevelControl) -> tuple[int, int]:
"""Return the level range of the device.
Brightness scaling divides by the width of the range, so a device that
reports a range without width gets the default range instead.
"""
min_level = level_control.minLevel or 1
max_level = level_control.maxLevel or 254
if max_level <= min_level:
return (1, 254)
return (min_level, max_level)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: MatterConfigEntry,
@@ -177,13 +190,7 @@ class MatterLight(MatterEntity, LightEntity):
assert level_control is not None
level = round(
renormalize(
brightness,
(0, 255),
(level_control.minLevel or 1, level_control.maxLevel or 254),
)
)
level = round(renormalize(brightness, (0, 255), _level_range(level_control)))
await self.send_device_command(
clusters.LevelControl.Commands.MoveToLevelWithOnOff(
@@ -280,9 +287,7 @@ class MatterLight(MatterEntity, LightEntity):
return round(
renormalize(
level_control.currentLevel,
(level_control.minLevel or 1, level_control.maxLevel or 254),
(0, 255),
level_control.currentLevel, _level_range(level_control), (0, 255)
)
)
+21
View File
@@ -504,3 +504,24 @@ async def test_fan_set_percentage_without_multispeed(
attribute_path="1/514/2",
value=75,
)
@pytest.mark.parametrize("node_fixture", ["mock_air_purifier"])
@pytest.mark.parametrize("attributes", [{"1/514/4": 0}])
async def test_fan_without_speed_steps(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test a fan reporting no speed steps keeps the default step size."""
entity_id = "fan.mock_air_purifier"
state = hass.states.get(entity_id)
assert state
assert state.attributes["percentage_step"] == 1
# a proper speed count is picked up when the features are recalculated
set_node_attribute(matter_node, 1, 514, 4, 5)
set_node_attribute(matter_node, 1, 514, 65532, 47)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get(entity_id)
assert state.attributes["percentage_step"] == 20
+33
View File
@@ -565,3 +565,36 @@ async def test_light_unexpected_color_mode(
transitionTime=0,
),
)
@pytest.mark.parametrize("node_fixture", ["mock_dimmable_light"])
async def test_dimmable_light_without_level_range(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test a light reporting a level range without width uses the default range."""
entity_id = "light.mock_dimmable_light"
set_node_attribute(matter_node, 1, 8, 2, 5)
set_node_attribute(matter_node, 1, 8, 3, 5)
set_node_attribute(matter_node, 1, 8, 0, 5)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get(entity_id)
assert state
assert state.attributes["brightness"] == 4
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": entity_id, "brightness": 128},
blocking=True,
)
assert matter_client.send_device_command.call_args == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.LevelControl.Commands.MoveToLevelWithOnOff(
level=128,
transitionTime=0,
),
)