Skip to content

Commit ab44aa6

Browse files
author
Tom Lasswell
committed
fix: Clamp brightness conversion to prevent values exceeding HA range (#24)
The API can return brightness values outside the device's declared capability range (e.g., H6104 returns 254 on a 0-100 scale), causing HA to display brightness as 255%. Clamp _device_to_ha_brightness output to [0, 255] and _ha_to_device_brightness output to [device_min, device_max]. Also include capability parameters in diagnostics output for easier remote debugging, and add debug logging for API state transitions (power/brightness changes) to help diagnose stale-state issues.
1 parent 5c9f897 commit ab44aa6

4 files changed

Lines changed: 96 additions & 2 deletions

File tree

custom_components/govee/coordinator.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,22 @@ async def _fetch_device_state(
473473
# Clear them when device is turned off (no longer active).
474474
existing_state = self._states.get(device_id)
475475
if existing_state:
476+
# Log state transitions from API for debugging stale-state issues
477+
if existing_state.power_state != state.power_state:
478+
_LOGGER.debug(
479+
"API state change for %s: power %s -> %s (was source=%s)",
480+
device_id,
481+
existing_state.power_state,
482+
state.power_state,
483+
existing_state.source,
484+
)
485+
if existing_state.brightness != state.brightness:
486+
_LOGGER.debug(
487+
"API state change for %s: brightness %s -> %s",
488+
device_id,
489+
existing_state.brightness,
490+
state.brightness,
491+
)
476492
# Scenes persist on device across power cycles — always preserve
477493
if existing_state.active_scene:
478494
state.active_scene = existing_state.active_scene

custom_components/govee/diagnostics.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ async def async_get_config_entry_diagnostics(
4949
{
5050
"type": cap.type,
5151
"instance": cap.instance,
52+
"parameters": cap.parameters,
5253
}
5354
for cap in device.capabilities
5455
],

custom_components/govee/light.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,20 +261,22 @@ def effect(self) -> str | None:
261261
def _ha_to_device_brightness(self, ha_brightness: int) -> int:
262262
"""Convert HA brightness (0-255) to device range, respecting min."""
263263
ratio = ha_brightness / HA_BRIGHTNESS_MAX
264-
return int(
264+
result = int(
265265
self._brightness_min + ratio * (self._brightness_max - self._brightness_min)
266266
)
267+
return max(self._brightness_min, min(self._brightness_max, result))
267268

268269
def _device_to_ha_brightness(self, device_brightness: int) -> int:
269270
"""Convert device brightness to HA range (0-255), respecting min."""
270271
device_range = self._brightness_max - self._brightness_min
271272
if device_range <= 0:
272273
return 0
273-
return int(
274+
result = int(
274275
(device_brightness - self._brightness_min)
275276
/ device_range
276277
* HA_BRIGHTNESS_MAX
277278
)
279+
return max(0, min(HA_BRIGHTNESS_MAX, result))
278280

279281
async def async_turn_on(self, **kwargs: Any) -> None:
280282
"""Turn the light on with optional parameters."""

tests/test_light.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,12 @@
1515
SceneCommand,
1616
)
1717
from custom_components.govee.models.device import (
18+
CAPABILITY_COLOR_SETTING,
1819
CAPABILITY_ON_OFF,
1920
CAPABILITY_RANGE,
2021
INSTANCE_BRIGHTNESS,
22+
INSTANCE_COLOR_RGB,
23+
INSTANCE_COLOR_TEMP,
2124
INSTANCE_POWER,
2225
)
2326

@@ -449,3 +452,75 @@ def test_color_mode_always_in_supported_modes(
449452
mock_device_state.color_temp_kelvin = None
450453
mock_coordinator.get_state.return_value = mock_device_state
451454
assert entity.color_mode in entity.supported_color_modes
455+
456+
457+
class TestBrightnessConversion:
458+
"""Test brightness conversion between HA (0-255) and device scales."""
459+
460+
def test_device_to_ha_normal(self, mock_coordinator, mock_light_device):
461+
"""Test normal brightness conversion from device (0-100) to HA (0-255)."""
462+
entity = GoveeLightEntity(mock_coordinator, mock_light_device, enable_scenes=False)
463+
# Device range is (0, 100); device=50 → 50% → 127
464+
assert entity._device_to_ha_brightness(50) == 127
465+
assert entity._device_to_ha_brightness(100) == 255
466+
assert entity._device_to_ha_brightness(0) == 0
467+
468+
def test_device_to_ha_clamped_when_exceeding_range(
469+
self, mock_coordinator, mock_light_device
470+
):
471+
"""Test brightness is clamped to 255 when device value exceeds declared range.
472+
473+
Regression test for GitHub issue #24: H6104 returns brightness=254
474+
from API despite declaring range (0, 100), causing HA to show 255%.
475+
"""
476+
entity = GoveeLightEntity(mock_coordinator, mock_light_device, enable_scenes=False)
477+
# Device claims range (0, 100) but API returns 254 → unclamped would be 647
478+
assert entity._device_to_ha_brightness(254) == 255
479+
480+
def test_device_to_ha_clamped_at_zero(self, mock_coordinator, mock_light_device):
481+
"""Test brightness is clamped to 0 for negative device values."""
482+
entity = GoveeLightEntity(mock_coordinator, mock_light_device, enable_scenes=False)
483+
assert entity._device_to_ha_brightness(-10) == 0
484+
485+
def test_ha_to_device_clamped_to_device_range(
486+
self, mock_coordinator, mock_light_device
487+
):
488+
"""Test HA-to-device conversion is clamped to device range."""
489+
entity = GoveeLightEntity(mock_coordinator, mock_light_device, enable_scenes=False)
490+
# Device range is (0, 100)
491+
assert entity._ha_to_device_brightness(255) == 100
492+
assert entity._ha_to_device_brightness(0) == 0
493+
494+
def test_device_to_ha_with_254_range(self, mock_coordinator):
495+
"""Test brightness conversion with 0-254 device range."""
496+
device = GoveeDevice(
497+
device_id="TEST:ID:00:00:00:00:00:00",
498+
sku="H6104",
499+
name="Test Light",
500+
device_type="devices.types.light",
501+
capabilities=(
502+
GoveeCapability(
503+
type=CAPABILITY_ON_OFF, instance=INSTANCE_POWER, parameters={}
504+
),
505+
GoveeCapability(
506+
type=CAPABILITY_RANGE,
507+
instance=INSTANCE_BRIGHTNESS,
508+
parameters={"range": {"min": 0, "max": 254}},
509+
),
510+
GoveeCapability(
511+
type=CAPABILITY_COLOR_SETTING,
512+
instance=INSTANCE_COLOR_RGB,
513+
parameters={},
514+
),
515+
GoveeCapability(
516+
type=CAPABILITY_COLOR_SETTING,
517+
instance=INSTANCE_COLOR_TEMP,
518+
parameters={"range": {"min": 2000, "max": 9000}},
519+
),
520+
),
521+
is_group=False,
522+
)
523+
entity = GoveeLightEntity(mock_coordinator, device, enable_scenes=False)
524+
assert entity._device_to_ha_brightness(254) == 255
525+
assert entity._device_to_ha_brightness(127) == 127
526+
assert entity._device_to_ha_brightness(0) == 0

0 commit comments

Comments
 (0)