mirror of
https://github.com/home-assistant/core.git
synced 2026-03-15 07:22:12 +01:00
Compare commits
4 Commits
python-3.1
...
zizmor-1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f208c16cb6 | ||
|
|
34a7fcf8d3 | ||
|
|
95a57a2984 | ||
|
|
7f39cc0aeb |
2
.github/workflows/builder.yml
vendored
2
.github/workflows/builder.yml
vendored
@@ -72,7 +72,7 @@ jobs:
|
||||
- name: Download Translations
|
||||
run: python3 -m script.translations download
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
|
||||
- name: Archive translations
|
||||
shell: bash
|
||||
|
||||
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
@@ -1400,7 +1400,7 @@ jobs:
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
flags: full-suite
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
|
||||
pytest-partial:
|
||||
name: Run tests Python ${{ matrix.python-version }} (${{ matrix.group }})
|
||||
@@ -1570,7 +1570,7 @@ jobs:
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
|
||||
upload-test-results:
|
||||
name: Upload test results to Codecov
|
||||
|
||||
4
.github/workflows/stale.yml
vendored
4
.github/workflows/stale.yml
vendored
@@ -58,8 +58,8 @@ jobs:
|
||||
# v1.7.0
|
||||
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a
|
||||
with:
|
||||
app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }}
|
||||
private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }}
|
||||
app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
|
||||
private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
|
||||
|
||||
# The 90 day stale policy for issues
|
||||
# Used for:
|
||||
|
||||
2
.github/workflows/translations.yml
vendored
2
.github/workflows/translations.yml
vendored
@@ -33,6 +33,6 @@ jobs:
|
||||
|
||||
- name: Upload Translations
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
run: |
|
||||
python3 -m script.translations upload
|
||||
|
||||
4
.github/workflows/wheels.yml
vendored
4
.github/workflows/wheels.yml
vendored
@@ -142,7 +142,7 @@ jobs:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
arch: ${{ matrix.arch }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }} # zizmor: ignore[secrets-outside-env]
|
||||
env-file: true
|
||||
apk: "libffi-dev;openssl-dev;yaml-dev;nasm;zlib-ng-dev"
|
||||
skip-binary: aiohttp;multidict;propcache;yarl;SQLAlchemy
|
||||
@@ -200,7 +200,7 @@ jobs:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
arch: ${{ matrix.arch }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }} # zizmor: ignore[secrets-outside-env]
|
||||
env-file: true
|
||||
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev"
|
||||
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
|
||||
|
||||
@@ -18,7 +18,7 @@ repos:
|
||||
exclude_types: [csv, json, html]
|
||||
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
|
||||
- repo: https://github.com/zizmorcore/zizmor-pre-commit
|
||||
rev: v1.22.0
|
||||
rev: v1.23.1
|
||||
hooks:
|
||||
- id: zizmor
|
||||
args:
|
||||
|
||||
@@ -14,13 +14,11 @@ from .coordinator import PranaConfigEntry, PranaCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Keep platforms sorted alphabetically to satisfy lint rule
|
||||
PLATFORMS = [Platform.SWITCH]
|
||||
PLATFORMS = [Platform.FAN, Platform.SWITCH]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: PranaConfigEntry) -> bool:
|
||||
"""Set up Prana from a config entry."""
|
||||
|
||||
coordinator = PranaCoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
186
homeassistant/components/prana/fan.py
Normal file
186
homeassistant/components/prana/fan.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""Fan platform for Prana integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from prana_local_api_client.models.prana_state import FanState
|
||||
|
||||
from homeassistant.components.fan import (
|
||||
FanEntity,
|
||||
FanEntityDescription,
|
||||
FanEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.percentage import (
|
||||
percentage_to_ranged_value,
|
||||
ranged_value_to_percentage,
|
||||
)
|
||||
from homeassistant.util.scaling import int_states_in_range
|
||||
|
||||
from . import PranaConfigEntry
|
||||
from .entity import PranaBaseEntity, PranaCoordinator, PranaEntityDescription, StrEnum
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
# The Prana device API expects fan speed values in scaled units (tenths of a speed step)
|
||||
# rather than the raw step value used internally by this integration. This factor is
|
||||
# applied when sending speeds to the API to match its expected units.
|
||||
PRANA_SPEED_MULTIPLIER = 10
|
||||
|
||||
|
||||
class PranaFanType(StrEnum):
|
||||
"""Enumerates Prana fan types exposed by the device API."""
|
||||
|
||||
SUPPLY = "supply"
|
||||
EXTRACT = "extract"
|
||||
BOUNDED = "bounded"
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class PranaFanEntityDescription(FanEntityDescription, PranaEntityDescription):
|
||||
"""Description of a Prana fan entity."""
|
||||
|
||||
value_fn: Callable[[PranaCoordinator], FanState]
|
||||
speed_range: Callable[[PranaCoordinator], tuple[int, int]]
|
||||
|
||||
|
||||
ENTITIES: tuple[PranaEntityDescription, ...] = (
|
||||
PranaFanEntityDescription(
|
||||
key=PranaFanType.SUPPLY,
|
||||
translation_key="supply",
|
||||
value_fn=lambda coord: (
|
||||
coord.data.supply if not coord.data.bound else coord.data.bounded
|
||||
),
|
||||
speed_range=lambda coord: (
|
||||
1,
|
||||
coord.data.supply.max_speed
|
||||
if not coord.data.bound
|
||||
else coord.data.bounded.max_speed,
|
||||
),
|
||||
),
|
||||
PranaFanEntityDescription(
|
||||
key=PranaFanType.EXTRACT,
|
||||
translation_key="extract",
|
||||
value_fn=lambda coord: (
|
||||
coord.data.extract if not coord.data.bound else coord.data.bounded
|
||||
),
|
||||
speed_range=lambda coord: (
|
||||
1,
|
||||
coord.data.extract.max_speed
|
||||
if not coord.data.bound
|
||||
else coord.data.bounded.max_speed,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: PranaConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Prana fan entities from a config entry."""
|
||||
async_add_entities(
|
||||
PranaFan(entry.runtime_data, entity_description)
|
||||
for entity_description in ENTITIES
|
||||
)
|
||||
|
||||
|
||||
class PranaFan(PranaBaseEntity, FanEntity):
|
||||
"""Representation of a Prana fan entity."""
|
||||
|
||||
entity_description: PranaFanEntityDescription
|
||||
_attr_preset_modes = ["night", "boost"]
|
||||
_attr_supported_features = (
|
||||
FanEntityFeature.SET_SPEED
|
||||
| FanEntityFeature.TURN_ON
|
||||
| FanEntityFeature.TURN_OFF
|
||||
| FanEntityFeature.PRESET_MODE
|
||||
)
|
||||
|
||||
@property
|
||||
def _api_target_key(self) -> str:
|
||||
"""Return the correct target key for API commands based on bounded state."""
|
||||
# If the device is in bound mode, both supply and extract fans control the same bounded fan speeds.
|
||||
if self.coordinator.data.bound:
|
||||
return PranaFanType.BOUNDED
|
||||
# Otherwise, return the specific fan type (supply or extract) for API commands.
|
||||
return self.entity_description.key
|
||||
|
||||
@property
|
||||
def speed_count(self) -> int:
|
||||
"""Return the number of speeds the fan supports."""
|
||||
return int_states_in_range(
|
||||
self.entity_description.speed_range(self.coordinator)
|
||||
)
|
||||
|
||||
@property
|
||||
def percentage(self) -> int | None:
|
||||
"""Return the current fan speed percentage."""
|
||||
current_speed = self.entity_description.value_fn(self.coordinator).speed
|
||||
return ranged_value_to_percentage(
|
||||
self.entity_description.speed_range(self.coordinator), current_speed
|
||||
)
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set fan speed (0-100%) by converting to device-specific speed steps."""
|
||||
if percentage == 0:
|
||||
await self.async_turn_off()
|
||||
return
|
||||
await self.coordinator.api_client.set_speed(
|
||||
math.ceil(
|
||||
percentage_to_ranged_value(
|
||||
self.entity_description.speed_range(self.coordinator),
|
||||
percentage,
|
||||
)
|
||||
)
|
||||
* PRANA_SPEED_MULTIPLIER,
|
||||
self._api_target_key,
|
||||
)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if the fan is on."""
|
||||
return self.entity_description.value_fn(self.coordinator).is_on
|
||||
|
||||
async def async_turn_on(
|
||||
self,
|
||||
percentage: int | None = None,
|
||||
preset_mode: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Turn the fan on and optionally set speed or preset mode."""
|
||||
if percentage == 0:
|
||||
await self.async_turn_off()
|
||||
return
|
||||
|
||||
await self.coordinator.api_client.set_speed_is_on(True, self._api_target_key)
|
||||
if percentage is not None:
|
||||
await self.async_set_percentage(percentage)
|
||||
if preset_mode is not None:
|
||||
await self.async_set_preset_mode(preset_mode)
|
||||
if percentage is None and preset_mode is None:
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the fan off."""
|
||||
await self.coordinator.api_client.set_speed_is_on(False, self._api_target_key)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set the preset mode (e.g., night or boost)."""
|
||||
await self.coordinator.api_client.set_switch(preset_mode, True)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the current preset mode."""
|
||||
if self.coordinator.data.night:
|
||||
return "night"
|
||||
if self.coordinator.data.boost:
|
||||
return "boost"
|
||||
return None
|
||||
@@ -1,5 +1,13 @@
|
||||
{
|
||||
"entity": {
|
||||
"fan": {
|
||||
"extract": {
|
||||
"default": "mdi:arrow-expand-right"
|
||||
},
|
||||
"supply": {
|
||||
"default": "mdi:arrow-expand-left"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"auto": {
|
||||
"default": "mdi:fan-auto"
|
||||
|
||||
@@ -25,6 +25,30 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"fan": {
|
||||
"extract": {
|
||||
"name": "Extract fan",
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"boost": "Boost",
|
||||
"night": "Night"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"supply": {
|
||||
"name": "Supply fan",
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"boost": "[%key:component::prana::entity::fan::extract::state_attributes::preset_mode::state::boost%]",
|
||||
"night": "[%key:component::prana::entity::fan::extract::state_attributes::preset_mode::state::night%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"auto": {
|
||||
"name": "Auto"
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["tuya_sharing"],
|
||||
"requirements": [
|
||||
"tuya-device-handlers==0.0.11",
|
||||
"tuya-device-handlers==0.0.12",
|
||||
"tuya-device-sharing-sdk==0.2.8"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"universal_silabs_flasher",
|
||||
"serialx"
|
||||
],
|
||||
"requirements": ["zha==1.0.1", "serialx==0.6.2"],
|
||||
"requirements": ["zha==1.0.2", "serialx==0.6.2"],
|
||||
"usb": [
|
||||
{
|
||||
"description": "*2652*",
|
||||
|
||||
4
requirements_all.txt
generated
4
requirements_all.txt
generated
@@ -3130,7 +3130,7 @@ ttls==1.8.3
|
||||
ttn_client==1.2.3
|
||||
|
||||
# homeassistant.components.tuya
|
||||
tuya-device-handlers==0.0.11
|
||||
tuya-device-handlers==0.0.12
|
||||
|
||||
# homeassistant.components.tuya
|
||||
tuya-device-sharing-sdk==0.2.8
|
||||
@@ -3356,7 +3356,7 @@ zeroconf==0.148.0
|
||||
zeversolar==0.3.2
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha==1.0.1
|
||||
zha==1.0.2
|
||||
|
||||
# homeassistant.components.zhong_hong
|
||||
zhong-hong-hvac==1.0.13
|
||||
|
||||
4
requirements_test_all.txt
generated
4
requirements_test_all.txt
generated
@@ -2633,7 +2633,7 @@ ttls==1.8.3
|
||||
ttn_client==1.2.3
|
||||
|
||||
# homeassistant.components.tuya
|
||||
tuya-device-handlers==0.0.11
|
||||
tuya-device-handlers==0.0.12
|
||||
|
||||
# homeassistant.components.tuya
|
||||
tuya-device-sharing-sdk==0.2.8
|
||||
@@ -2829,7 +2829,7 @@ zeroconf==0.148.0
|
||||
zeversolar==0.3.2
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha==1.0.1
|
||||
zha==1.0.2
|
||||
|
||||
# homeassistant.components.zinvolt
|
||||
zinvolt==0.3.0
|
||||
|
||||
2
requirements_test_pre_commit.txt
generated
2
requirements_test_pre_commit.txt
generated
@@ -3,4 +3,4 @@
|
||||
codespell==2.4.1
|
||||
ruff==0.15.1
|
||||
yamllint==1.37.1
|
||||
zizmor==1.22.0
|
||||
zizmor==1.23.1
|
||||
|
||||
@@ -4,6 +4,8 @@ from collections.abc import Generator
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from prana_local_api_client.models.prana_device_info import PranaDeviceInfo
|
||||
from prana_local_api_client.models.prana_state import PranaState
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.prana.const import DOMAIN
|
||||
@@ -44,8 +46,8 @@ def mock_prana_api() -> Generator[AsyncMock]:
|
||||
device_info_data = load_json_object_fixture("device_info.json", DOMAIN)
|
||||
state_data = load_json_object_fixture("state.json", DOMAIN)
|
||||
|
||||
device_info_obj = SimpleNamespace(**device_info_data)
|
||||
state_obj = SimpleNamespace(**state_data)
|
||||
device_info_obj = PranaDeviceInfo.from_dict(device_info_data)
|
||||
state_obj = PranaState.from_dict(state_data)
|
||||
|
||||
mock_api_class.return_value.get_device_info = AsyncMock(
|
||||
return_value=device_info_obj
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
"manufactureId": "ECC9FFE0E574",
|
||||
"isValid": true,
|
||||
"fwVersion": 46,
|
||||
"pranaModel": "PRANA RECUPERATOR 150",
|
||||
"pranaModel": "PRANA RECUPERATOR 200",
|
||||
"label": "PRANA RECUPERATOR"
|
||||
}
|
||||
|
||||
@@ -21,5 +21,7 @@
|
||||
"winter": false,
|
||||
"inside_temperature": 217,
|
||||
"humidity": 56,
|
||||
"brightness": 6
|
||||
"brightness": 6,
|
||||
"night": false,
|
||||
"boost": false
|
||||
}
|
||||
|
||||
125
tests/components/prana/snapshots/test_fan.ambr
Normal file
125
tests/components/prana/snapshots/test_fan.ambr
Normal file
@@ -0,0 +1,125 @@
|
||||
# serializer version: 1
|
||||
# name: test_fans[fan.prana_recuperator_extract_fan-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'preset_modes': list([
|
||||
'night',
|
||||
'boost',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'fan',
|
||||
'entity_category': None,
|
||||
'entity_id': 'fan.prana_recuperator_extract_fan',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Extract fan',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Extract fan',
|
||||
'platform': 'prana',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': <FanEntityFeature: 57>,
|
||||
'translation_key': 'extract',
|
||||
'unique_id': 'ECC9FFE0E574_extract',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_fans[fan.prana_recuperator_extract_fan-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'PRANA RECUPERATOR Extract fan',
|
||||
'percentage': 10,
|
||||
'percentage_step': 10.0,
|
||||
'preset_mode': None,
|
||||
'preset_modes': list([
|
||||
'night',
|
||||
'boost',
|
||||
]),
|
||||
'supported_features': <FanEntityFeature: 57>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'fan.prana_recuperator_extract_fan',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_fans[fan.prana_recuperator_supply_fan-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'preset_modes': list([
|
||||
'night',
|
||||
'boost',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'fan',
|
||||
'entity_category': None,
|
||||
'entity_id': 'fan.prana_recuperator_supply_fan',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Supply fan',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Supply fan',
|
||||
'platform': 'prana',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': <FanEntityFeature: 57>,
|
||||
'translation_key': 'supply',
|
||||
'unique_id': 'ECC9FFE0E574_supply',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_fans[fan.prana_recuperator_supply_fan-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'PRANA RECUPERATOR Supply fan',
|
||||
'percentage': 10,
|
||||
'percentage_step': 10.0,
|
||||
'preset_mode': None,
|
||||
'preset_modes': list([
|
||||
'night',
|
||||
'boost',
|
||||
]),
|
||||
'supported_features': <FanEntityFeature: 57>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'fan.prana_recuperator_supply_fan',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
@@ -20,7 +20,7 @@
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Prana',
|
||||
'model': 'PRANA RECUPERATOR 150',
|
||||
'model': 'PRANA RECUPERATOR 200',
|
||||
'model_id': None,
|
||||
'name': 'PRANA RECUPERATOR',
|
||||
'name_by_user': None,
|
||||
|
||||
203
tests/components/prana/test_fan.py
Normal file
203
tests/components/prana/test_fan.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""Integration-style tests for Prana fans."""
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.fan import (
|
||||
ATTR_PERCENTAGE,
|
||||
ATTR_PRESET_MODE,
|
||||
DOMAIN as FAN_DOMAIN,
|
||||
SERVICE_SET_PERCENTAGE,
|
||||
SERVICE_SET_PRESET_MODE,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
)
|
||||
from homeassistant.components.prana.fan import PRANA_SPEED_MULTIPLIER
|
||||
from homeassistant.const import ATTR_ENTITY_ID, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.util.percentage import percentage_to_ranged_value
|
||||
|
||||
from . import async_init_integration
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
FAN_TEST_CASES = [
|
||||
("supply", False, "supply"),
|
||||
("extract", False, "extract"),
|
||||
("supply", True, "bounded"),
|
||||
("extract", True, "bounded"),
|
||||
]
|
||||
|
||||
|
||||
async def _async_setup_fan_entity(
|
||||
hass: HomeAssistant,
|
||||
mock_prana_api: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
type_key: str,
|
||||
is_bound_mode: bool,
|
||||
) -> tuple[str, Any]:
|
||||
"""Set up a Prana fan entity for service tests."""
|
||||
mock_prana_api.get_state.return_value.bound = is_bound_mode
|
||||
fan_mock_state = getattr(
|
||||
mock_prana_api.get_state.return_value,
|
||||
"bounded" if is_bound_mode else type_key,
|
||||
)
|
||||
|
||||
await async_init_integration(hass, mock_config_entry)
|
||||
|
||||
unique_id = f"{mock_config_entry.unique_id}_{type_key}"
|
||||
target = entity_registry.async_get_entity_id(FAN_DOMAIN, "prana", unique_id)
|
||||
|
||||
assert target, f"Entity with unique_id {unique_id} not found"
|
||||
|
||||
return target, fan_mock_state
|
||||
|
||||
|
||||
async def test_fans(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mock_prana_api: AsyncMock,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test the Prana fans snapshot."""
|
||||
with patch("homeassistant.components.prana.PLATFORMS", [Platform.FAN]):
|
||||
await async_init_integration(hass, mock_config_entry)
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("type_key", "is_bound_mode", "expected_api_key"),
|
||||
FAN_TEST_CASES,
|
||||
)
|
||||
async def test_fans_turn_on_off(
|
||||
hass: HomeAssistant,
|
||||
mock_prana_api: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
type_key: str,
|
||||
is_bound_mode: bool,
|
||||
expected_api_key: str,
|
||||
) -> None:
|
||||
"""Test turning Prana fans on and off."""
|
||||
target, fan_mock_state = await _async_setup_fan_entity(
|
||||
hass,
|
||||
mock_prana_api,
|
||||
mock_config_entry,
|
||||
entity_registry,
|
||||
type_key,
|
||||
is_bound_mode,
|
||||
)
|
||||
|
||||
fan_mock_state.is_on = True
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.services.async_call(
|
||||
FAN_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
{ATTR_ENTITY_ID: target},
|
||||
blocking=True,
|
||||
)
|
||||
mock_prana_api.set_speed_is_on.assert_called_with(False, expected_api_key)
|
||||
mock_prana_api.reset_mock()
|
||||
|
||||
fan_mock_state.is_on = False
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.services.async_call(
|
||||
FAN_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: target},
|
||||
blocking=True,
|
||||
)
|
||||
mock_prana_api.set_speed_is_on.assert_called_with(True, expected_api_key)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("type_key", "is_bound_mode", "expected_api_key"),
|
||||
FAN_TEST_CASES,
|
||||
)
|
||||
async def test_fans_set_percentage(
|
||||
hass: HomeAssistant,
|
||||
mock_prana_api: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
type_key: str,
|
||||
is_bound_mode: bool,
|
||||
expected_api_key: str,
|
||||
) -> None:
|
||||
"""Test setting the Prana fan percentage."""
|
||||
target, fan_mock_state = await _async_setup_fan_entity(
|
||||
hass,
|
||||
mock_prana_api,
|
||||
mock_config_entry,
|
||||
entity_registry,
|
||||
type_key,
|
||||
is_bound_mode,
|
||||
)
|
||||
|
||||
fan_mock_state.is_on = True
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.services.async_call(
|
||||
FAN_DOMAIN,
|
||||
SERVICE_SET_PERCENTAGE,
|
||||
{ATTR_ENTITY_ID: target, ATTR_PERCENTAGE: 50},
|
||||
blocking=True,
|
||||
)
|
||||
expected_speed = (
|
||||
math.ceil(percentage_to_ranged_value((1, fan_mock_state.max_speed), 50))
|
||||
* PRANA_SPEED_MULTIPLIER
|
||||
)
|
||||
mock_prana_api.set_speed.assert_called_once_with(
|
||||
expected_speed,
|
||||
expected_api_key,
|
||||
)
|
||||
mock_prana_api.reset_mock()
|
||||
|
||||
await hass.services.async_call(
|
||||
FAN_DOMAIN,
|
||||
SERVICE_SET_PERCENTAGE,
|
||||
{ATTR_ENTITY_ID: target, ATTR_PERCENTAGE: 0},
|
||||
blocking=True,
|
||||
)
|
||||
mock_prana_api.set_speed_is_on.assert_called_with(False, expected_api_key)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("type_key", "is_bound_mode", "expected_api_key"),
|
||||
FAN_TEST_CASES,
|
||||
)
|
||||
async def test_fans_set_preset_mode(
|
||||
hass: HomeAssistant,
|
||||
mock_prana_api: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
type_key: str,
|
||||
is_bound_mode: bool,
|
||||
expected_api_key: str,
|
||||
) -> None:
|
||||
"""Test setting the Prana fan preset mode."""
|
||||
target, _ = await _async_setup_fan_entity(
|
||||
hass,
|
||||
mock_prana_api,
|
||||
mock_config_entry,
|
||||
entity_registry,
|
||||
type_key,
|
||||
is_bound_mode,
|
||||
)
|
||||
|
||||
await hass.services.async_call(
|
||||
FAN_DOMAIN,
|
||||
SERVICE_SET_PRESET_MODE,
|
||||
{ATTR_ENTITY_ID: target, ATTR_PRESET_MODE: "night"},
|
||||
blocking=True,
|
||||
)
|
||||
mock_prana_api.set_switch.assert_called_with("night", True)
|
||||
Reference in New Issue
Block a user