Skip to content

Commit 04216db

Browse files
LucaTheHackerLuca Dametto
authored andcommitted
Prana Integration - Refactoring
1 parent e889541 commit 04216db

File tree

10 files changed

+210
-752
lines changed

10 files changed

+210
-752
lines changed

homeassistant/components/prana/fan.py

Lines changed: 65 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -26,55 +26,59 @@
2626

2727
PARALLEL_UPDATES = 1
2828

29-
# The Prana device API expects fan speed values in scaled units (tenths of a speed step)
30-
# rather than the raw step value used internally by this integration. This factor is
31-
# applied when sending speeds to the API to match its expected units.
29+
# The device API expects speeds in tenths of a step.
3230
PRANA_SPEED_MULTIPLIER = 10
3331

32+
PRESET_AUTO = "auto"
33+
PRESET_AUTO_PLUS = "auto_plus"
34+
PRESET_NIGHT = "night"
35+
PRESET_BOOST = "boost"
36+
37+
PRESET_MODES = [
38+
PRESET_AUTO,
39+
PRESET_AUTO_PLUS,
40+
PRESET_NIGHT,
41+
PRESET_BOOST,
42+
]
43+
3444

3545
class PranaFanType(StrEnum):
36-
"""Enumerates Prana fan types exposed by the device API."""
46+
"""Target fan on the Prana API."""
3747

3848
SUPPLY = "supply"
3949
EXTRACT = "extract"
40-
BOUNDED = "bounded"
50+
VENTILATION = "bounded"
4151

4252

4353
@dataclass(frozen=True, kw_only=True)
4454
class PranaFanEntityDescription(FanEntityDescription):
4555
"""Description of a Prana fan entity."""
4656

47-
key: PranaFanType
57+
key: str
58+
api_target: PranaFanType
4859
value_fn: Callable[[PranaCoordinator], FanState]
49-
speed_range: Callable[[PranaCoordinator], tuple[int, int]]
5060

5161

52-
ENTITIES: tuple[PranaFanEntityDescription, ...] = (
62+
VENTILATION_DESCRIPTION = PranaFanEntityDescription(
63+
key="ventilation",
64+
translation_key="ventilation",
65+
api_target=PranaFanType.VENTILATION,
66+
name=None,
67+
value_fn=lambda coord: coord.data.bounded,
68+
)
69+
70+
SPLIT_DESCRIPTIONS: tuple[PranaFanEntityDescription, ...] = (
5371
PranaFanEntityDescription(
54-
key=PranaFanType.SUPPLY,
72+
key="supply",
5573
translation_key="supply",
56-
value_fn=lambda coord: (
57-
coord.data.supply if not coord.data.bound else coord.data.bounded
58-
),
59-
speed_range=lambda coord: (
60-
1,
61-
coord.data.supply.max_speed
62-
if not coord.data.bound
63-
else coord.data.bounded.max_speed,
64-
),
74+
api_target=PranaFanType.SUPPLY,
75+
value_fn=lambda coord: coord.data.supply,
6576
),
6677
PranaFanEntityDescription(
67-
key=PranaFanType.EXTRACT,
78+
key="extract",
6879
translation_key="extract",
69-
value_fn=lambda coord: (
70-
coord.data.extract if not coord.data.bound else coord.data.bounded
71-
),
72-
speed_range=lambda coord: (
73-
1,
74-
coord.data.extract.max_speed
75-
if not coord.data.bound
76-
else coord.data.bounded.max_speed,
77-
),
80+
api_target=PranaFanType.EXTRACT,
81+
value_fn=lambda coord: coord.data.extract,
7882
),
7983
)
8084

@@ -85,17 +89,24 @@ async def async_setup_entry(
8589
async_add_entities: AddConfigEntryEntitiesCallback,
8690
) -> None:
8791
"""Set up Prana fan entities from a config entry."""
92+
coordinator = entry.runtime_data
93+
# Whether supply and extract fans are physically linked is a device
94+
# capability reported in state: linked models expose one ventilation fan,
95+
# split models expose independent supply and extract fans.
96+
if coordinator.data.bound:
97+
descriptions: tuple[PranaFanEntityDescription, ...] = (VENTILATION_DESCRIPTION,)
98+
else:
99+
descriptions = SPLIT_DESCRIPTIONS
88100
async_add_entities(
89-
PranaFan(entry.runtime_data, entity_description)
90-
for entity_description in ENTITIES
101+
PranaFan(coordinator, description) for description in descriptions
91102
)
92103

93104

94105
class PranaFan(PranaBaseEntity, FanEntity):
95106
"""Representation of a Prana fan entity."""
96107

97108
entity_description: PranaFanEntityDescription
98-
_attr_preset_modes = ["night", "boost"]
109+
_attr_preset_modes = PRESET_MODES
99110
_attr_supported_features = (
100111
FanEntityFeature.SET_SPEED
101112
| FanEntityFeature.TURN_ON
@@ -104,43 +115,29 @@ class PranaFan(PranaBaseEntity, FanEntity):
104115
)
105116

106117
@property
107-
def _api_target_key(self) -> str:
108-
"""Return the correct target key for API commands based on bounded state."""
109-
# If the device is in bound mode, both supply and extract fans control the same bounded fan speeds.
110-
if self.coordinator.data.bound:
111-
return PranaFanType.BOUNDED
112-
# Otherwise, return the specific fan type (supply or extract) for API commands.
113-
return self.entity_description.key
118+
def _speed_range(self) -> tuple[int, int]:
119+
return (1, self.entity_description.value_fn(self.coordinator).max_speed)
114120

115121
@property
116122
def speed_count(self) -> int:
117123
"""Return the number of speeds the fan supports."""
118-
return int_states_in_range(
119-
self.entity_description.speed_range(self.coordinator)
120-
)
124+
return int_states_in_range(self._speed_range)
121125

122126
@property
123127
def percentage(self) -> int | None:
124128
"""Return the current fan speed percentage."""
125129
current_speed = self.entity_description.value_fn(self.coordinator).speed
126-
return ranged_value_to_percentage(
127-
self.entity_description.speed_range(self.coordinator), current_speed
128-
)
130+
return ranged_value_to_percentage(self._speed_range, current_speed)
129131

130132
async def async_set_percentage(self, percentage: int) -> None:
131-
"""Set fan speed (0-100%) by converting to device-specific speed steps."""
133+
"""Set fan speed (0-100%) by converting to device speed steps."""
132134
if percentage == 0:
133135
await self.async_turn_off()
134136
return
135137
await self.coordinator.api_client.set_speed(
136-
math.ceil(
137-
percentage_to_ranged_value(
138-
self.entity_description.speed_range(self.coordinator),
139-
percentage,
140-
)
141-
)
138+
math.ceil(percentage_to_ranged_value(self._speed_range, percentage))
142139
* PRANA_SPEED_MULTIPLIER,
143-
self._api_target_key,
140+
self.entity_description.api_target,
144141
)
145142
await self.coordinator.async_refresh()
146143

@@ -160,7 +157,9 @@ async def async_turn_on(
160157
await self.async_turn_off()
161158
return
162159

163-
await self.coordinator.api_client.set_speed_is_on(True, self._api_target_key)
160+
await self.coordinator.api_client.set_speed_is_on(
161+
True, self.entity_description.api_target
162+
)
164163
if percentage is not None:
165164
await self.async_set_percentage(percentage)
166165
if preset_mode is not None:
@@ -170,19 +169,25 @@ async def async_turn_on(
170169

171170
async def async_turn_off(self, **kwargs: Any) -> None:
172171
"""Turn the fan off."""
173-
await self.coordinator.api_client.set_speed_is_on(False, self._api_target_key)
172+
await self.coordinator.api_client.set_speed_is_on(
173+
False, self.entity_description.api_target
174+
)
174175
await self.coordinator.async_refresh()
175176

176177
async def async_set_preset_mode(self, preset_mode: str) -> None:
177-
"""Set the preset mode (e.g., night or boost)."""
178+
"""Activate a preset mode on the device.
179+
180+
Prana operating modes (auto/auto_plus/night/boost/winter) are
181+
mutually exclusive on the device: activating one clears the rest.
182+
"""
178183
await self.coordinator.api_client.set_switch(preset_mode, True)
179184
await self.coordinator.async_refresh()
180185

181186
@property
182187
def preset_mode(self) -> str | None:
183-
"""Return the current preset mode."""
184-
if self.coordinator.data.night:
185-
return "night"
186-
if self.coordinator.data.boost:
187-
return "boost"
188+
"""Return the current preset mode, if any."""
189+
data = self.coordinator.data
190+
for preset in PRESET_MODES:
191+
if getattr(data, preset, False):
192+
return preset
188193
return None

homeassistant/components/prana/icons.json

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
11
{
22
"entity": {
33
"fan": {
4+
"ventilation": {
5+
"default": "mdi:fan",
6+
"state_attributes": {
7+
"preset_mode": {
8+
"default": "mdi:tune",
9+
"state": {
10+
"auto": "mdi:fan-auto",
11+
"auto_plus": "mdi:fan-auto",
12+
"boost": "mdi:fan-plus",
13+
"night": "mdi:weather-night"
14+
}
15+
}
16+
}
17+
},
418
"extract": {
519
"default": "mdi:arrow-expand-right"
620
},
@@ -26,26 +40,11 @@
2640
"inside_temperature": {
2741
"default": "mdi:home-thermometer"
2842
},
29-
"inside_temperature_2": {
30-
"default": "mdi:home-thermometer"
31-
},
3243
"outside_temperature": {
3344
"default": "mdi:thermometer"
34-
},
35-
"outside_temperature_2": {
36-
"default": "mdi:thermometer"
3745
}
3846
},
3947
"switch": {
40-
"auto": {
41-
"default": "mdi:fan-auto"
42-
},
43-
"auto_plus": {
44-
"default": "mdi:fan-auto"
45-
},
46-
"bound": {
47-
"default": "mdi:link"
48-
},
4948
"heater": {
5049
"default": "mdi:radiator"
5150
},

homeassistant/components/prana/sensor.py

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,7 @@ class PranaSensorType(StrEnum):
3535
AIR_PRESSURE = "air_pressure"
3636
CO2 = "co2"
3737
INSIDE_TEMPERATURE = "inside_temperature"
38-
INSIDE_TEMPERATURE_2 = "inside_temperature_2"
3938
OUTSIDE_TEMPERATURE = "outside_temperature"
40-
OUTSIDE_TEMPERATURE_2 = "outside_temperature_2"
4139

4240

4341
@dataclass(frozen=True, kw_only=True)
@@ -74,31 +72,32 @@ class PranaSensorEntityDescription(SensorEntityDescription):
7472
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
7573
device_class=SensorDeviceClass.CO2,
7674
),
75+
# Devices only have two physical probes (one inside, one outside), but
76+
# the upstream library mislabels the secondary pair: `inside_temperature_2`
77+
# actually contains the outside reading and `outside_temperature_2` the
78+
# inside one. On models where the primary fields are populated we use
79+
# them; otherwise we fall back to the swapped secondary. Remove this
80+
# workaround once upstream fixes the field labeling:
81+
# https://github.com/prana-dev-official/prana-local-api
7782
PranaSensorEntityDescription(
7883
key=PranaSensorType.INSIDE_TEMPERATURE,
7984
translation_key="inside_temperature",
80-
value_fn=lambda coord: coord.data.inside_temperature,
81-
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
82-
device_class=SensorDeviceClass.TEMPERATURE,
83-
),
84-
PranaSensorEntityDescription(
85-
key=PranaSensorType.INSIDE_TEMPERATURE_2,
86-
translation_key="inside_temperature_2",
87-
value_fn=lambda coord: coord.data.inside_temperature_2,
85+
value_fn=lambda coord: (
86+
coord.data.inside_temperature
87+
if coord.data.inside_temperature is not None
88+
else coord.data.outside_temperature_2
89+
),
8890
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
8991
device_class=SensorDeviceClass.TEMPERATURE,
9092
),
9193
PranaSensorEntityDescription(
9294
key=PranaSensorType.OUTSIDE_TEMPERATURE,
9395
translation_key="outside_temperature",
94-
value_fn=lambda coord: coord.data.outside_temperature,
95-
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
96-
device_class=SensorDeviceClass.TEMPERATURE,
97-
),
98-
PranaSensorEntityDescription(
99-
key=PranaSensorType.OUTSIDE_TEMPERATURE_2,
100-
translation_key="outside_temperature_2",
101-
value_fn=lambda coord: coord.data.outside_temperature_2,
96+
value_fn=lambda coord: (
97+
coord.data.outside_temperature
98+
if coord.data.outside_temperature is not None
99+
else coord.data.inside_temperature_2
100+
),
102101
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
103102
device_class=SensorDeviceClass.TEMPERATURE,
104103
),

homeassistant/components/prana/strings.json

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -26,24 +26,40 @@
2626
},
2727
"entity": {
2828
"fan": {
29-
"extract": {
30-
"name": "Extract fan",
29+
"ventilation": {
3130
"state_attributes": {
3231
"preset_mode": {
3332
"state": {
33+
"auto": "Auto",
34+
"auto_plus": "Auto plus",
3435
"boost": "Boost",
3536
"night": "Night"
3637
}
3738
}
3839
}
3940
},
41+
"extract": {
42+
"name": "Extract fan",
43+
"state_attributes": {
44+
"preset_mode": {
45+
"state": {
46+
"auto": "[%key:component::prana::entity::fan::ventilation::state_attributes::preset_mode::state::auto%]",
47+
"auto_plus": "[%key:component::prana::entity::fan::ventilation::state_attributes::preset_mode::state::auto_plus%]",
48+
"boost": "[%key:component::prana::entity::fan::ventilation::state_attributes::preset_mode::state::boost%]",
49+
"night": "[%key:component::prana::entity::fan::ventilation::state_attributes::preset_mode::state::night%]"
50+
}
51+
}
52+
}
53+
},
4054
"supply": {
4155
"name": "Supply fan",
4256
"state_attributes": {
4357
"preset_mode": {
4458
"state": {
45-
"boost": "[%key:component::prana::entity::fan::extract::state_attributes::preset_mode::state::boost%]",
46-
"night": "[%key:component::prana::entity::fan::extract::state_attributes::preset_mode::state::night%]"
59+
"auto": "[%key:component::prana::entity::fan::ventilation::state_attributes::preset_mode::state::auto%]",
60+
"auto_plus": "[%key:component::prana::entity::fan::ventilation::state_attributes::preset_mode::state::auto_plus%]",
61+
"boost": "[%key:component::prana::entity::fan::ventilation::state_attributes::preset_mode::state::boost%]",
62+
"night": "[%key:component::prana::entity::fan::ventilation::state_attributes::preset_mode::state::night%]"
4763
}
4864
}
4965
}
@@ -58,31 +74,16 @@
5874
"inside_temperature": {
5975
"name": "Inside temperature"
6076
},
61-
"inside_temperature_2": {
62-
"name": "Inside temperature 2"
63-
},
6477
"outside_temperature": {
6578
"name": "Outside temperature"
66-
},
67-
"outside_temperature_2": {
68-
"name": "Outside temperature 2"
6979
}
7080
},
7181
"switch": {
72-
"auto": {
73-
"name": "Auto"
74-
},
75-
"auto_plus": {
76-
"name": "Auto plus"
77-
},
78-
"bound": {
79-
"name": "Bound"
80-
},
8182
"heater": {
8283
"name": "Heater"
8384
},
8485
"winter": {
85-
"name": "Winter"
86+
"name": "Winter mode"
8687
}
8788
}
8889
}

0 commit comments

Comments
 (0)