Compare commits

...

4 Commits

Author SHA1 Message Date
Jan Čermák
f208c16cb6 Update zizmor to v1.23.1
Zizmor v1.23.0 added new
[secrets-outside-env](https://docs.zizmor.sh/audits/#secrets-outside-env) that
can't satisfied without having admin access to the repo settings, so they're
currently ignored unless there's decision to harden this configuration.

Full changelog:
* https://github.com/zizmorcore/zizmor/blob/v1.23.1/docs/release-notes.md
2026-03-13 16:22:21 +01:00
TheJulianJES
34a7fcf8d3 Bump ZHA to 1.0.2 (#165423) 2026-03-13 16:15:51 +01:00
prana-dev-official
95a57a2984 Add fan platform for Prana Integration (#163379)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-13 16:05:37 +01:00
epenet
7f39cc0aeb Bump tuya-device-handlers to 0.0.12 (#165462) 2026-03-13 15:58:12 +01:00
21 changed files with 572 additions and 24 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View 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

View File

@@ -1,5 +1,13 @@
{
"entity": {
"fan": {
"extract": {
"default": "mdi:arrow-expand-right"
},
"supply": {
"default": "mdi:arrow-expand-left"
}
},
"switch": {
"auto": {
"default": "mdi:fan-auto"

View File

@@ -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"

View File

@@ -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"
]
}

View File

@@ -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
View File

@@ -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

View File

@@ -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

View File

@@ -3,4 +3,4 @@
codespell==2.4.1
ruff==0.15.1
yamllint==1.37.1
zizmor==1.22.0
zizmor==1.23.1

View File

@@ -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

View File

@@ -2,6 +2,6 @@
"manufactureId": "ECC9FFE0E574",
"isValid": true,
"fwVersion": 46,
"pranaModel": "PRANA RECUPERATOR 150",
"pranaModel": "PRANA RECUPERATOR 200",
"label": "PRANA RECUPERATOR"
}

View File

@@ -21,5 +21,7 @@
"winter": false,
"inside_temperature": 217,
"humidity": 56,
"brightness": 6
"brightness": 6,
"night": false,
"boost": false
}

View 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',
})
# ---

View File

@@ -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,

View 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)