2626
2727PARALLEL_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.
3230PRANA_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
3545class 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 )
4454class 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
94105class 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
0 commit comments