Palazzetti integration: Add support for additional fans (#135377)

* Add support for second and third fans

* Update test mock and snapshot

* Test coverage and error message

* Rename fans left and right instead of 2 and 3
This commit is contained in:
dotvav 2025-01-13 17:17:46 +01:00 committed by GitHub
parent 1fa3d90d73
commit 153496b5f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 238 additions and 9 deletions

View File

@ -122,7 +122,7 @@ class PalazzettiClimateEntity(PalazzettiEntity, ClimateEntity):
@property @property
def fan_mode(self) -> str | None: def fan_mode(self) -> str | None:
"""Return the fan mode.""" """Return the fan mode."""
api_state = self.coordinator.client.fan_speed api_state = self.coordinator.client.current_fan_speed()
return FAN_MODES[api_state] return FAN_MODES[api_state]
async def async_set_fan_mode(self, fan_mode: str) -> None: async def async_set_fan_mode(self, fan_mode: str) -> None:

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from pypalazzetti.exceptions import CommunicationError, ValidationError from pypalazzetti.exceptions import CommunicationError, ValidationError
from pypalazzetti.fan import FanType
from homeassistant.components.number import NumberDeviceClass, NumberEntity from homeassistant.components.number import NumberDeviceClass, NumberEntity
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -21,7 +22,18 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Palazzetti number platform.""" """Set up Palazzetti number platform."""
async_add_entities([PalazzettiCombustionPowerEntity(config_entry.runtime_data)])
entities: list[PalazzettiEntity] = [
PalazzettiCombustionPowerEntity(config_entry.runtime_data)
]
if config_entry.runtime_data.client.has_fan(FanType.LEFT):
entities.append(PalazzettiFanEntity(config_entry.runtime_data, FanType.LEFT))
if config_entry.runtime_data.client.has_fan(FanType.RIGHT):
entities.append(PalazzettiFanEntity(config_entry.runtime_data, FanType.RIGHT))
async_add_entities(entities)
class PalazzettiCombustionPowerEntity(PalazzettiEntity, NumberEntity): class PalazzettiCombustionPowerEntity(PalazzettiEntity, NumberEntity):
@ -64,3 +76,49 @@ class PalazzettiCombustionPowerEntity(PalazzettiEntity, NumberEntity):
) from err ) from err
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()
class PalazzettiFanEntity(PalazzettiEntity, NumberEntity):
"""Representation of Palazzetti number entity for Combustion power."""
_attr_device_class = NumberDeviceClass.WIND_SPEED
_attr_native_step = 1
def __init__(
self, coordinator: PalazzettiDataUpdateCoordinator, fan: FanType
) -> None:
"""Initialize the Palazzetti number entity."""
super().__init__(coordinator)
self.fan = fan
self._attr_translation_key = f"fan_{str.lower(fan.name)}_speed"
self._attr_native_min_value = coordinator.client.min_fan_speed(fan)
self._attr_native_max_value = coordinator.client.max_fan_speed(fan)
self._attr_unique_id = (
f"{coordinator.config_entry.unique_id}-fan_{str.lower(fan.name)}_speed"
)
@property
def native_value(self) -> float:
"""Return the state of the setting entity."""
return self.coordinator.client.current_fan_speed(self.fan)
async def async_set_native_value(self, value: float) -> None:
"""Update the setting."""
try:
await self.coordinator.client.set_fan_speed(int(value), self.fan)
except CommunicationError as err:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="cannot_connect"
) from err
except ValidationError as err:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_fan_speed",
translation_placeholders={
"name": str.lower(self.fan.name),
"value": str(value),
},
) from err
await self.coordinator.async_request_refresh()

View File

@ -27,6 +27,9 @@
"invalid_fan_mode": { "invalid_fan_mode": {
"message": "Fan mode {value} is invalid." "message": "Fan mode {value} is invalid."
}, },
"invalid_fan_speed": {
"message": "Fan {name} speed {value} is invalid."
},
"invalid_target_temperature": { "invalid_target_temperature": {
"message": "Target temperature {value} is invalid." "message": "Target temperature {value} is invalid."
}, },
@ -59,6 +62,12 @@
"number": { "number": {
"combustion_power": { "combustion_power": {
"name": "Combustion power" "name": "Combustion power"
},
"fan_left_speed": {
"name": "Left fan speed"
},
"fan_right_speed": {
"name": "Right fan speed"
} }
}, },
"sensor": { "sensor": {

View File

@ -79,7 +79,12 @@ def mock_palazzetti_client() -> Generator[AsyncMock]:
mock_client.target_temperature_max = 50 mock_client.target_temperature_max = 50
mock_client.pellet_quantity = 1248 mock_client.pellet_quantity = 1248
mock_client.pellet_level = 0 mock_client.pellet_level = 0
mock_client.has_second_fan = True
mock_client.has_second_fan = False
mock_client.fan_speed = 3 mock_client.fan_speed = 3
mock_client.current_fan_speed.return_value = 3
mock_client.min_fan_speed.return_value = 0
mock_client.max_fan_speed.return_value = 5
mock_client.connect.return_value = True mock_client.connect.return_value = True
mock_client.update_state.return_value = True mock_client.update_state.return_value = True
mock_client.set_on.return_value = True mock_client.set_on.return_value = True

View File

@ -55,3 +55,115 @@
'state': '3', 'state': '3',
}) })
# --- # ---
# name: test_all_entities[number.stove_left_fan_speed-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'max': 5,
'min': 0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': None,
'entity_id': 'number.stove_left_fan_speed',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <NumberDeviceClass.WIND_SPEED: 'wind_speed'>,
'original_icon': None,
'original_name': 'Left fan speed',
'platform': 'palazzetti',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'fan_left_speed',
'unique_id': '11:22:33:44:55:66-fan_left_speed',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[number.stove_left_fan_speed-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'wind_speed',
'friendly_name': 'Stove Left fan speed',
'max': 5,
'min': 0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'context': <ANY>,
'entity_id': 'number.stove_left_fan_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '3',
})
# ---
# name: test_all_entities[number.stove_right_fan_speed-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'max': 5,
'min': 0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': None,
'entity_id': 'number.stove_right_fan_speed',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <NumberDeviceClass.WIND_SPEED: 'wind_speed'>,
'original_icon': None,
'original_name': 'Right fan speed',
'platform': 'palazzetti',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'fan_right_speed',
'unique_id': '11:22:33:44:55:66-fan_right_speed',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[number.stove_right_fan_speed-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'wind_speed',
'friendly_name': 'Stove Right fan speed',
'max': 5,
'min': 0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'context': <ANY>,
'entity_id': 'number.stove_right_fan_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '3',
})
# ---

View File

@ -3,6 +3,7 @@
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from pypalazzetti.exceptions import CommunicationError, ValidationError from pypalazzetti.exceptions import CommunicationError, ValidationError
from pypalazzetti.fan import FanType
import pytest import pytest
from syrupy import SnapshotAssertion from syrupy import SnapshotAssertion
@ -16,7 +17,8 @@ from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform from tests.common import MockConfigEntry, snapshot_platform
ENTITY_ID = "number.stove_combustion_power" POWER_ENTITY_ID = "number.stove_combustion_power"
FAN_ENTITY_ID = "number.stove_left_fan_speed"
async def test_all_entities( async def test_all_entities(
@ -33,7 +35,7 @@ async def test_all_entities(
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_async_set_data( async def test_async_set_data_power(
hass: HomeAssistant, hass: HomeAssistant,
mock_palazzetti_client: AsyncMock, mock_palazzetti_client: AsyncMock,
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
@ -45,7 +47,7 @@ async def test_async_set_data(
await hass.services.async_call( await hass.services.async_call(
NUMBER_DOMAIN, NUMBER_DOMAIN,
SERVICE_SET_VALUE, SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: ENTITY_ID, "value": 1}, {ATTR_ENTITY_ID: POWER_ENTITY_ID, "value": 1},
blocking=True, blocking=True,
) )
mock_palazzetti_client.set_power_mode.assert_called_once_with(1) mock_palazzetti_client.set_power_mode.assert_called_once_with(1)
@ -53,20 +55,63 @@ async def test_async_set_data(
# Set value: Error # Set value: Error
mock_palazzetti_client.set_power_mode.side_effect = CommunicationError() mock_palazzetti_client.set_power_mode.side_effect = CommunicationError()
with pytest.raises(HomeAssistantError): message = "Could not connect to the device"
with pytest.raises(HomeAssistantError, match=message):
await hass.services.async_call( await hass.services.async_call(
NUMBER_DOMAIN, NUMBER_DOMAIN,
SERVICE_SET_VALUE, SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: ENTITY_ID, "value": 1}, {ATTR_ENTITY_ID: POWER_ENTITY_ID, "value": 1},
blocking=True, blocking=True,
) )
mock_palazzetti_client.set_power_mode.reset_mock() mock_palazzetti_client.set_power_mode.reset_mock()
mock_palazzetti_client.set_power_mode.side_effect = ValidationError() mock_palazzetti_client.set_power_mode.side_effect = ValidationError()
with pytest.raises(ServiceValidationError): message = "Combustion power 1.0 is invalid"
with pytest.raises(ServiceValidationError, match=message):
await hass.services.async_call( await hass.services.async_call(
NUMBER_DOMAIN, NUMBER_DOMAIN,
SERVICE_SET_VALUE, SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: ENTITY_ID, "value": 1}, {ATTR_ENTITY_ID: POWER_ENTITY_ID, "value": 1},
blocking=True,
)
async def test_async_set_data_fan(
hass: HomeAssistant,
mock_palazzetti_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setting number data via service call."""
await setup_integration(hass, mock_config_entry)
# Set value: Success
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: FAN_ENTITY_ID, "value": 1},
blocking=True,
)
mock_palazzetti_client.set_fan_speed.assert_called_once_with(1, FanType.LEFT)
mock_palazzetti_client.set_on.reset_mock()
# Set value: Error
mock_palazzetti_client.set_fan_speed.side_effect = CommunicationError()
message = "Could not connect to the device"
with pytest.raises(HomeAssistantError, match=message):
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: FAN_ENTITY_ID, "value": 1},
blocking=True,
)
mock_palazzetti_client.set_on.reset_mock()
mock_palazzetti_client.set_fan_speed.side_effect = ValidationError()
message = "Fan left speed 1.0 is invalid"
with pytest.raises(ServiceValidationError, match=message):
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: FAN_ENTITY_ID, "value": 1},
blocking=True, blocking=True,
) )