1
0
mirror of https://github.com/home-assistant/core.git synced 2026-02-15 07:36:16 +00:00

Add AirPatrol integration (#149247)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Anton Dalgren
2025-12-09 21:26:21 +01:00
committed by GitHub
parent bc031e7a81
commit 4ac0567ccc
20 changed files with 1529 additions and 0 deletions

2
CODEOWNERS generated
View File

@@ -73,6 +73,8 @@ build.json @home-assistant/supervisor
/tests/components/airobot/ @mettolen
/homeassistant/components/airos/ @CoMPaTech
/tests/components/airos/ @CoMPaTech
/homeassistant/components/airpatrol/ @antondalgren
/tests/components/airpatrol/ @antondalgren
/homeassistant/components/airq/ @Sibgatulin @dl2080
/tests/components/airq/ @Sibgatulin @dl2080
/homeassistant/components/airthings/ @danielhiversen @LaStrada

View File

@@ -0,0 +1,24 @@
"""The AirPatrol integration."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from .const import PLATFORMS
from .coordinator import AirPatrolConfigEntry, AirPatrolDataUpdateCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: AirPatrolConfigEntry) -> bool:
"""Set up AirPatrol from a config entry."""
coordinator = AirPatrolDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: AirPatrolConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,208 @@
"""Climate platform for AirPatrol integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components.climate import (
FAN_AUTO,
FAN_HIGH,
FAN_LOW,
SWING_OFF,
SWING_ON,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AirPatrolConfigEntry
from .coordinator import AirPatrolDataUpdateCoordinator
from .entity import AirPatrolEntity
PARALLEL_UPDATES = 0
AP_TO_HA_HVAC_MODES = {
"heat": HVACMode.HEAT,
"cool": HVACMode.COOL,
"off": HVACMode.OFF,
}
HA_TO_AP_HVAC_MODES = {value: key for key, value in AP_TO_HA_HVAC_MODES.items()}
AP_TO_HA_FAN_MODES = {
"min": FAN_LOW,
"max": FAN_HIGH,
"auto": FAN_AUTO,
}
HA_TO_AP_FAN_MODES = {value: key for key, value in AP_TO_HA_FAN_MODES.items()}
AP_TO_HA_SWING_MODES = {
"on": SWING_ON,
"off": SWING_OFF,
}
HA_TO_AP_SWING_MODES = {value: key for key, value in AP_TO_HA_SWING_MODES.items()}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AirPatrolConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AirPatrol climate entities."""
coordinator = config_entry.runtime_data
units = coordinator.data
async_add_entities(
AirPatrolClimate(coordinator, unit_id)
for unit_id, unit in units.items()
if "climate" in unit
)
class AirPatrolClimate(AirPatrolEntity, ClimateEntity):
"""AirPatrol climate entity."""
_attr_name = None
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.SWING_MODE
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
_attr_hvac_modes = [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF]
_attr_fan_modes = [FAN_LOW, FAN_HIGH, FAN_AUTO]
_attr_swing_modes = [SWING_ON, SWING_OFF]
_attr_min_temp = 16.0
_attr_max_temp = 30.0
def __init__(
self,
coordinator: AirPatrolDataUpdateCoordinator,
unit_id: str,
) -> None:
"""Initialize the climate entity."""
super().__init__(coordinator, unit_id)
self._attr_unique_id = f"{coordinator.config_entry.unique_id}-{unit_id}"
@property
def climate_data(self) -> dict[str, Any]:
"""Return the climate data."""
return self.device_data.get("climate") or {}
@property
def params(self) -> dict[str, Any]:
"""Return the current parameters for the climate entity."""
return self.climate_data.get("ParametersData") or {}
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and bool(self.climate_data)
@property
def current_humidity(self) -> float | None:
"""Return the current humidity."""
if humidity := self.climate_data.get("RoomHumidity"):
return float(humidity)
return None
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if temp := self.climate_data.get("RoomTemp"):
return float(temp)
return None
@property
def target_temperature(self) -> float | None:
"""Return the target temperature."""
if temp := self.params.get("PumpTemp"):
return float(temp)
return None
@property
def hvac_mode(self) -> HVACMode | None:
"""Return the current HVAC mode."""
pump_power = self.params.get("PumpPower")
pump_mode = self.params.get("PumpMode")
if pump_power and pump_power == "on" and pump_mode:
return AP_TO_HA_HVAC_MODES.get(pump_mode)
return HVACMode.OFF
@property
def fan_mode(self) -> str | None:
"""Return the current fan mode."""
fan_speed = self.params.get("FanSpeed")
if fan_speed:
return AP_TO_HA_FAN_MODES.get(fan_speed)
return None
@property
def swing_mode(self) -> str | None:
"""Return the current swing mode."""
swing = self.params.get("Swing")
if swing:
return AP_TO_HA_SWING_MODES.get(swing)
return None
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
params = self.params.copy()
if ATTR_TEMPERATURE in kwargs:
temp = kwargs[ATTR_TEMPERATURE]
params["PumpTemp"] = f"{temp:.3f}"
await self._async_set_params(params)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
params = self.params.copy()
if hvac_mode == HVACMode.OFF:
params["PumpPower"] = "off"
else:
params["PumpPower"] = "on"
params["PumpMode"] = HA_TO_AP_HVAC_MODES.get(hvac_mode)
await self._async_set_params(params)
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
params = self.params.copy()
params["FanSpeed"] = HA_TO_AP_FAN_MODES.get(fan_mode)
await self._async_set_params(params)
async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Set new target swing mode."""
params = self.params.copy()
params["Swing"] = HA_TO_AP_SWING_MODES.get(swing_mode)
await self._async_set_params(params)
async def async_turn_on(self) -> None:
"""Turn the entity on."""
params = self.params.copy()
if mode := AP_TO_HA_HVAC_MODES.get(params["PumpMode"]):
await self.async_set_hvac_mode(mode)
async def async_turn_off(self) -> None:
"""Turn the entity off."""
await self.async_set_hvac_mode(HVACMode.OFF)
async def _async_set_params(self, params: dict[str, Any]) -> None:
"""Set the unit to dry mode."""
new_climate_data = self.climate_data.copy()
new_climate_data["ParametersData"] = params
await self.coordinator.api.set_unit_climate_data(
self._unit_id, new_climate_data
)
await self.coordinator.async_request_refresh()

View File

@@ -0,0 +1,111 @@
"""Config flow for the AirPatrol integration."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from airpatrol.api import AirPatrolAPI, AirPatrolAuthenticationError, AirPatrolError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .const import DOMAIN
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_EMAIL): TextSelector(
TextSelectorConfig(
type=TextSelectorType.EMAIL,
autocomplete="email",
)
),
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD,
autocomplete="current-password",
)
),
}
)
async def validate_api(
hass: HomeAssistant, user_input: dict[str, str]
) -> tuple[str | None, str | None, dict[str, str]]:
"""Validate the API connection."""
errors: dict[str, str] = {}
session = async_get_clientsession(hass)
access_token = None
unique_id = None
try:
api = await AirPatrolAPI.authenticate(
session, user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
)
except AirPatrolAuthenticationError:
errors["base"] = "invalid_auth"
except AirPatrolError:
errors["base"] = "cannot_connect"
else:
access_token = api.get_access_token()
unique_id = api.get_unique_id()
return (access_token, unique_id, errors)
class AirPatrolConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for AirPatrol."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
access_token, unique_id, errors = await validate_api(self.hass, user_input)
if access_token and unique_id:
user_input[CONF_ACCESS_TOKEN] = access_token
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_input[CONF_EMAIL], data=user_input
)
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
async def async_step_reauth(
self, user_input: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauthentication with new credentials."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauthentication confirmation."""
errors: dict[str, str] = {}
if user_input:
access_token, unique_id, errors = await validate_api(self.hass, user_input)
if access_token and unique_id:
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_mismatch()
user_input[CONF_ACCESS_TOKEN] = access_token
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data_updates=user_input
)
return self.async_show_form(
step_id="reauth_confirm", data_schema=DATA_SCHEMA, errors=errors
)

View File

@@ -0,0 +1,16 @@
"""Constants for the AirPatrol integration."""
from datetime import timedelta
import logging
from airpatrol.api import AirPatrolAuthenticationError, AirPatrolError
from homeassistant.const import Platform
DOMAIN = "airpatrol"
LOGGER = logging.getLogger(__package__)
PLATFORMS = [Platform.CLIMATE]
SCAN_INTERVAL = timedelta(minutes=1)
AIRPATROL_ERRORS = (AirPatrolAuthenticationError, AirPatrolError)

View File

@@ -0,0 +1,100 @@
"""Data update coordinator for AirPatrol."""
from __future__ import annotations
from typing import Any
from airpatrol.api import AirPatrolAPI, AirPatrolAuthenticationError, AirPatrolError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER, SCAN_INTERVAL
type AirPatrolConfigEntry = ConfigEntry[AirPatrolDataUpdateCoordinator]
class AirPatrolDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]):
"""Class to manage fetching AirPatrol data."""
config_entry: AirPatrolConfigEntry
api: AirPatrolAPI
def __init__(self, hass: HomeAssistant, config_entry: AirPatrolConfigEntry) -> None:
"""Initialize."""
super().__init__(
hass,
LOGGER,
name=f"{DOMAIN.capitalize()} {config_entry.title}",
update_interval=SCAN_INTERVAL,
config_entry=config_entry,
)
async def _async_setup(self) -> None:
try:
await self._setup_client()
except AirPatrolError as api_err:
raise UpdateFailed(
f"Error communicating with AirPatrol API: {api_err}"
) from api_err
async def _async_update_data(self) -> dict[str, dict[str, Any]]:
"""Update unit data from AirPatrol API."""
return {unit_data["unit_id"]: unit_data for unit_data in await self._get_data()}
async def _get_data(self, retry: bool = False) -> list[dict[str, Any]]:
"""Fetch data from API."""
try:
return await self.api.get_data()
except AirPatrolAuthenticationError as auth_err:
if retry:
raise ConfigEntryAuthFailed(
"Authentication with AirPatrol failed"
) from auth_err
await self._update_token()
return await self._get_data(retry=True)
except AirPatrolError as err:
raise UpdateFailed(
f"Error communicating with AirPatrol API: {err}"
) from err
async def _update_token(self) -> None:
"""Refresh the AirPatrol API client and update the access token."""
session = async_get_clientsession(self.hass)
try:
self.api = await AirPatrolAPI.authenticate(
session,
self.config_entry.data[CONF_EMAIL],
self.config_entry.data[CONF_PASSWORD],
)
except AirPatrolAuthenticationError as auth_err:
raise ConfigEntryAuthFailed(
"Authentication with AirPatrol failed"
) from auth_err
self.hass.config_entries.async_update_entry(
self.config_entry,
data={
**self.config_entry.data,
CONF_ACCESS_TOKEN: self.api.get_access_token(),
},
)
async def _setup_client(self) -> None:
"""Set up the AirPatrol API client from stored access_token."""
session = async_get_clientsession(self.hass)
api = AirPatrolAPI(
session,
self.config_entry.data[CONF_ACCESS_TOKEN],
self.config_entry.unique_id,
)
try:
await api.get_data()
except AirPatrolAuthenticationError:
await self._update_token()
self.api = api

View File

@@ -0,0 +1,44 @@
"""Base entity for AirPatrol integration."""
from __future__ import annotations
from typing import Any
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AirPatrolDataUpdateCoordinator
class AirPatrolEntity(CoordinatorEntity[AirPatrolDataUpdateCoordinator]):
"""Base entity for AirPatrol devices."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AirPatrolDataUpdateCoordinator,
unit_id: str,
) -> None:
"""Initialize the AirPatrol entity."""
super().__init__(coordinator)
self._unit_id = unit_id
device = coordinator.data[unit_id]
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unit_id)},
name=device["name"],
manufacturer=device["manufacturer"],
model=device["model"],
serial_number=device["hwid"],
)
@property
def device_data(self) -> dict[str, Any]:
"""Return the device data."""
return self.coordinator.data[self._unit_id]
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self._unit_id in self.coordinator.data

View File

@@ -0,0 +1,11 @@
{
"domain": "airpatrol",
"name": "AirPatrol",
"codeowners": ["@antondalgren"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/airpatrol",
"integration_type": "device",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["airpatrol==0.1.0"]
}

View File

@@ -0,0 +1,65 @@
rules:
# Bronze
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not provide custom actions
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
Entities doesn't subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: done
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo

View File

@@ -0,0 +1,38 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"unique_id_mismatch": "Login credentials do not match the configured account"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"email": "[%key:component::airpatrol::config::step::user::data_description::email%]",
"password": "[%key:component::airpatrol::config::step::user::data_description::password%]"
},
"description": "Reauthenticate with AirPatrol"
},
"user": {
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"email": "Your AirPatrol email address",
"password": "Your AirPatrol password"
},
"description": "Connect to AirPatrol"
}
}
}
}

View File

@@ -40,6 +40,7 @@ FLOWS = {
"airnow",
"airobot",
"airos",
"airpatrol",
"airq",
"airthings",
"airthings_ble",

View File

@@ -129,6 +129,12 @@
"config_flow": true,
"iot_class": "local_polling"
},
"airpatrol": {
"name": "AirPatrol",
"integration_type": "device",
"config_flow": true,
"iot_class": "cloud_polling"
},
"airq": {
"name": "air-Q",
"integration_type": "hub",

3
requirements_all.txt generated
View File

@@ -464,6 +464,9 @@ airly==1.1.0
# homeassistant.components.airos
airos==0.6.0
# homeassistant.components.airpatrol
airpatrol==0.1.0
# homeassistant.components.airthings_ble
airthings-ble==1.2.0

View File

@@ -449,6 +449,9 @@ airly==1.1.0
# homeassistant.components.airos
airos==0.6.0
# homeassistant.components.airpatrol
airpatrol==0.1.0
# homeassistant.components.airthings_ble
airthings-ble==1.2.0

View File

@@ -0,0 +1 @@
"""Tests for the AirPatrol integration."""

View File

@@ -0,0 +1,98 @@
"""Common fixtures for the AirPatrol tests."""
from collections.abc import Generator
from typing import Any
from unittest.mock import AsyncMock, patch
from airpatrol.api import AirPatrolAPI
import pytest
from homeassistant.components.airpatrol.const import DOMAIN
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
DEFAULT_UNIT_ID = "test_unit_001"
@pytest.fixture(name="get_client")
def mock_airpatrol_client(get_data) -> Generator[AsyncMock]:
"""Mock an AirPatrol client and config."""
with (
patch(
"homeassistant.components.airpatrol.coordinator.AirPatrolAPI",
autospec=True,
) as mock_client,
patch(
"homeassistant.components.airpatrol.config_flow.AirPatrolAPI",
new=mock_client,
),
):
client = mock_client.return_value
client.get_unique_id.return_value = "test_user_id"
client.get_access_token.return_value = "test_access_token"
client.get_data.return_value = get_data
client.set_unit_climate_data.return_value = AsyncMock()
mock_client.authenticate.return_value = client
yield client
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Mock config entry."""
return MockConfigEntry(
domain=DOMAIN,
data={
CONF_EMAIL: "test@example.com",
CONF_PASSWORD: "test_password",
CONF_ACCESS_TOKEN: "test_access_token",
},
unique_id="test_user_id",
title="test@example.com",
)
@pytest.fixture
async def load_integration(
hass: HomeAssistant,
get_client: AirPatrolAPI,
mock_config_entry: MockConfigEntry,
) -> MockConfigEntry:
"""Load the integration."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry
@pytest.fixture
def get_data(climate_data: dict[str, Any]) -> list[dict[str, Any]]:
"""Return data."""
return [
{
"unit_id": DEFAULT_UNIT_ID,
"name": "living room",
"manufacturer": "AirPatrol",
"model": "apw",
"hwid": "hw01",
"climate": climate_data,
}
]
@pytest.fixture
def climate_data() -> dict[str, Any]:
"""Return data."""
return {
"ParametersData": {
"PumpPower": "on",
"PumpTemp": "22.000",
"PumpMode": "cool",
"FanSpeed": "max",
"Swing": "off",
},
"RoomTemp": "22.5",
"RoomHumidity": "45",
}

View File

@@ -0,0 +1,170 @@
# serializer version: 1
# name: test_climate_entities[None][climate.living_room-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'fan_modes': list([
'low',
'high',
'auto',
]),
'hvac_modes': list([
<HVACMode.HEAT: 'heat'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.OFF: 'off'>,
]),
'max_temp': 30.0,
'min_temp': 16.0,
'swing_modes': list([
'on',
'off',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.living_room',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'airpatrol',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 425>,
'translation_key': None,
'unique_id': 'test_user_id-test_unit_001',
'unit_of_measurement': None,
})
# ---
# name: test_climate_entities[None][climate.living_room-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'fan_modes': list([
'low',
'high',
'auto',
]),
'friendly_name': 'living room',
'hvac_modes': list([
<HVACMode.HEAT: 'heat'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.OFF: 'off'>,
]),
'max_temp': 30.0,
'min_temp': 16.0,
'supported_features': <ClimateEntityFeature: 425>,
'swing_modes': list([
'on',
'off',
]),
}),
'context': <ANY>,
'entity_id': 'climate.living_room',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_climate_entities[climate_data0][climate.living_room-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'fan_modes': list([
'low',
'high',
'auto',
]),
'hvac_modes': list([
<HVACMode.HEAT: 'heat'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.OFF: 'off'>,
]),
'max_temp': 30.0,
'min_temp': 16.0,
'swing_modes': list([
'on',
'off',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.living_room',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'airpatrol',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 425>,
'translation_key': None,
'unique_id': 'test_user_id-test_unit_001',
'unit_of_measurement': None,
})
# ---
# name: test_climate_entities[climate_data0][climate.living_room-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_humidity': 45.0,
'current_temperature': 22.5,
'fan_mode': 'high',
'fan_modes': list([
'low',
'high',
'auto',
]),
'friendly_name': 'living room',
'hvac_modes': list([
<HVACMode.HEAT: 'heat'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.OFF: 'off'>,
]),
'max_temp': 30.0,
'min_temp': 16.0,
'supported_features': <ClimateEntityFeature: 425>,
'swing_mode': 'off',
'swing_modes': list([
'on',
'off',
]),
'temperature': 22.0,
}),
'context': <ANY>,
'entity_id': 'climate.living_room',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'cool',
})
# ---

View File

@@ -0,0 +1,370 @@
"""Test the AirPatrol climate platform."""
from datetime import timedelta
from typing import Any
from airpatrol.api import AirPatrolAPI
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.airpatrol.climate import (
HA_TO_AP_FAN_MODES,
HA_TO_AP_HVAC_MODES,
HA_TO_AP_SWING_MODES,
)
from homeassistant.components.climate import (
ATTR_FAN_MODE,
ATTR_HVAC_MODE,
ATTR_SWING_MODE,
DOMAIN as CLIMATE_DOMAIN,
FAN_HIGH,
FAN_LOW,
SERVICE_SET_FAN_MODE,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_SWING_MODE,
SERVICE_SET_TEMPERATURE,
SWING_OFF,
SWING_ON,
HVACMode,
)
from homeassistant.const import (
ATTR_TEMPERATURE,
CONF_ENTITY_ID,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import (
MockConfigEntry,
SnapshotAssertion,
async_fire_time_changed,
snapshot_platform,
)
@pytest.mark.parametrize(
"climate_data",
[
{
"ParametersData": {
"PumpPower": "on",
"PumpTemp": "22.000",
"PumpMode": "cool",
"FanSpeed": "max",
"Swing": "off",
},
"RoomTemp": "22.5",
"RoomHumidity": "45",
},
None,
],
)
async def test_climate_entities(
hass: HomeAssistant,
load_integration: MockConfigEntry,
get_client: AirPatrolAPI,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test climate."""
await snapshot_platform(
hass,
entity_registry,
snapshot,
load_integration.entry_id,
)
async def test_climate_entity_unavailable(
hass: HomeAssistant,
load_integration: MockConfigEntry,
get_client: AirPatrolAPI,
get_data: dict[str, Any],
freezer: FrozenDateTimeFactory,
) -> None:
"""Test climate entity when climate data is missing."""
state = hass.states.get("climate.living_room")
assert state
assert state.state == HVACMode.COOL
get_data[0]["climate"] = None
freezer.tick(timedelta(minutes=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("climate.living_room")
assert state
assert state.state == "unavailable"
async def test_climate_set_temperature(
hass: HomeAssistant,
load_integration: MockConfigEntry,
get_client: AirPatrolAPI,
climate_data: dict[str, Any],
) -> None:
"""Test setting temperature."""
TARGET_TEMP = 25.0
state = hass.states.get("climate.living_room")
assert state.attributes[ATTR_TEMPERATURE] == 22.0
climate_data["ParametersData"]["PumpTemp"] = f"{TARGET_TEMP:.3f}"
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{
CONF_ENTITY_ID: state.entity_id,
ATTR_TEMPERATURE: TARGET_TEMP,
},
)
get_client.set_unit_climate_data.assert_called_once()
state = hass.states.get("climate.living_room")
assert state.attributes[ATTR_TEMPERATURE] == TARGET_TEMP
async def test_climate_set_hvac_mode(
hass: HomeAssistant,
load_integration: MockConfigEntry,
get_client: AirPatrolAPI,
climate_data: dict[str, Any],
) -> None:
"""Test setting HVAC mode."""
state = hass.states.get("climate.living_room")
assert state.state == HVACMode.COOL
climate_data["ParametersData"]["PumpMode"] = HA_TO_AP_HVAC_MODES[HVACMode.HEAT]
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{
CONF_ENTITY_ID: state.entity_id,
ATTR_HVAC_MODE: HVACMode.HEAT,
},
)
get_client.set_unit_climate_data.assert_called_once()
state = hass.states.get("climate.living_room")
assert state.state == HVACMode.HEAT
async def test_climate_set_fan_mode(
hass: HomeAssistant,
load_integration: MockConfigEntry,
get_client: AirPatrolAPI,
climate_data: dict[str, Any],
) -> None:
"""Test setting fan mode."""
state = hass.states.get("climate.living_room")
assert state.attributes[ATTR_FAN_MODE] == FAN_HIGH
climate_data["ParametersData"]["FanSpeed"] = HA_TO_AP_FAN_MODES[FAN_LOW]
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_FAN_MODE,
{
CONF_ENTITY_ID: state.entity_id,
ATTR_FAN_MODE: FAN_LOW,
},
)
get_client.set_unit_climate_data.assert_called_once()
state = hass.states.get("climate.living_room")
assert state.attributes[ATTR_FAN_MODE] == FAN_LOW
async def test_climate_set_swing_mode(
hass: HomeAssistant,
load_integration: MockConfigEntry,
get_client: AirPatrolAPI,
climate_data: dict[str, Any],
) -> None:
"""Test setting swing mode."""
state = hass.states.get("climate.living_room")
assert state.attributes[ATTR_SWING_MODE] == SWING_OFF
climate_data["ParametersData"]["Swing"] = HA_TO_AP_SWING_MODES[SWING_ON]
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_SWING_MODE,
{
CONF_ENTITY_ID: state.entity_id,
ATTR_SWING_MODE: SWING_ON,
},
)
get_client.set_unit_climate_data.assert_called_once()
state = hass.states.get("climate.living_room")
assert state.attributes[ATTR_SWING_MODE] == SWING_ON
@pytest.mark.parametrize(
"climate_data",
[
{
"ParametersData": {
"PumpPower": "off",
"PumpTemp": "22.000",
"PumpMode": "cool",
"FanSpeed": "max",
"Swing": "off",
},
"RoomTemp": "22.5",
"RoomHumidity": "45",
}
],
)
async def test_climate_turn_on(
hass: HomeAssistant,
load_integration: MockConfigEntry,
get_client: AirPatrolAPI,
climate_data: dict[str, Any],
) -> None:
"""Test turning climate on."""
state = hass.states.get("climate.living_room")
assert state.state == HVACMode.OFF
climate_data["ParametersData"]["PumpPower"] = "on"
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_TURN_ON,
{
CONF_ENTITY_ID: state.entity_id,
},
)
get_client.set_unit_climate_data.assert_called_once()
state = hass.states.get("climate.living_room")
assert state.state == HVACMode.COOL
async def test_climate_turn_off(
hass: HomeAssistant,
load_integration: MockConfigEntry,
get_client: AirPatrolAPI,
climate_data: dict[str, Any],
) -> None:
"""Test turning climate off."""
state = hass.states.get("climate.living_room")
assert state.state == HVACMode.COOL
climate_data["ParametersData"]["PumpPower"] = "off"
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_TURN_OFF,
{
CONF_ENTITY_ID: state.entity_id,
},
)
get_client.set_unit_climate_data.assert_called_once()
state = hass.states.get("climate.living_room")
assert state.state == HVACMode.OFF
@pytest.mark.parametrize(
"climate_data",
[
{
"ParametersData": {
"PumpPower": "on",
"PumpTemp": "22.000",
"PumpMode": "heat",
"FanSpeed": "max",
"Swing": "off",
},
"RoomTemp": "22.5",
"RoomHumidity": "45",
}
],
)
async def test_climate_heat_mode(
hass: HomeAssistant,
load_integration: MockConfigEntry,
get_client: AirPatrolAPI,
) -> None:
"""Test climate in heat mode."""
state = hass.states.get("climate.living_room")
assert state.state == HVACMode.HEAT
async def test_climate_set_temperature_api_error(
hass: HomeAssistant,
load_integration: MockConfigEntry,
get_client: AirPatrolAPI,
) -> None:
"""Test async_set_temperature handles API error."""
state = hass.states.get("climate.living_room")
assert state.attributes[ATTR_TEMPERATURE] == 22.0
get_client.set_unit_climate_data.side_effect = Exception("API Error")
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{
CONF_ENTITY_ID: state.entity_id,
ATTR_TEMPERATURE: 25.0,
},
)
state = hass.states.get("climate.living_room")
assert state.attributes[ATTR_TEMPERATURE] == 22.0
@pytest.mark.parametrize(
"climate_data",
[
{
"ParametersData": {
"PumpPower": "off",
"PumpTemp": "22.000",
"PumpMode": "cool",
"FanSpeed": "sideways",
"Swing": "off",
},
"RoomTemp": "22.5",
"RoomHumidity": "45",
}
],
)
async def test_climate_fan_mode_invalid(
hass: HomeAssistant,
get_client: AirPatrolAPI,
get_data: dict[str, Any],
load_integration: MockConfigEntry,
) -> None:
"""Test fan_mode with unexpected value."""
state = hass.states.get("climate.living_room")
assert state.attributes[ATTR_FAN_MODE] is None
@pytest.mark.parametrize(
"climate_data",
[
{
"ParametersData": {
"PumpPower": "off",
"PumpTemp": "22.000",
"PumpMode": "cool",
"FanSpeed": "max",
"Swing": "sideways",
},
"RoomTemp": "22.5",
"RoomHumidity": "45",
}
],
)
async def test_climate_swing_mode_invalid(
hass: HomeAssistant,
load_integration: MockConfigEntry,
get_client: AirPatrolAPI,
) -> None:
"""Test swing_mode with unexpected value."""
state = hass.states.get("climate.living_room")
assert state.attributes[ATTR_SWING_MODE] is None

View File

@@ -0,0 +1,182 @@
"""Test the AirPatrol config flow."""
from unittest.mock import patch
from airpatrol.api import AirPatrolAPI, AirPatrolAuthenticationError, AirPatrolError
import pytest
from homeassistant.components.airpatrol.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
TEST_USER_INPUT = {
CONF_EMAIL: "test@example.com",
CONF_PASSWORD: "test_password",
}
async def test_user_flow_success(
hass: HomeAssistant,
get_client: AirPatrolAPI,
) -> None:
"""Test successful user flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=TEST_USER_INPUT
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == TEST_USER_INPUT[CONF_EMAIL]
assert result["data"] == {
**TEST_USER_INPUT,
CONF_ACCESS_TOKEN: "test_access_token",
}
assert result["result"].unique_id == "test_user_id"
async def test_async_step_reauth_confirm_success(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, get_client: AirPatrolAPI
) -> None:
"""Test successful reauthentication via async_step_reauth_confirm."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=TEST_USER_INPUT
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data[CONF_PASSWORD] == "test_password"
assert mock_config_entry.data[CONF_ACCESS_TOKEN] == "test_access_token"
async def test_async_step_reauth_confirm_invalid_auth(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
get_client: AirPatrolAPI,
) -> None:
"""Test reauthentication failure due to invalid credentials."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
with patch(
"homeassistant.components.airpatrol.config_flow.AirPatrolAPI.authenticate",
side_effect=AirPatrolAuthenticationError("fail"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=TEST_USER_INPUT
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result["errors"] == {"base": "invalid_auth"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=TEST_USER_INPUT,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data[CONF_PASSWORD] == "test_password"
assert mock_config_entry.data[CONF_ACCESS_TOKEN] == "test_access_token"
async def test_async_step_reauth_confirm_another_account_failure(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, get_client: AirPatrolAPI
) -> None:
"""Test reauthentication failure due to another account."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
get_client.get_unique_id.return_value = "different_user_id"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_EMAIL: "test2@example.com", CONF_PASSWORD: "test_password2"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unique_id_mismatch"
@pytest.mark.parametrize(
("side_effect", "expected_error"),
[
(AirPatrolError("fail"), "cannot_connect"),
(AirPatrolAuthenticationError("fail"), "invalid_auth"),
],
)
async def test_user_flow_error(
hass: HomeAssistant,
side_effect,
expected_error,
get_client: AirPatrolAPI,
) -> None:
"""Test user flow with invalid authentication."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
with patch(
"homeassistant.components.airpatrol.config_flow.AirPatrolAPI.authenticate",
side_effect=side_effect,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=TEST_USER_INPUT
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": expected_error}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=TEST_USER_INPUT
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == TEST_USER_INPUT[CONF_EMAIL]
assert result["data"] == {
**TEST_USER_INPUT,
CONF_ACCESS_TOKEN: "test_access_token",
}
assert result["result"].unique_id == "test_user_id"
async def test_user_flow_already_configured(
hass: HomeAssistant,
get_client: AirPatrolAPI,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test user flow when already configured."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=TEST_USER_INPUT
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"

View File

@@ -0,0 +1,76 @@
"""Test the AirPatrol integration setup."""
from unittest.mock import patch
from airpatrol.api import AirPatrolAPI, AirPatrolAuthenticationError
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, State
from tests.common import MockConfigEntry
async def test_load_unload_config_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
get_client: AirPatrolAPI,
) -> None:
"""Test loading and unloading the config entry."""
# Add the config entry to hass first
mock_config_entry.add_to_hass(hass)
# Load the config entry
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
async def test_update_data_refresh_token_success(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
get_client: AirPatrolAPI,
get_data,
) -> None:
"""Test data update with expired token and successful token refresh."""
get_client.get_data.side_effect = [
AirPatrolAuthenticationError("fail"),
get_data,
]
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert get_client.get_data.call_count == 2
assert hass.states.get("climate.living_room")
async def test_update_data_auth_failure(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
get_client: AirPatrolAPI,
) -> None:
"""Test permanent authentication failure."""
mock_config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.airpatrol.coordinator.AirPatrolAPI.authenticate",
side_effect=AirPatrolAuthenticationError("fail"),
):
get_client.get_data.side_effect = AirPatrolAuthenticationError("fail")
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
state: State | None = hass.states.get("climate.living_room")
assert state is None
entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
assert entry.state is ConfigEntryState.SETUP_ERROR
assert entry.reason == "Authentication with AirPatrol failed"