Skip to content

Commit 4ac0567

Browse files
Add AirPatrol integration (home-assistant#149247)
Co-authored-by: Joost Lekkerkerker <[email protected]>
1 parent bc031e7 commit 4ac0567

File tree

20 files changed

+1529
-0
lines changed

20 files changed

+1529
-0
lines changed

CODEOWNERS

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""The AirPatrol integration."""
2+
3+
from __future__ import annotations
4+
5+
from homeassistant.core import HomeAssistant
6+
7+
from .const import PLATFORMS
8+
from .coordinator import AirPatrolConfigEntry, AirPatrolDataUpdateCoordinator
9+
10+
11+
async def async_setup_entry(hass: HomeAssistant, entry: AirPatrolConfigEntry) -> bool:
12+
"""Set up AirPatrol from a config entry."""
13+
coordinator = AirPatrolDataUpdateCoordinator(hass, entry)
14+
15+
await coordinator.async_config_entry_first_refresh()
16+
entry.runtime_data = coordinator
17+
18+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
19+
return True
20+
21+
22+
async def async_unload_entry(hass: HomeAssistant, entry: AirPatrolConfigEntry) -> bool:
23+
"""Unload a config entry."""
24+
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
"""Climate platform for AirPatrol integration."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from homeassistant.components.climate import (
8+
FAN_AUTO,
9+
FAN_HIGH,
10+
FAN_LOW,
11+
SWING_OFF,
12+
SWING_ON,
13+
ClimateEntity,
14+
ClimateEntityFeature,
15+
HVACMode,
16+
)
17+
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
18+
from homeassistant.core import HomeAssistant
19+
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
20+
21+
from . import AirPatrolConfigEntry
22+
from .coordinator import AirPatrolDataUpdateCoordinator
23+
from .entity import AirPatrolEntity
24+
25+
PARALLEL_UPDATES = 0
26+
27+
AP_TO_HA_HVAC_MODES = {
28+
"heat": HVACMode.HEAT,
29+
"cool": HVACMode.COOL,
30+
"off": HVACMode.OFF,
31+
}
32+
HA_TO_AP_HVAC_MODES = {value: key for key, value in AP_TO_HA_HVAC_MODES.items()}
33+
34+
AP_TO_HA_FAN_MODES = {
35+
"min": FAN_LOW,
36+
"max": FAN_HIGH,
37+
"auto": FAN_AUTO,
38+
}
39+
HA_TO_AP_FAN_MODES = {value: key for key, value in AP_TO_HA_FAN_MODES.items()}
40+
41+
AP_TO_HA_SWING_MODES = {
42+
"on": SWING_ON,
43+
"off": SWING_OFF,
44+
}
45+
HA_TO_AP_SWING_MODES = {value: key for key, value in AP_TO_HA_SWING_MODES.items()}
46+
47+
48+
async def async_setup_entry(
49+
hass: HomeAssistant,
50+
config_entry: AirPatrolConfigEntry,
51+
async_add_entities: AddConfigEntryEntitiesCallback,
52+
) -> None:
53+
"""Set up AirPatrol climate entities."""
54+
coordinator = config_entry.runtime_data
55+
units = coordinator.data
56+
57+
async_add_entities(
58+
AirPatrolClimate(coordinator, unit_id)
59+
for unit_id, unit in units.items()
60+
if "climate" in unit
61+
)
62+
63+
64+
class AirPatrolClimate(AirPatrolEntity, ClimateEntity):
65+
"""AirPatrol climate entity."""
66+
67+
_attr_name = None
68+
_attr_temperature_unit = UnitOfTemperature.CELSIUS
69+
_attr_supported_features = (
70+
ClimateEntityFeature.TARGET_TEMPERATURE
71+
| ClimateEntityFeature.FAN_MODE
72+
| ClimateEntityFeature.SWING_MODE
73+
| ClimateEntityFeature.TURN_OFF
74+
| ClimateEntityFeature.TURN_ON
75+
)
76+
_attr_hvac_modes = [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF]
77+
_attr_fan_modes = [FAN_LOW, FAN_HIGH, FAN_AUTO]
78+
_attr_swing_modes = [SWING_ON, SWING_OFF]
79+
_attr_min_temp = 16.0
80+
_attr_max_temp = 30.0
81+
82+
def __init__(
83+
self,
84+
coordinator: AirPatrolDataUpdateCoordinator,
85+
unit_id: str,
86+
) -> None:
87+
"""Initialize the climate entity."""
88+
super().__init__(coordinator, unit_id)
89+
self._attr_unique_id = f"{coordinator.config_entry.unique_id}-{unit_id}"
90+
91+
@property
92+
def climate_data(self) -> dict[str, Any]:
93+
"""Return the climate data."""
94+
return self.device_data.get("climate") or {}
95+
96+
@property
97+
def params(self) -> dict[str, Any]:
98+
"""Return the current parameters for the climate entity."""
99+
return self.climate_data.get("ParametersData") or {}
100+
101+
@property
102+
def available(self) -> bool:
103+
"""Return if entity is available."""
104+
return super().available and bool(self.climate_data)
105+
106+
@property
107+
def current_humidity(self) -> float | None:
108+
"""Return the current humidity."""
109+
if humidity := self.climate_data.get("RoomHumidity"):
110+
return float(humidity)
111+
return None
112+
113+
@property
114+
def current_temperature(self) -> float | None:
115+
"""Return the current temperature."""
116+
if temp := self.climate_data.get("RoomTemp"):
117+
return float(temp)
118+
return None
119+
120+
@property
121+
def target_temperature(self) -> float | None:
122+
"""Return the target temperature."""
123+
if temp := self.params.get("PumpTemp"):
124+
return float(temp)
125+
return None
126+
127+
@property
128+
def hvac_mode(self) -> HVACMode | None:
129+
"""Return the current HVAC mode."""
130+
pump_power = self.params.get("PumpPower")
131+
pump_mode = self.params.get("PumpMode")
132+
133+
if pump_power and pump_power == "on" and pump_mode:
134+
return AP_TO_HA_HVAC_MODES.get(pump_mode)
135+
return HVACMode.OFF
136+
137+
@property
138+
def fan_mode(self) -> str | None:
139+
"""Return the current fan mode."""
140+
fan_speed = self.params.get("FanSpeed")
141+
if fan_speed:
142+
return AP_TO_HA_FAN_MODES.get(fan_speed)
143+
return None
144+
145+
@property
146+
def swing_mode(self) -> str | None:
147+
"""Return the current swing mode."""
148+
swing = self.params.get("Swing")
149+
if swing:
150+
return AP_TO_HA_SWING_MODES.get(swing)
151+
return None
152+
153+
async def async_set_temperature(self, **kwargs: Any) -> None:
154+
"""Set new target temperature."""
155+
params = self.params.copy()
156+
157+
if ATTR_TEMPERATURE in kwargs:
158+
temp = kwargs[ATTR_TEMPERATURE]
159+
params["PumpTemp"] = f"{temp:.3f}"
160+
161+
await self._async_set_params(params)
162+
163+
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
164+
"""Set new target hvac mode."""
165+
params = self.params.copy()
166+
167+
if hvac_mode == HVACMode.OFF:
168+
params["PumpPower"] = "off"
169+
else:
170+
params["PumpPower"] = "on"
171+
params["PumpMode"] = HA_TO_AP_HVAC_MODES.get(hvac_mode)
172+
173+
await self._async_set_params(params)
174+
175+
async def async_set_fan_mode(self, fan_mode: str) -> None:
176+
"""Set new target fan mode."""
177+
params = self.params.copy()
178+
params["FanSpeed"] = HA_TO_AP_FAN_MODES.get(fan_mode)
179+
180+
await self._async_set_params(params)
181+
182+
async def async_set_swing_mode(self, swing_mode: str) -> None:
183+
"""Set new target swing mode."""
184+
params = self.params.copy()
185+
params["Swing"] = HA_TO_AP_SWING_MODES.get(swing_mode)
186+
187+
await self._async_set_params(params)
188+
189+
async def async_turn_on(self) -> None:
190+
"""Turn the entity on."""
191+
params = self.params.copy()
192+
if mode := AP_TO_HA_HVAC_MODES.get(params["PumpMode"]):
193+
await self.async_set_hvac_mode(mode)
194+
195+
async def async_turn_off(self) -> None:
196+
"""Turn the entity off."""
197+
await self.async_set_hvac_mode(HVACMode.OFF)
198+
199+
async def _async_set_params(self, params: dict[str, Any]) -> None:
200+
"""Set the unit to dry mode."""
201+
new_climate_data = self.climate_data.copy()
202+
new_climate_data["ParametersData"] = params
203+
204+
await self.coordinator.api.set_unit_climate_data(
205+
self._unit_id, new_climate_data
206+
)
207+
208+
await self.coordinator.async_request_refresh()
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Config flow for the AirPatrol integration."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Mapping
6+
from typing import Any
7+
8+
from airpatrol.api import AirPatrolAPI, AirPatrolAuthenticationError, AirPatrolError
9+
import voluptuous as vol
10+
11+
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
12+
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD
13+
from homeassistant.core import HomeAssistant
14+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
15+
from homeassistant.helpers.selector import (
16+
TextSelector,
17+
TextSelectorConfig,
18+
TextSelectorType,
19+
)
20+
21+
from .const import DOMAIN
22+
23+
DATA_SCHEMA = vol.Schema(
24+
{
25+
vol.Required(CONF_EMAIL): TextSelector(
26+
TextSelectorConfig(
27+
type=TextSelectorType.EMAIL,
28+
autocomplete="email",
29+
)
30+
),
31+
vol.Required(CONF_PASSWORD): TextSelector(
32+
TextSelectorConfig(
33+
type=TextSelectorType.PASSWORD,
34+
autocomplete="current-password",
35+
)
36+
),
37+
}
38+
)
39+
40+
41+
async def validate_api(
42+
hass: HomeAssistant, user_input: dict[str, str]
43+
) -> tuple[str | None, str | None, dict[str, str]]:
44+
"""Validate the API connection."""
45+
errors: dict[str, str] = {}
46+
session = async_get_clientsession(hass)
47+
access_token = None
48+
unique_id = None
49+
try:
50+
api = await AirPatrolAPI.authenticate(
51+
session, user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
52+
)
53+
except AirPatrolAuthenticationError:
54+
errors["base"] = "invalid_auth"
55+
except AirPatrolError:
56+
errors["base"] = "cannot_connect"
57+
else:
58+
access_token = api.get_access_token()
59+
unique_id = api.get_unique_id()
60+
61+
return (access_token, unique_id, errors)
62+
63+
64+
class AirPatrolConfigFlow(ConfigFlow, domain=DOMAIN):
65+
"""Handle a config flow for AirPatrol."""
66+
67+
VERSION = 1
68+
69+
async def async_step_user(
70+
self, user_input: dict[str, Any] | None = None
71+
) -> ConfigFlowResult:
72+
"""Handle the initial step."""
73+
errors: dict[str, str] = {}
74+
if user_input is not None:
75+
access_token, unique_id, errors = await validate_api(self.hass, user_input)
76+
if access_token and unique_id:
77+
user_input[CONF_ACCESS_TOKEN] = access_token
78+
await self.async_set_unique_id(unique_id)
79+
self._abort_if_unique_id_configured()
80+
return self.async_create_entry(
81+
title=user_input[CONF_EMAIL], data=user_input
82+
)
83+
84+
return self.async_show_form(
85+
step_id="user", data_schema=DATA_SCHEMA, errors=errors
86+
)
87+
88+
async def async_step_reauth(
89+
self, user_input: Mapping[str, Any]
90+
) -> ConfigFlowResult:
91+
"""Handle reauthentication with new credentials."""
92+
return await self.async_step_reauth_confirm()
93+
94+
async def async_step_reauth_confirm(
95+
self, user_input: dict[str, Any] | None = None
96+
) -> ConfigFlowResult:
97+
"""Handle reauthentication confirmation."""
98+
errors: dict[str, str] = {}
99+
100+
if user_input:
101+
access_token, unique_id, errors = await validate_api(self.hass, user_input)
102+
if access_token and unique_id:
103+
await self.async_set_unique_id(unique_id)
104+
self._abort_if_unique_id_mismatch()
105+
user_input[CONF_ACCESS_TOKEN] = access_token
106+
return self.async_update_reload_and_abort(
107+
self._get_reauth_entry(), data_updates=user_input
108+
)
109+
return self.async_show_form(
110+
step_id="reauth_confirm", data_schema=DATA_SCHEMA, errors=errors
111+
)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Constants for the AirPatrol integration."""
2+
3+
from datetime import timedelta
4+
import logging
5+
6+
from airpatrol.api import AirPatrolAuthenticationError, AirPatrolError
7+
8+
from homeassistant.const import Platform
9+
10+
DOMAIN = "airpatrol"
11+
12+
LOGGER = logging.getLogger(__package__)
13+
PLATFORMS = [Platform.CLIMATE]
14+
SCAN_INTERVAL = timedelta(minutes=1)
15+
16+
AIRPATROL_ERRORS = (AirPatrolAuthenticationError, AirPatrolError)

0 commit comments

Comments
 (0)