Compare commits

...

8 Commits

Author SHA1 Message Date
Paulus Schoutsen
d1e65fb535 Address comment 2026-04-06 20:28:26 +02:00
Paulus Schoutsen
fe964bc93f Add a fan test 2026-04-06 20:27:05 +02:00
Paulus Schoutsen
48fdc5e1b7 Add more tests 2026-04-06 20:21:56 +02:00
Paulus Schoutsen
1f1fe1b7ce Address comments 2026-04-05 23:04:16 +02:00
Paulus Schoutsen
48ee57c234 Address review feedback
- Remove None fallbacks for min/max in hood fan init (values are never None)
- Remove runtime min/max update in _update_status (values don't change)
- Add deprecation with repair issue for fan_speed number entity on
  hood/microwave_oven devices, breaking in 2026.11.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 20:58:41 +02:00
Paulus Schoutsen
f8ea687aa4 Reduce diff 2026-04-05 20:41:52 +02:00
Paulus Schoutsen
4b77b00a95 Address comments 2026-04-05 20:41:52 +02:00
Paulus Schoutsen
7119c5da3a represent ThinQ hoods as fans instead of number entities 2026-04-05 20:41:36 +02:00
11 changed files with 586 additions and 14 deletions

View File

@@ -20,6 +20,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
ordered_list_item_to_percentage,
percentage_to_ordered_list_item,
percentage_to_ranged_value,
ranged_value_to_percentage,
)
from . import ThinqConfigEntry
@@ -35,6 +37,11 @@ class ThinQFanEntityDescription(FanEntityDescription):
preset_modes: list[str] | None = None
HOOD_FAN_DESC = FanEntityDescription(
key=ThinQProperty.FAN_SPEED,
translation_key=ThinQProperty.FAN_SPEED,
)
DEVICE_TYPE_FAN_MAP: dict[DeviceType, tuple[ThinQFanEntityDescription, ...]] = {
DeviceType.CEILING_FAN: (
ThinQFanEntityDescription(
@@ -54,6 +61,8 @@ DEVICE_TYPE_FAN_MAP: dict[DeviceType, tuple[ThinQFanEntityDescription, ...]] = {
),
}
HOOD_DEVICE_TYPES: set[DeviceType] = {DeviceType.HOOD, DeviceType.MICROWAVE_OVEN}
ORDERED_NAMED_FAN_SPEEDS = ["low", "mid", "high", "turbo", "power"]
_LOGGER = logging.getLogger(__name__)
@@ -65,11 +74,20 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up an entry for fan platform."""
entities: list[ThinQFanEntity] = []
entities: list[ThinQFanEntity | ThinQHoodFanEntity] = []
for coordinator in entry.runtime_data.coordinators.values():
if (
descriptions := DEVICE_TYPE_FAN_MAP.get(coordinator.api.device.device_type)
) is not None:
device_type = coordinator.api.device.device_type
# Handle hood-type devices with numeric fan speed
if device_type in HOOD_DEVICE_TYPES:
entities.extend(
ThinQHoodFanEntity(coordinator, HOOD_FAN_DESC, property_id)
for property_id in coordinator.api.get_active_idx(
HOOD_FAN_DESC.key, ActiveMode.READ_WRITE
)
)
# Handle other fan devices with named speeds
elif (descriptions := DEVICE_TYPE_FAN_MAP.get(device_type)) is not None:
for description in descriptions:
entities.extend(
ThinQFanEntity(coordinator, description, property_id)
@@ -212,3 +230,112 @@ class ThinQFanEntity(ThinQEntity, FanEntity):
await self.async_call_api(
self.coordinator.api.async_turn_off(self._operation_id)
)
class ThinQHoodFanEntity(ThinQEntity, FanEntity):
"""Represent a thinq hood fan platform.
Hood fans use numeric speed values (e.g., 0=off, 1=low, 2=high)
rather than named speed presets.
"""
_attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
)
def __init__(
self,
coordinator: DeviceDataUpdateCoordinator,
entity_description: FanEntityDescription,
property_id: str,
) -> None:
"""Initialize hood fan platform."""
super().__init__(coordinator, entity_description, property_id)
self._min_speed: int = int(self.data.min)
self._max_speed: int = int(self.data.max)
# Speed count is the number of non-zero speeds
self._attr_speed_count = self._max_speed - self._min_speed
@property
def _speed_range(self) -> tuple[int, int]:
"""Return the speed range excluding off (0)."""
return (self._min_speed + 1, self._max_speed)
def _update_status(self) -> None:
"""Update status itself."""
super()._update_status()
# Get current speed value
current_speed = self.data.value
if current_speed is None or current_speed == self._min_speed:
self._attr_is_on = False
self._attr_percentage = 0
else:
self._attr_is_on = True
self._attr_percentage = ranged_value_to_percentage(
self._speed_range, current_speed
)
_LOGGER.debug(
"[%s:%s] update status: is_on=%s, percentage=%s, speed=%s, min=%s, max=%s",
self.coordinator.device_name,
self.property_id,
self.is_on,
self.percentage,
current_speed,
self._min_speed,
self._max_speed,
)
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed percentage of the fan."""
if percentage == 0:
await self.async_turn_off()
return
speed = round(percentage_to_ranged_value(self._speed_range, percentage))
_LOGGER.debug(
"[%s:%s] async_set_percentage: percentage=%s -> speed=%s",
self.coordinator.device_name,
self.property_id,
percentage,
speed,
)
await self.async_call_api(self.coordinator.api.post(self.property_id, speed))
async def async_turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs: Any,
) -> None:
"""Turn on the fan."""
if percentage is not None:
await self.async_set_percentage(percentage)
return
# Default to lowest non-zero speed
speed = self._min_speed + 1
_LOGGER.debug(
"[%s:%s] async_turn_on: speed=%s",
self.coordinator.device_name,
self.property_id,
speed,
)
await self.async_call_api(self.coordinator.api.post(self.property_id, speed))
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the fan off."""
_LOGGER.debug(
"[%s:%s] async_turn_off",
self.coordinator.device_name,
self.property_id,
)
await self.async_call_api(
self.coordinator.api.post(self.property_id, self._min_speed)
)

View File

@@ -8,23 +8,33 @@ from thinqconnect import DeviceType
from thinqconnect.devices.const import Property as ThinQProperty
from thinqconnect.integration import ActiveMode, TimerProperty
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
NumberMode,
)
from homeassistant.components.script import scripts_with_entity
from homeassistant.const import PERCENTAGE, UnitOfTemperature, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from . import ThinqConfigEntry
from .const import DOMAIN
from .entity import ThinQEntity
NUMBER_DESC: dict[ThinQProperty, NumberEntityDescription] = {
ThinQProperty.FAN_SPEED: NumberEntityDescription(
key=ThinQProperty.FAN_SPEED,
translation_key=ThinQProperty.FAN_SPEED,
entity_registry_enabled_default=False,
),
ThinQProperty.LAMP_BRIGHTNESS: NumberEntityDescription(
key=ThinQProperty.LAMP_BRIGHTNESS,
@@ -128,9 +138,71 @@ DEVICE_TYPE_NUMBER_MAP: dict[DeviceType, tuple[NumberEntityDescription, ...]] =
),
}
DEPRECATED_FAN_SPEED_DEVICE_TYPES: set[DeviceType] = {
DeviceType.HOOD,
DeviceType.MICROWAVE_OVEN,
}
_LOGGER = logging.getLogger(__name__)
def _check_deprecated_fan_speed_entity(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
unique_id: str,
) -> bool:
"""Check if a deprecated fan speed number entity should be created.
Returns True if the entity exists and is enabled (should still be created).
"""
if not (
entity_id := entity_registry.async_get_entity_id("number", DOMAIN, unique_id)
):
return False
entity_entry = entity_registry.async_get(entity_id)
if not entity_entry:
return False
if entity_entry.disabled:
entity_registry.async_remove(entity_id)
async_delete_issue(hass, DOMAIN, f"deprecated_fan_speed_number_{entity_id}")
return False
translation_key = "deprecated_fan_speed_number"
placeholders: dict[str, str] = {
"entity_id": entity_id,
"entity_name": entity_entry.name or entity_entry.original_name or "Unknown",
}
automation_entities = automations_with_entity(hass, entity_id)
script_entities = scripts_with_entity(hass, entity_id)
if automation_entities or script_entities:
translation_key = f"{translation_key}_scripts"
placeholders["items"] = "\n".join(
f"- [{item.original_name}](/config/{integration}/edit/{item.unique_id})"
for integration, entities in (
("automation", automation_entities),
("script", script_entities),
)
for eid in entities
if (item := entity_registry.async_get(eid))
)
async_create_issue(
hass,
DOMAIN,
f"deprecated_fan_speed_number_{entity_id}",
breaks_in_ha_version="2026.11.0",
is_fixable=True,
severity=IssueSeverity.WARNING,
translation_key=translation_key,
translation_placeholders=placeholders,
data={"entity_id": entity_id, **placeholders},
)
return True
async def async_setup_entry(
hass: HomeAssistant,
entry: ThinqConfigEntry,
@@ -138,18 +210,27 @@ async def async_setup_entry(
) -> None:
"""Set up an entry for number platform."""
entities: list[ThinQNumberEntity] = []
entity_registry = er.async_get(hass)
for coordinator in entry.runtime_data.coordinators.values():
if (
descriptions := DEVICE_TYPE_NUMBER_MAP.get(
coordinator.api.device.device_type
)
) is not None:
for description in descriptions:
entities.extend(
descriptions = DEVICE_TYPE_NUMBER_MAP.get(coordinator.api.device.device_type)
if descriptions is None:
continue
for description in descriptions:
for property_id in coordinator.api.get_active_idx(
description.key, ActiveMode.READ_WRITE
):
if (
description.key == ThinQProperty.FAN_SPEED
and coordinator.api.device.device_type
in DEPRECATED_FAN_SPEED_DEVICE_TYPES
):
unique_id = f"{coordinator.unique_id}_{property_id}"
if not _check_deprecated_fan_speed_entity(
hass, entity_registry, unique_id
):
continue
entities.append(
ThinQNumberEntity(coordinator, description, property_id)
for property_id in coordinator.api.get_active_idx(
description.key, ActiveMode.READ_WRITE
)
)
if entities:

View File

@@ -0,0 +1,55 @@
"""Repairs for LG ThinQ integration."""
from __future__ import annotations
import voluptuous as vol
from homeassistant import data_entry_flow
from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
class DeprecatedFanSpeedRepairFlow(RepairsFlow):
"""Handler for deprecated fan speed number entity fixing flow."""
def __init__(self, data: dict[str, str]) -> None:
"""Initialize."""
self.entity_id = data["entity_id"]
self._placeholders = data
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_confirm()
async def async_step_confirm(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the confirm step of a fix flow."""
if user_input is not None:
entity_registry = er.async_get(self.hass)
if entity_registry.async_get(self.entity_id):
entity_registry.async_update_entity(
self.entity_id,
disabled_by=er.RegistryEntryDisabler.USER,
)
return self.async_create_entry(data={})
return self.async_show_form(
step_id="confirm",
data_schema=vol.Schema({}),
description_placeholders=self._placeholders,
)
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
data: dict[str, str],
) -> RepairsFlow:
"""Create flow."""
if issue_id.startswith("deprecated_fan_speed_number_"):
return DeprecatedFanSpeedRepairFlow(data)
return ConfirmRepairFlow()

View File

@@ -199,6 +199,11 @@
}
}
},
"fan": {
"fan_speed": {
"name": "Hood"
}
},
"humidifier": {
"dehumidifier": {
"state_attributes": {
@@ -1154,5 +1159,29 @@
"failed_to_connect_mqtt": {
"message": "Failed to connect MQTT: {error}"
}
},
"issues": {
"deprecated_fan_speed_number": {
"fix_flow": {
"step": {
"confirm": {
"description": "The number entity {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a fan entity.\n\nPlease update your dashboards and templates to use the new fan entity.\n\nClick **Submit** to disable the number entity and fix this issue.",
"title": "Fan speed number entity deprecated"
}
}
},
"title": "[%key:component::lg_thinq::issues::deprecated_fan_speed_number::fix_flow::step::confirm::title%]"
},
"deprecated_fan_speed_number_scripts": {
"fix_flow": {
"step": {
"confirm": {
"description": "The number entity {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a fan entity.\n\nThe entity was used in the following automations or scripts:\n{items}\n\nPlease update the above automations or scripts to use the new fan entity.\n\nClick **Submit** to disable the number entity and fix this issue.",
"title": "[%key:component::lg_thinq::issues::deprecated_fan_speed_number::fix_flow::step::confirm::title%]"
}
}
},
"title": "[%key:component::lg_thinq::issues::deprecated_fan_speed_number::fix_flow::step::confirm::title%]"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"deviceId": "MW2-2E247F93-B570-46A6-B827-920E9E10F966",
"deviceInfo": {
"deviceType": "DEVICE_HOOD",
"modelName": "HOOD_TEST",
"alias": "Test hood",
"reportable": true
}
}

View File

@@ -0,0 +1,4 @@
{
"resultCode": "0000",
"result": {}
}

View File

@@ -0,0 +1,40 @@
{
"property": {
"ventilation": {
"fanSpeed": {
"mode": ["r", "w"],
"type": "range",
"value": {
"r": {
"max": 5,
"min": 0,
"step": 1
},
"w": {
"max": 5,
"min": 0,
"step": 1
}
}
}
},
"lamp": {
"lampBrightness": {
"mode": ["r", "w"],
"type": "range",
"value": {
"r": {
"max": 2,
"min": 0,
"step": 1
},
"w": {
"max": 2,
"min": 0,
"step": 1
}
}
}
}
}
}

View File

@@ -0,0 +1,8 @@
{
"ventilation": {
"fanSpeed": 1
},
"lamp": {
"lampBrightness": 2
}
}

View File

@@ -0,0 +1,58 @@
# serializer version: 1
# name: test_fan_entities[hood][fan.test_hood_hood-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'preset_modes': None,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'fan',
'entity_category': None,
'entity_id': 'fan.test_hood_hood',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Hood',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Hood',
'platform': 'lg_thinq',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <FanEntityFeature: 49>,
'translation_key': <Property.FAN_SPEED: 'fan_speed'>,
'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_fan_speed',
'unit_of_measurement': None,
})
# ---
# name: test_fan_entities[hood][fan.test_hood_hood-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test hood Hood',
'percentage': 20,
'percentage_step': 20.0,
'preset_mode': None,
'preset_modes': None,
'supported_features': <FanEntityFeature: 49>,
}),
'context': <ANY>,
'entity_id': 'fan.test_hood_hood',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---

View File

@@ -0,0 +1,78 @@
"""Tests for the LG ThinQ fan platform."""
from unittest.mock import AsyncMock, patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.fan import (
ATTR_PERCENTAGE,
DOMAIN as FAN_DOMAIN,
SERVICE_SET_PERCENTAGE,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
HOOD_FAN_ENTITY_ID = "fan.test_hood_hood"
@pytest.mark.parametrize("device_fixture", ["hood"])
async def test_fan_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
devices: AsyncMock,
mock_thinq_api: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test all entities."""
with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.FAN]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
("device_fixture", "service", "service_data", "expected_value"),
[
("hood", SERVICE_TURN_ON, {}, 1),
("hood", SERVICE_TURN_OFF, {}, 0),
("hood", SERVICE_SET_PERCENTAGE, {ATTR_PERCENTAGE: 100}, 5),
("hood", SERVICE_TURN_ON, {ATTR_PERCENTAGE: 60}, 3),
("hood", SERVICE_SET_PERCENTAGE, {ATTR_PERCENTAGE: 0}, 0),
],
)
async def test_fan_service_calls(
hass: HomeAssistant,
devices: AsyncMock,
mock_thinq_api: AsyncMock,
mock_config_entry: MockConfigEntry,
service: str,
service_data: dict,
expected_value: int,
) -> None:
"""Test hood fan service calls post the correct speed values."""
with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.FAN]):
await setup_integration(hass, mock_config_entry)
coordinator = next(iter(mock_config_entry.runtime_data.coordinators.values()))
coordinator.api.post = AsyncMock()
await hass.services.async_call(
FAN_DOMAIN,
service,
{ATTR_ENTITY_ID: HOOD_FAN_ENTITY_ID, **service_data},
blocking=True,
)
coordinator.api.post.assert_awaited_once_with("fan_speed", expected_value)

View File

@@ -0,0 +1,83 @@
"""Tests for the LG ThinQ repairs."""
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.lg_thinq.const import DOMAIN
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er, issue_registry as ir
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
from tests.components.repairs import (
async_process_repairs_platforms,
process_repair_fix_flow,
start_repair_fix_flow,
)
from tests.typing import ClientSessionGenerator
@pytest.mark.parametrize("device_fixture", ["hood"])
async def test_deprecated_fan_speed_number_repair_flow(
hass: HomeAssistant,
devices: AsyncMock,
mock_thinq_api: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
issue_registry: ir.IssueRegistry,
hass_client: ClientSessionGenerator,
) -> None:
"""Test the deprecated fan speed number entity repair flow."""
assert await async_setup_component(hass, "repairs", {})
# Add config entry first so we can pre-create the deprecated entity
mock_config_entry.add_to_hass(hass)
# Pre-create the deprecated number entity so it's found during setup
entity_registry.async_get_or_create(
"number",
DOMAIN,
"MW2-2E247F93-B570-46A6-B827-920E9E10F966_fan_speed",
suggested_object_id="test_hood_fan_speed",
original_name="Fan speed",
config_entry=mock_config_entry,
)
with patch(
"homeassistant.components.lg_thinq.PLATFORMS",
[Platform.FAN, Platform.NUMBER],
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entity_id = "number.test_hood_fan_speed"
# Verify the issue was created
issue = issue_registry.async_get_issue(
domain=DOMAIN,
issue_id=f"deprecated_fan_speed_number_{entity_id}",
)
assert issue is not None
assert issue.is_fixable is True
# Start the repair flow
await async_process_repairs_platforms(hass)
client = await hass_client()
result = await start_repair_fix_flow(
client, DOMAIN, f"deprecated_fan_speed_number_{entity_id}"
)
flow_id = result["flow_id"]
assert result["step_id"] == "confirm"
# Submit the repair flow
result = await process_repair_fix_flow(client, flow_id)
assert result["type"] == "create_entry"
# Verify the entity was disabled
entity = entity_registry.async_get(entity_id)
assert entity is not None
assert entity.disabled is True
assert entity.disabled_by is er.RegistryEntryDisabler.USER