diff --git a/homeassistant/components/matter/fan.py b/homeassistant/components/matter/fan.py index 4981f7aadc4c..5439b1105fbd 100644 --- a/homeassistant/components/matter/fan.py +++ b/homeassistant/components/matter/fan.py @@ -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. diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index 97d601c835b1..206db521b9a4 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -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) ) ) diff --git a/tests/components/matter/test_fan.py b/tests/components/matter/test_fan.py index 67e5f9614230..ff471255e2f0 100644 --- a/tests/components/matter/test_fan.py +++ b/tests/components/matter/test_fan.py @@ -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 diff --git a/tests/components/matter/test_light.py b/tests/components/matter/test_light.py index 4b67e8c7b8c7..0275cd53019f 100644 --- a/tests/components/matter/test_light.py +++ b/tests/components/matter/test_light.py @@ -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, + ), + )