mirror of
https://github.com/home-assistant/core.git
synced 2026-03-16 16:02:06 +01:00
Compare commits
43 Commits
add_motion
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0167182e2e | ||
|
|
11411a880d | ||
|
|
ce47abe1d3 | ||
|
|
b58513c19a | ||
|
|
4e1dab6d8b | ||
|
|
5ae8e1c319 | ||
|
|
17bf6ca591 | ||
|
|
256d30c38d | ||
|
|
5d182394c2 | ||
|
|
011e6863d8 | ||
|
|
b902b590b1 | ||
|
|
960666e15b | ||
|
|
1fb59c9f11 | ||
|
|
332bf95e16 | ||
|
|
e35fc8267e | ||
|
|
f8b4ffc0d7 | ||
|
|
003ee5a699 | ||
|
|
c91d805174 | ||
|
|
c478d19ae3 | ||
|
|
09169b0f06 | ||
|
|
aa1dbee315 | ||
|
|
daf89e5673 | ||
|
|
85dc81c147 | ||
|
|
5acf24cb53 | ||
|
|
79829a311c | ||
|
|
ce2c62ae28 | ||
|
|
1cda3f47d6 | ||
|
|
e254716615 | ||
|
|
1d410f4cbd | ||
|
|
6616793e2b | ||
|
|
6766961327 | ||
|
|
dd6fc11d28 | ||
|
|
cb5b8b212c | ||
|
|
66b96d096e | ||
|
|
e86160de36 | ||
|
|
7617007edd | ||
|
|
3e065b31b3 | ||
|
|
5f909a6f3a | ||
|
|
6117a20ec6 | ||
|
|
93bc05bb3f | ||
|
|
e7397ccaa7 | ||
|
|
91a43873a2 | ||
|
|
469e06fb8c |
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:
|
||||
|
||||
@@ -78,19 +78,13 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity):
|
||||
index: int = 0,
|
||||
) -> None:
|
||||
"""Initialize a pipeline selector."""
|
||||
if index < 1:
|
||||
# Keep compatibility
|
||||
key_suffix = ""
|
||||
placeholder = ""
|
||||
else:
|
||||
key_suffix = f"_{index + 1}"
|
||||
placeholder = f" {index + 1}"
|
||||
|
||||
self.entity_description = replace(
|
||||
self.entity_description,
|
||||
key=f"pipeline{key_suffix}",
|
||||
translation_placeholders={"index": placeholder},
|
||||
)
|
||||
if index >= 1:
|
||||
self.entity_description = replace(
|
||||
self.entity_description,
|
||||
key=f"pipeline_{index + 1}",
|
||||
translation_key="pipeline_n",
|
||||
translation_placeholders={"index": str(index + 1)},
|
||||
)
|
||||
|
||||
self._domain = domain
|
||||
self._unique_id_prefix = unique_id_prefix
|
||||
|
||||
@@ -7,11 +7,17 @@
|
||||
},
|
||||
"select": {
|
||||
"pipeline": {
|
||||
"name": "Assistant{index}",
|
||||
"name": "Assistant",
|
||||
"state": {
|
||||
"preferred": "Preferred"
|
||||
}
|
||||
},
|
||||
"pipeline_n": {
|
||||
"name": "Assistant {index}",
|
||||
"state": {
|
||||
"preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]"
|
||||
}
|
||||
},
|
||||
"vad_sensitivity": {
|
||||
"name": "Finished speaking detection",
|
||||
"state": {
|
||||
|
||||
@@ -128,7 +128,6 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"motion",
|
||||
"person",
|
||||
"siren",
|
||||
"switch",
|
||||
|
||||
@@ -13,7 +13,6 @@ from homeassistant.helpers.trigger import (
|
||||
TriggerConfig,
|
||||
make_entity_numerical_state_changed_trigger,
|
||||
make_entity_numerical_state_crossed_threshold_trigger,
|
||||
make_entity_target_state_attribute_trigger,
|
||||
make_entity_target_state_trigger,
|
||||
make_entity_transition_trigger,
|
||||
)
|
||||
@@ -47,11 +46,11 @@ class HVACModeChangedTrigger(EntityTargetStateTriggerBase):
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"hvac_mode_changed": HVACModeChangedTrigger,
|
||||
"started_cooling": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
|
||||
"started_cooling": make_entity_target_state_trigger(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.COOLING
|
||||
),
|
||||
"started_drying": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
|
||||
"started_drying": make_entity_target_state_trigger(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
|
||||
),
|
||||
"target_humidity_changed": make_entity_numerical_state_changed_trigger(
|
||||
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}
|
||||
@@ -80,8 +79,8 @@ TRIGGERS: dict[str, type[Trigger]] = {
|
||||
HVACMode.HEAT_COOL,
|
||||
},
|
||||
),
|
||||
"started_heating": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.HEATING
|
||||
"started_heating": make_entity_target_state_trigger(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiocomelit"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiocomelit==2.0.0"]
|
||||
"requirements": ["aiocomelit==2.0.1"]
|
||||
}
|
||||
|
||||
@@ -123,19 +123,13 @@ class EsphomeAssistSatelliteWakeWordSelect(
|
||||
|
||||
def __init__(self, entry_data: RuntimeEntryData, index: int = 0) -> None:
|
||||
"""Initialize a wake word selector."""
|
||||
if index < 1:
|
||||
# Keep compatibility
|
||||
key_suffix = ""
|
||||
placeholder = ""
|
||||
else:
|
||||
key_suffix = f"_{index + 1}"
|
||||
placeholder = f" {index + 1}"
|
||||
|
||||
self.entity_description = replace(
|
||||
self.entity_description,
|
||||
key=f"wake_word{key_suffix}",
|
||||
translation_placeholders={"index": placeholder},
|
||||
)
|
||||
if index >= 1:
|
||||
self.entity_description = replace(
|
||||
self.entity_description,
|
||||
key=f"wake_word_{index + 1}",
|
||||
translation_key="wake_word_n",
|
||||
translation_placeholders={"index": str(index + 1)},
|
||||
)
|
||||
|
||||
EsphomeAssistEntity.__init__(self, entry_data)
|
||||
|
||||
|
||||
@@ -107,6 +107,12 @@
|
||||
"preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]"
|
||||
}
|
||||
},
|
||||
"pipeline_n": {
|
||||
"name": "[%key:component::assist_pipeline::entity::select::pipeline_n::name%]",
|
||||
"state": {
|
||||
"preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]"
|
||||
}
|
||||
},
|
||||
"vad_sensitivity": {
|
||||
"name": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::name%]",
|
||||
"state": {
|
||||
@@ -116,11 +122,18 @@
|
||||
}
|
||||
},
|
||||
"wake_word": {
|
||||
"name": "Wake word{index}",
|
||||
"name": "Wake word",
|
||||
"state": {
|
||||
"no_wake_word": "No wake word",
|
||||
"okay_nabu": "Okay Nabu"
|
||||
}
|
||||
},
|
||||
"wake_word_n": {
|
||||
"name": "Wake word {index}",
|
||||
"state": {
|
||||
"no_wake_word": "[%key:component::esphome::entity::select::wake_word::state::no_wake_word%]",
|
||||
"okay_nabu": "[%key:component::esphome::entity::select::wake_word::state::okay_nabu%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["fritzconnection"],
|
||||
"quality_scale": "bronze",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["fritzconnection[qr]==1.15.1", "xmltodict==1.0.2"],
|
||||
"ssdp": [
|
||||
{
|
||||
|
||||
@@ -29,9 +29,7 @@ rules:
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: we are close to the goal of 95%
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
|
||||
@@ -133,26 +133,20 @@ async def _async_wifi_entities_list(
|
||||
]
|
||||
)
|
||||
_LOGGER.debug("WiFi networks count: %s", wifi_count)
|
||||
networks: dict = {}
|
||||
networks: dict[int, dict[str, Any]] = {}
|
||||
for i in range(1, wifi_count + 1):
|
||||
network_info = await avm_wrapper.async_get_wlan_configuration(i)
|
||||
# Devices with 4 WLAN services, use the 2nd for internal communications
|
||||
if not (wifi_count == 4 and i == 2):
|
||||
networks[i] = {
|
||||
"ssid": network_info["NewSSID"],
|
||||
"bssid": network_info["NewBSSID"],
|
||||
"standard": network_info["NewStandard"],
|
||||
"enabled": network_info["NewEnable"],
|
||||
"status": network_info["NewStatus"],
|
||||
}
|
||||
networks[i] = network_info
|
||||
for i, network in networks.copy().items():
|
||||
networks[i]["switch_name"] = network["ssid"]
|
||||
networks[i]["switch_name"] = network["NewSSID"]
|
||||
if (
|
||||
len(
|
||||
[
|
||||
j
|
||||
for j, n in networks.items()
|
||||
if slugify(n["ssid"]) == slugify(network["ssid"])
|
||||
if slugify(n["NewSSID"]) == slugify(network["NewSSID"])
|
||||
]
|
||||
)
|
||||
> 1
|
||||
@@ -434,13 +428,11 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch):
|
||||
for key, attr in attributes_dict.items():
|
||||
self._attributes[attr] = self.port_mapping[key]
|
||||
|
||||
async def _async_switch_on_off_executor(self, turn_on: bool) -> bool:
|
||||
async def _async_switch_on_off_executor(self, turn_on: bool) -> None:
|
||||
self.port_mapping["NewEnabled"] = "1" if turn_on else "0"
|
||||
|
||||
resp = await self._avm_wrapper.async_add_port_mapping(
|
||||
await self._avm_wrapper.async_add_port_mapping(
|
||||
self.connection_type, self.port_mapping
|
||||
)
|
||||
return bool(resp is not None)
|
||||
|
||||
|
||||
class FritzBoxDeflectionSwitch(FritzBoxBaseCoordinatorSwitch):
|
||||
@@ -525,12 +517,11 @@ class FritzBoxProfileSwitch(FritzDeviceBase, SwitchEntity):
|
||||
"""Turn off switch."""
|
||||
await self._async_handle_turn_on_off(turn_on=False)
|
||||
|
||||
async def _async_handle_turn_on_off(self, turn_on: bool) -> bool:
|
||||
async def _async_handle_turn_on_off(self, turn_on: bool) -> None:
|
||||
"""Handle switch state change request."""
|
||||
await self._avm_wrapper.async_set_allow_wan_access(self.ip_address, turn_on)
|
||||
self._avm_wrapper.devices[self._mac].wan_access = turn_on
|
||||
self.async_write_ha_state()
|
||||
return True
|
||||
|
||||
|
||||
class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
@@ -541,10 +532,11 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
avm_wrapper: AvmWrapper,
|
||||
device_friendly_name: str,
|
||||
network_num: int,
|
||||
network_data: dict,
|
||||
network_data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Init Fritz Wifi switch."""
|
||||
self._avm_wrapper = avm_wrapper
|
||||
self._wifi_info = network_data
|
||||
|
||||
self._attributes = {}
|
||||
self._attr_entity_category = EntityCategory.CONFIG
|
||||
@@ -560,7 +552,7 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
type=SWITCH_TYPE_WIFINETWORK,
|
||||
callback_update=self._async_fetch_update,
|
||||
callback_switch=self._async_switch_on_off_executor,
|
||||
init_state=network_data["enabled"],
|
||||
init_state=network_data["NewEnable"],
|
||||
)
|
||||
super().__init__(self._avm_wrapper, device_friendly_name, switch_info)
|
||||
|
||||
@@ -587,7 +579,9 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
self._attributes["mac_address_control"] = wifi_info[
|
||||
"NewMACAddressControlEnabled"
|
||||
]
|
||||
self._wifi_info = wifi_info
|
||||
|
||||
async def _async_switch_on_off_executor(self, turn_on: bool) -> None:
|
||||
"""Handle wifi switch."""
|
||||
self._wifi_info["NewEnable"] = turn_on
|
||||
await self._avm_wrapper.async_set_wlan_configuration(self._network_num, turn_on)
|
||||
|
||||
@@ -45,6 +45,7 @@ RESPONSE_HEADERS_FILTER = {
|
||||
}
|
||||
|
||||
MIN_COMPRESSED_SIZE = 128
|
||||
MAX_WEBSOCKET_MESSAGE_SIZE = 16 * 1024 * 1024 # 16 MiB
|
||||
MAX_SIMPLE_RESPONSE_SIZE = 4194000
|
||||
|
||||
DISABLED_TIMEOUT = ClientTimeout(total=None)
|
||||
@@ -126,7 +127,10 @@ class HassIOIngress(HomeAssistantView):
|
||||
req_protocols = ()
|
||||
|
||||
ws_server = web.WebSocketResponse(
|
||||
protocols=req_protocols, autoclose=False, autoping=False
|
||||
protocols=req_protocols,
|
||||
autoclose=False,
|
||||
autoping=False,
|
||||
max_msg_size=MAX_WEBSOCKET_MESSAGE_SIZE,
|
||||
)
|
||||
await ws_server.prepare(request)
|
||||
|
||||
@@ -149,6 +153,7 @@ class HassIOIngress(HomeAssistantView):
|
||||
protocols=req_protocols,
|
||||
autoclose=False,
|
||||
autoping=False,
|
||||
max_msg_size=MAX_WEBSOCKET_MESSAGE_SIZE,
|
||||
) as ws_client:
|
||||
# Proxy requests
|
||||
await asyncio.wait(
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/hassio",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["aiohasupervisor==0.3.3"],
|
||||
"requirements": ["aiohasupervisor==0.4.1"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -225,10 +225,6 @@
|
||||
"description": "System is unsupported because Home Assistant cannot determine when an Internet connection is available. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - Connectivity check disabled"
|
||||
},
|
||||
"unsupported_content_trust": {
|
||||
"description": "System is unsupported because Home Assistant cannot verify content being run is trusted and not modified by attackers. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - Content-trust check disabled"
|
||||
},
|
||||
"unsupported_dbus": {
|
||||
"description": "System is unsupported because D-Bus is working incorrectly. Many things fail without this as Supervisor cannot communicate with the host. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - D-Bus issues"
|
||||
@@ -281,10 +277,6 @@
|
||||
"description": "System is unsupported because additional software outside the Home Assistant ecosystem has been detected. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - Unsupported software"
|
||||
},
|
||||
"unsupported_source_mods": {
|
||||
"description": "System is unsupported because Supervisor source code has been modified. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - Supervisor source modifications"
|
||||
},
|
||||
"unsupported_supervisor_version": {
|
||||
"description": "System is unsupported because an out-of-date version of Supervisor is in use and auto-update has been disabled. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - Supervisor version"
|
||||
|
||||
@@ -38,6 +38,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.CLIMATE,
|
||||
Platform.FAN,
|
||||
Platform.LIGHT,
|
||||
Platform.NUMBER,
|
||||
|
||||
325
homeassistant/components/home_connect/climate.py
Normal file
325
homeassistant/components/home_connect/climate.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""Provides climate entities for Home Connect."""
|
||||
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
from aiohomeconnect.model.program import Execution
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
FAN_AUTO,
|
||||
ClimateEntity,
|
||||
ClimateEntityDescription,
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .common import setup_home_connect_entry
|
||||
from .const import BSH_POWER_ON, BSH_POWER_STANDBY, DOMAIN
|
||||
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
HVAC_MODES_PROGRAMS_MAP = {
|
||||
HVACMode.AUTO: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO,
|
||||
HVACMode.COOL: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_COOL,
|
||||
HVACMode.DRY: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_DRY,
|
||||
HVACMode.FAN_ONLY: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN,
|
||||
HVACMode.HEAT: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_HEAT,
|
||||
}
|
||||
|
||||
PROGRAMS_HVAC_MODES_MAP = {v: k for k, v in HVAC_MODES_PROGRAMS_MAP.items()}
|
||||
|
||||
PRESET_MODES_PROGRAMS_MAP = {
|
||||
"active_clean": ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN,
|
||||
}
|
||||
PROGRAMS_PRESET_MODES_MAP = {v: k for k, v in PRESET_MODES_PROGRAMS_MAP.items()}
|
||||
|
||||
FAN_MODES_OPTIONS = {
|
||||
FAN_AUTO: "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic",
|
||||
"manual": "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual",
|
||||
}
|
||||
|
||||
FAN_MODES_OPTIONS_INVERTED = {v: k for k, v in FAN_MODES_OPTIONS.items()}
|
||||
|
||||
|
||||
AIR_CONDITIONER_ENTITY_DESCRIPTION = ClimateEntityDescription(
|
||||
key="air_conditioner",
|
||||
translation_key="air_conditioner",
|
||||
name=None,
|
||||
)
|
||||
|
||||
|
||||
def _get_entities_for_appliance(
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
) -> list[HomeConnectEntity]:
|
||||
"""Get a list of entities."""
|
||||
return (
|
||||
[HomeConnectAirConditioningEntity(appliance_coordinator)]
|
||||
if (programs := appliance_coordinator.data.programs)
|
||||
and any(
|
||||
program.key in PROGRAMS_HVAC_MODES_MAP
|
||||
and (
|
||||
program.constraints is None
|
||||
or program.constraints.execution
|
||||
in (Execution.SELECT_AND_START, Execution.START_ONLY)
|
||||
)
|
||||
for program in programs
|
||||
)
|
||||
else []
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HomeConnectConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Home Connect climate entities."""
|
||||
setup_home_connect_entry(
|
||||
hass,
|
||||
entry,
|
||||
_get_entities_for_appliance,
|
||||
async_add_entities,
|
||||
)
|
||||
|
||||
|
||||
class HomeConnectAirConditioningEntity(HomeConnectEntity, ClimateEntity):
|
||||
"""Representation of a Home Connect climate entity."""
|
||||
|
||||
# Note: The base class requires this to be set even though this
|
||||
# class doesn't support any temperature related functionality.
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HomeConnectApplianceCoordinator,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(
|
||||
coordinator,
|
||||
AIR_CONDITIONER_ENTITY_DESCRIPTION,
|
||||
context_override=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
)
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
"""Return the list of available hvac operation modes."""
|
||||
hvac_modes = [
|
||||
hvac_mode
|
||||
for program in self.appliance.programs
|
||||
if (hvac_mode := PROGRAMS_HVAC_MODES_MAP.get(program.key))
|
||||
and (
|
||||
program.constraints is None
|
||||
or program.constraints.execution
|
||||
in (Execution.SELECT_AND_START, Execution.START_ONLY)
|
||||
)
|
||||
]
|
||||
if SettingKey.BSH_COMMON_POWER_STATE in self.appliance.settings:
|
||||
hvac_modes.append(HVACMode.OFF)
|
||||
return hvac_modes
|
||||
|
||||
@property
|
||||
def preset_modes(self) -> list[str] | None:
|
||||
"""Return a list of available preset modes."""
|
||||
return (
|
||||
[
|
||||
PROGRAMS_PRESET_MODES_MAP[
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN
|
||||
]
|
||||
]
|
||||
if any(
|
||||
program.key
|
||||
is ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN
|
||||
for program in self.appliance.programs
|
||||
)
|
||||
else None
|
||||
)
|
||||
|
||||
@property
|
||||
def supported_features(self) -> ClimateEntityFeature:
|
||||
"""Return the list of supported features."""
|
||||
features = ClimateEntityFeature(0)
|
||||
if SettingKey.BSH_COMMON_POWER_STATE in self.appliance.settings:
|
||||
features |= ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
|
||||
if self.preset_modes:
|
||||
features |= ClimateEntityFeature.PRESET_MODE
|
||||
if self.appliance.options.get(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
|
||||
):
|
||||
features |= ClimateEntityFeature.FAN_MODE
|
||||
return features
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update_fan_mode(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.async_write_ha_state()
|
||||
_LOGGER.debug(
|
||||
"Updated %s (fan mode), new state: %s", self.entity_id, self.fan_mode
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_listener(
|
||||
self.async_write_ha_state,
|
||||
EventKey.BSH_COMMON_APPLIANCE_CONNECTED,
|
||||
)
|
||||
)
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_listener(
|
||||
self._handle_coordinator_update_fan_mode,
|
||||
EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
)
|
||||
)
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_listener(
|
||||
self._handle_coordinator_update,
|
||||
EventKey(SettingKey.BSH_COMMON_POWER_STATE),
|
||||
)
|
||||
)
|
||||
|
||||
def update_native_value(self) -> None:
|
||||
"""Set the HVAC Mode and preset mode values."""
|
||||
event = self.appliance.events.get(EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM)
|
||||
program_key = cast(ProgramKey, event.value) if event else None
|
||||
power_state = self.appliance.settings.get(SettingKey.BSH_COMMON_POWER_STATE)
|
||||
self._attr_hvac_mode = (
|
||||
HVACMode.OFF
|
||||
if power_state is not None and power_state.value != BSH_POWER_ON
|
||||
else PROGRAMS_HVAC_MODES_MAP.get(program_key)
|
||||
if program_key
|
||||
and program_key
|
||||
!= ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN
|
||||
else None
|
||||
)
|
||||
self._attr_preset_mode = (
|
||||
PROGRAMS_PRESET_MODES_MAP.get(program_key)
|
||||
if program_key
|
||||
== ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN
|
||||
else None
|
||||
)
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str | None:
|
||||
"""Return the fan setting."""
|
||||
option_value = None
|
||||
if event := self.appliance.events.get(
|
||||
EventKey(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
|
||||
)
|
||||
):
|
||||
option_value = event.value
|
||||
return (
|
||||
FAN_MODES_OPTIONS_INVERTED.get(cast(str, option_value))
|
||||
if option_value is not None
|
||||
else None
|
||||
)
|
||||
|
||||
@property
|
||||
def fan_modes(self) -> list[str] | None:
|
||||
"""Return the list of available fan modes."""
|
||||
if (
|
||||
(
|
||||
option_definition := self.appliance.options.get(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
|
||||
)
|
||||
)
|
||||
and (option_constraints := option_definition.constraints)
|
||||
and option_constraints.allowed_values
|
||||
):
|
||||
return [
|
||||
fan_mode
|
||||
for fan_mode, api_value in FAN_MODES_OPTIONS.items()
|
||||
if api_value in option_constraints.allowed_values
|
||||
]
|
||||
if option_definition:
|
||||
# Then the constraints or the allowed values are not present
|
||||
# So we stick to the default values
|
||||
return list(FAN_MODES_OPTIONS.keys())
|
||||
return None
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Switch the device on."""
|
||||
try:
|
||||
await self.coordinator.client.set_setting(
|
||||
self.appliance.info.ha_id,
|
||||
setting_key=SettingKey.BSH_COMMON_POWER_STATE,
|
||||
value=BSH_POWER_ON,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="power_on",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
"appliance_name": self.appliance.info.name,
|
||||
"value": BSH_POWER_ON,
|
||||
},
|
||||
) from err
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Switch the device off."""
|
||||
try:
|
||||
await self.coordinator.client.set_setting(
|
||||
self.appliance.info.ha_id,
|
||||
setting_key=SettingKey.BSH_COMMON_POWER_STATE,
|
||||
value=BSH_POWER_STANDBY,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="power_off",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
"appliance_name": self.appliance.info.name,
|
||||
"value": BSH_POWER_STANDBY,
|
||||
},
|
||||
) from err
|
||||
|
||||
async def _set_program(self, program_key: ProgramKey) -> None:
|
||||
try:
|
||||
await self.coordinator.client.start_program(
|
||||
self.appliance.info.ha_id, program_key=program_key
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="start_program",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
"program": program_key.value,
|
||||
},
|
||||
) from err
|
||||
_LOGGER.debug("Updated %s, new state: %s", self.entity_id, self.state)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target hvac mode."""
|
||||
if hvac_mode is HVACMode.OFF:
|
||||
await self.async_turn_off()
|
||||
else:
|
||||
await self._set_program(HVAC_MODES_PROGRAMS_MAP[hvac_mode])
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new preset mode."""
|
||||
await self._set_program(PRESET_MODES_PROGRAMS_MAP[preset_mode])
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set new target fan mode."""
|
||||
await super().async_set_option_with_key(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
FAN_MODES_OPTIONS[fan_mode],
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated %s's speed mode option, new state: %s", self.entity_id, self.state
|
||||
)
|
||||
@@ -79,6 +79,29 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectApplianceCoordinator]):
|
||||
"""
|
||||
return self.appliance.info.connected and self._attr_available
|
||||
|
||||
async def async_set_option_with_key(
|
||||
self, option_key: OptionKey, value: Any
|
||||
) -> None:
|
||||
"""Set an option for the entity."""
|
||||
try:
|
||||
# We try to set the active program option first,
|
||||
# if it fails we try to set the selected program option
|
||||
with contextlib.suppress(ActiveProgramNotSetError):
|
||||
await self.coordinator.client.set_active_program_option(
|
||||
self.appliance.info.ha_id, option_key=option_key, value=value
|
||||
)
|
||||
return
|
||||
|
||||
await self.coordinator.client.set_selected_program_option(
|
||||
self.appliance.info.ha_id, option_key=option_key, value=value
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_option",
|
||||
translation_placeholders=get_dict_from_home_connect_error(err),
|
||||
) from err
|
||||
|
||||
|
||||
class HomeConnectOptionEntity(HomeConnectEntity):
|
||||
"""Class for entities that represents program options."""
|
||||
@@ -95,40 +118,9 @@ class HomeConnectOptionEntity(HomeConnectEntity):
|
||||
return event.value
|
||||
return None
|
||||
|
||||
async def async_set_option(self, value: str | float | bool) -> None:
|
||||
async def async_set_option(self, value: Any) -> None:
|
||||
"""Set an option for the entity."""
|
||||
try:
|
||||
# We try to set the active program option first,
|
||||
# if it fails we try to set the selected program option
|
||||
with contextlib.suppress(ActiveProgramNotSetError):
|
||||
await self.coordinator.client.set_active_program_option(
|
||||
self.appliance.info.ha_id,
|
||||
option_key=self.bsh_key,
|
||||
value=value,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated %s for the active program, new state: %s",
|
||||
self.entity_id,
|
||||
self.state,
|
||||
)
|
||||
return
|
||||
|
||||
await self.coordinator.client.set_selected_program_option(
|
||||
self.appliance.info.ha_id,
|
||||
option_key=self.bsh_key,
|
||||
value=value,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated %s for the selected program, new state: %s",
|
||||
self.entity_id,
|
||||
self.state,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_option",
|
||||
translation_placeholders=get_dict_from_home_connect_error(err),
|
||||
) from err
|
||||
await super().async_set_option_with_key(self.bsh_key, value)
|
||||
|
||||
@property
|
||||
def bsh_key(self) -> OptionKey:
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"""Provides fan entities for Home Connect."""
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from aiohomeconnect.model import EventKey, OptionKey
|
||||
from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError
|
||||
|
||||
from homeassistant.components.fan import (
|
||||
FanEntity,
|
||||
@@ -13,14 +11,11 @@ from homeassistant.components.fan import (
|
||||
FanEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .common import setup_home_connect_entry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -176,7 +171,7 @@ class HomeConnectAirConditioningFanEntity(HomeConnectEntity, FanEntity):
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the speed of the fan, as a percentage."""
|
||||
await self._async_set_option(
|
||||
await super().async_set_option_with_key(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
|
||||
percentage,
|
||||
)
|
||||
@@ -188,41 +183,14 @@ class HomeConnectAirConditioningFanEntity(HomeConnectEntity, FanEntity):
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new target fan mode."""
|
||||
await self._async_set_option(
|
||||
await super().async_set_option_with_key(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
FAN_SPEED_MODE_OPTIONS[preset_mode],
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated %s's speed mode option, new state: %s",
|
||||
self.entity_id,
|
||||
self.state,
|
||||
"Updated %s's speed mode option, new state: %s", self.entity_id, self.state
|
||||
)
|
||||
|
||||
async def _async_set_option(self, key: OptionKey, value: str | int) -> None:
|
||||
"""Set an option for the entity."""
|
||||
try:
|
||||
# We try to set the active program option first,
|
||||
# if it fails we try to set the selected program option
|
||||
with contextlib.suppress(ActiveProgramNotSetError):
|
||||
await self.coordinator.client.set_active_program_option(
|
||||
self.appliance.info.ha_id,
|
||||
option_key=key,
|
||||
value=value,
|
||||
)
|
||||
return
|
||||
|
||||
await self.coordinator.client.set_selected_program_option(
|
||||
self.appliance.info.ha_id,
|
||||
option_key=key,
|
||||
value=value,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_option",
|
||||
translation_placeholders=get_dict_from_home_connect_error(err),
|
||||
) from err
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
|
||||
@@ -119,6 +119,23 @@
|
||||
"name": "Stop program"
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
"air_conditioner": {
|
||||
"state_attributes": {
|
||||
"fan_mode": {
|
||||
"state": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"manual": "[%key:common::state::manual%]"
|
||||
}
|
||||
},
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"active_clean": "Active clean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"fan": {
|
||||
"air_conditioner": {
|
||||
"state_attributes": {
|
||||
|
||||
@@ -2,20 +2,17 @@
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.trigger import (
|
||||
Trigger,
|
||||
make_entity_target_state_attribute_trigger,
|
||||
make_entity_target_state_trigger,
|
||||
)
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
|
||||
|
||||
from .const import ATTR_ACTION, DOMAIN, HumidifierAction
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"started_drying": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_ACTION, HumidifierAction.DRYING
|
||||
"started_drying": make_entity_target_state_trigger(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.DRYING
|
||||
),
|
||||
"started_humidifying": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_ACTION, HumidifierAction.HUMIDIFYING
|
||||
"started_humidifying": make_entity_target_state_trigger(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING
|
||||
),
|
||||
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
|
||||
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
|
||||
|
||||
@@ -18,9 +18,9 @@ from homeassistant.components.weather import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.automation import NumericalDomainSpec
|
||||
from homeassistant.helpers.trigger import (
|
||||
EntityNumericalStateAttributeChangedTriggerBase,
|
||||
EntityNumericalStateAttributeCrossedThresholdTriggerBase,
|
||||
Trigger,
|
||||
make_entity_numerical_state_changed_trigger,
|
||||
make_entity_numerical_state_crossed_threshold_trigger,
|
||||
)
|
||||
|
||||
HUMIDITY_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = {
|
||||
@@ -38,24 +38,11 @@ HUMIDITY_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = {
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class HumidityChangedTrigger(EntityNumericalStateAttributeChangedTriggerBase):
|
||||
"""Trigger for humidity value changes across multiple domains."""
|
||||
|
||||
_domain_specs = HUMIDITY_DOMAIN_SPECS
|
||||
|
||||
|
||||
class HumidityCrossedThresholdTrigger(
|
||||
EntityNumericalStateAttributeCrossedThresholdTriggerBase
|
||||
):
|
||||
"""Trigger for humidity value crossing a threshold across multiple domains."""
|
||||
|
||||
_domain_specs = HUMIDITY_DOMAIN_SPECS
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"changed": HumidityChangedTrigger,
|
||||
"crossed_threshold": HumidityCrossedThresholdTrigger,
|
||||
"changed": make_entity_numerical_state_changed_trigger(HUMIDITY_DOMAIN_SPECS),
|
||||
"crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
|
||||
HUMIDITY_DOMAIN_SPECS
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.automation import NumericalDomainSpec
|
||||
from homeassistant.helpers.trigger import (
|
||||
EntityNumericalStateAttributeChangedTriggerBase,
|
||||
EntityNumericalStateAttributeCrossedThresholdTriggerBase,
|
||||
Trigger,
|
||||
make_entity_numerical_state_changed_trigger,
|
||||
make_entity_numerical_state_crossed_threshold_trigger,
|
||||
make_entity_target_state_trigger,
|
||||
)
|
||||
|
||||
@@ -28,24 +28,13 @@ BRIGHTNESS_DOMAIN_SPECS = {
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class BrightnessChangedTrigger(EntityNumericalStateAttributeChangedTriggerBase):
|
||||
"""Trigger for brightness changed."""
|
||||
|
||||
_domain_specs = BRIGHTNESS_DOMAIN_SPECS
|
||||
|
||||
|
||||
class BrightnessCrossedThresholdTrigger(
|
||||
EntityNumericalStateAttributeCrossedThresholdTriggerBase
|
||||
):
|
||||
"""Trigger for brightness crossed threshold."""
|
||||
|
||||
_domain_specs = BRIGHTNESS_DOMAIN_SPECS
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"brightness_changed": BrightnessChangedTrigger,
|
||||
"brightness_crossed_threshold": BrightnessCrossedThresholdTrigger,
|
||||
"brightness_changed": make_entity_numerical_state_changed_trigger(
|
||||
BRIGHTNESS_DOMAIN_SPECS
|
||||
),
|
||||
"brightness_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
|
||||
BRIGHTNESS_DOMAIN_SPECS
|
||||
),
|
||||
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
|
||||
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
|
||||
}
|
||||
|
||||
@@ -218,12 +218,6 @@
|
||||
"message": "Invalid credentials. Please check your username and password, then try again"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_entity": {
|
||||
"description": "The Litter-Robot entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your dashboards, automations and scripts, disable `{entity}` and reload the integration/restart Home Assistant to fix this issue.",
|
||||
"title": "{name} is deprecated"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"set_sleep_mode": {
|
||||
"description": "Sets the sleep mode and start time.",
|
||||
|
||||
@@ -6,24 +6,13 @@ from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Generic
|
||||
|
||||
from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, LitterRobot4, Robot
|
||||
from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, Robot
|
||||
|
||||
from homeassistant.components.switch import (
|
||||
DOMAIN as SWITCH_DOMAIN,
|
||||
SwitchEntity,
|
||||
SwitchEntityDescription,
|
||||
)
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.issue_registry import (
|
||||
IssueSeverity,
|
||||
async_create_issue,
|
||||
async_delete_issue,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LitterRobotConfigEntry
|
||||
from .entity import LitterRobotEntity, _WhiskerEntityT, whisker_command
|
||||
|
||||
@@ -77,54 +66,13 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up Litter-Robot switches using config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
entities = [
|
||||
async_add_entities(
|
||||
RobotSwitchEntity(robot=robot, coordinator=coordinator, description=description)
|
||||
for robot in coordinator.account.robots
|
||||
for robot_type, entity_descriptions in SWITCH_MAP.items()
|
||||
if isinstance(robot, robot_type)
|
||||
for description in entity_descriptions
|
||||
]
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
|
||||
def add_deprecated_entity(
|
||||
robot: LitterRobot4,
|
||||
description: RobotSwitchEntityDescription,
|
||||
entity_cls: type[RobotSwitchEntity],
|
||||
) -> None:
|
||||
"""Add deprecated entities."""
|
||||
unique_id = f"{robot.serial}-{description.key}"
|
||||
if entity_id := ent_reg.async_get_entity_id(SWITCH_DOMAIN, DOMAIN, unique_id):
|
||||
entity_entry = ent_reg.async_get(entity_id)
|
||||
if entity_entry and entity_entry.disabled:
|
||||
ent_reg.async_remove(entity_id)
|
||||
async_delete_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"deprecated_entity_{unique_id}",
|
||||
)
|
||||
elif entity_entry:
|
||||
entities.append(entity_cls(robot, coordinator, description))
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"deprecated_entity_{unique_id}",
|
||||
breaks_in_ha_version="2026.4.0",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_entity",
|
||||
translation_placeholders={
|
||||
"name": f"{robot.name} {entity_entry.name or entity_entry.original_name}",
|
||||
"entity": entity_id,
|
||||
},
|
||||
)
|
||||
|
||||
for robot in coordinator.account.get_robots(LitterRobot4):
|
||||
add_deprecated_entity(
|
||||
robot, NIGHT_LIGHT_MODE_ENTITY_DESCRIPTION, RobotSwitchEntity
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
)
|
||||
|
||||
|
||||
class RobotSwitchEntity(LitterRobotEntity[_WhiskerEntityT], SwitchEntity):
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
"""Provides conditions for motion."""
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DOMAIN as BINARY_SENSOR_DOMAIN,
|
||||
BinarySensorDeviceClass,
|
||||
)
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.condition import Condition, EntityStateConditionBase
|
||||
|
||||
|
||||
class MotionIsDetectedCondition(EntityStateConditionBase):
|
||||
"""Condition for motion detected (binary sensor ON)."""
|
||||
|
||||
_domain_specs = {
|
||||
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.MOTION)
|
||||
}
|
||||
_states = {STATE_ON}
|
||||
|
||||
|
||||
class MotionIsNotDetectedCondition(EntityStateConditionBase):
|
||||
"""Condition for motion not detected (binary sensor OFF)."""
|
||||
|
||||
_domain_specs = {
|
||||
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.MOTION)
|
||||
}
|
||||
_states = {STATE_OFF}
|
||||
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"is_detected": MotionIsDetectedCondition,
|
||||
"is_not_detected": MotionIsNotDetectedCondition,
|
||||
}
|
||||
|
||||
|
||||
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
|
||||
"""Return the conditions for motion."""
|
||||
return CONDITIONS
|
||||
@@ -1,24 +0,0 @@
|
||||
.condition_common_fields: &condition_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: condition_behavior
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
|
||||
is_detected:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: motion
|
||||
|
||||
is_not_detected:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: motion
|
||||
@@ -1,12 +1,4 @@
|
||||
{
|
||||
"conditions": {
|
||||
"is_detected": {
|
||||
"condition": "mdi:motion-sensor"
|
||||
},
|
||||
"is_not_detected": {
|
||||
"condition": "mdi:motion-sensor-off"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"cleared": {
|
||||
"trigger": "mdi:motion-sensor-off"
|
||||
|
||||
@@ -1,39 +1,9 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_description": "How the state should match on the targeted motion sensors.",
|
||||
"condition_behavior_name": "Behavior",
|
||||
"trigger_behavior_description": "The behavior of the targeted motion sensors to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"conditions": {
|
||||
"is_detected": {
|
||||
"description": "Tests if one or more motion sensors are detecting motion.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::motion::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::motion::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Motion is detected"
|
||||
},
|
||||
"is_not_detected": {
|
||||
"description": "Tests if one or more motion sensors are not detecting motion.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::motion::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::motion::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Motion is not detected"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"condition_behavior": {
|
||||
"options": {
|
||||
"all": "All",
|
||||
"any": "Any"
|
||||
}
|
||||
},
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
|
||||
@@ -7,36 +7,15 @@ from homeassistant.components.binary_sensor import (
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.trigger import (
|
||||
EntityTargetStateTriggerBase,
|
||||
EntityTriggerBase,
|
||||
Trigger,
|
||||
)
|
||||
|
||||
|
||||
class _MotionBinaryTriggerBase(EntityTriggerBase):
|
||||
"""Base trigger for motion binary sensor state changes."""
|
||||
|
||||
_domain_specs = {
|
||||
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.MOTION)
|
||||
}
|
||||
|
||||
|
||||
class MotionDetectedTrigger(_MotionBinaryTriggerBase, EntityTargetStateTriggerBase):
|
||||
"""Trigger for motion detected (binary sensor ON)."""
|
||||
|
||||
_to_states = {STATE_ON}
|
||||
|
||||
|
||||
class MotionClearedTrigger(_MotionBinaryTriggerBase, EntityTargetStateTriggerBase):
|
||||
"""Trigger for motion cleared (binary sensor OFF)."""
|
||||
|
||||
_to_states = {STATE_OFF}
|
||||
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
|
||||
|
||||
_MOTION_DOMAIN_SPECS = {
|
||||
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.MOTION)
|
||||
}
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"detected": MotionDetectedTrigger,
|
||||
"cleared": MotionClearedTrigger,
|
||||
"detected": make_entity_target_state_trigger(_MOTION_DOMAIN_SPECS, STATE_ON),
|
||||
"cleared": make_entity_target_state_trigger(_MOTION_DOMAIN_SPECS, STATE_OFF),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -163,8 +163,6 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity):
|
||||
latitude: float | None
|
||||
longitude: float | None
|
||||
gps_accuracy: float
|
||||
# Reset manually set location to allow automatic zone detection
|
||||
self._attr_location_name = None
|
||||
if isinstance(
|
||||
latitude := extra_state_attributes.get(ATTR_LATITUDE), (int, float)
|
||||
) and isinstance(
|
||||
|
||||
@@ -24,7 +24,7 @@ class StationPriceData:
|
||||
prices: dict[tuple[int, str], float]
|
||||
|
||||
|
||||
class NSWFuelStationCoordinator(DataUpdateCoordinator[StationPriceData | None]):
|
||||
class NSWFuelStationCoordinator(DataUpdateCoordinator[StationPriceData]):
|
||||
"""Class to manage fetching NSW fuel station data."""
|
||||
|
||||
config_entry: None
|
||||
@@ -40,14 +40,14 @@ class NSWFuelStationCoordinator(DataUpdateCoordinator[StationPriceData | None]):
|
||||
)
|
||||
self.client = client
|
||||
|
||||
async def _async_update_data(self) -> StationPriceData | None:
|
||||
async def _async_update_data(self) -> StationPriceData:
|
||||
"""Fetch data from API."""
|
||||
return await self.hass.async_add_executor_job(
|
||||
_fetch_station_price_data, self.client
|
||||
)
|
||||
|
||||
|
||||
def _fetch_station_price_data(client: FuelCheckClient) -> StationPriceData | None:
|
||||
def _fetch_station_price_data(client: FuelCheckClient) -> StationPriceData:
|
||||
"""Fetch fuel price and station data."""
|
||||
try:
|
||||
raw_price_data = client.get_fuel_prices()
|
||||
|
||||
@@ -65,10 +65,6 @@ def setup_platform(
|
||||
|
||||
coordinator: NSWFuelStationCoordinator = hass.data[DATA_NSW_FUEL_STATION]
|
||||
|
||||
if coordinator.data is None:
|
||||
_LOGGER.error("Initial fuel station price data not available")
|
||||
return
|
||||
|
||||
entities = []
|
||||
for fuel_type in fuel_types:
|
||||
if coordinator.data.prices.get((station_id, fuel_type)) is None:
|
||||
@@ -110,9 +106,6 @@ class StationPriceSensor(CoordinatorEntity[NSWFuelStationCoordinator], SensorEnt
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the state of the sensor."""
|
||||
if self.coordinator.data is None:
|
||||
return None
|
||||
|
||||
prices = self.coordinator.data.prices
|
||||
return prices.get((self._station_id, self._fuel_type))
|
||||
|
||||
@@ -129,16 +122,13 @@ class StationPriceSensor(CoordinatorEntity[NSWFuelStationCoordinator], SensorEnt
|
||||
"""Return the units of measurement."""
|
||||
return f"{CURRENCY_CENT}/{UnitOfVolume.LITERS}"
|
||||
|
||||
def _get_station_name(self):
|
||||
default_name = f"station {self._station_id}"
|
||||
if self.coordinator.data is None:
|
||||
return default_name
|
||||
def _get_station_name(self) -> str:
|
||||
if (
|
||||
station := self.coordinator.data.stations.get(self._station_id)
|
||||
) is not None:
|
||||
return station.name
|
||||
|
||||
station = self.coordinator.data.stations.get(self._station_id)
|
||||
if station is None:
|
||||
return default_name
|
||||
|
||||
return station.name
|
||||
return f"station {self._station_id}"
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str | None:
|
||||
|
||||
@@ -7,40 +7,15 @@ from homeassistant.components.binary_sensor import (
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.trigger import (
|
||||
EntityTargetStateTriggerBase,
|
||||
EntityTriggerBase,
|
||||
Trigger,
|
||||
)
|
||||
|
||||
|
||||
class _OccupancyBinaryTriggerBase(EntityTriggerBase):
|
||||
"""Base trigger for occupancy binary sensor state changes."""
|
||||
|
||||
_domain_specs = {
|
||||
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.OCCUPANCY)
|
||||
}
|
||||
|
||||
|
||||
class OccupancyDetectedTrigger(
|
||||
_OccupancyBinaryTriggerBase, EntityTargetStateTriggerBase
|
||||
):
|
||||
"""Trigger for occupancy detected (binary sensor ON)."""
|
||||
|
||||
_to_states = {STATE_ON}
|
||||
|
||||
|
||||
class OccupancyClearedTrigger(
|
||||
_OccupancyBinaryTriggerBase, EntityTargetStateTriggerBase
|
||||
):
|
||||
"""Trigger for occupancy cleared (binary sensor OFF)."""
|
||||
|
||||
_to_states = {STATE_OFF}
|
||||
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
|
||||
|
||||
_OCCUPANCY_DOMAIN_SPECS = {
|
||||
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.OCCUPANCY)
|
||||
}
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"detected": OccupancyDetectedTrigger,
|
||||
"cleared": OccupancyClearedTrigger,
|
||||
"detected": make_entity_target_state_trigger(_OCCUPANCY_DOMAIN_SPECS, STATE_ON),
|
||||
"cleared": make_entity_target_state_trigger(_OCCUPANCY_DOMAIN_SPECS, STATE_OFF),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ from .const import (
|
||||
CONF_REASONING_EFFORT,
|
||||
CONF_REASONING_SUMMARY,
|
||||
CONF_RECOMMENDED,
|
||||
CONF_SERVICE_TIER,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_TOP_P,
|
||||
CONF_TTS_SPEED,
|
||||
@@ -80,6 +81,7 @@ from .const import (
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_REASONING_EFFORT,
|
||||
RECOMMENDED_REASONING_SUMMARY,
|
||||
RECOMMENDED_SERVICE_TIER,
|
||||
RECOMMENDED_STT_MODEL,
|
||||
RECOMMENDED_STT_OPTIONS,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
@@ -92,8 +94,10 @@ from .const import (
|
||||
RECOMMENDED_WEB_SEARCH_INLINE_CITATIONS,
|
||||
RECOMMENDED_WEB_SEARCH_USER_LOCATION,
|
||||
UNSUPPORTED_CODE_INTERPRETER_MODELS,
|
||||
UNSUPPORTED_FLEX_SERVICE_TIERS_MODELS,
|
||||
UNSUPPORTED_IMAGE_MODELS,
|
||||
UNSUPPORTED_MODELS,
|
||||
UNSUPPORTED_PRIORITY_SERVICE_TIERS_MODELS,
|
||||
UNSUPPORTED_WEB_SEARCH_MODELS,
|
||||
)
|
||||
|
||||
@@ -443,6 +447,25 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
|
||||
if not model.startswith("gpt-5"):
|
||||
options.pop(CONF_REASONING_SUMMARY)
|
||||
|
||||
service_tiers = self._get_service_tiers(model)
|
||||
if "flex" in service_tiers or "priority" in service_tiers:
|
||||
step_schema[
|
||||
vol.Optional(
|
||||
CONF_SERVICE_TIER,
|
||||
default=RECOMMENDED_SERVICE_TIER,
|
||||
)
|
||||
] = SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=service_tiers,
|
||||
translation_key=CONF_SERVICE_TIER,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
)
|
||||
else:
|
||||
options.pop(CONF_SERVICE_TIER, None)
|
||||
if options.get(CONF_SERVICE_TIER) not in service_tiers:
|
||||
options.pop(CONF_SERVICE_TIER, None)
|
||||
|
||||
if self._subentry_type == "conversation" and not model.startswith(
|
||||
tuple(UNSUPPORTED_WEB_SEARCH_MODELS)
|
||||
):
|
||||
@@ -563,6 +586,20 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
|
||||
return options
|
||||
return [] # pragma: no cover
|
||||
|
||||
def _get_service_tiers(self, model: str) -> list[str]:
|
||||
"""Get service tier options based on model."""
|
||||
service_tiers = ["auto"]
|
||||
|
||||
if not model.startswith(tuple(UNSUPPORTED_FLEX_SERVICE_TIERS_MODELS)):
|
||||
service_tiers.append("flex")
|
||||
|
||||
service_tiers.append("default")
|
||||
|
||||
if not model.startswith(tuple(UNSUPPORTED_PRIORITY_SERVICE_TIERS_MODELS)):
|
||||
service_tiers.append("priority")
|
||||
|
||||
return service_tiers
|
||||
|
||||
async def _get_location_data(self) -> dict[str, str]:
|
||||
"""Get approximate location data of the user."""
|
||||
location_data: dict[str, str] = {}
|
||||
|
||||
@@ -24,6 +24,7 @@ CONF_PROMPT = "prompt"
|
||||
CONF_REASONING_EFFORT = "reasoning_effort"
|
||||
CONF_REASONING_SUMMARY = "reasoning_summary"
|
||||
CONF_RECOMMENDED = "recommended"
|
||||
CONF_SERVICE_TIER = "service_tier"
|
||||
CONF_TEMPERATURE = "temperature"
|
||||
CONF_TOP_P = "top_p"
|
||||
CONF_TTS_SPEED = "tts_speed"
|
||||
@@ -42,6 +43,7 @@ RECOMMENDED_IMAGE_MODEL = "gpt-image-1.5"
|
||||
RECOMMENDED_MAX_TOKENS = 3000
|
||||
RECOMMENDED_REASONING_EFFORT = "low"
|
||||
RECOMMENDED_REASONING_SUMMARY = "auto"
|
||||
RECOMMENDED_SERVICE_TIER = "auto"
|
||||
RECOMMENDED_STT_MODEL = "gpt-4o-mini-transcribe"
|
||||
RECOMMENDED_TEMPERATURE = 1.0
|
||||
RECOMMENDED_TOP_P = 1.0
|
||||
@@ -119,3 +121,38 @@ RECOMMENDED_TTS_OPTIONS = {
|
||||
CONF_PROMPT: "",
|
||||
CONF_CHAT_MODEL: "gpt-4o-mini-tts",
|
||||
}
|
||||
|
||||
UNSUPPORTED_FLEX_SERVICE_TIERS_MODELS: list[str] = [
|
||||
"gpt-5.3",
|
||||
"gpt-5.2-chat",
|
||||
"gpt-5.1-chat",
|
||||
"gpt-5-chat",
|
||||
"gpt-5.2-codex",
|
||||
"gpt-5.1-codex",
|
||||
"gpt-5-codex",
|
||||
"gpt-5.2-pro",
|
||||
"gpt-5-pro",
|
||||
"gpt-4",
|
||||
"o1",
|
||||
"o3-pro",
|
||||
"o3-deep-research",
|
||||
"o4-mini-deep-research",
|
||||
"o3-mini",
|
||||
"codex-mini",
|
||||
]
|
||||
UNSUPPORTED_PRIORITY_SERVICE_TIERS_MODELS: list[str] = [
|
||||
"gpt-5-nano",
|
||||
"gpt-5.3-chat",
|
||||
"gpt-5.2-chat",
|
||||
"gpt-5.1-chat",
|
||||
"gpt-5.1-codex-mini",
|
||||
"gpt-5-chat",
|
||||
"gpt-5.2-pro",
|
||||
"gpt-5-pro",
|
||||
"o1",
|
||||
"o3-pro",
|
||||
"o3-deep-research",
|
||||
"o4-mini-deep-research",
|
||||
"o3-mini",
|
||||
"codex-mini",
|
||||
]
|
||||
|
||||
@@ -74,6 +74,7 @@ from .const import (
|
||||
CONF_MAX_TOKENS,
|
||||
CONF_REASONING_EFFORT,
|
||||
CONF_REASONING_SUMMARY,
|
||||
CONF_SERVICE_TIER,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_TOP_P,
|
||||
CONF_VERBOSITY,
|
||||
@@ -92,6 +93,7 @@ from .const import (
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_REASONING_EFFORT,
|
||||
RECOMMENDED_REASONING_SUMMARY,
|
||||
RECOMMENDED_SERVICE_TIER,
|
||||
RECOMMENDED_STT_MODEL,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
RECOMMENDED_TOP_P,
|
||||
@@ -499,6 +501,7 @@ class OpenAIBaseLLMEntity(Entity):
|
||||
input=messages,
|
||||
max_output_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
|
||||
user=chat_log.conversation_id,
|
||||
service_tier=options.get(CONF_SERVICE_TIER, RECOMMENDED_SERVICE_TIER),
|
||||
store=False,
|
||||
stream=True,
|
||||
)
|
||||
@@ -655,6 +658,15 @@ class OpenAIBaseLLMEntity(Entity):
|
||||
)
|
||||
)
|
||||
except openai.RateLimitError as err:
|
||||
if (
|
||||
model_args["service_tier"] == "flex"
|
||||
and "resource unavailable" in (err.message or "").lower()
|
||||
):
|
||||
LOGGER.info(
|
||||
"Flex tier is not available at the moment, continuing with default tier"
|
||||
)
|
||||
model_args["service_tier"] = "default"
|
||||
continue
|
||||
LOGGER.error("Rate limited by OpenAI: %s", err)
|
||||
raise HomeAssistantError("Rate limited or insufficient funds") from err
|
||||
except openai.OpenAIError as err:
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::reasoning_effort%]",
|
||||
"reasoning_summary": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::reasoning_summary%]",
|
||||
"search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::search_context_size%]",
|
||||
"service_tier": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::service_tier%]",
|
||||
"user_location": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::user_location%]",
|
||||
"web_search": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::web_search%]"
|
||||
},
|
||||
@@ -80,6 +81,7 @@
|
||||
"reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::reasoning_effort%]",
|
||||
"reasoning_summary": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::reasoning_summary%]",
|
||||
"search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::search_context_size%]",
|
||||
"service_tier": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::service_tier%]",
|
||||
"user_location": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::user_location%]",
|
||||
"web_search": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::web_search%]"
|
||||
},
|
||||
@@ -131,6 +133,7 @@
|
||||
"reasoning_effort": "Reasoning effort",
|
||||
"reasoning_summary": "Reasoning summary",
|
||||
"search_context_size": "Search context size",
|
||||
"service_tier": "Service tier",
|
||||
"user_location": "Include home location",
|
||||
"web_search": "Enable web search"
|
||||
},
|
||||
@@ -141,6 +144,7 @@
|
||||
"reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt",
|
||||
"reasoning_summary": "Controls the length and detail of reasoning summaries provided by the model",
|
||||
"search_context_size": "High level guidance for the amount of context window space to use for the search",
|
||||
"service_tier": "Controls the cost and response time",
|
||||
"user_location": "Refine search results based on geography",
|
||||
"web_search": "Allow the model to search the web for the latest information before generating a response"
|
||||
},
|
||||
@@ -242,6 +246,14 @@
|
||||
"medium": "[%key:common::state::medium%]"
|
||||
}
|
||||
},
|
||||
"service_tier": {
|
||||
"options": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"default": "Standard",
|
||||
"flex": "Flex",
|
||||
"priority": "Priority"
|
||||
}
|
||||
},
|
||||
"verbosity": {
|
||||
"options": {
|
||||
"high": "[%key:common::state::high%]",
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
"""Defines base Prana entity."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components.switch import StrEnum
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
@@ -12,26 +9,16 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from .const import DOMAIN
|
||||
from .coordinator import PranaCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class PranaEntityDescription(EntityDescription):
|
||||
"""Description for all Prana entities."""
|
||||
|
||||
key: StrEnum
|
||||
|
||||
|
||||
class PranaBaseEntity(CoordinatorEntity[PranaCoordinator]):
|
||||
"""Defines a base Prana entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_entity_description: PranaEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: PranaCoordinator,
|
||||
description: PranaEntityDescription,
|
||||
description: EntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the Prana entity."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
@@ -21,7 +22,7 @@ from homeassistant.util.percentage import (
|
||||
from homeassistant.util.scaling import int_states_in_range
|
||||
|
||||
from . import PranaConfigEntry
|
||||
from .entity import PranaBaseEntity, PranaCoordinator, PranaEntityDescription, StrEnum
|
||||
from .entity import PranaBaseEntity, PranaCoordinator
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
@@ -40,14 +41,15 @@ class PranaFanType(StrEnum):
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class PranaFanEntityDescription(FanEntityDescription, PranaEntityDescription):
|
||||
class PranaFanEntityDescription(FanEntityDescription):
|
||||
"""Description of a Prana fan entity."""
|
||||
|
||||
key: PranaFanType
|
||||
value_fn: Callable[[PranaCoordinator], FanState]
|
||||
speed_range: Callable[[PranaCoordinator], tuple[int, int]]
|
||||
|
||||
|
||||
ENTITIES: tuple[PranaEntityDescription, ...] = (
|
||||
ENTITIES: tuple[PranaFanEntityDescription, ...] = (
|
||||
PranaFanEntityDescription(
|
||||
key=PranaFanType.SUPPLY,
|
||||
translation_key="supply",
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
"""Switch platform for Prana integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from aioesphomeapi import dataclass
|
||||
|
||||
from homeassistant.components.switch import (
|
||||
StrEnum,
|
||||
SwitchEntity,
|
||||
SwitchEntityDescription,
|
||||
)
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import PranaConfigEntry, PranaCoordinator
|
||||
from .entity import PranaBaseEntity, PranaEntityDescription
|
||||
from .entity import PranaBaseEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
@@ -32,13 +28,14 @@ class PranaSwitchType(StrEnum):
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class PranaSwitchEntityDescription(SwitchEntityDescription, PranaEntityDescription):
|
||||
class PranaSwitchEntityDescription(SwitchEntityDescription):
|
||||
"""Description of a Prana switch entity."""
|
||||
|
||||
key: PranaSwitchType
|
||||
value_fn: Callable[[PranaCoordinator], bool]
|
||||
|
||||
|
||||
ENTITIES: tuple[PranaEntityDescription, ...] = (
|
||||
ENTITIES: tuple[PranaSwitchEntityDescription, ...] = (
|
||||
PranaSwitchEntityDescription(
|
||||
key=PranaSwitchType.BOUND,
|
||||
translation_key="bound",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pysmlight==0.2.16"],
|
||||
"requirements": ["pysmlight==0.3.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_slzb-06._tcp.local."
|
||||
|
||||
@@ -33,6 +33,7 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
from .const import (
|
||||
CONF_BROWSE_LIMIT,
|
||||
CONF_HTTPS,
|
||||
CONF_SERVER_LIST,
|
||||
CONF_VOLUME_STEP,
|
||||
DEFAULT_BROWSE_LIMIT,
|
||||
DEFAULT_PORT,
|
||||
@@ -45,45 +46,23 @@ _LOGGER = logging.getLogger(__name__)
|
||||
TIMEOUT = 5
|
||||
|
||||
|
||||
def _base_schema(
|
||||
discovery_info: dict[str, Any] | None = None,
|
||||
) -> vol.Schema:
|
||||
"""Generate base schema."""
|
||||
base_schema: dict[Any, Any] = {}
|
||||
if discovery_info and CONF_HOST in discovery_info:
|
||||
base_schema.update(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_HOST,
|
||||
description={"suggested_value": discovery_info[CONF_HOST]},
|
||||
): str,
|
||||
}
|
||||
)
|
||||
else:
|
||||
base_schema.update({vol.Required(CONF_HOST): str})
|
||||
FULL_EDIT_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
|
||||
vol.Optional(CONF_USERNAME): str,
|
||||
vol.Optional(CONF_PASSWORD): str,
|
||||
vol.Optional(CONF_HTTPS, default=False): bool,
|
||||
}
|
||||
)
|
||||
|
||||
if discovery_info and CONF_PORT in discovery_info:
|
||||
base_schema.update(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_PORT,
|
||||
default=DEFAULT_PORT,
|
||||
description={"suggested_value": discovery_info[CONF_PORT]},
|
||||
): int,
|
||||
}
|
||||
)
|
||||
else:
|
||||
base_schema.update({vol.Required(CONF_PORT, default=DEFAULT_PORT): int})
|
||||
|
||||
base_schema.update(
|
||||
{
|
||||
vol.Optional(CONF_USERNAME): str,
|
||||
vol.Optional(CONF_PASSWORD): str,
|
||||
vol.Optional(CONF_HTTPS, default=False): bool,
|
||||
}
|
||||
)
|
||||
|
||||
return vol.Schema(base_schema)
|
||||
SHORT_EDIT_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_USERNAME): str,
|
||||
vol.Optional(CONF_PASSWORD): str,
|
||||
vol.Optional(CONF_HTTPS, default=False): bool,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
@@ -93,8 +72,9 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize an instance of the squeezebox config flow."""
|
||||
self.data_schema = _base_schema()
|
||||
self.discovery_info: dict[str, Any] | None = None
|
||||
self.discovery_task: asyncio.Task | None = None
|
||||
self.discovered_servers: list[dict[str, Any]] = []
|
||||
self.chosen_server: dict[str, Any] = {}
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
@@ -102,34 +82,43 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Get the options flow for this handler."""
|
||||
return OptionsFlowHandler()
|
||||
|
||||
async def _discover(self, uuid: str | None = None) -> None:
|
||||
async def _discover(self) -> None:
|
||||
"""Discover an unconfigured LMS server."""
|
||||
self.discovery_info = None
|
||||
discovery_event = asyncio.Event()
|
||||
# Reset discovery state to avoid stale or duplicate servers across runs
|
||||
self.discovered_servers = []
|
||||
self.chosen_server = {}
|
||||
_discovery_task: asyncio.Task | None = None
|
||||
|
||||
def _discovery_callback(server: Server) -> None:
|
||||
_discovery_info: dict[str, Any] | None = {}
|
||||
if server.uuid:
|
||||
# ignore already configured uuids
|
||||
for entry in self._async_current_entries():
|
||||
if entry.unique_id == server.uuid:
|
||||
return
|
||||
self.discovery_info = {
|
||||
_discovery_info = {
|
||||
CONF_HOST: server.host,
|
||||
CONF_PORT: int(server.port),
|
||||
"uuid": server.uuid,
|
||||
"name": server.name,
|
||||
}
|
||||
_LOGGER.debug("Discovered server: %s", self.discovery_info)
|
||||
discovery_event.set()
|
||||
|
||||
discovery_task = self.hass.async_create_task(
|
||||
_LOGGER.debug(
|
||||
"Discovered server: %s, creating discovery_info %s",
|
||||
server,
|
||||
_discovery_info,
|
||||
)
|
||||
if _discovery_info not in self.discovered_servers:
|
||||
self.discovered_servers.append(_discovery_info)
|
||||
|
||||
_discovery_task = self.hass.async_create_task(
|
||||
async_discover(_discovery_callback)
|
||||
)
|
||||
|
||||
await discovery_event.wait()
|
||||
discovery_task.cancel() # stop searching as soon as we find server
|
||||
await asyncio.sleep(TIMEOUT)
|
||||
|
||||
# update with suggested values from discovery
|
||||
self.data_schema = _base_schema(self.discovery_info)
|
||||
_LOGGER.debug("Discovered Servers %s", self.discovered_servers)
|
||||
_discovery_task.cancel()
|
||||
|
||||
async def _validate_input(self, data: dict[str, Any]) -> str | None:
|
||||
"""Validate the user input allows us to connect.
|
||||
@@ -142,7 +131,7 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data[CONF_PORT],
|
||||
data.get(CONF_USERNAME),
|
||||
data.get(CONF_PASSWORD),
|
||||
https=data[CONF_HTTPS],
|
||||
https=data.get(CONF_HTTPS, False),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -164,35 +153,78 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
return None
|
||||
|
||||
async def async_step_choose_server(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Choose manual or discover flow."""
|
||||
_chosen_host: str
|
||||
|
||||
if user_input:
|
||||
_chosen_host = user_input[CONF_SERVER_LIST]
|
||||
for _server in self.discovered_servers:
|
||||
if _chosen_host == _server[CONF_HOST]:
|
||||
self.chosen_server[CONF_HOST] = _chosen_host
|
||||
self.chosen_server[CONF_PORT] = _server[CONF_PORT]
|
||||
self.chosen_server[CONF_HTTPS] = False
|
||||
return await self.async_step_edit_discovered()
|
||||
|
||||
_options = {
|
||||
_server[CONF_HOST]: f"{_server['name']} ({_server[CONF_HOST]})"
|
||||
for _server in self.discovered_servers
|
||||
}
|
||||
return self.async_show_form(
|
||||
step_id="choose_server",
|
||||
data_schema=vol.Schema({vol.Required(CONF_SERVER_LIST): vol.In(_options)}),
|
||||
)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initialized by the user."""
|
||||
errors = {}
|
||||
if user_input and CONF_HOST in user_input:
|
||||
# update with host provided by user
|
||||
self.data_schema = _base_schema(user_input)
|
||||
return await self.async_step_edit()
|
||||
|
||||
# no host specified, see if we can discover an unconfigured LMS server
|
||||
try:
|
||||
async with asyncio.timeout(TIMEOUT):
|
||||
await self._discover()
|
||||
return await self.async_step_edit()
|
||||
except TimeoutError:
|
||||
errors["base"] = "no_server_found"
|
||||
return self.async_show_menu(
|
||||
step_id="user", menu_options=["start_discovery", "edit"]
|
||||
)
|
||||
|
||||
# display the form
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema({vol.Optional(CONF_HOST): str}),
|
||||
errors=errors,
|
||||
async def async_step_discovery_failed(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a failed discovery."""
|
||||
|
||||
return self.async_show_menu(step_id="discovery_failed", menu_options=["edit"])
|
||||
|
||||
async def async_step_start_discovery(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initialized by the user."""
|
||||
|
||||
if not self.discovery_task:
|
||||
self.discovery_task = self.hass.async_create_task(self._discover())
|
||||
|
||||
if self.discovery_task.done():
|
||||
self.discovery_task.cancel()
|
||||
self.discovery_task = None
|
||||
# Sleep to allow task cancellation to complete
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
return self.async_show_progress_done(
|
||||
next_step_id="choose_server"
|
||||
if self.discovered_servers
|
||||
else "discovery_failed"
|
||||
)
|
||||
|
||||
return self.async_show_progress(
|
||||
step_id="start_discovery",
|
||||
progress_action="start_discovery",
|
||||
progress_task=self.discovery_task,
|
||||
)
|
||||
|
||||
async def async_step_edit(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Edit a discovered or manually inputted server."""
|
||||
|
||||
errors = {}
|
||||
if user_input:
|
||||
error = await self._validate_input(user_input)
|
||||
@@ -203,39 +235,95 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = error
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="edit", data_schema=self.data_schema, errors=errors
|
||||
step_id="edit",
|
||||
data_schema=FULL_EDIT_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_edit_discovered(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Edit a discovered or manually inputted server."""
|
||||
|
||||
if not (await self._validate_input(self.chosen_server)):
|
||||
# Attempt to connect with default data successful
|
||||
return self.async_create_entry(
|
||||
title=self.chosen_server[CONF_HOST], data=self.chosen_server
|
||||
)
|
||||
errors = {}
|
||||
if user_input:
|
||||
user_input[CONF_HOST] = self.chosen_server[CONF_HOST]
|
||||
user_input[CONF_PORT] = self.chosen_server[CONF_PORT]
|
||||
error = await self._validate_input(user_input)
|
||||
if not error:
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_HOST], data=user_input
|
||||
)
|
||||
errors["base"] = error
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="edit_discovered",
|
||||
description_placeholders={
|
||||
"host": self.chosen_server[CONF_HOST],
|
||||
"port": self.chosen_server[CONF_PORT],
|
||||
},
|
||||
data_schema=SHORT_EDIT_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_edit_integration_discovered(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Edit a discovered or manually inputted server."""
|
||||
|
||||
errors = {}
|
||||
if user_input:
|
||||
user_input[CONF_HOST] = self.chosen_server[CONF_HOST]
|
||||
user_input[CONF_PORT] = self.chosen_server[CONF_PORT]
|
||||
error = await self._validate_input(user_input)
|
||||
if not error:
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_HOST], data=user_input
|
||||
)
|
||||
errors["base"] = error
|
||||
return self.async_show_form(
|
||||
step_id="edit_integration_discovered",
|
||||
description_placeholders={
|
||||
"desc": f"LMS Host: {self.chosen_server[CONF_HOST]}, Port: {self.chosen_server[CONF_PORT]}"
|
||||
},
|
||||
data_schema=SHORT_EDIT_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_integration_discovery(
|
||||
self, discovery_info: dict[str, Any]
|
||||
self, _discovery_info: dict[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle discovery of a server."""
|
||||
_LOGGER.debug("Reached server discovery flow with info: %s", discovery_info)
|
||||
if "uuid" in discovery_info:
|
||||
await self.async_set_unique_id(discovery_info.pop("uuid"))
|
||||
_LOGGER.debug("Reached server discovery flow with info: %s", _discovery_info)
|
||||
if "uuid" in _discovery_info:
|
||||
await self.async_set_unique_id(_discovery_info.pop("uuid"))
|
||||
self._abort_if_unique_id_configured()
|
||||
else:
|
||||
# attempt to connect to server and determine uuid. will fail if
|
||||
# password required
|
||||
error = await self._validate_input(discovery_info)
|
||||
error = await self._validate_input(_discovery_info)
|
||||
if error:
|
||||
await self._async_handle_discovery_without_unique_id()
|
||||
|
||||
# update schema with suggested values from discovery
|
||||
self.data_schema = _base_schema(discovery_info)
|
||||
|
||||
self.context.update({"title_placeholders": {"host": discovery_info[CONF_HOST]}})
|
||||
|
||||
return await self.async_step_edit()
|
||||
self.context.update(
|
||||
{"title_placeholders": {"host": _discovery_info[CONF_HOST]}}
|
||||
)
|
||||
self.chosen_server = _discovery_info
|
||||
return await self.async_step_edit_integration_discovered()
|
||||
|
||||
async def async_step_dhcp(
|
||||
self, discovery_info: DhcpServiceInfo
|
||||
self, _discovery_info: DhcpServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle dhcp discovery of a Squeezebox player."""
|
||||
_LOGGER.debug(
|
||||
"Reached dhcp discovery of a player with info: %s", discovery_info
|
||||
"Reached dhcp discovery of a player with info: %s", _discovery_info
|
||||
)
|
||||
await self.async_set_unique_id(format_mac(discovery_info.macaddress))
|
||||
await self.async_set_unique_id(format_mac(_discovery_info.macaddress))
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
_LOGGER.debug("Configuring dhcp player with unique id: %s", self.unique_id)
|
||||
|
||||
@@ -56,3 +56,4 @@ ATTR_VOLUME = "volume"
|
||||
ATTR_URL = "url"
|
||||
UPDATE_PLUGINS_RELEASE_SUMMARY = "update_plugins_release_summary"
|
||||
UPDATE_RELEASE_SUMMARY = "update_release_summary"
|
||||
CONF_SERVER_LIST = "server_list"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"no_server_found": "No LMS found."
|
||||
},
|
||||
"error": {
|
||||
@@ -12,7 +13,27 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"flow_title": "{host}",
|
||||
"progress": {
|
||||
"start_discovery": "Attempting to discover new LMS servers\n\nThis will take about 5 seconds",
|
||||
"title": "LMS discovery"
|
||||
},
|
||||
"step": {
|
||||
"choose_server": {
|
||||
"data": {
|
||||
"server_list": "Server list"
|
||||
},
|
||||
"data_description": {
|
||||
"server_list": "Choose the server to configure."
|
||||
},
|
||||
"title": "Discovered servers"
|
||||
},
|
||||
"discovery_failed": {
|
||||
"description": "No LMS were discovered on the network.",
|
||||
"menu_options": {
|
||||
"edit": "Enter configuration manually"
|
||||
},
|
||||
"title": "Discovery failed"
|
||||
},
|
||||
"edit": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
@@ -22,21 +43,47 @@
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "[%key:component::squeezebox::config::step::user::data_description::host%]",
|
||||
"host": "The IP address or hostname of the LMS.",
|
||||
"https": "Connect to the LMS over HTTPS (requires reverse proxy).",
|
||||
"password": "The password from LMS Advanced Security (if defined).",
|
||||
"port": "The web interface port on the LMS. The default is 9000.",
|
||||
"username": "The username from LMS Advanced Security (if defined)."
|
||||
},
|
||||
"title": "Edit connection information"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"edit_discovered": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
"https": "Connect over HTTPS (requires reverse proxy)",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Lyrion Music Server."
|
||||
}
|
||||
"https": "Connect to the LMS over HTTPS (requires reverse proxy).",
|
||||
"password": "The password from LMS Advanced Security (if defined).",
|
||||
"username": "The username from LMS Advanced Security (if defined)."
|
||||
},
|
||||
"description": "LMS Host: {host}, Port {port}",
|
||||
"title": "Edit additional connection information"
|
||||
},
|
||||
"edit_integration_discovered": {
|
||||
"data": {
|
||||
"https": "Connect over HTTPS (requires reverse proxy)",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"https": "Connect to the LMS over HTTPS (requires reverse proxy).",
|
||||
"password": "The password from LMS Advanced Security (if defined).",
|
||||
"username": "The username from LMS Advanced Security (if defined)."
|
||||
},
|
||||
"description": "{desc}",
|
||||
"title": "Edit additional connection information"
|
||||
},
|
||||
"user": {
|
||||
"menu_options": {
|
||||
"edit": "Enter configuration manually",
|
||||
"start_discovery": "Discover new LMS"
|
||||
},
|
||||
"title": "LMS configuration"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -260,11 +307,11 @@
|
||||
"description": "Calls a custom Squeezebox JSONRPC API.",
|
||||
"fields": {
|
||||
"command": {
|
||||
"description": "Command to pass to Lyrion Music Server (p0 in the CLI documentation).",
|
||||
"description": "Command to pass to LMS (p0 in the CLI documentation).",
|
||||
"name": "Command"
|
||||
},
|
||||
"parameters": {
|
||||
"description": "Array of additional parameters to pass to Lyrion Music Server (p1, ..., pN in the CLI documentation).",
|
||||
"description": "Array of additional parameters to pass to LMS (p1, ..., pN in the CLI documentation).",
|
||||
"name": "Parameters"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -245,15 +245,17 @@ async def make_device_data(
|
||||
devices_data.binary_sensors.append((device, coordinator))
|
||||
devices_data.sensors.append((device, coordinator))
|
||||
|
||||
if isinstance(device, Device) and device.device_type in [
|
||||
"Battery Circulator Fan",
|
||||
"Circulator Fan",
|
||||
]:
|
||||
if isinstance(device, Device) and device.device_type == "Battery Circulator Fan":
|
||||
coordinator = await coordinator_for_device(
|
||||
hass, entry, api, device, coordinators_by_id
|
||||
)
|
||||
devices_data.fans.append((device, coordinator))
|
||||
devices_data.sensors.append((device, coordinator))
|
||||
if isinstance(device, Device) and device.device_type == "Circulator Fan":
|
||||
coordinator = await coordinator_for_device(
|
||||
hass, entry, api, device, coordinators_by_id
|
||||
)
|
||||
devices_data.fans.append((device, coordinator))
|
||||
if isinstance(device, Device) and device.device_type in [
|
||||
"Curtain",
|
||||
"Curtain3",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Support for the Switchbot Battery Circulator fan."""
|
||||
"""Support for the Switchbot (Battery) Circulator fan."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
@@ -43,7 +43,7 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity):
|
||||
"""Representation of a SwitchBot Battery Circulator Fan."""
|
||||
"""Representation of a SwitchBot (Battery) Circulator Fan."""
|
||||
|
||||
_attr_name = None
|
||||
|
||||
@@ -110,10 +110,6 @@ class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity):
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the speed of the fan, as a percentage."""
|
||||
await self.send_api_command(
|
||||
command=BatteryCirculatorFanCommands.SET_WIND_MODE,
|
||||
parameters=str(BatteryCirculatorFanMode.DIRECT.value),
|
||||
)
|
||||
await self.send_api_command(
|
||||
command=BatteryCirculatorFanCommands.SET_WIND_SPEED,
|
||||
parameters=str(percentage),
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["telegram"],
|
||||
"quality_scale": "gold",
|
||||
"requirements": ["python-telegram-bot[socks]==22.1"]
|
||||
"requirements": ["python-telegram-bot[socks]==22.6"]
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ class TextChangedTrigger(EntityTriggerBase):
|
||||
_domain_specs = {DOMAIN: DomainSpec()}
|
||||
_schema = ENTITY_STATE_TRIGGER_SCHEMA
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
"""Check if the origin state is valid and the state has changed."""
|
||||
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||
return False
|
||||
return from_state.state != to_state.state
|
||||
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the new state is not invalid."""
|
||||
return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
|
||||
|
||||
@@ -31,7 +31,7 @@ rules:
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: There are no configuration parameters
|
||||
docs-installation-parameters: todo
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
@@ -48,13 +48,13 @@ rules:
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: Can't be discovered
|
||||
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
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from base64 import b64decode
|
||||
from typing import Any
|
||||
|
||||
from tuya_device_handlers.device_wrapper.alarm_control_panel import (
|
||||
AlarmActionWrapper,
|
||||
AlarmChangedByWrapper,
|
||||
AlarmStateWrapper,
|
||||
)
|
||||
from tuya_device_handlers.device_wrapper.base import DeviceWrapper
|
||||
from tuya_device_handlers.device_wrapper.common import (
|
||||
DPCodeEnumWrapper,
|
||||
DPCodeRawWrapper,
|
||||
from tuya_device_handlers.helpers.homeassistant import (
|
||||
TuyaAlarmControlPanelAction,
|
||||
TuyaAlarmControlPanelState,
|
||||
)
|
||||
from tuya_device_handlers.type_information import EnumTypeInformation
|
||||
from tuya_sharing import CustomerDevice, Manager
|
||||
@@ -36,84 +38,18 @@ ALARM: dict[DeviceCategory, tuple[AlarmControlPanelEntityDescription, ...]] = {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
class _AlarmChangedByWrapper(DPCodeRawWrapper[str]):
|
||||
"""Wrapper for changed_by.
|
||||
|
||||
Decode base64 to utf-16be string, but only if alarm has been triggered.
|
||||
"""
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> str | None:
|
||||
"""Read the device status."""
|
||||
if (
|
||||
device.status.get(DPCode.MASTER_STATE) != "alarm"
|
||||
or (status := self._read_dpcode_value(device)) is None
|
||||
):
|
||||
return None
|
||||
return status.decode("utf-16be")
|
||||
|
||||
|
||||
class _AlarmStateWrapper(DPCodeEnumWrapper[AlarmControlPanelState]):
|
||||
"""Wrapper for the alarm state of a device.
|
||||
|
||||
Handles alarm mode enum values and determines the alarm state,
|
||||
including logic for detecting when the alarm is triggered and
|
||||
distinguishing triggered state from battery warnings.
|
||||
"""
|
||||
|
||||
_STATE_MAPPINGS = {
|
||||
# Tuya device mode => Home Assistant panel state
|
||||
"disarmed": AlarmControlPanelState.DISARMED,
|
||||
"arm": AlarmControlPanelState.ARMED_AWAY,
|
||||
"home": AlarmControlPanelState.ARMED_HOME,
|
||||
"sos": AlarmControlPanelState.TRIGGERED,
|
||||
}
|
||||
|
||||
def read_device_status(
|
||||
self, device: CustomerDevice
|
||||
) -> AlarmControlPanelState | None:
|
||||
"""Read the device status."""
|
||||
# When the alarm is triggered, only its 'state' is changing. From 'normal' to 'alarm'.
|
||||
# The 'mode' doesn't change, and stays as 'arm' or 'home'.
|
||||
if device.status.get(DPCode.MASTER_STATE) == "alarm":
|
||||
# Only report as triggered if NOT a battery warning
|
||||
if not (
|
||||
(encoded_msg := device.status.get(DPCode.ALARM_MSG))
|
||||
and (decoded_message := b64decode(encoded_msg).decode("utf-16be"))
|
||||
and "Sensor Low Battery" in decoded_message
|
||||
):
|
||||
return AlarmControlPanelState.TRIGGERED
|
||||
|
||||
if (status := self._read_dpcode_value(device)) is None:
|
||||
return None
|
||||
return self._STATE_MAPPINGS.get(status)
|
||||
|
||||
|
||||
class _AlarmActionWrapper(DPCodeEnumWrapper):
|
||||
"""Wrapper for setting the alarm mode of a device."""
|
||||
|
||||
_ACTION_MAPPINGS = {
|
||||
# Home Assistant action => Tuya device mode
|
||||
"arm_home": "home",
|
||||
"arm_away": "arm",
|
||||
"disarm": "disarmed",
|
||||
"trigger": "sos",
|
||||
}
|
||||
|
||||
def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None:
|
||||
"""Init _AlarmActionWrapper."""
|
||||
super().__init__(dpcode, type_information)
|
||||
self.options = [
|
||||
ha_action
|
||||
for ha_action, tuya_action in self._ACTION_MAPPINGS.items()
|
||||
if tuya_action in type_information.range
|
||||
]
|
||||
|
||||
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
|
||||
"""Convert value to raw value."""
|
||||
if value in self.options:
|
||||
return self._ACTION_MAPPINGS[value]
|
||||
raise ValueError(f"Unsupported value {value} for {self.dpcode}")
|
||||
_TUYA_TO_HA_STATE_MAPPINGS = {
|
||||
TuyaAlarmControlPanelState.DISARMED: AlarmControlPanelState.DISARMED,
|
||||
TuyaAlarmControlPanelState.ARMED_HOME: AlarmControlPanelState.ARMED_HOME,
|
||||
TuyaAlarmControlPanelState.ARMED_AWAY: AlarmControlPanelState.ARMED_AWAY,
|
||||
TuyaAlarmControlPanelState.ARMED_NIGHT: AlarmControlPanelState.ARMED_NIGHT,
|
||||
TuyaAlarmControlPanelState.ARMED_VACATION: AlarmControlPanelState.ARMED_VACATION,
|
||||
TuyaAlarmControlPanelState.ARMED_CUSTOM_BYPASS: AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
|
||||
TuyaAlarmControlPanelState.PENDING: AlarmControlPanelState.PENDING,
|
||||
TuyaAlarmControlPanelState.ARMING: AlarmControlPanelState.ARMING,
|
||||
TuyaAlarmControlPanelState.DISARMING: AlarmControlPanelState.DISARMING,
|
||||
TuyaAlarmControlPanelState.TRIGGERED: AlarmControlPanelState.TRIGGERED,
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -136,13 +72,13 @@ async def async_setup_entry(
|
||||
device,
|
||||
manager,
|
||||
description,
|
||||
action_wrapper=_AlarmActionWrapper(
|
||||
action_wrapper=AlarmActionWrapper(
|
||||
master_mode.dpcode, master_mode
|
||||
),
|
||||
changed_by_wrapper=_AlarmChangedByWrapper.find_dpcode(
|
||||
changed_by_wrapper=AlarmChangedByWrapper.find_dpcode(
|
||||
device, DPCode.ALARM_MSG
|
||||
),
|
||||
state_wrapper=_AlarmStateWrapper(
|
||||
state_wrapper=AlarmStateWrapper(
|
||||
master_mode.dpcode, master_mode
|
||||
),
|
||||
)
|
||||
@@ -174,9 +110,9 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
|
||||
device_manager: Manager,
|
||||
description: AlarmControlPanelEntityDescription,
|
||||
*,
|
||||
action_wrapper: DeviceWrapper[str],
|
||||
action_wrapper: DeviceWrapper[TuyaAlarmControlPanelAction],
|
||||
changed_by_wrapper: DeviceWrapper[str] | None,
|
||||
state_wrapper: DeviceWrapper[AlarmControlPanelState],
|
||||
state_wrapper: DeviceWrapper[TuyaAlarmControlPanelState],
|
||||
) -> None:
|
||||
"""Init Tuya Alarm."""
|
||||
super().__init__(device, device_manager)
|
||||
@@ -187,17 +123,18 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
|
||||
self._state_wrapper = state_wrapper
|
||||
|
||||
# Determine supported modes
|
||||
if "arm_home" in action_wrapper.options:
|
||||
if TuyaAlarmControlPanelAction.ARM_HOME in action_wrapper.options:
|
||||
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME
|
||||
if "arm_away" in action_wrapper.options:
|
||||
if TuyaAlarmControlPanelAction.ARM_AWAY in action_wrapper.options:
|
||||
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
if "trigger" in action_wrapper.options:
|
||||
if TuyaAlarmControlPanelAction.TRIGGER in action_wrapper.options:
|
||||
self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER
|
||||
|
||||
@property
|
||||
def alarm_state(self) -> AlarmControlPanelState | None:
|
||||
"""Return the state of the device."""
|
||||
return self._read_wrapper(self._state_wrapper)
|
||||
tuya_value = self._read_wrapper(self._state_wrapper)
|
||||
return _TUYA_TO_HA_STATE_MAPPINGS.get(tuya_value) if tuya_value else None
|
||||
|
||||
@property
|
||||
def changed_by(self) -> str | None:
|
||||
@@ -206,16 +143,24 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
|
||||
|
||||
async def async_alarm_disarm(self, code: str | None = None) -> None:
|
||||
"""Send Disarm command."""
|
||||
await self._async_send_wrapper_updates(self._action_wrapper, "disarm")
|
||||
await self._async_send_wrapper_updates(
|
||||
self._action_wrapper, TuyaAlarmControlPanelAction.DISARM
|
||||
)
|
||||
|
||||
async def async_alarm_arm_home(self, code: str | None = None) -> None:
|
||||
"""Send Home command."""
|
||||
await self._async_send_wrapper_updates(self._action_wrapper, "arm_home")
|
||||
await self._async_send_wrapper_updates(
|
||||
self._action_wrapper, TuyaAlarmControlPanelAction.ARM_HOME
|
||||
)
|
||||
|
||||
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
||||
"""Send Arm command."""
|
||||
await self._async_send_wrapper_updates(self._action_wrapper, "arm_away")
|
||||
await self._async_send_wrapper_updates(
|
||||
self._action_wrapper, TuyaAlarmControlPanelAction.ARM_AWAY
|
||||
)
|
||||
|
||||
async def async_alarm_trigger(self, code: str | None = None) -> None:
|
||||
"""Send SOS command."""
|
||||
await self._async_send_wrapper_updates(self._action_wrapper, "trigger")
|
||||
await self._async_send_wrapper_updates(
|
||||
self._action_wrapper, TuyaAlarmControlPanelAction.TRIGGER
|
||||
)
|
||||
|
||||
@@ -2,17 +2,25 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Self
|
||||
from typing import Any, cast
|
||||
|
||||
from tuya_device_handlers.device_wrapper.base import DeviceWrapper
|
||||
from tuya_device_handlers.device_wrapper.climate import (
|
||||
DefaultHVACModeWrapper,
|
||||
DefaultPresetModeWrapper,
|
||||
SwingModeCompositeWrapper,
|
||||
)
|
||||
from tuya_device_handlers.device_wrapper.common import (
|
||||
DPCodeBooleanWrapper,
|
||||
DPCodeEnumWrapper,
|
||||
DPCodeIntegerWrapper,
|
||||
)
|
||||
from tuya_device_handlers.type_information import EnumTypeInformation
|
||||
from tuya_device_handlers.device_wrapper.extended import DPCodeRoundedIntegerWrapper
|
||||
from tuya_device_handlers.helpers.homeassistant import (
|
||||
TuyaClimateHVACMode,
|
||||
TuyaClimateSwingMode,
|
||||
)
|
||||
from tuya_sharing import CustomerDevice, Manager
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
@@ -41,173 +49,26 @@ from .const import (
|
||||
)
|
||||
from .entity import TuyaEntity
|
||||
|
||||
TUYA_HVAC_TO_HA = {
|
||||
"auto": HVACMode.HEAT_COOL,
|
||||
"cold": HVACMode.COOL,
|
||||
"freeze": HVACMode.COOL,
|
||||
"heat": HVACMode.HEAT,
|
||||
"hot": HVACMode.HEAT,
|
||||
"manual": HVACMode.HEAT_COOL,
|
||||
"off": HVACMode.OFF,
|
||||
"wet": HVACMode.DRY,
|
||||
"wind": HVACMode.FAN_ONLY,
|
||||
_TUYA_TO_HA_HVACMODE_MAPPINGS: dict[TuyaClimateHVACMode | None, HVACMode | None] = {
|
||||
None: None,
|
||||
TuyaClimateHVACMode.OFF: HVACMode.OFF,
|
||||
TuyaClimateHVACMode.HEAT: HVACMode.HEAT,
|
||||
TuyaClimateHVACMode.COOL: HVACMode.COOL,
|
||||
TuyaClimateHVACMode.FAN_ONLY: HVACMode.FAN_ONLY,
|
||||
TuyaClimateHVACMode.DRY: HVACMode.DRY,
|
||||
TuyaClimateHVACMode.HEAT_COOL: HVACMode.HEAT_COOL,
|
||||
TuyaClimateHVACMode.AUTO: HVACMode.AUTO,
|
||||
}
|
||||
_HA_TO_TUYA_HVACMODE_MAPPINGS = {v: k for k, v in _TUYA_TO_HA_HVACMODE_MAPPINGS.items()}
|
||||
|
||||
|
||||
class _RoundedIntegerWrapper(DPCodeIntegerWrapper[int]):
|
||||
"""An integer that always rounds its value."""
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> int | None:
|
||||
"""Read and round the device status."""
|
||||
if (value := self._read_dpcode_value(device)) is None:
|
||||
return None
|
||||
return round(value)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class _SwingModeWrapper(DeviceWrapper[str]):
|
||||
"""Wrapper for managing climate swing mode operations across multiple DPCodes."""
|
||||
|
||||
on_off: DPCodeBooleanWrapper | None = None
|
||||
horizontal: DPCodeBooleanWrapper | None = None
|
||||
vertical: DPCodeBooleanWrapper | None = None
|
||||
options: list[str]
|
||||
|
||||
@classmethod
|
||||
def find_dpcode(cls, device: CustomerDevice) -> Self | None:
|
||||
"""Find and return a _SwingModeWrapper for the given DP codes."""
|
||||
on_off = DPCodeBooleanWrapper.find_dpcode(
|
||||
device, (DPCode.SWING, DPCode.SHAKE), prefer_function=True
|
||||
)
|
||||
horizontal = DPCodeBooleanWrapper.find_dpcode(
|
||||
device, DPCode.SWITCH_HORIZONTAL, prefer_function=True
|
||||
)
|
||||
vertical = DPCodeBooleanWrapper.find_dpcode(
|
||||
device, DPCode.SWITCH_VERTICAL, prefer_function=True
|
||||
)
|
||||
if on_off or horizontal or vertical:
|
||||
options = [SWING_OFF]
|
||||
if on_off:
|
||||
options.append(SWING_ON)
|
||||
if horizontal:
|
||||
options.append(SWING_HORIZONTAL)
|
||||
if vertical:
|
||||
options.append(SWING_VERTICAL)
|
||||
return cls(
|
||||
on_off=on_off,
|
||||
horizontal=horizontal,
|
||||
vertical=vertical,
|
||||
options=options,
|
||||
)
|
||||
return None
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> str | None:
|
||||
"""Read the device swing mode."""
|
||||
if self.on_off and self.on_off.read_device_status(device):
|
||||
return SWING_ON
|
||||
|
||||
horizontal = (
|
||||
self.horizontal.read_device_status(device) if self.horizontal else None
|
||||
)
|
||||
vertical = self.vertical.read_device_status(device) if self.vertical else None
|
||||
if horizontal and vertical:
|
||||
return SWING_BOTH
|
||||
if horizontal:
|
||||
return SWING_HORIZONTAL
|
||||
if vertical:
|
||||
return SWING_VERTICAL
|
||||
|
||||
return SWING_OFF
|
||||
|
||||
def get_update_commands(
|
||||
self, device: CustomerDevice, value: str
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Set new target swing operation."""
|
||||
commands = []
|
||||
if self.on_off:
|
||||
commands.extend(self.on_off.get_update_commands(device, value == SWING_ON))
|
||||
|
||||
if self.vertical:
|
||||
commands.extend(
|
||||
self.vertical.get_update_commands(
|
||||
device, value in (SWING_BOTH, SWING_VERTICAL)
|
||||
)
|
||||
)
|
||||
if self.horizontal:
|
||||
commands.extend(
|
||||
self.horizontal.get_update_commands(
|
||||
device, value in (SWING_BOTH, SWING_HORIZONTAL)
|
||||
)
|
||||
)
|
||||
return commands
|
||||
|
||||
|
||||
def _filter_hvac_mode_mappings(tuya_range: list[str]) -> dict[str, HVACMode | None]:
|
||||
"""Filter TUYA_HVAC_TO_HA modes that are not in the range.
|
||||
|
||||
If multiple Tuya modes map to the same HA mode, set the mapping to None to avoid
|
||||
ambiguity when converting back from HA to Tuya modes.
|
||||
"""
|
||||
modes_in_range = {
|
||||
tuya_mode: TUYA_HVAC_TO_HA.get(tuya_mode) for tuya_mode in tuya_range
|
||||
}
|
||||
modes_occurrences = collections.Counter(modes_in_range.values())
|
||||
for key, value in modes_in_range.items():
|
||||
if value is not None and modes_occurrences[value] > 1:
|
||||
modes_in_range[key] = None
|
||||
return modes_in_range
|
||||
|
||||
|
||||
class _HvacModeWrapper(DPCodeEnumWrapper[HVACMode]):
|
||||
"""Wrapper for managing climate HVACMode."""
|
||||
|
||||
# Modes that do not map to HVAC modes are ignored (they are handled by PresetWrapper)
|
||||
|
||||
def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None:
|
||||
"""Init _HvacModeWrapper."""
|
||||
super().__init__(dpcode, type_information)
|
||||
self._mappings = _filter_hvac_mode_mappings(type_information.range)
|
||||
self.options = [
|
||||
ha_mode for ha_mode in self._mappings.values() if ha_mode is not None
|
||||
]
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> HVACMode | None:
|
||||
"""Read the device status."""
|
||||
if (raw := self._read_dpcode_value(device)) not in TUYA_HVAC_TO_HA:
|
||||
return None
|
||||
return TUYA_HVAC_TO_HA[raw]
|
||||
|
||||
def _convert_value_to_raw_value(
|
||||
self,
|
||||
device: CustomerDevice,
|
||||
value: HVACMode,
|
||||
) -> Any:
|
||||
"""Convert value to raw value."""
|
||||
return next(
|
||||
tuya_mode
|
||||
for tuya_mode, ha_mode in self._mappings.items()
|
||||
if ha_mode == value
|
||||
)
|
||||
|
||||
|
||||
class _PresetWrapper(DPCodeEnumWrapper):
|
||||
"""Wrapper for managing climate preset modes."""
|
||||
|
||||
# Modes that map to HVAC modes are ignored (they are handled by HVACModeWrapper)
|
||||
|
||||
def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None:
|
||||
"""Init _PresetWrapper."""
|
||||
super().__init__(dpcode, type_information)
|
||||
mappings = _filter_hvac_mode_mappings(type_information.range)
|
||||
self.options = [
|
||||
tuya_mode for tuya_mode, ha_mode in mappings.items() if ha_mode is None
|
||||
]
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> str | None:
|
||||
"""Read the device status."""
|
||||
if (raw := self._read_dpcode_value(device)) not in self.options:
|
||||
return None
|
||||
return raw
|
||||
_TUYA_TO_HA_SWING_MAPPINGS = {
|
||||
TuyaClimateSwingMode.BOTH: SWING_BOTH,
|
||||
TuyaClimateSwingMode.HORIZONTAL: SWING_HORIZONTAL,
|
||||
TuyaClimateSwingMode.OFF: SWING_OFF,
|
||||
TuyaClimateSwingMode.ON: SWING_ON,
|
||||
TuyaClimateSwingMode.VERTICAL: SWING_VERTICAL,
|
||||
}
|
||||
_HA_TO_TUYA_SWING_MAPPINGS = {v: k for k, v in _TUYA_TO_HA_SWING_MAPPINGS.items()}
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
@@ -358,7 +219,7 @@ async def async_setup_entry(
|
||||
device,
|
||||
manager,
|
||||
CLIMATE_DESCRIPTIONS[device.category],
|
||||
current_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode(
|
||||
current_humidity_wrapper=DPCodeRoundedIntegerWrapper.find_dpcode(
|
||||
device, DPCode.HUMIDITY_CURRENT
|
||||
),
|
||||
current_temperature_wrapper=temperature_wrappers[0],
|
||||
@@ -367,18 +228,18 @@ async def async_setup_entry(
|
||||
(DPCode.FAN_SPEED_ENUM, DPCode.LEVEL, DPCode.WINDSPEED),
|
||||
prefer_function=True,
|
||||
),
|
||||
hvac_mode_wrapper=_HvacModeWrapper.find_dpcode(
|
||||
hvac_mode_wrapper=DefaultHVACModeWrapper.find_dpcode(
|
||||
device, DPCode.MODE, prefer_function=True
|
||||
),
|
||||
preset_wrapper=_PresetWrapper.find_dpcode(
|
||||
preset_wrapper=DefaultPresetModeWrapper.find_dpcode(
|
||||
device, DPCode.MODE, prefer_function=True
|
||||
),
|
||||
set_temperature_wrapper=temperature_wrappers[1],
|
||||
swing_wrapper=_SwingModeWrapper.find_dpcode(device),
|
||||
swing_wrapper=SwingModeCompositeWrapper.find_dpcode(device),
|
||||
switch_wrapper=DPCodeBooleanWrapper.find_dpcode(
|
||||
device, DPCode.SWITCH, prefer_function=True
|
||||
),
|
||||
target_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode(
|
||||
target_humidity_wrapper=DPCodeRoundedIntegerWrapper.find_dpcode(
|
||||
device, DPCode.HUMIDITY_SET, prefer_function=True
|
||||
),
|
||||
temperature_unit=temperature_wrappers[2],
|
||||
@@ -408,10 +269,10 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
current_humidity_wrapper: DeviceWrapper[int] | None,
|
||||
current_temperature_wrapper: DeviceWrapper[float] | None,
|
||||
fan_mode_wrapper: DeviceWrapper[str] | None,
|
||||
hvac_mode_wrapper: DeviceWrapper[HVACMode] | None,
|
||||
hvac_mode_wrapper: DeviceWrapper[TuyaClimateHVACMode] | None,
|
||||
preset_wrapper: DeviceWrapper[str] | None,
|
||||
set_temperature_wrapper: DeviceWrapper[float] | None,
|
||||
swing_wrapper: DeviceWrapper[str] | None,
|
||||
swing_wrapper: DeviceWrapper[TuyaClimateSwingMode] | None,
|
||||
switch_wrapper: DeviceWrapper[bool] | None,
|
||||
target_humidity_wrapper: DeviceWrapper[int] | None,
|
||||
temperature_unit: UnitOfTemperature,
|
||||
@@ -475,7 +336,13 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
# Determine swing modes
|
||||
if swing_wrapper:
|
||||
self._attr_supported_features |= ClimateEntityFeature.SWING_MODE
|
||||
self._attr_swing_modes = swing_wrapper.options
|
||||
self._attr_swing_modes = [
|
||||
ha_swing_mode
|
||||
for tuya_swing_mode in cast(
|
||||
list[TuyaClimateSwingMode], swing_wrapper.options
|
||||
)
|
||||
if (ha_swing_mode := _TUYA_TO_HA_SWING_MAPPINGS.get(tuya_swing_mode))
|
||||
]
|
||||
|
||||
if switch_wrapper:
|
||||
self._attr_supported_features |= (
|
||||
@@ -491,9 +358,13 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
self.device, hvac_mode != HVACMode.OFF
|
||||
)
|
||||
)
|
||||
if self._hvac_mode_wrapper and hvac_mode in self._hvac_mode_wrapper.options:
|
||||
if (
|
||||
self._hvac_mode_wrapper
|
||||
and hvac_mode in self._hvac_mode_wrapper.options
|
||||
and (tuya_mode := _HA_TO_TUYA_HVACMODE_MAPPINGS.get(hvac_mode))
|
||||
):
|
||||
commands.extend(
|
||||
self._hvac_mode_wrapper.get_update_commands(self.device, hvac_mode)
|
||||
self._hvac_mode_wrapper.get_update_commands(self.device, tuya_mode)
|
||||
)
|
||||
await self._async_send_commands(commands)
|
||||
|
||||
@@ -511,7 +382,8 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
|
||||
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
||||
"""Set new target swing operation."""
|
||||
await self._async_send_wrapper_updates(self._swing_wrapper, swing_mode)
|
||||
if tuya_mode := _HA_TO_TUYA_SWING_MAPPINGS.get(swing_mode):
|
||||
await self._async_send_wrapper_updates(self._swing_wrapper, tuya_mode)
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
@@ -554,7 +426,9 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
return None
|
||||
|
||||
# If we do have a mode wrapper, check if the mode maps to an HVAC mode.
|
||||
return self._read_wrapper(self._hvac_mode_wrapper)
|
||||
return _TUYA_TO_HA_HVACMODE_MAPPINGS.get(
|
||||
self._read_wrapper(self._hvac_mode_wrapper)
|
||||
)
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
@@ -569,7 +443,8 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
@property
|
||||
def swing_mode(self) -> str | None:
|
||||
"""Return swing mode."""
|
||||
return self._read_wrapper(self._swing_wrapper)
|
||||
tuya_value = self._read_wrapper(self._swing_wrapper)
|
||||
return _TUYA_TO_HA_SWING_MAPPINGS.get(tuya_value) if tuya_value else None
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn the device on, retaining current HVAC (if supported)."""
|
||||
|
||||
@@ -6,16 +6,19 @@ from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from tuya_device_handlers.device_wrapper.base import DeviceWrapper
|
||||
from tuya_device_handlers.device_wrapper.common import (
|
||||
DPCodeBooleanWrapper,
|
||||
DPCodeEnumWrapper,
|
||||
DPCodeIntegerWrapper,
|
||||
from tuya_device_handlers.device_wrapper.cover import (
|
||||
ControlBackModePercentageMappingWrapper,
|
||||
CoverClosedEnumWrapper,
|
||||
CoverInstructionBooleanWrapper,
|
||||
CoverInstructionEnumWrapper,
|
||||
CoverInstructionSpecialEnumWrapper,
|
||||
)
|
||||
from tuya_device_handlers.type_information import (
|
||||
EnumTypeInformation,
|
||||
IntegerTypeInformation,
|
||||
from tuya_device_handlers.device_wrapper.extended import (
|
||||
DPCodeInvertedBooleanWrapper,
|
||||
DPCodeInvertedPercentageWrapper,
|
||||
DPCodePercentageWrapper,
|
||||
)
|
||||
from tuya_device_handlers.utils import RemapHelper
|
||||
from tuya_device_handlers.helpers.homeassistant import TuyaCoverAction
|
||||
from tuya_sharing import CustomerDevice, Manager
|
||||
|
||||
from homeassistant.components.cover import (
|
||||
@@ -35,123 +38,17 @@ from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
|
||||
from .entity import TuyaEntity
|
||||
|
||||
|
||||
class _DPCodePercentageMappingWrapper(DPCodeIntegerWrapper[int]):
|
||||
"""Wrapper for DPCode position values mapping to 0-100 range."""
|
||||
|
||||
def __init__(self, dpcode: str, type_information: IntegerTypeInformation) -> None:
|
||||
"""Init DPCodeIntegerWrapper."""
|
||||
super().__init__(dpcode, type_information)
|
||||
self._remap_helper = RemapHelper.from_type_information(type_information, 0, 100)
|
||||
|
||||
def _position_reversed(self, device: CustomerDevice) -> bool:
|
||||
"""Check if the position and direction should be reversed."""
|
||||
return False
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> int | None:
|
||||
if (value := device.status.get(self.dpcode)) is None:
|
||||
return None
|
||||
|
||||
return round(
|
||||
self._remap_helper.remap_value_to(
|
||||
value, reverse=self._position_reversed(device)
|
||||
)
|
||||
)
|
||||
|
||||
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
|
||||
return round(
|
||||
self._remap_helper.remap_value_from(
|
||||
value, reverse=self._position_reversed(device)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class _InvertedPercentageMappingWrapper(_DPCodePercentageMappingWrapper):
|
||||
"""Wrapper for DPCode position values mapping to 0-100 range."""
|
||||
|
||||
def _position_reversed(self, device: CustomerDevice) -> bool:
|
||||
"""Check if the position and direction should be reversed."""
|
||||
return True
|
||||
|
||||
|
||||
class _ControlBackModePercentageMappingWrapper(_DPCodePercentageMappingWrapper):
|
||||
"""Wrapper for DPCode position values with control_back_mode support."""
|
||||
|
||||
def _position_reversed(self, device: CustomerDevice) -> bool:
|
||||
"""Check if the position and direction should be reversed."""
|
||||
return device.status.get(DPCode.CONTROL_BACK_MODE) != "back"
|
||||
|
||||
|
||||
class _InstructionBooleanWrapper(DPCodeBooleanWrapper):
|
||||
"""Wrapper for boolean-based open/close instructions."""
|
||||
|
||||
options = ["open", "close"]
|
||||
_ACTION_MAPPINGS = {"open": True, "close": False}
|
||||
|
||||
def _convert_value_to_raw_value(self, device: CustomerDevice, value: str) -> bool:
|
||||
return self._ACTION_MAPPINGS[value]
|
||||
|
||||
|
||||
class _InstructionEnumWrapper(DPCodeEnumWrapper):
|
||||
"""Wrapper for enum-based open/close/stop instructions."""
|
||||
|
||||
_ACTION_MAPPINGS = {"open": "open", "close": "close", "stop": "stop"}
|
||||
|
||||
def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None:
|
||||
super().__init__(dpcode, type_information)
|
||||
self.options = [
|
||||
ha_action
|
||||
for ha_action, tuya_action in self._ACTION_MAPPINGS.items()
|
||||
if tuya_action in type_information.range
|
||||
]
|
||||
|
||||
def _convert_value_to_raw_value(self, device: CustomerDevice, value: str) -> str:
|
||||
return self._ACTION_MAPPINGS[value]
|
||||
|
||||
|
||||
class _SpecialInstructionEnumWrapper(_InstructionEnumWrapper):
|
||||
"""Wrapper for enum-based instructions with special values (FZ/ZZ/STOP)."""
|
||||
|
||||
_ACTION_MAPPINGS = {"open": "FZ", "close": "ZZ", "stop": "STOP"}
|
||||
|
||||
|
||||
class _IsClosedInvertedWrapper(DPCodeBooleanWrapper):
|
||||
"""Boolean wrapper for checking if cover is closed (inverted)."""
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> bool | None:
|
||||
if (value := self._read_dpcode_value(device)) is None:
|
||||
return None
|
||||
return not value
|
||||
|
||||
|
||||
class _IsClosedEnumWrapper(DPCodeEnumWrapper[bool]):
|
||||
"""Enum wrapper for checking if state is closed."""
|
||||
|
||||
_MAPPINGS = {
|
||||
"close": True,
|
||||
"fully_close": True,
|
||||
"open": False,
|
||||
"fully_open": False,
|
||||
}
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> bool | None:
|
||||
if (value := self._read_dpcode_value(device)) is None:
|
||||
return None
|
||||
return self._MAPPINGS.get(value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuyaCoverEntityDescription(CoverEntityDescription):
|
||||
"""Describe a Tuya cover entity."""
|
||||
|
||||
current_state: DPCode | tuple[DPCode, ...] | None = None
|
||||
current_state_wrapper: type[_IsClosedInvertedWrapper | _IsClosedEnumWrapper] = (
|
||||
_IsClosedEnumWrapper
|
||||
)
|
||||
current_state_wrapper: type[
|
||||
DPCodeInvertedBooleanWrapper | CoverClosedEnumWrapper
|
||||
] = CoverClosedEnumWrapper
|
||||
current_position: DPCode | tuple[DPCode, ...] | None = None
|
||||
instruction_wrapper: type[_InstructionEnumWrapper] = _InstructionEnumWrapper
|
||||
position_wrapper: type[_DPCodePercentageMappingWrapper] = (
|
||||
_InvertedPercentageMappingWrapper
|
||||
)
|
||||
instruction_wrapper: type[CoverInstructionEnumWrapper] = CoverInstructionEnumWrapper
|
||||
position_wrapper: type[DPCodePercentageWrapper] = DPCodeInvertedPercentageWrapper
|
||||
set_position: DPCode | None = None
|
||||
|
||||
|
||||
@@ -162,7 +59,7 @@ COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
|
||||
translation_key="indexed_door",
|
||||
translation_placeholders={"index": "1"},
|
||||
current_state=DPCode.DOORCONTACT_STATE,
|
||||
current_state_wrapper=_IsClosedInvertedWrapper,
|
||||
current_state_wrapper=DPCodeInvertedBooleanWrapper,
|
||||
device_class=CoverDeviceClass.GARAGE,
|
||||
),
|
||||
TuyaCoverEntityDescription(
|
||||
@@ -170,7 +67,7 @@ COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
|
||||
translation_key="indexed_door",
|
||||
translation_placeholders={"index": "2"},
|
||||
current_state=DPCode.DOORCONTACT_STATE_2,
|
||||
current_state_wrapper=_IsClosedInvertedWrapper,
|
||||
current_state_wrapper=DPCodeInvertedBooleanWrapper,
|
||||
device_class=CoverDeviceClass.GARAGE,
|
||||
),
|
||||
TuyaCoverEntityDescription(
|
||||
@@ -178,7 +75,7 @@ COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
|
||||
translation_key="indexed_door",
|
||||
translation_placeholders={"index": "3"},
|
||||
current_state=DPCode.DOORCONTACT_STATE_3,
|
||||
current_state_wrapper=_IsClosedInvertedWrapper,
|
||||
current_state_wrapper=DPCodeInvertedBooleanWrapper,
|
||||
device_class=CoverDeviceClass.GARAGE,
|
||||
),
|
||||
),
|
||||
@@ -213,7 +110,7 @@ COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
|
||||
current_position=DPCode.POSITION,
|
||||
set_position=DPCode.POSITION,
|
||||
device_class=CoverDeviceClass.CURTAIN,
|
||||
instruction_wrapper=_SpecialInstructionEnumWrapper,
|
||||
instruction_wrapper=CoverInstructionSpecialEnumWrapper,
|
||||
),
|
||||
# switch_1 is an undocumented code that behaves identically to control
|
||||
# It is used by the Kogan Smart Blinds Driver
|
||||
@@ -230,7 +127,7 @@ COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
|
||||
key=DPCode.CONTROL,
|
||||
translation_key="curtain",
|
||||
current_position=DPCode.PERCENT_CONTROL,
|
||||
position_wrapper=_ControlBackModePercentageMappingWrapper,
|
||||
position_wrapper=ControlBackModePercentageMappingWrapper,
|
||||
set_position=DPCode.PERCENT_CONTROL,
|
||||
device_class=CoverDeviceClass.CURTAIN,
|
||||
),
|
||||
@@ -239,7 +136,7 @@ COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
|
||||
translation_key="indexed_curtain",
|
||||
translation_placeholders={"index": "2"},
|
||||
current_position=DPCode.PERCENT_CONTROL_2,
|
||||
position_wrapper=_ControlBackModePercentageMappingWrapper,
|
||||
position_wrapper=ControlBackModePercentageMappingWrapper,
|
||||
set_position=DPCode.PERCENT_CONTROL_2,
|
||||
device_class=CoverDeviceClass.CURTAIN,
|
||||
),
|
||||
@@ -266,7 +163,7 @@ def _get_instruction_wrapper(
|
||||
return enum_wrapper
|
||||
|
||||
# Fallback to a boolean wrapper if available
|
||||
return _InstructionBooleanWrapper.find_dpcode(
|
||||
return CoverInstructionBooleanWrapper.find_dpcode(
|
||||
device, description.key, prefer_function=True
|
||||
)
|
||||
|
||||
@@ -338,7 +235,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
|
||||
*,
|
||||
current_position: DeviceWrapper[int] | None,
|
||||
current_state_wrapper: DeviceWrapper[bool] | None,
|
||||
instruction_wrapper: DeviceWrapper[str] | None,
|
||||
instruction_wrapper: DeviceWrapper[TuyaCoverAction] | None,
|
||||
set_position: DeviceWrapper[int] | None,
|
||||
tilt_position: DeviceWrapper[int] | None,
|
||||
) -> None:
|
||||
@@ -355,11 +252,11 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
|
||||
self._tilt_position = tilt_position
|
||||
|
||||
if instruction_wrapper:
|
||||
if "open" in instruction_wrapper.options:
|
||||
if TuyaCoverAction.OPEN in instruction_wrapper.options:
|
||||
self._attr_supported_features |= CoverEntityFeature.OPEN
|
||||
if "close" in instruction_wrapper.options:
|
||||
if TuyaCoverAction.CLOSE in instruction_wrapper.options:
|
||||
self._attr_supported_features |= CoverEntityFeature.CLOSE
|
||||
if "stop" in instruction_wrapper.options:
|
||||
if TuyaCoverAction.STOP in instruction_wrapper.options:
|
||||
self._attr_supported_features |= CoverEntityFeature.STOP
|
||||
|
||||
if set_position:
|
||||
@@ -399,10 +296,11 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
|
||||
|
||||
if (
|
||||
self._instruction_wrapper
|
||||
and (options := self._instruction_wrapper.options)
|
||||
and "open" in options
|
||||
and TuyaCoverAction.OPEN in self._instruction_wrapper.options
|
||||
):
|
||||
await self._async_send_wrapper_updates(self._instruction_wrapper, "open")
|
||||
await self._async_send_wrapper_updates(
|
||||
self._instruction_wrapper, TuyaCoverAction.OPEN
|
||||
)
|
||||
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Close cover."""
|
||||
@@ -414,10 +312,11 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
|
||||
|
||||
if (
|
||||
self._instruction_wrapper
|
||||
and (options := self._instruction_wrapper.options)
|
||||
and "close" in options
|
||||
and TuyaCoverAction.CLOSE in self._instruction_wrapper.options
|
||||
):
|
||||
await self._async_send_wrapper_updates(self._instruction_wrapper, "close")
|
||||
await self._async_send_wrapper_updates(
|
||||
self._instruction_wrapper, TuyaCoverAction.CLOSE
|
||||
)
|
||||
|
||||
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover to a specific position."""
|
||||
@@ -427,8 +326,13 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
|
||||
|
||||
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||
"""Stop the cover."""
|
||||
if self._instruction_wrapper and "stop" in self._instruction_wrapper.options:
|
||||
await self._async_send_wrapper_updates(self._instruction_wrapper, "stop")
|
||||
if (
|
||||
self._instruction_wrapper
|
||||
and TuyaCoverAction.STOP in self._instruction_wrapper.options
|
||||
):
|
||||
await self._async_send_wrapper_updates(
|
||||
self._instruction_wrapper, TuyaCoverAction.STOP
|
||||
)
|
||||
|
||||
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover tilt to a specific position."""
|
||||
|
||||
@@ -2,16 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from base64 import b64decode
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from tuya_device_handlers.device_wrapper.base import DeviceWrapper
|
||||
from tuya_device_handlers.device_wrapper.common import (
|
||||
DPCodeEnumWrapper,
|
||||
DPCodeRawWrapper,
|
||||
DPCodeStringWrapper,
|
||||
DPCodeTypeInformationWrapper,
|
||||
from tuya_device_handlers.device_wrapper.common import DPCodeTypeInformationWrapper
|
||||
from tuya_device_handlers.device_wrapper.event import (
|
||||
Base64Utf8RawEventWrapper,
|
||||
Base64Utf8StringEventWrapper,
|
||||
SimpleEventEnumWrapper,
|
||||
)
|
||||
from tuya_sharing import CustomerDevice, Manager
|
||||
|
||||
@@ -29,58 +28,11 @@ from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
|
||||
from .entity import TuyaEntity
|
||||
|
||||
|
||||
class _EventEnumWrapper(DPCodeEnumWrapper[tuple[str, None]]):
|
||||
"""Wrapper for event enum DP codes."""
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> tuple[str, None] | None:
|
||||
"""Return the event details."""
|
||||
if (raw_value := self._read_dpcode_value(device)) is None:
|
||||
return None
|
||||
return (raw_value, None)
|
||||
|
||||
|
||||
class _AlarmMessageWrapper(DPCodeStringWrapper[tuple[str, dict[str, Any]]]):
|
||||
"""Wrapper for a STRING message on DPCode.ALARM_MESSAGE."""
|
||||
|
||||
def __init__(self, dpcode: str, type_information: Any) -> None:
|
||||
"""Init _AlarmMessageWrapper."""
|
||||
super().__init__(dpcode, type_information)
|
||||
self.options = ["triggered"]
|
||||
|
||||
def read_device_status(
|
||||
self, device: CustomerDevice
|
||||
) -> tuple[str, dict[str, Any]] | None:
|
||||
"""Return the event attributes for the alarm message."""
|
||||
if (raw_value := self._read_dpcode_value(device)) is None:
|
||||
return None
|
||||
return ("triggered", {"message": b64decode(raw_value).decode("utf-8")})
|
||||
|
||||
|
||||
class _DoorbellPicWrapper(DPCodeRawWrapper[tuple[str, dict[str, Any]]]):
|
||||
"""Wrapper for a RAW message on DPCode.DOORBELL_PIC.
|
||||
|
||||
It is expected that the RAW data is base64/utf8 encoded URL of the picture.
|
||||
"""
|
||||
|
||||
def __init__(self, dpcode: str, type_information: Any) -> None:
|
||||
"""Init _DoorbellPicWrapper."""
|
||||
super().__init__(dpcode, type_information)
|
||||
self.options = ["triggered"]
|
||||
|
||||
def read_device_status(
|
||||
self, device: CustomerDevice
|
||||
) -> tuple[str, dict[str, Any]] | None:
|
||||
"""Return the event attributes for the doorbell picture."""
|
||||
if (status := self._read_dpcode_value(device)) is None:
|
||||
return None
|
||||
return ("triggered", {"message": status.decode("utf-8")})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuyaEventEntityDescription(EventEntityDescription):
|
||||
"""Describe a Tuya Event entity."""
|
||||
|
||||
wrapper_class: type[DPCodeTypeInformationWrapper] = _EventEnumWrapper
|
||||
wrapper_class: type[DPCodeTypeInformationWrapper] = SimpleEventEnumWrapper
|
||||
|
||||
|
||||
# All descriptions can be found here. Mostly the Enum data types in the
|
||||
@@ -92,13 +44,13 @@ EVENTS: dict[DeviceCategory, tuple[TuyaEventEntityDescription, ...]] = {
|
||||
key=DPCode.ALARM_MESSAGE,
|
||||
device_class=EventDeviceClass.DOORBELL,
|
||||
translation_key="doorbell_message",
|
||||
wrapper_class=_AlarmMessageWrapper,
|
||||
wrapper_class=Base64Utf8StringEventWrapper,
|
||||
),
|
||||
TuyaEventEntityDescription(
|
||||
key=DPCode.DOORBELL_PIC,
|
||||
device_class=EventDeviceClass.DOORBELL,
|
||||
translation_key="doorbell_picture",
|
||||
wrapper_class=_DoorbellPicWrapper,
|
||||
wrapper_class=Base64Utf8RawEventWrapper,
|
||||
),
|
||||
),
|
||||
DeviceCategory.WXKG: (
|
||||
|
||||
@@ -8,10 +8,13 @@ from tuya_device_handlers.device_wrapper.base import DeviceWrapper
|
||||
from tuya_device_handlers.device_wrapper.common import (
|
||||
DPCodeBooleanWrapper,
|
||||
DPCodeEnumWrapper,
|
||||
DPCodeIntegerWrapper,
|
||||
)
|
||||
from tuya_device_handlers.type_information import IntegerTypeInformation
|
||||
from tuya_device_handlers.utils import RemapHelper
|
||||
from tuya_device_handlers.device_wrapper.fan import (
|
||||
FanDirectionEnumWrapper,
|
||||
FanSpeedEnumWrapper,
|
||||
FanSpeedIntegerWrapper,
|
||||
)
|
||||
from tuya_device_handlers.helpers.homeassistant import TuyaFanDirection
|
||||
from tuya_sharing import CustomerDevice, Manager
|
||||
|
||||
from homeassistant.components.fan import (
|
||||
@@ -23,10 +26,6 @@ from homeassistant.components.fan import (
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.percentage import (
|
||||
ordered_list_item_to_percentage,
|
||||
percentage_to_ordered_list_item,
|
||||
)
|
||||
|
||||
from . import TuyaConfigEntry
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
|
||||
@@ -53,18 +52,13 @@ TUYA_SUPPORT_TYPE: set[DeviceCategory] = {
|
||||
DeviceCategory.KS,
|
||||
}
|
||||
|
||||
|
||||
class _DirectionEnumWrapper(DPCodeEnumWrapper):
|
||||
"""Wrapper for fan direction DP code."""
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> str | None:
|
||||
"""Read the device status and return the direction string."""
|
||||
if (value := self._read_dpcode_value(device)) and value in {
|
||||
DIRECTION_FORWARD,
|
||||
DIRECTION_REVERSE,
|
||||
}:
|
||||
return value
|
||||
return None
|
||||
_TUYA_TO_HA_DIRECTION_MAPPINGS = {
|
||||
TuyaFanDirection.FORWARD: DIRECTION_FORWARD,
|
||||
TuyaFanDirection.REVERSE: DIRECTION_REVERSE,
|
||||
}
|
||||
_HA_TO_TUYA_DIRECTION_MAPPINGS = {
|
||||
v: k for k, v in _TUYA_TO_HA_DIRECTION_MAPPINGS.items()
|
||||
}
|
||||
|
||||
|
||||
def _has_a_valid_dpcode(device: CustomerDevice) -> bool:
|
||||
@@ -80,50 +74,15 @@ def _has_a_valid_dpcode(device: CustomerDevice) -> bool:
|
||||
return any(get_dpcode(device, code) for code in properties_to_check)
|
||||
|
||||
|
||||
class _FanSpeedEnumWrapper(DPCodeEnumWrapper[int]):
|
||||
"""Wrapper for fan speed DP code (from an enum)."""
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> int | None:
|
||||
"""Get the current speed as a percentage."""
|
||||
if (value := self._read_dpcode_value(device)) is None:
|
||||
return None
|
||||
return ordered_list_item_to_percentage(self.options, value)
|
||||
|
||||
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
|
||||
"""Convert a Home Assistant value back to a raw device value."""
|
||||
return percentage_to_ordered_list_item(self.options, value)
|
||||
|
||||
|
||||
class _FanSpeedIntegerWrapper(DPCodeIntegerWrapper[int]):
|
||||
"""Wrapper for fan speed DP code (from an integer)."""
|
||||
|
||||
def __init__(self, dpcode: str, type_information: IntegerTypeInformation) -> None:
|
||||
"""Init DPCodeIntegerWrapper."""
|
||||
super().__init__(dpcode, type_information)
|
||||
self._remap_helper = RemapHelper.from_type_information(type_information, 1, 100)
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> int | None:
|
||||
"""Get the current speed as a percentage."""
|
||||
if (value := self._read_dpcode_value(device)) is None:
|
||||
return None
|
||||
return round(self._remap_helper.remap_value_to(value))
|
||||
|
||||
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
|
||||
"""Convert a Home Assistant value back to a raw device value."""
|
||||
return round(self._remap_helper.remap_value_from(value))
|
||||
|
||||
|
||||
def _get_speed_wrapper(
|
||||
device: CustomerDevice,
|
||||
) -> _FanSpeedEnumWrapper | _FanSpeedIntegerWrapper | None:
|
||||
) -> DeviceWrapper[int] | None:
|
||||
"""Get the speed wrapper for the device."""
|
||||
if int_wrapper := _FanSpeedIntegerWrapper.find_dpcode(
|
||||
if int_wrapper := FanSpeedIntegerWrapper.find_dpcode(
|
||||
device, _SPEED_DPCODES, prefer_function=True
|
||||
):
|
||||
return int_wrapper
|
||||
return _FanSpeedEnumWrapper.find_dpcode(
|
||||
device, _SPEED_DPCODES, prefer_function=True
|
||||
)
|
||||
return FanSpeedEnumWrapper.find_dpcode(device, _SPEED_DPCODES, prefer_function=True)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -145,7 +104,7 @@ async def async_setup_entry(
|
||||
TuyaFanEntity(
|
||||
device,
|
||||
manager,
|
||||
direction_wrapper=_DirectionEnumWrapper.find_dpcode(
|
||||
direction_wrapper=FanDirectionEnumWrapper.find_dpcode(
|
||||
device, _DIRECTION_DPCODES, prefer_function=True
|
||||
),
|
||||
mode_wrapper=DPCodeEnumWrapper.find_dpcode(
|
||||
@@ -179,7 +138,7 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
|
||||
device: CustomerDevice,
|
||||
device_manager: Manager,
|
||||
*,
|
||||
direction_wrapper: DeviceWrapper[str] | None,
|
||||
direction_wrapper: DeviceWrapper[TuyaFanDirection] | None,
|
||||
mode_wrapper: DeviceWrapper[str] | None,
|
||||
oscillate_wrapper: DeviceWrapper[bool] | None,
|
||||
speed_wrapper: DeviceWrapper[int] | None,
|
||||
@@ -220,7 +179,8 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
|
||||
|
||||
async def async_set_direction(self, direction: str) -> None:
|
||||
"""Set the direction of the fan."""
|
||||
await self._async_send_wrapper_updates(self._direction_wrapper, direction)
|
||||
if tuya_value := _HA_TO_TUYA_DIRECTION_MAPPINGS.get(direction):
|
||||
await self._async_send_wrapper_updates(self._direction_wrapper, tuya_value)
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the speed of the fan, as a percentage."""
|
||||
@@ -265,7 +225,8 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
|
||||
@property
|
||||
def current_direction(self) -> str | None:
|
||||
"""Return the current direction of the fan."""
|
||||
return self._read_wrapper(self._direction_wrapper)
|
||||
tuya_value = self._read_wrapper(self._direction_wrapper)
|
||||
return _TUYA_TO_HA_DIRECTION_MAPPINGS.get(tuya_value) if tuya_value else None
|
||||
|
||||
@property
|
||||
def oscillating(self) -> bool | None:
|
||||
|
||||
@@ -9,8 +9,8 @@ from tuya_device_handlers.device_wrapper.base import DeviceWrapper
|
||||
from tuya_device_handlers.device_wrapper.common import (
|
||||
DPCodeBooleanWrapper,
|
||||
DPCodeEnumWrapper,
|
||||
DPCodeIntegerWrapper,
|
||||
)
|
||||
from tuya_device_handlers.device_wrapper.extended import DPCodeRoundedIntegerWrapper
|
||||
from tuya_sharing import CustomerDevice, Manager
|
||||
|
||||
from homeassistant.components.humidifier import (
|
||||
@@ -29,16 +29,6 @@ from .entity import TuyaEntity
|
||||
from .util import ActionDPCodeNotFoundError, get_dpcode
|
||||
|
||||
|
||||
class _RoundedIntegerWrapper(DPCodeIntegerWrapper[int]):
|
||||
"""An integer that always rounds its value."""
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> int | None:
|
||||
"""Read and round the device status."""
|
||||
if (value := self._read_dpcode_value(device)) is None:
|
||||
return None
|
||||
return round(value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuyaHumidifierEntityDescription(HumidifierEntityDescription):
|
||||
"""Describe an Tuya (de)humidifier entity."""
|
||||
@@ -104,7 +94,7 @@ async def async_setup_entry(
|
||||
device,
|
||||
manager,
|
||||
description,
|
||||
current_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode(
|
||||
current_humidity_wrapper=DPCodeRoundedIntegerWrapper.find_dpcode(
|
||||
device, description.current_humidity
|
||||
),
|
||||
mode_wrapper=DPCodeEnumWrapper.find_dpcode(
|
||||
@@ -115,7 +105,7 @@ async def async_setup_entry(
|
||||
description.dpcode or description.key,
|
||||
prefer_function=True,
|
||||
),
|
||||
target_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode(
|
||||
target_humidity_wrapper=DPCodeRoundedIntegerWrapper.find_dpcode(
|
||||
device, description.humidity, prefer_function=True
|
||||
),
|
||||
)
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
import json
|
||||
from typing import Any, cast
|
||||
|
||||
from tuya_device_handlers.device_wrapper.base import DeviceWrapper
|
||||
@@ -12,9 +11,15 @@ from tuya_device_handlers.device_wrapper.common import (
|
||||
DPCodeBooleanWrapper,
|
||||
DPCodeEnumWrapper,
|
||||
DPCodeIntegerWrapper,
|
||||
DPCodeJsonWrapper,
|
||||
)
|
||||
from tuya_device_handlers.type_information import IntegerTypeInformation
|
||||
from tuya_device_handlers.device_wrapper.light import (
|
||||
DEFAULT_H_TYPE_V2,
|
||||
DEFAULT_S_TYPE_V2,
|
||||
DEFAULT_V_TYPE_V2,
|
||||
BrightnessWrapper,
|
||||
ColorDataWrapper,
|
||||
ColorTempWrapper,
|
||||
)
|
||||
from tuya_device_handlers.utils import RemapHelper
|
||||
from tuya_sharing import CustomerDevice, Manager
|
||||
|
||||
@@ -33,7 +38,6 @@ from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import color as color_util
|
||||
from homeassistant.util.json import json_loads_object
|
||||
|
||||
from . import TuyaConfigEntry
|
||||
@@ -41,169 +45,6 @@ from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, WorkMode
|
||||
from .entity import TuyaEntity
|
||||
|
||||
|
||||
class _BrightnessWrapper(DPCodeIntegerWrapper[int]):
|
||||
"""Wrapper for brightness DP code.
|
||||
|
||||
Handles brightness value conversion between device scale and Home Assistant's
|
||||
0-255 scale. Supports optional dynamic brightness_min and brightness_max
|
||||
wrappers that allow the device to specify runtime brightness range limits.
|
||||
"""
|
||||
|
||||
brightness_min: DPCodeIntegerWrapper | None = None
|
||||
brightness_max: DPCodeIntegerWrapper | None = None
|
||||
brightness_min_remap: RemapHelper | None = None
|
||||
brightness_max_remap: RemapHelper | None = None
|
||||
|
||||
def __init__(self, dpcode: str, type_information: IntegerTypeInformation) -> None:
|
||||
"""Init DPCodeIntegerWrapper."""
|
||||
super().__init__(dpcode, type_information)
|
||||
self._remap_helper = RemapHelper.from_type_information(type_information, 0, 255)
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> int | None:
|
||||
"""Return the brightness of this light between 0..255."""
|
||||
if (brightness := device.status.get(self.dpcode)) is None:
|
||||
return None
|
||||
|
||||
# Remap value to our scale
|
||||
brightness = self._remap_helper.remap_value_to(brightness)
|
||||
|
||||
# If there is a min/max value, the brightness is actually limited.
|
||||
# Meaning it is actually not on a 0-255 scale.
|
||||
if (
|
||||
self.brightness_max is not None
|
||||
and self.brightness_min is not None
|
||||
and self.brightness_max_remap is not None
|
||||
and self.brightness_min_remap is not None
|
||||
and (brightness_max := device.status.get(self.brightness_max.dpcode))
|
||||
is not None
|
||||
and (brightness_min := device.status.get(self.brightness_min.dpcode))
|
||||
is not None
|
||||
):
|
||||
# Remap values onto our scale
|
||||
brightness_max = self.brightness_max_remap.remap_value_to(brightness_max)
|
||||
brightness_min = self.brightness_min_remap.remap_value_to(brightness_min)
|
||||
|
||||
# Remap the brightness value from their min-max to our 0-255 scale
|
||||
brightness = RemapHelper.remap_value(
|
||||
brightness,
|
||||
from_min=brightness_min,
|
||||
from_max=brightness_max,
|
||||
to_min=0,
|
||||
to_max=255,
|
||||
)
|
||||
|
||||
return round(brightness)
|
||||
|
||||
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
|
||||
"""Convert a Home Assistant value (0..255) back to a raw device value."""
|
||||
# If there is a min/max value, the brightness is actually limited.
|
||||
# Meaning it is actually not on a 0-255 scale.
|
||||
if (
|
||||
self.brightness_max is not None
|
||||
and self.brightness_min is not None
|
||||
and self.brightness_max_remap is not None
|
||||
and self.brightness_min_remap is not None
|
||||
and (brightness_max := device.status.get(self.brightness_max.dpcode))
|
||||
is not None
|
||||
and (brightness_min := device.status.get(self.brightness_min.dpcode))
|
||||
is not None
|
||||
):
|
||||
# Remap values onto our scale
|
||||
brightness_max = self.brightness_max_remap.remap_value_to(brightness_max)
|
||||
brightness_min = self.brightness_min_remap.remap_value_to(brightness_min)
|
||||
|
||||
# Remap the brightness value from our 0-255 scale to their min-max
|
||||
value = RemapHelper.remap_value(
|
||||
value,
|
||||
from_min=0,
|
||||
from_max=255,
|
||||
to_min=brightness_min,
|
||||
to_max=brightness_max,
|
||||
)
|
||||
return round(self._remap_helper.remap_value_from(value))
|
||||
|
||||
|
||||
class _ColorTempWrapper(DPCodeIntegerWrapper[int]):
|
||||
"""Wrapper for color temperature DP code."""
|
||||
|
||||
def __init__(self, dpcode: str, type_information: IntegerTypeInformation) -> None:
|
||||
"""Init DPCodeIntegerWrapper."""
|
||||
super().__init__(dpcode, type_information)
|
||||
self._remap_helper = RemapHelper.from_type_information(
|
||||
type_information, MIN_MIREDS, MAX_MIREDS
|
||||
)
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> int | None:
|
||||
"""Return the color temperature value in Kelvin."""
|
||||
if (temperature := device.status.get(self.dpcode)) is None:
|
||||
return None
|
||||
|
||||
return color_util.color_temperature_mired_to_kelvin(
|
||||
self._remap_helper.remap_value_to(temperature, reverse=True)
|
||||
)
|
||||
|
||||
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
|
||||
"""Convert a Home Assistant value (Kelvin) back to a raw device value."""
|
||||
return round(
|
||||
self._remap_helper.remap_value_from(
|
||||
color_util.color_temperature_kelvin_to_mired(value), reverse=True
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_H_TYPE = RemapHelper(source_min=1, source_max=360, target_min=0, target_max=360)
|
||||
DEFAULT_S_TYPE = RemapHelper(source_min=1, source_max=255, target_min=0, target_max=100)
|
||||
DEFAULT_V_TYPE = RemapHelper(source_min=1, source_max=255, target_min=0, target_max=255)
|
||||
|
||||
|
||||
DEFAULT_H_TYPE_V2 = RemapHelper(
|
||||
source_min=1, source_max=360, target_min=0, target_max=360
|
||||
)
|
||||
DEFAULT_S_TYPE_V2 = RemapHelper(
|
||||
source_min=1, source_max=1000, target_min=0, target_max=100
|
||||
)
|
||||
DEFAULT_V_TYPE_V2 = RemapHelper(
|
||||
source_min=1, source_max=1000, target_min=0, target_max=255
|
||||
)
|
||||
|
||||
|
||||
class _ColorDataWrapper(DPCodeJsonWrapper[tuple[float, float, float]]):
|
||||
"""Wrapper for color data DP code."""
|
||||
|
||||
h_type = DEFAULT_H_TYPE
|
||||
s_type = DEFAULT_S_TYPE
|
||||
v_type = DEFAULT_V_TYPE
|
||||
|
||||
def read_device_status(
|
||||
self, device: CustomerDevice
|
||||
) -> tuple[float, float, float] | None:
|
||||
"""Return a tuple (H, S, V) from this color data."""
|
||||
if (status := self._read_dpcode_value(device)) is None:
|
||||
return None
|
||||
return (
|
||||
self.h_type.remap_value_to(status["h"]),
|
||||
self.s_type.remap_value_to(status["s"]),
|
||||
self.v_type.remap_value_to(status["v"]),
|
||||
)
|
||||
|
||||
def _convert_value_to_raw_value(
|
||||
self, device: CustomerDevice, value: tuple[float, float, float]
|
||||
) -> Any:
|
||||
"""Convert a Home Assistant tuple (H, S, V) back to a raw device value."""
|
||||
hue, saturation, brightness = value
|
||||
return json.dumps(
|
||||
{
|
||||
"h": round(self.h_type.remap_value_from(hue)),
|
||||
"s": round(self.s_type.remap_value_from(saturation)),
|
||||
"v": round(self.v_type.remap_value_from(brightness)),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
MAX_MIREDS = 500 # 2000 K
|
||||
MIN_MIREDS = 153 # 6500 K
|
||||
|
||||
|
||||
class FallbackColorDataMode(StrEnum):
|
||||
"""Fallback color data mode."""
|
||||
|
||||
@@ -551,9 +392,9 @@ LIGHTS[DeviceCategory.TDQ] = LIGHTS[DeviceCategory.TGQ]
|
||||
|
||||
def _get_brightness_wrapper(
|
||||
device: CustomerDevice, description: TuyaLightEntityDescription
|
||||
) -> _BrightnessWrapper | None:
|
||||
) -> BrightnessWrapper | None:
|
||||
if (
|
||||
brightness_wrapper := _BrightnessWrapper.find_dpcode(
|
||||
brightness_wrapper := BrightnessWrapper.find_dpcode(
|
||||
device, description.brightness, prefer_function=True
|
||||
)
|
||||
) is None:
|
||||
@@ -578,10 +419,10 @@ def _get_brightness_wrapper(
|
||||
def _get_color_data_wrapper(
|
||||
device: CustomerDevice,
|
||||
description: TuyaLightEntityDescription,
|
||||
brightness_wrapper: _BrightnessWrapper | None,
|
||||
) -> _ColorDataWrapper | None:
|
||||
brightness_wrapper: BrightnessWrapper | None,
|
||||
) -> ColorDataWrapper | None:
|
||||
if (
|
||||
color_data_wrapper := _ColorDataWrapper.find_dpcode(
|
||||
color_data_wrapper := ColorDataWrapper.find_dpcode(
|
||||
device, description.color_data, prefer_function=True
|
||||
)
|
||||
) is None:
|
||||
@@ -643,7 +484,7 @@ async def async_setup_entry(
|
||||
color_mode_wrapper=DPCodeEnumWrapper.find_dpcode(
|
||||
device, description.color_mode, prefer_function=True
|
||||
),
|
||||
color_temp_wrapper=_ColorTempWrapper.find_dpcode(
|
||||
color_temp_wrapper=ColorTempWrapper.find_dpcode(
|
||||
device, description.color_temp, prefer_function=True
|
||||
),
|
||||
switch_wrapper=switch_wrapper,
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["tuya_sharing"],
|
||||
"requirements": [
|
||||
"tuya-device-handlers==0.0.12",
|
||||
"tuya-device-handlers==0.0.13",
|
||||
"tuya-device-sharing-sdk==0.2.8"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Self
|
||||
from typing import Any
|
||||
|
||||
from tuya_device_handlers.device_wrapper.base import DeviceWrapper
|
||||
from tuya_device_handlers.device_wrapper.common import (
|
||||
DPCodeBooleanWrapper,
|
||||
DPCodeEnumWrapper,
|
||||
from tuya_device_handlers.device_wrapper.common import DPCodeEnumWrapper
|
||||
from tuya_device_handlers.device_wrapper.vacuum import (
|
||||
VacuumActionWrapper,
|
||||
VacuumActivityWrapper,
|
||||
)
|
||||
from tuya_device_handlers.helpers.homeassistant import (
|
||||
TuyaVacuumAction,
|
||||
TuyaVacuumActivity,
|
||||
)
|
||||
from tuya_sharing import CustomerDevice, Manager
|
||||
|
||||
@@ -24,138 +29,14 @@ from . import TuyaConfigEntry
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
|
||||
from .entity import TuyaEntity
|
||||
|
||||
|
||||
class _VacuumActivityWrapper(DeviceWrapper[VacuumActivity]):
|
||||
"""Wrapper for the state of a device."""
|
||||
|
||||
_TUYA_STATUS_TO_HA = {
|
||||
"charge_done": VacuumActivity.DOCKED,
|
||||
"chargecompleted": VacuumActivity.DOCKED,
|
||||
"chargego": VacuumActivity.DOCKED,
|
||||
"charging": VacuumActivity.DOCKED,
|
||||
"cleaning": VacuumActivity.CLEANING,
|
||||
"docking": VacuumActivity.RETURNING,
|
||||
"goto_charge": VacuumActivity.RETURNING,
|
||||
"goto_pos": VacuumActivity.CLEANING,
|
||||
"mop_clean": VacuumActivity.CLEANING,
|
||||
"part_clean": VacuumActivity.CLEANING,
|
||||
"paused": VacuumActivity.PAUSED,
|
||||
"pick_zone_clean": VacuumActivity.CLEANING,
|
||||
"pos_arrived": VacuumActivity.CLEANING,
|
||||
"pos_unarrive": VacuumActivity.CLEANING,
|
||||
"random": VacuumActivity.CLEANING,
|
||||
"sleep": VacuumActivity.IDLE,
|
||||
"smart_clean": VacuumActivity.CLEANING,
|
||||
"smart": VacuumActivity.CLEANING,
|
||||
"spot_clean": VacuumActivity.CLEANING,
|
||||
"standby": VacuumActivity.IDLE,
|
||||
"wall_clean": VacuumActivity.CLEANING,
|
||||
"wall_follow": VacuumActivity.CLEANING,
|
||||
"zone_clean": VacuumActivity.CLEANING,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pause_wrapper: DPCodeBooleanWrapper | None = None,
|
||||
status_wrapper: DPCodeEnumWrapper | None = None,
|
||||
) -> None:
|
||||
"""Init _VacuumActivityWrapper."""
|
||||
self._pause_wrapper = pause_wrapper
|
||||
self._status_wrapper = status_wrapper
|
||||
|
||||
@classmethod
|
||||
def find_dpcode(cls, device: CustomerDevice) -> Self | None:
|
||||
"""Find and return a _VacuumActivityWrapper for the given DP codes."""
|
||||
pause_wrapper = DPCodeBooleanWrapper.find_dpcode(device, DPCode.PAUSE)
|
||||
status_wrapper = DPCodeEnumWrapper.find_dpcode(device, DPCode.STATUS)
|
||||
if pause_wrapper or status_wrapper:
|
||||
return cls(pause_wrapper=pause_wrapper, status_wrapper=status_wrapper)
|
||||
return None
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> VacuumActivity | None:
|
||||
"""Read the device status."""
|
||||
if (
|
||||
self._status_wrapper
|
||||
and (status := self._status_wrapper.read_device_status(device)) is not None
|
||||
):
|
||||
return self._TUYA_STATUS_TO_HA.get(status)
|
||||
|
||||
if self._pause_wrapper and self._pause_wrapper.read_device_status(device):
|
||||
return VacuumActivity.PAUSED
|
||||
return None
|
||||
|
||||
|
||||
class _VacuumActionWrapper(DeviceWrapper):
|
||||
"""Wrapper for sending actions to a vacuum."""
|
||||
|
||||
_TUYA_MODE_RETURN_HOME = "chargego"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
charge_wrapper: DPCodeBooleanWrapper | None,
|
||||
locate_wrapper: DPCodeBooleanWrapper | None,
|
||||
pause_wrapper: DPCodeBooleanWrapper | None,
|
||||
mode_wrapper: DPCodeEnumWrapper | None,
|
||||
switch_wrapper: DPCodeBooleanWrapper | None,
|
||||
) -> None:
|
||||
"""Init _VacuumActionWrapper."""
|
||||
self._charge_wrapper = charge_wrapper
|
||||
self._locate_wrapper = locate_wrapper
|
||||
self._mode_wrapper = mode_wrapper
|
||||
self._switch_wrapper = switch_wrapper
|
||||
|
||||
self.options = []
|
||||
if charge_wrapper or (
|
||||
mode_wrapper and self._TUYA_MODE_RETURN_HOME in mode_wrapper.options
|
||||
):
|
||||
self.options.append("return_to_base")
|
||||
if locate_wrapper:
|
||||
self.options.append("locate")
|
||||
if pause_wrapper:
|
||||
self.options.append("pause")
|
||||
if switch_wrapper:
|
||||
self.options.append("start")
|
||||
self.options.append("stop")
|
||||
|
||||
@classmethod
|
||||
def find_dpcode(cls, device: CustomerDevice) -> Self:
|
||||
"""Find and return a _VacuumActionWrapper for the given DP codes."""
|
||||
return cls(
|
||||
charge_wrapper=DPCodeBooleanWrapper.find_dpcode(
|
||||
device, DPCode.SWITCH_CHARGE, prefer_function=True
|
||||
),
|
||||
locate_wrapper=DPCodeBooleanWrapper.find_dpcode(
|
||||
device, DPCode.SEEK, prefer_function=True
|
||||
),
|
||||
mode_wrapper=DPCodeEnumWrapper.find_dpcode(
|
||||
device, DPCode.MODE, prefer_function=True
|
||||
),
|
||||
pause_wrapper=DPCodeBooleanWrapper.find_dpcode(device, DPCode.PAUSE),
|
||||
switch_wrapper=DPCodeBooleanWrapper.find_dpcode(
|
||||
device, DPCode.POWER_GO, prefer_function=True
|
||||
),
|
||||
)
|
||||
|
||||
def get_update_commands(
|
||||
self, device: CustomerDevice, value: Any
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get the commands for the action wrapper."""
|
||||
if value == "locate" and self._locate_wrapper:
|
||||
return self._locate_wrapper.get_update_commands(device, True)
|
||||
if value == "pause" and self._switch_wrapper:
|
||||
return self._switch_wrapper.get_update_commands(device, False)
|
||||
if value == "return_to_base":
|
||||
if self._charge_wrapper:
|
||||
return self._charge_wrapper.get_update_commands(device, True)
|
||||
if self._mode_wrapper:
|
||||
return self._mode_wrapper.get_update_commands(
|
||||
device, self._TUYA_MODE_RETURN_HOME
|
||||
)
|
||||
if value == "start" and self._switch_wrapper:
|
||||
return self._switch_wrapper.get_update_commands(device, True)
|
||||
if value == "stop" and self._switch_wrapper:
|
||||
return self._switch_wrapper.get_update_commands(device, False)
|
||||
return []
|
||||
_TUYA_TO_HA_ACTIVITY_MAPPINGS = {
|
||||
TuyaVacuumActivity.CLEANING: VacuumActivity.CLEANING,
|
||||
TuyaVacuumActivity.DOCKED: VacuumActivity.DOCKED,
|
||||
TuyaVacuumActivity.IDLE: VacuumActivity.IDLE,
|
||||
TuyaVacuumActivity.PAUSED: VacuumActivity.PAUSED,
|
||||
TuyaVacuumActivity.RETURNING: VacuumActivity.RETURNING,
|
||||
TuyaVacuumActivity.ERROR: VacuumActivity.ERROR,
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -177,8 +58,8 @@ async def async_setup_entry(
|
||||
TuyaVacuumEntity(
|
||||
device,
|
||||
manager,
|
||||
action_wrapper=_VacuumActionWrapper.find_dpcode(device),
|
||||
activity_wrapper=_VacuumActivityWrapper.find_dpcode(device),
|
||||
action_wrapper=VacuumActionWrapper.find_dpcode(device),
|
||||
activity_wrapper=VacuumActivityWrapper.find_dpcode(device),
|
||||
fan_speed_wrapper=DPCodeEnumWrapper.find_dpcode(
|
||||
device, DPCode.SUCTION, prefer_function=True
|
||||
),
|
||||
@@ -203,8 +84,8 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
|
||||
device: CustomerDevice,
|
||||
device_manager: Manager,
|
||||
*,
|
||||
action_wrapper: DeviceWrapper[str] | None,
|
||||
activity_wrapper: DeviceWrapper[VacuumActivity] | None,
|
||||
action_wrapper: DeviceWrapper[TuyaVacuumAction] | None,
|
||||
activity_wrapper: DeviceWrapper[TuyaVacuumActivity] | None,
|
||||
fan_speed_wrapper: DeviceWrapper[str] | None,
|
||||
) -> None:
|
||||
"""Init Tuya vacuum."""
|
||||
@@ -217,15 +98,15 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
|
||||
self._attr_supported_features = VacuumEntityFeature.SEND_COMMAND
|
||||
|
||||
if action_wrapper:
|
||||
if "pause" in action_wrapper.options:
|
||||
if TuyaVacuumAction.PAUSE in action_wrapper.options:
|
||||
self._attr_supported_features |= VacuumEntityFeature.PAUSE
|
||||
if "return_to_base" in action_wrapper.options:
|
||||
if TuyaVacuumAction.RETURN_TO_BASE in action_wrapper.options:
|
||||
self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME
|
||||
if "locate" in action_wrapper.options:
|
||||
if TuyaVacuumAction.LOCATE in action_wrapper.options:
|
||||
self._attr_supported_features |= VacuumEntityFeature.LOCATE
|
||||
if "start" in action_wrapper.options:
|
||||
if TuyaVacuumAction.START in action_wrapper.options:
|
||||
self._attr_supported_features |= VacuumEntityFeature.START
|
||||
if "stop" in action_wrapper.options:
|
||||
if TuyaVacuumAction.STOP in action_wrapper.options:
|
||||
self._attr_supported_features |= VacuumEntityFeature.STOP
|
||||
|
||||
if activity_wrapper:
|
||||
@@ -243,27 +124,38 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
|
||||
@property
|
||||
def activity(self) -> VacuumActivity | None:
|
||||
"""Return Tuya vacuum device state."""
|
||||
return self._read_wrapper(self._activity_wrapper)
|
||||
tuya_value = self._read_wrapper(self._activity_wrapper)
|
||||
return _TUYA_TO_HA_ACTIVITY_MAPPINGS.get(tuya_value) if tuya_value else None
|
||||
|
||||
async def async_start(self, **kwargs: Any) -> None:
|
||||
"""Start the device."""
|
||||
await self._async_send_wrapper_updates(self._action_wrapper, "start")
|
||||
await self._async_send_wrapper_updates(
|
||||
self._action_wrapper, TuyaVacuumAction.START
|
||||
)
|
||||
|
||||
async def async_stop(self, **kwargs: Any) -> None:
|
||||
"""Stop the device."""
|
||||
await self._async_send_wrapper_updates(self._action_wrapper, "stop")
|
||||
await self._async_send_wrapper_updates(
|
||||
self._action_wrapper, TuyaVacuumAction.STOP
|
||||
)
|
||||
|
||||
async def async_pause(self, **kwargs: Any) -> None:
|
||||
"""Pause the device."""
|
||||
await self._async_send_wrapper_updates(self._action_wrapper, "pause")
|
||||
await self._async_send_wrapper_updates(
|
||||
self._action_wrapper, TuyaVacuumAction.PAUSE
|
||||
)
|
||||
|
||||
async def async_return_to_base(self, **kwargs: Any) -> None:
|
||||
"""Return device to dock."""
|
||||
await self._async_send_wrapper_updates(self._action_wrapper, "return_to_base")
|
||||
await self._async_send_wrapper_updates(
|
||||
self._action_wrapper, TuyaVacuumAction.RETURN_TO_BASE
|
||||
)
|
||||
|
||||
async def async_locate(self, **kwargs: Any) -> None:
|
||||
"""Locate the device."""
|
||||
await self._async_send_wrapper_updates(self._action_wrapper, "locate")
|
||||
await self._async_send_wrapper_updates(
|
||||
self._action_wrapper, TuyaVacuumAction.LOCATE
|
||||
)
|
||||
|
||||
async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:
|
||||
"""Set fan speed."""
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
from aiodns.error import DNSError
|
||||
from aiohttp.client_exceptions import ClientConnectionError
|
||||
from uhooapi import Client
|
||||
from uhooapi.errors import UhooError, UnauthorizedError
|
||||
from uhooapi.errors import ForbiddenError, UhooError, UnauthorizedError
|
||||
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import PLATFORMS
|
||||
@@ -28,8 +28,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: UhooConfigEntry)
|
||||
await client.setup_devices()
|
||||
except (ClientConnectionError, DNSError) as err:
|
||||
raise ConfigEntryNotReady(f"Cannot connect to uHoo servers: {err}") from err
|
||||
except UnauthorizedError as err:
|
||||
raise ConfigEntryError(f"Invalid API credentials: {err}") from err
|
||||
except (UnauthorizedError, ForbiddenError) as err:
|
||||
raise ConfigEntryAuthFailed(f"Invalid API credentials: {err}") from err
|
||||
except UhooError as err:
|
||||
raise ConfigEntryNotReady(err) from err
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Custom uhoo config flow setup."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from uhooapi import Client
|
||||
from uhooapi.errors import UhooError, UnauthorizedError
|
||||
from uhooapi.errors import ForbiddenError, UhooError, UnauthorizedError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
@@ -45,7 +46,7 @@ class UhooConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
client = Client(user_input[CONF_API_KEY], session, debug=True)
|
||||
try:
|
||||
await client.login()
|
||||
except UnauthorizedError:
|
||||
except UnauthorizedError, ForbiddenError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except UhooError:
|
||||
errors["base"] = "cannot_connect"
|
||||
@@ -65,3 +66,39 @@ class UhooConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Perform reauthentication upon an API authentication error."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm reauthentication dialog."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
session = async_create_clientsession(self.hass)
|
||||
client = Client(user_input[CONF_API_KEY], session, debug=True)
|
||||
try:
|
||||
await client.login()
|
||||
except UnauthorizedError, ForbiddenError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except UhooError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
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=self.add_suggested_values_to_schema(
|
||||
USER_DATA_SCHEMA, user_input
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Custom uhoo data update coordinator."""
|
||||
|
||||
from uhooapi import Client, Device
|
||||
from uhooapi.errors import UhooError
|
||||
from uhooapi.errors import ForbiddenError, UhooError, UnauthorizedError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, LOGGER, UPDATE_INTERVAL
|
||||
@@ -34,6 +35,8 @@ class UhooDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
|
||||
if self.client.devices:
|
||||
for device_id in self.client.devices:
|
||||
await self.client.get_latest_data(device_id)
|
||||
except (UnauthorizedError, ForbiddenError) as error:
|
||||
raise ConfigEntryAuthFailed(f"Invalid API credentials: {error}") from error
|
||||
except UhooError as error:
|
||||
raise UpdateFailed(f"The device is unavailable: {error}") from error
|
||||
else:
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/uhooair",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["uhooapi==1.2.8"]
|
||||
}
|
||||
|
||||
@@ -26,9 +26,9 @@ rules:
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -9,6 +10,14 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "Your uHoo API key. You can find this in your uHoo account settings."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
|
||||
@@ -11,7 +11,12 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.EVENT, Platform.SWITCH]
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.EVENT,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: UnifiAccessConfigEntry) -> bool:
|
||||
|
||||
50
homeassistant/components/unifi_access/binary_sensor.py
Normal file
50
homeassistant/components/unifi_access/binary_sensor.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Binary sensor platform for the UniFi Access integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unifi_access_api import Door, DoorPositionStatus
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator
|
||||
from .entity import UnifiAccessEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: UnifiAccessConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up UniFi Access binary sensor entities."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
UnifiAccessDoorPositionBinarySensor(coordinator, door)
|
||||
for door in coordinator.data.doors.values()
|
||||
)
|
||||
|
||||
|
||||
class UnifiAccessDoorPositionBinarySensor(UnifiAccessEntity, BinarySensorEntity):
|
||||
"""Representation of a UniFi Access door position binary sensor."""
|
||||
|
||||
_attr_name = None
|
||||
_attr_device_class = BinarySensorDeviceClass.DOOR
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: UnifiAccessCoordinator,
|
||||
door: Door,
|
||||
) -> None:
|
||||
"""Initialize the binary sensor entity."""
|
||||
super().__init__(coordinator, door, "access_door_dps")
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return whether the door is open."""
|
||||
return self._door.door_position_status == DoorPositionStatus.OPEN
|
||||
@@ -78,6 +78,7 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
data,
|
||||
session,
|
||||
)
|
||||
self._session = session
|
||||
|
||||
# Last resort as no MAC or S/N can be retrieved via API
|
||||
self._id = config_entry.unique_id
|
||||
@@ -135,11 +136,15 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
_LOGGER.debug("Polling Vodafone Station host: %s", self.api.base_url.host)
|
||||
|
||||
try:
|
||||
await self.api.login()
|
||||
if not self._session.cookie_jar.filter_cookies(self.api.base_url):
|
||||
_LOGGER.debug(
|
||||
"Session cookies missing for host %s, re-login",
|
||||
self.api.base_url.host,
|
||||
)
|
||||
await self.api.login()
|
||||
raw_data_devices = await self.api.get_devices_data()
|
||||
data_sensors = await self.api.get_sensor_data()
|
||||
data_wifi = await self.api.get_wifi_data()
|
||||
await self.api.logout()
|
||||
except exceptions.CannotAuthenticate as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -104,6 +104,7 @@ class VodafoneSwitchEntity(CoordinatorEntity[VodafoneStationRouter], SwitchEntit
|
||||
await self.coordinator.api.set_wifi_status(
|
||||
status, self.entity_description.typology, self.entity_description.band
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
except CannotAuthenticate as err:
|
||||
self.coordinator.config_entry.async_start_reauth(self.hass)
|
||||
raise HomeAssistantError(
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
"preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]"
|
||||
}
|
||||
},
|
||||
"pipeline_n": {
|
||||
"name": "[%key:component::assist_pipeline::entity::select::pipeline_n::name%]",
|
||||
"state": {
|
||||
"preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]"
|
||||
}
|
||||
},
|
||||
"vad_sensitivity": {
|
||||
"name": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::name%]",
|
||||
"state": {
|
||||
|
||||
@@ -55,6 +55,12 @@
|
||||
"preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]"
|
||||
}
|
||||
},
|
||||
"pipeline_n": {
|
||||
"name": "[%key:component::assist_pipeline::entity::select::pipeline_n::name%]",
|
||||
"state": {
|
||||
"preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]"
|
||||
}
|
||||
},
|
||||
"vad_sensitivity": {
|
||||
"name": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::name%]",
|
||||
"state": {
|
||||
|
||||
@@ -362,12 +362,16 @@ class EntityTriggerBase[DomainSpecT: DomainSpec = DomainSpec](Trigger):
|
||||
"""Filter entities matching any of the domain specs."""
|
||||
return filter_by_domain_specs(self._hass, self._domain_specs, entities)
|
||||
|
||||
def _get_tracked_value(self, state: State) -> Any:
|
||||
"""Get the tracked value from a state based on the DomainSpec."""
|
||||
domain_spec = self._domain_specs[split_entity_id(state.entity_id)[0]]
|
||||
if domain_spec.value_source is None:
|
||||
return state.state
|
||||
return state.attributes.get(domain_spec.value_source)
|
||||
|
||||
@abc.abstractmethod
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
"""Check if the origin state is valid and the state has changed."""
|
||||
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||
return False
|
||||
|
||||
return from_state.state != to_state.state
|
||||
|
||||
@abc.abstractmethod
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
@@ -448,7 +452,11 @@ class EntityTriggerBase[DomainSpecT: DomainSpec = DomainSpec](Trigger):
|
||||
|
||||
|
||||
class EntityTargetStateTriggerBase(EntityTriggerBase):
|
||||
"""Trigger for entity state changes to a specific state."""
|
||||
"""Trigger for entity state changes to a specific state.
|
||||
|
||||
Uses _get_tracked_value to extract the value, so it works for both
|
||||
state-based and attribute-based triggers depending on the DomainSpec.
|
||||
"""
|
||||
|
||||
_to_states: set[str]
|
||||
|
||||
@@ -457,14 +465,15 @@ class EntityTargetStateTriggerBase(EntityTriggerBase):
|
||||
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||
return False
|
||||
|
||||
from_value = self._get_tracked_value(from_state)
|
||||
return (
|
||||
from_state.state != to_state.state
|
||||
and from_state.state not in self._to_states
|
||||
from_value != self._get_tracked_value(to_state)
|
||||
and from_value not in self._to_states
|
||||
)
|
||||
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the new state matches the expected state."""
|
||||
return state.state in self._to_states
|
||||
return self._get_tracked_value(state) in self._to_states
|
||||
|
||||
|
||||
class EntityTransitionTriggerBase(EntityTriggerBase):
|
||||
@@ -475,14 +484,18 @@ class EntityTransitionTriggerBase(EntityTriggerBase):
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
"""Check if the origin state matches the expected ones."""
|
||||
if not super().is_valid_transition(from_state, to_state):
|
||||
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||
return False
|
||||
|
||||
return from_state.state in self._from_states
|
||||
from_value = self._get_tracked_value(from_state)
|
||||
return (
|
||||
from_value != self._get_tracked_value(to_state)
|
||||
and from_value in self._from_states
|
||||
)
|
||||
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the new state matches the expected states."""
|
||||
return state.state in self._to_states
|
||||
return self._get_tracked_value(state) in self._to_states
|
||||
|
||||
|
||||
class EntityOriginStateTriggerBase(EntityTriggerBase):
|
||||
@@ -492,33 +505,14 @@ class EntityOriginStateTriggerBase(EntityTriggerBase):
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
"""Check if the origin state matches the expected one and that the state changed."""
|
||||
return (
|
||||
from_state.state == self._from_state and to_state.state != self._from_state
|
||||
return bool(
|
||||
self._get_tracked_value(from_state) == self._from_state
|
||||
and self._get_tracked_value(to_state) != self._from_state
|
||||
)
|
||||
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the new state is not the same as the expected origin state."""
|
||||
return state.state != self._from_state
|
||||
|
||||
|
||||
class EntityTargetStateAttributeTriggerBase(EntityTriggerBase):
|
||||
"""Trigger for entity state attribute changes to a specific state."""
|
||||
|
||||
_attribute: str
|
||||
_attribute_to_state: str
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
"""Check if the origin state is valid and the state has changed."""
|
||||
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||
return False
|
||||
|
||||
return from_state.attributes.get(self._attribute) != to_state.attributes.get(
|
||||
self._attribute
|
||||
)
|
||||
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the new state attribute matches the expected one."""
|
||||
return state.attributes.get(self._attribute) == self._attribute_to_state
|
||||
return bool(self._get_tracked_value(state) != self._from_state)
|
||||
|
||||
|
||||
def _validate_range[_T: dict[str, Any]](
|
||||
@@ -599,7 +593,7 @@ def _get_numerical_value(
|
||||
return entity_or_float
|
||||
|
||||
|
||||
class EntityNumericalStateBase(EntityTriggerBase[NumericalDomainSpec]):
|
||||
class EntityNumericalStateTriggerBase(EntityTriggerBase[NumericalDomainSpec]):
|
||||
"""Base class for numerical state and state attribute triggers."""
|
||||
|
||||
def _get_tracked_value(self, state: State) -> Any:
|
||||
@@ -617,7 +611,7 @@ class EntityNumericalStateBase(EntityTriggerBase[NumericalDomainSpec]):
|
||||
return float
|
||||
|
||||
|
||||
class EntityNumericalStateAttributeChangedTriggerBase(EntityNumericalStateBase):
|
||||
class EntityNumericalStateChangedTriggerBase(EntityNumericalStateTriggerBase):
|
||||
"""Trigger for numerical state and state attribute changes."""
|
||||
|
||||
_schema = NUMERICAL_ATTRIBUTE_CHANGED_TRIGGER_SCHEMA
|
||||
@@ -721,9 +715,7 @@ NUMERICAL_ATTRIBUTE_CROSSED_THRESHOLD_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.exten
|
||||
)
|
||||
|
||||
|
||||
class EntityNumericalStateAttributeCrossedThresholdTriggerBase(
|
||||
EntityNumericalStateBase
|
||||
):
|
||||
class EntityNumericalStateCrossedThresholdTriggerBase(EntityNumericalStateTriggerBase):
|
||||
"""Trigger for numerical state and state attribute changes.
|
||||
|
||||
This trigger only fires when the observed attribute changes from not within to within
|
||||
@@ -790,10 +782,25 @@ class EntityNumericalStateAttributeCrossedThresholdTriggerBase(
|
||||
return not between
|
||||
|
||||
|
||||
def _normalize_domain_specs(
|
||||
domain_specs: Mapping[str, DomainSpec] | str,
|
||||
) -> Mapping[str, DomainSpec]:
|
||||
"""Normalize domain_specs argument to a Mapping."""
|
||||
if isinstance(domain_specs, str):
|
||||
return {domain_specs: DomainSpec()}
|
||||
return domain_specs
|
||||
|
||||
|
||||
def make_entity_target_state_trigger(
|
||||
domain: str, to_states: str | set[str]
|
||||
domain_specs: Mapping[str, DomainSpec] | str,
|
||||
to_states: str | set[str],
|
||||
) -> type[EntityTargetStateTriggerBase]:
|
||||
"""Create a trigger for entity state changes to specific state(s)."""
|
||||
"""Create a trigger for entity state changes to specific state(s).
|
||||
|
||||
domain_specs can be a string (domain name) for simple state-based triggers,
|
||||
or a Mapping[str, DomainSpec] for attribute-based or multi-domain triggers.
|
||||
"""
|
||||
specs = _normalize_domain_specs(domain_specs)
|
||||
|
||||
if isinstance(to_states, str):
|
||||
to_states_set = {to_states}
|
||||
@@ -803,21 +810,29 @@ def make_entity_target_state_trigger(
|
||||
class CustomTrigger(EntityTargetStateTriggerBase):
|
||||
"""Trigger for entity state changes."""
|
||||
|
||||
_domain_specs = {domain: DomainSpec()}
|
||||
_domain_specs = specs
|
||||
_to_states = to_states_set
|
||||
|
||||
return CustomTrigger
|
||||
|
||||
|
||||
def make_entity_transition_trigger(
|
||||
domain: str, *, from_states: set[str], to_states: set[str]
|
||||
domain_specs: Mapping[str, DomainSpec] | str,
|
||||
*,
|
||||
from_states: set[str],
|
||||
to_states: set[str],
|
||||
) -> type[EntityTransitionTriggerBase]:
|
||||
"""Create a trigger for entity state changes between specific states."""
|
||||
"""Create a trigger for entity state changes between specific states.
|
||||
|
||||
domain_specs can be a string (domain name) for simple state-based triggers,
|
||||
or a Mapping[str, DomainSpec] for attribute-based or multi-domain triggers.
|
||||
"""
|
||||
specs = _normalize_domain_specs(domain_specs)
|
||||
|
||||
class CustomTrigger(EntityTransitionTriggerBase):
|
||||
"""Trigger for conditional entity state changes."""
|
||||
|
||||
_domain_specs = {domain: DomainSpec()}
|
||||
_domain_specs = specs
|
||||
_from_states = from_states
|
||||
_to_states = to_states
|
||||
|
||||
@@ -825,14 +840,21 @@ def make_entity_transition_trigger(
|
||||
|
||||
|
||||
def make_entity_origin_state_trigger(
|
||||
domain: str, *, from_state: str
|
||||
domain_specs: Mapping[str, DomainSpec] | str,
|
||||
*,
|
||||
from_state: str,
|
||||
) -> type[EntityOriginStateTriggerBase]:
|
||||
"""Create a trigger for entity state changes from a specific state."""
|
||||
"""Create a trigger for entity state changes from a specific state.
|
||||
|
||||
domain_specs can be a string (domain name) for simple state-based triggers,
|
||||
or a Mapping[str, DomainSpec] for attribute-based or multi-domain triggers.
|
||||
"""
|
||||
specs = _normalize_domain_specs(domain_specs)
|
||||
|
||||
class CustomTrigger(EntityOriginStateTriggerBase):
|
||||
"""Trigger for entity "from state" changes."""
|
||||
|
||||
_domain_specs = {domain: DomainSpec()}
|
||||
_domain_specs = specs
|
||||
_from_state = from_state
|
||||
|
||||
return CustomTrigger
|
||||
@@ -840,10 +862,10 @@ def make_entity_origin_state_trigger(
|
||||
|
||||
def make_entity_numerical_state_changed_trigger(
|
||||
domain_specs: Mapping[str, NumericalDomainSpec],
|
||||
) -> type[EntityNumericalStateAttributeChangedTriggerBase]:
|
||||
) -> type[EntityNumericalStateChangedTriggerBase]:
|
||||
"""Create a trigger for numerical state value change."""
|
||||
|
||||
class CustomTrigger(EntityNumericalStateAttributeChangedTriggerBase):
|
||||
class CustomTrigger(EntityNumericalStateChangedTriggerBase):
|
||||
"""Trigger for numerical state value changes."""
|
||||
|
||||
_domain_specs = domain_specs
|
||||
@@ -853,10 +875,10 @@ def make_entity_numerical_state_changed_trigger(
|
||||
|
||||
def make_entity_numerical_state_crossed_threshold_trigger(
|
||||
domain_specs: Mapping[str, NumericalDomainSpec],
|
||||
) -> type[EntityNumericalStateAttributeCrossedThresholdTriggerBase]:
|
||||
) -> type[EntityNumericalStateCrossedThresholdTriggerBase]:
|
||||
"""Create a trigger for numerical state value crossing a threshold."""
|
||||
|
||||
class CustomTrigger(EntityNumericalStateAttributeCrossedThresholdTriggerBase):
|
||||
class CustomTrigger(EntityNumericalStateCrossedThresholdTriggerBase):
|
||||
"""Trigger for numerical state value crossing a threshold."""
|
||||
|
||||
_domain_specs = domain_specs
|
||||
@@ -864,21 +886,6 @@ def make_entity_numerical_state_crossed_threshold_trigger(
|
||||
return CustomTrigger
|
||||
|
||||
|
||||
def make_entity_target_state_attribute_trigger(
|
||||
domain: str, attribute: str, to_state: str
|
||||
) -> type[EntityTargetStateAttributeTriggerBase]:
|
||||
"""Create a trigger for entity state attribute changes to a specific state."""
|
||||
|
||||
class CustomTrigger(EntityTargetStateAttributeTriggerBase):
|
||||
"""Trigger for entity state changes."""
|
||||
|
||||
_domain_specs = {domain: DomainSpec()}
|
||||
_attribute = attribute
|
||||
_attribute_to_state = to_state
|
||||
|
||||
return CustomTrigger
|
||||
|
||||
|
||||
class TriggerProtocol(Protocol):
|
||||
"""Define the format of trigger modules.
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ aiodhcpwatcher==1.2.1
|
||||
aiodiscover==2.7.1
|
||||
aiodns==4.0.0
|
||||
aiogithubapi==26.0.0
|
||||
aiohasupervisor==0.3.3
|
||||
aiohttp-asyncmdnsresolver==0.1.1
|
||||
aiohttp-fast-zlib==0.3.0
|
||||
aiohttp==3.13.3
|
||||
|
||||
@@ -28,10 +28,6 @@ dependencies = [
|
||||
# module level in `bootstrap.py` and its requirements thus need to be in
|
||||
# requirements.txt to ensure they are always installed
|
||||
"aiogithubapi==26.0.0",
|
||||
# Integrations may depend on hassio integration without listing it to
|
||||
# change behavior based on presence of supervisor. Deprecated with #127228
|
||||
# Lib can be removed with 2025.11
|
||||
"aiohasupervisor==0.3.3",
|
||||
"aiohttp==3.13.3",
|
||||
"aiohttp_cors==0.8.1",
|
||||
"aiohttp-fast-zlib==0.3.0",
|
||||
|
||||
1
requirements.txt
generated
1
requirements.txt
generated
@@ -5,7 +5,6 @@
|
||||
# Home Assistant Core
|
||||
aiodns==4.0.0
|
||||
aiogithubapi==26.0.0
|
||||
aiohasupervisor==0.3.3
|
||||
aiohttp-asyncmdnsresolver==0.1.1
|
||||
aiohttp-fast-zlib==0.3.0
|
||||
aiohttp==3.13.3
|
||||
|
||||
10
requirements_all.txt
generated
10
requirements_all.txt
generated
@@ -224,7 +224,7 @@ aiobafi6==0.9.0
|
||||
aiobotocore==2.21.1
|
||||
|
||||
# homeassistant.components.comelit
|
||||
aiocomelit==2.0.0
|
||||
aiocomelit==2.0.1
|
||||
|
||||
# homeassistant.components.dhcp
|
||||
aiodhcpwatcher==1.2.1
|
||||
@@ -276,7 +276,7 @@ aioguardian==2026.01.1
|
||||
aioharmony==0.5.3
|
||||
|
||||
# homeassistant.components.hassio
|
||||
aiohasupervisor==0.3.3
|
||||
aiohasupervisor==0.4.1
|
||||
|
||||
# homeassistant.components.home_connect
|
||||
aiohomeconnect==0.30.0
|
||||
@@ -2500,7 +2500,7 @@ pysmhi==1.1.0
|
||||
pysml==0.1.5
|
||||
|
||||
# homeassistant.components.smlight
|
||||
pysmlight==0.2.16
|
||||
pysmlight==0.3.0
|
||||
|
||||
# homeassistant.components.snmp
|
||||
pysnmp==7.1.22
|
||||
@@ -2666,7 +2666,7 @@ python-tado==0.18.16
|
||||
python-technove==2.0.0
|
||||
|
||||
# homeassistant.components.telegram_bot
|
||||
python-telegram-bot[socks]==22.1
|
||||
python-telegram-bot[socks]==22.6
|
||||
|
||||
# homeassistant.components.vlc
|
||||
python-vlc==3.0.18122
|
||||
@@ -3139,7 +3139,7 @@ ttls==1.8.3
|
||||
ttn_client==1.2.3
|
||||
|
||||
# homeassistant.components.tuya
|
||||
tuya-device-handlers==0.0.12
|
||||
tuya-device-handlers==0.0.13
|
||||
|
||||
# homeassistant.components.tuya
|
||||
tuya-device-sharing-sdk==0.2.8
|
||||
|
||||
10
requirements_test_all.txt
generated
10
requirements_test_all.txt
generated
@@ -215,7 +215,7 @@ aiobafi6==0.9.0
|
||||
aiobotocore==2.21.1
|
||||
|
||||
# homeassistant.components.comelit
|
||||
aiocomelit==2.0.0
|
||||
aiocomelit==2.0.1
|
||||
|
||||
# homeassistant.components.dhcp
|
||||
aiodhcpwatcher==1.2.1
|
||||
@@ -264,7 +264,7 @@ aioguardian==2026.01.1
|
||||
aioharmony==0.5.3
|
||||
|
||||
# homeassistant.components.hassio
|
||||
aiohasupervisor==0.3.3
|
||||
aiohasupervisor==0.4.1
|
||||
|
||||
# homeassistant.components.home_connect
|
||||
aiohomeconnect==0.30.0
|
||||
@@ -2132,7 +2132,7 @@ pysmhi==1.1.0
|
||||
pysml==0.1.5
|
||||
|
||||
# homeassistant.components.smlight
|
||||
pysmlight==0.2.16
|
||||
pysmlight==0.3.0
|
||||
|
||||
# homeassistant.components.snmp
|
||||
pysnmp==7.1.22
|
||||
@@ -2262,7 +2262,7 @@ python-tado==0.18.16
|
||||
python-technove==2.0.0
|
||||
|
||||
# homeassistant.components.telegram_bot
|
||||
python-telegram-bot[socks]==22.1
|
||||
python-telegram-bot[socks]==22.6
|
||||
|
||||
# homeassistant.components.xbox
|
||||
python-xbox==0.2.0
|
||||
@@ -2642,7 +2642,7 @@ ttls==1.8.3
|
||||
ttn_client==1.2.3
|
||||
|
||||
# homeassistant.components.tuya
|
||||
tuya-device-handlers==0.0.12
|
||||
tuya-device-handlers==0.0.13
|
||||
|
||||
# homeassistant.components.tuya
|
||||
tuya-device-sharing-sdk==0.2.8
|
||||
|
||||
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
|
||||
|
||||
@@ -78,6 +78,7 @@ PACKAGE_CHECK_VERSION_RANGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = {
|
||||
# Current has an upper bound on major >=3.10.0,<4.0.0
|
||||
"pystiebeleltron": {"pymodbus"}
|
||||
},
|
||||
"telegram_bot": {"python-telegram-bot": {"httpx"}},
|
||||
"xiaomi_miio": {
|
||||
"python-miio": {"zeroconf"},
|
||||
},
|
||||
|
||||
@@ -180,6 +180,7 @@ async def test_float(hass: HomeAssistant) -> None:
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_SWITCH}, blocking=True
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
switch = hass.states.get(ENTITY_SWITCH)
|
||||
assert switch.state == STATE_ON
|
||||
@@ -195,6 +196,7 @@ async def test_float(hass: HomeAssistant) -> None:
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_SWITCH}, blocking=True
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
plug_it = emulated_kasa.get_plug_devices(hass, config)
|
||||
plug = next(plug_it).generate_response()
|
||||
@@ -260,6 +262,7 @@ async def test_template(hass: HomeAssistant) -> None:
|
||||
{ATTR_ENTITY_ID: ENTITY_FAN, ATTR_PERCENTAGE: 33},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
fan = hass.states.get(ENTITY_FAN)
|
||||
assert fan.state == STATE_ON
|
||||
@@ -278,6 +281,7 @@ async def test_template(hass: HomeAssistant) -> None:
|
||||
{ATTR_ENTITY_ID: ENTITY_FAN, ATTR_PERCENTAGE: 100},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
plug_it = emulated_kasa.get_plug_devices(hass, config)
|
||||
plug = next(plug_it).generate_response()
|
||||
assert nested_value(plug, "system", "get_sysinfo", "alias") == ENTITY_FAN_NAME
|
||||
@@ -288,6 +292,7 @@ async def test_template(hass: HomeAssistant) -> None:
|
||||
await hass.services.async_call(
|
||||
FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_FAN}, blocking=True
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
plug_it = emulated_kasa.get_plug_devices(hass, config)
|
||||
plug = next(plug_it).generate_response()
|
||||
assert nested_value(plug, "system", "get_sysinfo", "alias") == ENTITY_FAN_NAME
|
||||
@@ -441,6 +446,7 @@ async def test_multiple_devices(hass: HomeAssistant) -> None:
|
||||
{ATTR_ENTITY_ID: ENTITY_FAN, ATTR_PERCENTAGE: 66},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# All of them should now be on
|
||||
switch = hass.states.get(ENTITY_SWITCH)
|
||||
|
||||
@@ -396,6 +396,11 @@ async def test_switch_device_no_ip_address(
|
||||
"async_set_deflection_enable",
|
||||
STATE_ON,
|
||||
),
|
||||
(
|
||||
"switch.mock_title_wi_fi_mywifi",
|
||||
"async_set_wlan_configuration",
|
||||
STATE_ON,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_switch_turn_on_off(
|
||||
|
||||
@@ -72,7 +72,7 @@ def mock_resolution_info(
|
||||
if suggestions_by_issue
|
||||
else [],
|
||||
checks=[
|
||||
Check(enabled=True, slug=CheckType.SUPERVISOR_TRUST),
|
||||
Check(enabled=True, slug=CheckType.DOCKER_CONFIG),
|
||||
Check(enabled=True, slug=CheckType.FREE_SPACE),
|
||||
],
|
||||
)
|
||||
@@ -197,7 +197,7 @@ async def test_unsupported_issues(
|
||||
"""Test issues added for unsupported systems."""
|
||||
mock_resolution_info(
|
||||
supervisor_client,
|
||||
unsupported=[UnsupportedReason.CONTENT_TRUST, UnsupportedReason.OS],
|
||||
unsupported=[UnsupportedReason.CONNECTIVITY_CHECK, UnsupportedReason.OS],
|
||||
)
|
||||
|
||||
result = await async_setup_component(hass, "hassio", {})
|
||||
@@ -210,7 +210,7 @@ async def test_unsupported_issues(
|
||||
assert msg["success"]
|
||||
assert len(msg["result"]["issues"]) == 2
|
||||
assert_repair_in_list(
|
||||
msg["result"]["issues"], unhealthy=False, reason="content_trust"
|
||||
msg["result"]["issues"], unhealthy=False, reason="connectivity_check"
|
||||
)
|
||||
assert_repair_in_list(msg["result"]["issues"], unhealthy=False, reason="os")
|
||||
|
||||
@@ -502,7 +502,7 @@ async def test_reasons_added_and_removed(
|
||||
|
||||
mock_resolution_info(
|
||||
supervisor_client,
|
||||
unsupported=[UnsupportedReason.CONTENT_TRUST],
|
||||
unsupported=[UnsupportedReason.CONNECTIVITY_CHECK],
|
||||
unhealthy=[UnhealthyReason.SETUP],
|
||||
)
|
||||
await client.send_json(
|
||||
@@ -526,7 +526,7 @@ async def test_reasons_added_and_removed(
|
||||
assert len(msg["result"]["issues"]) == 2
|
||||
assert_repair_in_list(msg["result"]["issues"], unhealthy=True, reason="setup")
|
||||
assert_repair_in_list(
|
||||
msg["result"]["issues"], unhealthy=False, reason="content_trust"
|
||||
msg["result"]["issues"], unhealthy=False, reason="connectivity_check"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -210,13 +210,13 @@
|
||||
"data": {
|
||||
"programs": [
|
||||
{
|
||||
"key": "HeatingVentilationAirConditioning.AirConditioner.Program.ActiveClean",
|
||||
"key": "HeatingVentilationAirConditioning.AirConditioner.Program.Auto",
|
||||
"constraints": {
|
||||
"execution": "startonly"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "HeatingVentilationAirConditioning.AirConditioner.Program.Auto",
|
||||
"key": "HeatingVentilationAirConditioning.AirConditioner.Program.ActiveClean",
|
||||
"constraints": {
|
||||
"execution": "startonly"
|
||||
}
|
||||
|
||||
@@ -191,5 +191,16 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"AirConditioner": {
|
||||
"data": {
|
||||
"settings": [
|
||||
{
|
||||
"key": "BSH.Common.Setting.PowerState",
|
||||
"value": "BSH.Common.EnumType.PowerState.On",
|
||||
"type": "BSH.Common.EnumType.PowerState"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,14 +68,17 @@
|
||||
'ha_id': '8765432109876543210',
|
||||
'name': 'Air conditioner',
|
||||
'programs': list([
|
||||
'HeatingVentilationAirConditioning.AirConditioner.Program.ActiveClean',
|
||||
'HeatingVentilationAirConditioning.AirConditioner.Program.Auto',
|
||||
'HeatingVentilationAirConditioning.AirConditioner.Program.ActiveClean',
|
||||
'HeatingVentilationAirConditioning.AirConditioner.Program.Cool',
|
||||
'HeatingVentilationAirConditioning.AirConditioner.Program.Dry',
|
||||
'HeatingVentilationAirConditioning.AirConditioner.Program.Fan',
|
||||
'HeatingVentilationAirConditioning.AirConditioner.Program.Heat',
|
||||
]),
|
||||
'settings': dict({
|
||||
'BSH.Common.Setting.PowerState': dict({
|
||||
'value': 'BSH.Common.EnumType.PowerState.On',
|
||||
}),
|
||||
}),
|
||||
'status': dict({
|
||||
'BSH.Common.Status.DoorState': dict({
|
||||
|
||||
956
tests/components/home_connect/test_climate.py
Normal file
956
tests/components/home_connect/test_climate.py
Normal file
@@ -0,0 +1,956 @@
|
||||
"""Tests for home_connect climate entities."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from aiohomeconnect.model import (
|
||||
ArrayOfEvents,
|
||||
ArrayOfPrograms,
|
||||
ArrayOfSettings,
|
||||
Event,
|
||||
EventKey,
|
||||
EventMessage,
|
||||
EventType,
|
||||
GetSetting,
|
||||
HomeAppliance,
|
||||
OptionKey,
|
||||
ProgramDefinition,
|
||||
ProgramKey,
|
||||
SettingKey,
|
||||
)
|
||||
from aiohomeconnect.model.error import (
|
||||
ActiveProgramNotSetError,
|
||||
HomeConnectApiError,
|
||||
HomeConnectError,
|
||||
SelectedProgramNotSetError,
|
||||
)
|
||||
from aiohomeconnect.model.program import (
|
||||
EnumerateProgram,
|
||||
EnumerateProgramConstraints,
|
||||
Execution,
|
||||
ProgramDefinitionConstraints,
|
||||
ProgramDefinitionOption,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_FAN_MODE,
|
||||
ATTR_FAN_MODES,
|
||||
ATTR_HVAC_MODE,
|
||||
ATTR_HVAC_MODES,
|
||||
ATTR_PRESET_MODE,
|
||||
ATTR_PRESET_MODES,
|
||||
DOMAIN as CLIMATE_DOMAIN,
|
||||
FAN_AUTO,
|
||||
SERVICE_SET_FAN_MODE,
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
SERVICE_SET_PRESET_MODE,
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.components.home_connect.const import (
|
||||
BSH_POWER_ON,
|
||||
BSH_POWER_STANDBY,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platforms() -> list[str]:
|
||||
"""Fixture to specify platforms to test."""
|
||||
return [Platform.CLIMATE]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
async def test_paired_depaired_devices_flow(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
appliance: HomeAppliance,
|
||||
) -> None:
|
||||
"""Test that removed devices are correctly removed from and added to hass on API events."""
|
||||
client.get_available_program = AsyncMock(
|
||||
return_value=ProgramDefinition(
|
||||
ProgramKey.UNKNOWN,
|
||||
options=[
|
||||
ProgramDefinitionOption(
|
||||
OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE,
|
||||
"Enumeration",
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
|
||||
assert device
|
||||
entity_entries = entity_registry.entities.get_entries_for_device_id(device.id)
|
||||
assert entity_entries
|
||||
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance.ha_id,
|
||||
EventType.DEPAIRED,
|
||||
data=ArrayOfEvents([]),
|
||||
)
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
|
||||
assert not device
|
||||
for entity_entry in entity_entries:
|
||||
assert not entity_registry.async_get(entity_entry.entity_id)
|
||||
|
||||
# Now that everything related to the device is removed, pair it again
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance.ha_id,
|
||||
EventType.PAIRED,
|
||||
data=ArrayOfEvents([]),
|
||||
)
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
|
||||
for entity_entry in entity_entries:
|
||||
assert entity_registry.async_get(entity_entry.entity_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
async def test_connected_devices(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
appliance: HomeAppliance,
|
||||
) -> None:
|
||||
"""Test that devices reconnected.
|
||||
|
||||
Specifically those devices whose settings, status, etc. could
|
||||
not be obtained while disconnected and once connected, the entities are added.
|
||||
"""
|
||||
get_settings_original_mock = client.get_settings
|
||||
get_all_programs_mock = client.get_all_programs
|
||||
|
||||
async def get_settings_side_effect(ha_id: str):
|
||||
if ha_id == appliance.ha_id:
|
||||
raise HomeConnectApiError(
|
||||
"SDK.Error.HomeAppliance.Connection.Initialization.Failed"
|
||||
)
|
||||
return await get_settings_original_mock.side_effect(ha_id)
|
||||
|
||||
async def get_all_programs_side_effect(ha_id: str):
|
||||
if ha_id == appliance.ha_id:
|
||||
raise HomeConnectApiError(
|
||||
"SDK.Error.HomeAppliance.Connection.Initialization.Failed"
|
||||
)
|
||||
return await get_all_programs_mock.side_effect(ha_id)
|
||||
|
||||
client.get_settings = AsyncMock(side_effect=get_settings_side_effect)
|
||||
client.get_all_programs = AsyncMock(side_effect=get_all_programs_side_effect)
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
client.get_settings = get_settings_original_mock
|
||||
client.get_all_programs = get_all_programs_mock
|
||||
|
||||
device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
|
||||
assert device
|
||||
assert not entity_registry.async_get_entity_id(
|
||||
Platform.CLIMATE,
|
||||
DOMAIN,
|
||||
f"{appliance.ha_id}-air_conditioner",
|
||||
)
|
||||
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance.ha_id,
|
||||
EventType.CONNECTED,
|
||||
data=ArrayOfEvents([]),
|
||||
)
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entity_registry.async_get_entity_id(
|
||||
Platform.CLIMATE,
|
||||
DOMAIN,
|
||||
f"{appliance.ha_id}-air_conditioner",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
async def test_climate_entity_availability(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
appliance: HomeAppliance,
|
||||
) -> None:
|
||||
"""Test if climate entities availability are based on the appliance connection state."""
|
||||
entity_ids = [
|
||||
"climate.air_conditioner",
|
||||
]
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
for entity_id in entity_ids:
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state != STATE_UNAVAILABLE
|
||||
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance.ha_id,
|
||||
EventType.DISCONNECTED,
|
||||
ArrayOfEvents([]),
|
||||
)
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
for entity_id in entity_ids:
|
||||
assert hass.states.is_state(entity_id, STATE_UNAVAILABLE)
|
||||
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance.ha_id,
|
||||
EventType.CONNECTED,
|
||||
ArrayOfEvents([]),
|
||||
)
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
for entity_id in entity_ids:
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state != STATE_UNAVAILABLE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
@pytest.mark.parametrize(
|
||||
"program_keys",
|
||||
[
|
||||
[],
|
||||
[ProgramKey.LAUNDRY_CARE_DRYER_ANTI_SHRINK],
|
||||
],
|
||||
)
|
||||
async def test_entity_not_added_if_no_air_conditioner_programs(
|
||||
entity_registry: er.EntityRegistry,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
program_keys: list[ProgramKey],
|
||||
) -> None:
|
||||
"""Test that the air conditioner entity is not added if there are no air conditioner programs."""
|
||||
client.get_all_programs.side_effect = None
|
||||
client.get_all_programs.return_value = ArrayOfPrograms(
|
||||
[
|
||||
EnumerateProgram(
|
||||
key=program_key,
|
||||
raw_key=program_key.value,
|
||||
constraints=EnumerateProgramConstraints(
|
||||
execution=Execution.SELECT_AND_START,
|
||||
),
|
||||
)
|
||||
for program_key in program_keys
|
||||
]
|
||||
)
|
||||
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
assert not entity_registry.async_get("climate.air_conditioner")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
async def test_turn_on_off(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
appliance: HomeAppliance,
|
||||
) -> None:
|
||||
"""Test turning the climate entity on and off.
|
||||
|
||||
The test also checks that the entity state is updated accordingly.
|
||||
"""
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
entity_id = "climate.air_conditioner"
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
hvac_mode_state_while_turned_on = state.state
|
||||
assert hvac_mode_state_while_turned_on not in (HVACMode.OFF, STATE_UNKNOWN)
|
||||
|
||||
for service, expected_setting_value, expected_state, call_count in (
|
||||
(SERVICE_TURN_OFF, BSH_POWER_STANDBY, HVACMode.OFF, 1),
|
||||
(SERVICE_TURN_ON, BSH_POWER_ON, hvac_mode_state_while_turned_on, 2),
|
||||
):
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN, service, {"entity_id": entity_id}, True
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client.set_setting.assert_awaited_with(
|
||||
appliance.ha_id,
|
||||
setting_key=SettingKey.BSH_COMMON_POWER_STATE,
|
||||
value=expected_setting_value,
|
||||
)
|
||||
assert client.set_setting.call_count == call_count
|
||||
assert hass.states.is_state(entity_id, expected_state)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
@pytest.mark.parametrize(
|
||||
("service", "expected_error"),
|
||||
[
|
||||
(SERVICE_TURN_ON, r"Error.*turn.*on.*"),
|
||||
(SERVICE_TURN_OFF, r"Error.*turn.*off.*"),
|
||||
],
|
||||
)
|
||||
async def test_turn_on_off_exception(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
service: str,
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
"""Test Home Connect exception while turning the climate entity on and off."""
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
client.set_setting.side_effect = HomeConnectError("Test error")
|
||||
|
||||
with pytest.raises(HomeAssistantError, match=expected_error):
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN, service, {"entity_id": "climate.air_conditioner"}, True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
@pytest.mark.parametrize(
|
||||
("program_keys", "expected_hvac_modes"),
|
||||
[
|
||||
(
|
||||
[
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO,
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_COOL,
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_DRY,
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN,
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_HEAT,
|
||||
],
|
||||
[
|
||||
HVACMode.AUTO,
|
||||
HVACMode.COOL,
|
||||
HVACMode.DRY,
|
||||
HVACMode.FAN_ONLY,
|
||||
HVACMode.HEAT,
|
||||
],
|
||||
),
|
||||
*[
|
||||
([program_key], [hvac_mode])
|
||||
for program_key, hvac_mode in zip(
|
||||
[
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO,
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_COOL,
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_DRY,
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN,
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_HEAT,
|
||||
],
|
||||
[
|
||||
HVACMode.AUTO,
|
||||
HVACMode.COOL,
|
||||
HVACMode.DRY,
|
||||
HVACMode.FAN_ONLY,
|
||||
HVACMode.HEAT,
|
||||
],
|
||||
strict=True,
|
||||
)
|
||||
],
|
||||
(
|
||||
[
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_COOL,
|
||||
ProgramKey.LAUNDRY_CARE_DRYER_ANTI_SHRINK,
|
||||
],
|
||||
[HVACMode.COOL],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_hvac_modes_programs_mapping_and_functionality(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
appliance: HomeAppliance,
|
||||
expected_hvac_modes: list[HVACMode],
|
||||
program_keys: list[ProgramKey],
|
||||
) -> None:
|
||||
"""Test the HVAC modes to programs mapping."""
|
||||
client.get_all_programs.side_effect = None
|
||||
client.get_all_programs.return_value = ArrayOfPrograms(
|
||||
[
|
||||
EnumerateProgram(
|
||||
key=program_key,
|
||||
raw_key=program_key.value,
|
||||
constraints=EnumerateProgramConstraints(
|
||||
execution=Execution.SELECT_AND_START,
|
||||
),
|
||||
)
|
||||
for program_key in program_keys
|
||||
]
|
||||
)
|
||||
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
entity = entity_registry.async_get("climate.air_conditioner")
|
||||
assert entity
|
||||
assert entity.capabilities
|
||||
assert entity.capabilities[ATTR_HVAC_MODES] == [*expected_hvac_modes, HVACMode.OFF]
|
||||
assert entity.supported_features & (
|
||||
ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{ATTR_ENTITY_ID: entity.entity_id, ATTR_HVAC_MODE: expected_hvac_modes[0]},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client.start_program.assert_called_once_with(
|
||||
appliance.ha_id, program_key=program_keys[0]
|
||||
)
|
||||
assert hass.states.is_state(entity.entity_id, expected_hvac_modes[0])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
async def test_set_hvac_mode_raises_home_assistant_error_on_api_errors(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
) -> None:
|
||||
"""Test that setting HVAC mode raises HomeAssistantError on API errors."""
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
client.start_program.side_effect = HomeConnectError("Test error")
|
||||
|
||||
with pytest.raises(HomeAssistantError, match="Test error"):
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{ATTR_ENTITY_ID: "climate.air_conditioner", ATTR_HVAC_MODE: HVACMode.COOL},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
async def test_hvac_mode_off_functionality(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
appliance: HomeAppliance,
|
||||
) -> None:
|
||||
"""Test setting the HVAC mode to off."""
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{ATTR_ENTITY_ID: "climate.air_conditioner", ATTR_HVAC_MODE: HVACMode.OFF},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
client.set_setting.assert_called_once_with(
|
||||
appliance.ha_id,
|
||||
setting_key=SettingKey.BSH_COMMON_POWER_STATE,
|
||||
value=BSH_POWER_STANDBY,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
async def test_hvac_mode_off_exception(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
) -> None:
|
||||
"""Test Home Connect exception while setting the HVAC mode to off."""
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
client.set_setting.side_effect = HomeConnectError("Test error")
|
||||
|
||||
with pytest.raises(HomeAssistantError, match=r"Error.*turn.*off.*"):
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{ATTR_ENTITY_ID: "climate.air_conditioner", ATTR_HVAC_MODE: HVACMode.OFF},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
async def test_state_when_appliance_is_in_standby(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
) -> None:
|
||||
"""Test that when the appliance is in standby, the climate entity state is off."""
|
||||
client.get_settings = AsyncMock(
|
||||
return_value=ArrayOfSettings(
|
||||
[
|
||||
GetSetting(
|
||||
SettingKey.BSH_COMMON_POWER_STATE,
|
||||
SettingKey.BSH_COMMON_POWER_STATE.value,
|
||||
BSH_POWER_STANDBY,
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
assert hass.states.is_state("climate.air_conditioner", HVACMode.OFF)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
async def test_not_supported_functionality_if_not_power_setting(
|
||||
entity_registry: er.EntityRegistry,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
) -> None:
|
||||
"""Test the off HVAC mode, and turn on/off is not supported when the setting is not present."""
|
||||
client.get_settings = AsyncMock(return_value=ArrayOfSettings([]))
|
||||
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
entity = entity_registry.async_get("climate.air_conditioner")
|
||||
assert entity
|
||||
assert entity.capabilities
|
||||
assert entity.capabilities[ATTR_HVAC_MODES]
|
||||
assert HVACMode.OFF not in entity.capabilities[ATTR_HVAC_MODES]
|
||||
assert not entity.supported_features & (
|
||||
ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
@pytest.mark.parametrize(
|
||||
("program_keys", "expected_preset_modes"),
|
||||
[
|
||||
(
|
||||
[
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN
|
||||
],
|
||||
["active_clean"],
|
||||
),
|
||||
(
|
||||
[
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN,
|
||||
ProgramKey.LAUNDRY_CARE_DRYER_ANTI_SHRINK,
|
||||
],
|
||||
["active_clean"],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_preset_modes_programs_mapping_and_functionality(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
appliance: HomeAppliance,
|
||||
program_keys: list[ProgramKey],
|
||||
expected_preset_modes: list[str],
|
||||
) -> None:
|
||||
"""Test the preset modes to programs mapping and functionality."""
|
||||
client.get_all_programs.side_effect = None
|
||||
client.get_all_programs.return_value = ArrayOfPrograms(
|
||||
[
|
||||
EnumerateProgram(
|
||||
key=ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO,
|
||||
raw_key=ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO.value,
|
||||
constraints=EnumerateProgramConstraints(
|
||||
execution=Execution.SELECT_AND_START,
|
||||
),
|
||||
),
|
||||
*[
|
||||
EnumerateProgram(
|
||||
key=program_key,
|
||||
raw_key=program_key.value,
|
||||
constraints=EnumerateProgramConstraints(
|
||||
execution=Execution.SELECT_AND_START,
|
||||
),
|
||||
)
|
||||
for program_key in program_keys
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
entity = entity_registry.async_get("climate.air_conditioner")
|
||||
assert entity
|
||||
assert entity.capabilities
|
||||
assert entity.capabilities[ATTR_PRESET_MODES] == expected_preset_modes
|
||||
state = hass.states.get(entity.entity_id)
|
||||
assert state
|
||||
assert state.attributes[ATTR_SUPPORTED_FEATURES] & ClimateEntityFeature.PRESET_MODE
|
||||
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_PRESET_MODE,
|
||||
{ATTR_ENTITY_ID: entity.entity_id, ATTR_PRESET_MODE: expected_preset_modes[0]},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client.start_program.assert_called_once_with(
|
||||
appliance.ha_id, program_key=program_keys[0]
|
||||
)
|
||||
entity_state = hass.states.get(entity.entity_id)
|
||||
assert entity_state
|
||||
assert entity_state.attributes[ATTR_PRESET_MODE] == expected_preset_modes[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
async def test_set_preset_mode_raises_home_assistant_error_on_api_errors(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
) -> None:
|
||||
"""Test that setting preset mode raises HomeAssistantError on API errors."""
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
client.start_program.side_effect = HomeConnectError("Test error")
|
||||
|
||||
with pytest.raises(HomeAssistantError, match="Test error"):
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_PRESET_MODE,
|
||||
{
|
||||
ATTR_ENTITY_ID: "climate.air_conditioner",
|
||||
ATTR_PRESET_MODE: "active_clean",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"set_active_program_options_side_effect",
|
||||
"set_selected_program_options_side_effect",
|
||||
"called_mock_method",
|
||||
),
|
||||
[
|
||||
(
|
||||
None,
|
||||
SelectedProgramNotSetError("error.key"),
|
||||
"set_active_program_option",
|
||||
),
|
||||
(
|
||||
ActiveProgramNotSetError("error.key"),
|
||||
None,
|
||||
"set_selected_program_option",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("allowed_values", "expected_fan_modes"),
|
||||
[
|
||||
(
|
||||
None,
|
||||
[FAN_AUTO, "manual"],
|
||||
),
|
||||
(
|
||||
[
|
||||
"HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic",
|
||||
"HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual",
|
||||
],
|
||||
[FAN_AUTO, "manual"],
|
||||
),
|
||||
(
|
||||
[
|
||||
"HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic",
|
||||
],
|
||||
[FAN_AUTO],
|
||||
),
|
||||
(
|
||||
[
|
||||
"HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual",
|
||||
"A.Non.Documented.Option",
|
||||
],
|
||||
["manual"],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_fan_mode_functionality(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
allowed_values: list[str | None] | None,
|
||||
expected_fan_modes: list[str],
|
||||
appliance: HomeAppliance,
|
||||
set_active_program_options_side_effect: ActiveProgramNotSetError | None,
|
||||
set_selected_program_options_side_effect: SelectedProgramNotSetError | None,
|
||||
called_mock_method: str,
|
||||
) -> None:
|
||||
"""Test options functionality."""
|
||||
entity_id = "climate.air_conditioner"
|
||||
option_key = (
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
|
||||
)
|
||||
if set_active_program_options_side_effect:
|
||||
client.set_active_program_option.side_effect = (
|
||||
set_active_program_options_side_effect
|
||||
)
|
||||
else:
|
||||
assert set_selected_program_options_side_effect
|
||||
client.set_selected_program_option.side_effect = (
|
||||
set_selected_program_options_side_effect
|
||||
)
|
||||
called_mock: AsyncMock = getattr(client, called_mock_method)
|
||||
client.get_available_program = AsyncMock(
|
||||
return_value=ProgramDefinition(
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO,
|
||||
options=[
|
||||
ProgramDefinitionOption(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
"Enumeration",
|
||||
constraints=ProgramDefinitionConstraints(
|
||||
allowed_values=allowed_values
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
entity_state = hass.states.get(entity_id)
|
||||
assert entity_state
|
||||
assert entity_state.attributes[ATTR_FAN_MODES] == expected_fan_modes
|
||||
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_FAN_MODE,
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
ATTR_FAN_MODE: expected_fan_modes[0],
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
called_mock.assert_called_once_with(
|
||||
appliance.ha_id,
|
||||
option_key=option_key,
|
||||
value=allowed_values[0]
|
||||
if allowed_values
|
||||
else "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic",
|
||||
)
|
||||
entity_state = hass.states.get(entity_id)
|
||||
assert entity_state
|
||||
assert entity_state.attributes[ATTR_FAN_MODE] == expected_fan_modes[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
async def test_set_fan_mode_raises_home_assistant_error_on_api_errors(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
) -> None:
|
||||
"""Test that setting a fan mode raises HomeAssistantError on API errors."""
|
||||
entity_id = "climate.air_conditioner"
|
||||
client.get_available_program = AsyncMock(
|
||||
return_value=ProgramDefinition(
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO,
|
||||
options=[
|
||||
ProgramDefinitionOption(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
"Enumeration",
|
||||
constraints=ProgramDefinitionConstraints(
|
||||
allowed_values=[
|
||||
"HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic",
|
||||
"HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual",
|
||||
]
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
client.set_active_program_option.side_effect = HomeConnectError("Test error")
|
||||
with pytest.raises(HomeAssistantError, match="Test error"):
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_FAN_MODE,
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
ATTR_FAN_MODE: FAN_AUTO,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
async def test_fan_mode_feature_supported(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
appliance: HomeAppliance,
|
||||
) -> None:
|
||||
"""Test that fan feature is supported depending on the fan speed mode option availability."""
|
||||
client.get_available_program = AsyncMock(
|
||||
return_value=ProgramDefinition(
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO,
|
||||
options=[
|
||||
ProgramDefinitionOption(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
"Enumeration",
|
||||
constraints=ProgramDefinitionConstraints(
|
||||
allowed_values=[
|
||||
"HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic",
|
||||
"HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual",
|
||||
]
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
entity_id = "climate.air_conditioner"
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
|
||||
assert state.attributes[ATTR_SUPPORTED_FEATURES] & ClimateEntityFeature.FAN_MODE
|
||||
|
||||
client.get_available_program = AsyncMock(
|
||||
return_value=ProgramDefinition(
|
||||
ProgramKey.UNKNOWN,
|
||||
options=[],
|
||||
)
|
||||
)
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance.ha_id,
|
||||
EventType.NOTIFY,
|
||||
data=ArrayOfEvents(
|
||||
[
|
||||
Event(
|
||||
key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
raw_key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM.value,
|
||||
timestamp=0,
|
||||
level="",
|
||||
handling="",
|
||||
value=ProgramKey.UNKNOWN.value,
|
||||
)
|
||||
]
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert not state.attributes[ATTR_SUPPORTED_FEATURES] & ClimateEntityFeature.FAN_MODE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
@pytest.mark.parametrize(
|
||||
"program_keys",
|
||||
[
|
||||
[ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO],
|
||||
[
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO,
|
||||
ProgramKey.LAUNDRY_CARE_DRYER_ANTI_SHRINK,
|
||||
],
|
||||
],
|
||||
)
|
||||
async def test_preset_mode_feature_not_supported_on_missing_active_clean(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
program_keys: list[ProgramKey],
|
||||
) -> None:
|
||||
"""Test that the preset modes are not supported if active clean program is missing."""
|
||||
client.get_all_programs.side_effect = None
|
||||
client.get_all_programs.return_value = ArrayOfPrograms(
|
||||
[
|
||||
EnumerateProgram(
|
||||
key=program_key,
|
||||
raw_key=program_key.value,
|
||||
constraints=EnumerateProgramConstraints(
|
||||
execution=Execution.SELECT_AND_START,
|
||||
),
|
||||
)
|
||||
for program_key in program_keys
|
||||
]
|
||||
)
|
||||
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
state = hass.states.get("climate.air_conditioner")
|
||||
assert state
|
||||
assert (
|
||||
not state.attributes[ATTR_SUPPORTED_FEATURES] & ClimateEntityFeature.PRESET_MODE
|
||||
)
|
||||
@@ -5,7 +5,6 @@ from unittest.mock import MagicMock
|
||||
from pylitterbot import FeederRobot, Robot
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.litterrobot import DOMAIN
|
||||
from homeassistant.components.switch import (
|
||||
DOMAIN as SWITCH_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
@@ -14,7 +13,7 @@ from homeassistant.components.switch import (
|
||||
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er, issue_registry as ir
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .conftest import setup_integration
|
||||
|
||||
@@ -94,50 +93,6 @@ async def test_feeder_robot_switch(
|
||||
assert state.state == new_state
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("preexisting_entity", "disabled_by", "expected_entity", "expected_issue"),
|
||||
[
|
||||
(True, None, True, True),
|
||||
(True, er.RegistryEntryDisabler.USER, False, False),
|
||||
(False, None, False, False),
|
||||
],
|
||||
)
|
||||
async def test_litterrobot_4_deprecated_switch(
|
||||
hass: HomeAssistant,
|
||||
mock_account_with_litterrobot_4: MagicMock,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
preexisting_entity: bool,
|
||||
disabled_by: er.RegistryEntryDisabler,
|
||||
expected_entity: bool,
|
||||
expected_issue: bool,
|
||||
) -> None:
|
||||
"""Test switch deprecation issue."""
|
||||
entity_uid = "LR4C010001-night_light_mode_enabled"
|
||||
if preexisting_entity:
|
||||
suggested_id = NIGHT_LIGHT_MODE_ENTITY_ID.replace(f"{SWITCH_DOMAIN}.", "")
|
||||
entity_registry.async_get_or_create(
|
||||
SWITCH_DOMAIN,
|
||||
DOMAIN,
|
||||
entity_uid,
|
||||
suggested_object_id=suggested_id,
|
||||
disabled_by=disabled_by,
|
||||
)
|
||||
|
||||
await setup_integration(hass, mock_account_with_litterrobot_4, SWITCH_DOMAIN)
|
||||
|
||||
assert (
|
||||
entity_registry.async_get(NIGHT_LIGHT_MODE_ENTITY_ID) is not None
|
||||
) is expected_entity
|
||||
assert (
|
||||
issue_registry.async_get_issue(
|
||||
domain=DOMAIN,
|
||||
issue_id=f"deprecated_entity_{entity_uid}",
|
||||
)
|
||||
is not None
|
||||
) is expected_issue
|
||||
|
||||
|
||||
async def test_switch_command_exception(
|
||||
hass: HomeAssistant, mock_account_with_side_effects: MagicMock
|
||||
) -> None:
|
||||
|
||||
@@ -96,6 +96,7 @@ FIXTURES = [
|
||||
"secuyou_smart_lock",
|
||||
"silabs_dishwasher",
|
||||
"silabs_evse_charging",
|
||||
"silabs_fan",
|
||||
"silabs_laundrywasher",
|
||||
"silabs_light_switch",
|
||||
"silabs_refrigerator",
|
||||
|
||||
639
tests/components/matter/fixtures/nodes/silabs_fan.json
Normal file
639
tests/components/matter/fixtures/nodes/silabs_fan.json
Normal file
@@ -0,0 +1,639 @@
|
||||
{
|
||||
"node_id": 99,
|
||||
"date_commissioned": "2026-03-15T20:01:47.597000",
|
||||
"last_interview": "2026-03-15T20:02:02.001000",
|
||||
"interview_version": 6,
|
||||
"available": true,
|
||||
"is_bridge": false,
|
||||
"attributes": {
|
||||
"0/65/0": [],
|
||||
"0/65/65533": 1,
|
||||
"0/65/65532": 0,
|
||||
"0/65/65531": [0, 65532, 65533, 65528, 65529, 65531],
|
||||
"0/65/65529": [],
|
||||
"0/65/65528": [],
|
||||
"0/64/0": [],
|
||||
"0/64/65533": 1,
|
||||
"0/64/65532": 0,
|
||||
"0/64/65531": [0, 65532, 65533, 65528, 65529, 65531],
|
||||
"0/64/65529": [],
|
||||
"0/64/65528": [],
|
||||
"0/63/0": [],
|
||||
"0/63/1": [],
|
||||
"0/63/2": 4,
|
||||
"0/63/3": 3,
|
||||
"0/63/65533": 2,
|
||||
"0/63/65532": 0,
|
||||
"0/63/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531],
|
||||
"0/63/65529": [0, 1, 3, 4],
|
||||
"0/63/65528": [5, 2],
|
||||
"0/62/0": [
|
||||
{
|
||||
"1": "FTABAQQkAgE3AyQTAhgmBBmLaC8mBZnBFUQ3BiQVAiQRYxgkBwEkCAEwCUEEaIMteYNolcHMUEZyU1kf4IEgR79a9nb37HZroVrTrZJxxSmgVqeMcylsR4pStGWGQHRxfURvhrhSCW2Uqa0zazcKNQEoARgkAgE2AwQCBAEYMAQULQNi8ynkb3SqgbenQcJZTYeOP3owBRRa34d1hFPuca7UFWclq9cFnlPhShgwC0D6K3Ds3SVlY1rjljz8+u0LB/7YFkpxK5DCD5wRQz86w3bX25sQFbjOjD8x/nlcDBBKf5LRgI3yTMOWlrCkAdYfGA==",
|
||||
"2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEAV5qZprx2HWOKSP2iCzsI7A0CHgZVtbwsQ/y4ssETfB9z00733STIN0AfD552Vi1h6fJSeEg0/pA82bJL/y0azcKNQEpARgkAmAwBBRa34d1hFPuca7UFWclq9cFnlPhSjAFFG9oKFV1nAO5dx/+jKvq8o8oKcZbGDALQDD8OnB1NcHRxx387f9wZeFDYf32VZ3ZENQrlWBTQZqEKP+K6XjWmjTWttDEeW1kiNtB1T5ZBIaJUxVdqMuNQx8Y",
|
||||
"254": 3
|
||||
}
|
||||
],
|
||||
"0/62/1": [
|
||||
{
|
||||
"1": "BG1FPaj8U/IZMJ0lkYRWnL7PNr67I7GmbKqrzOPKp92GWZkMLbxHskSpehAxMxW6nepQHWr+Eq9LLp7wy4CB7a4=",
|
||||
"2": 4939,
|
||||
"3": 2,
|
||||
"4": 99,
|
||||
"5": "ha-test",
|
||||
"254": 3
|
||||
}
|
||||
],
|
||||
"0/62/2": 5,
|
||||
"0/62/3": 3,
|
||||
"0/62/4": [
|
||||
"FTABAQAkAgE3AyYUyakYCSYVj6gLsxgmBFO9STEkBQA3BiYUyakYCSYVj6gLsxgkBwEkCAEwCUEEgYwxrTB+tyiEGfrRwjlXTG34MiQtJXbg5Qqd0ohdRW7MfwYY7vZiX/0h9hI8MqUralFaVPcnghAP0MSJm1YrqTcKNQEpARgkAmAwBBS3BS9aJzt+p6i28Nj+trB2Uu+vdzAFFLcFL1onO36nqLbw2P62sHZS7693GDALQL0jOtrtJoye0t5B1pFR1l51E5jeBZGVGRx8yHq0gvIgC0e96LokvX6O+Whm63Tl+8blZa1h/WTdiydMnGKVV4UY",
|
||||
"FTABAQAkAgE3AycUQhmZbaIbYjokFQIYJgRWZLcqJAUANwYnFEIZmW2iG2I6JBUCGCQHASQIATAJQQT2AlKGW/kOMjqayzeO0md523/fuhrhGEUU91uQpTiKo0I7wcPpKnmrwfQNPX6g0kEQl+VGaXa3e22lzfu5Tzp0Nwo1ASkBGCQCYDAEFOOMk13ScMKuT2hlaydi1yEJnhTqMAUU44yTXdJwwq5PaGVrJ2LXIQmeFOoYMAtAv2jJd1qd5miXbYesH1XrJ+vgyY0hzGuZ78N6Jw4Cb1oN1sLSpA+PNM0u7+hsEqcSvvn2eSV8EaRR+hg5YQjHDxg=",
|
||||
"FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEbUU9qPxT8hkwnSWRhFacvs82vrsjsaZsqqvM48qn3YZZmQwtvEeyRKl6EDEzFbqd6lAdav4Sr0sunvDLgIHtrjcKNQEpARgkAmAwBBRvaChVdZwDuXcf/oyr6vKPKCnGWzAFFG9oKFV1nAO5dx/+jKvq8o8oKcZbGDALQLa3jnnqN0/o6VG8wM4V9FDzrgDfKPd5cn3BBz77K80Jzo/aNotaTNOa6zX//yIvOkBZfGyq1Dh1vXZ4g2NKcXoY"
|
||||
],
|
||||
"0/62/5": 3,
|
||||
"0/62/65533": 2,
|
||||
"0/62/65532": 0,
|
||||
"0/62/65531": [0, 1, 2, 3, 4, 5, 65532, 65533, 65528, 65529, 65531],
|
||||
"0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11, 12, 13],
|
||||
"0/62/65528": [1, 3, 5, 8, 14],
|
||||
"0/60/0": 0,
|
||||
"0/60/1": null,
|
||||
"0/60/2": null,
|
||||
"0/60/65533": 1,
|
||||
"0/60/65532": 0,
|
||||
"0/60/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531],
|
||||
"0/60/65529": [0, 2],
|
||||
"0/60/65528": [],
|
||||
"0/52/0": [
|
||||
{
|
||||
"0": 10,
|
||||
"1": "UART",
|
||||
"3": 336
|
||||
},
|
||||
{
|
||||
"0": 9,
|
||||
"1": "Fan",
|
||||
"3": 3076
|
||||
},
|
||||
{
|
||||
"0": 8,
|
||||
"1": "Bluetoot",
|
||||
"3": 1224
|
||||
},
|
||||
{
|
||||
"0": 7,
|
||||
"1": "Bluetoot",
|
||||
"3": 608
|
||||
},
|
||||
{
|
||||
"0": 6,
|
||||
"1": "Bluetoot",
|
||||
"3": 380
|
||||
},
|
||||
{
|
||||
"0": 5,
|
||||
"1": "shell",
|
||||
"3": 1280
|
||||
},
|
||||
{
|
||||
"0": 4,
|
||||
"1": "OT Seria",
|
||||
"3": 2896
|
||||
},
|
||||
{
|
||||
"0": 3,
|
||||
"1": "Tmr Svc",
|
||||
"3": 1060
|
||||
},
|
||||
{
|
||||
"0": 2,
|
||||
"1": "IDLE",
|
||||
"3": 1064
|
||||
},
|
||||
{
|
||||
"0": 1,
|
||||
"1": "OT Stack",
|
||||
"3": 2832
|
||||
},
|
||||
{
|
||||
"0": 0,
|
||||
"1": "CHIP",
|
||||
"3": 3088
|
||||
}
|
||||
],
|
||||
"0/52/1": 104320,
|
||||
"0/52/2": 26112,
|
||||
"0/52/65533": 1,
|
||||
"0/52/65532": 1,
|
||||
"0/52/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531],
|
||||
"0/52/65529": [0],
|
||||
"0/52/65528": [],
|
||||
"0/52/3": 35288,
|
||||
"0/51/0": [
|
||||
{
|
||||
"0": "MyHome",
|
||||
"1": true,
|
||||
"2": null,
|
||||
"3": null,
|
||||
"4": "Hh33j1J0OU4=",
|
||||
"5": [],
|
||||
"6": [
|
||||
"/QANuACgAAAAAAD//gAkAA==",
|
||||
"/VX8YmMnAAEQOUd3e+xZhA==",
|
||||
"/QANuACgAACU6CQJ0+DGIQ==",
|
||||
"/oAAAAAAAAAcHfePUnQ5Tg=="
|
||||
],
|
||||
"7": 4
|
||||
}
|
||||
],
|
||||
"0/51/1": 1,
|
||||
"0/51/2": 394,
|
||||
"0/51/3": 0,
|
||||
"0/51/4": 6,
|
||||
"0/51/5": [],
|
||||
"0/51/6": [],
|
||||
"0/51/7": [],
|
||||
"0/51/8": false,
|
||||
"0/51/65533": 2,
|
||||
"0/51/65532": 0,
|
||||
"0/51/65531": [
|
||||
0, 1, 8, 3, 4, 5, 6, 7, 2, 65532, 65533, 65528, 65529, 65531
|
||||
],
|
||||
"0/51/65529": [0, 1],
|
||||
"0/51/65528": [2],
|
||||
"0/48/0": 0,
|
||||
"0/48/1": {
|
||||
"0": 60,
|
||||
"1": 900
|
||||
},
|
||||
"0/48/2": 0,
|
||||
"0/48/3": 0,
|
||||
"0/48/4": true,
|
||||
"0/48/65533": 2,
|
||||
"0/48/65532": 0,
|
||||
"0/48/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531],
|
||||
"0/48/65529": [0, 2, 4],
|
||||
"0/48/65528": [1, 3, 5],
|
||||
"0/44/0": 0,
|
||||
"0/44/65533": 1,
|
||||
"0/44/65532": 0,
|
||||
"0/44/65531": [0, 65532, 65533, 65528, 65529, 65531],
|
||||
"0/44/65529": [],
|
||||
"0/44/65528": [],
|
||||
"0/43/0": "en-US",
|
||||
"0/43/1": ["en-US"],
|
||||
"0/43/65533": 1,
|
||||
"0/43/65532": 0,
|
||||
"0/43/65531": [0, 1, 65532, 65533, 65528, 65529, 65531],
|
||||
"0/43/65529": [],
|
||||
"0/43/65528": [],
|
||||
"0/40/0": 19,
|
||||
"0/40/1": "Silabs",
|
||||
"0/40/2": 65521,
|
||||
"0/40/3": "SL_Fan",
|
||||
"0/40/4": 32773,
|
||||
"0/40/5": "",
|
||||
"0/40/6": "**REDACTED**",
|
||||
"0/40/7": 1,
|
||||
"0/40/8": "TEST_VERSION",
|
||||
"0/40/9": 1,
|
||||
"0/40/10": "1",
|
||||
"0/40/11": "20200101",
|
||||
"0/40/12": "",
|
||||
"0/40/13": "",
|
||||
"0/40/14": "",
|
||||
"0/40/15": "",
|
||||
"0/40/16": false,
|
||||
"0/40/18": "7C09BB25275DC136",
|
||||
"0/40/19": {
|
||||
"0": 3,
|
||||
"1": 3
|
||||
},
|
||||
"0/40/21": 17104896,
|
||||
"0/40/22": 1,
|
||||
"0/40/24": 1,
|
||||
"0/40/65533": 5,
|
||||
"0/40/65532": 0,
|
||||
"0/40/65531": [
|
||||
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 19, 21, 22, 11, 12, 13, 14, 15, 16, 18,
|
||||
65532, 65533, 65528, 65529, 65531
|
||||
],
|
||||
"0/40/65529": [],
|
||||
"0/40/65528": [],
|
||||
"0/31/0": [
|
||||
{
|
||||
"1": 5,
|
||||
"2": 2,
|
||||
"3": [112233],
|
||||
"4": null,
|
||||
"254": 3
|
||||
}
|
||||
],
|
||||
"0/31/2": 4,
|
||||
"0/31/3": 3,
|
||||
"0/31/4": 4,
|
||||
"0/31/65533": 2,
|
||||
"0/31/65532": 0,
|
||||
"0/31/65531": [0, 2, 3, 4, 65532, 65533, 65528, 65529, 65531],
|
||||
"0/31/65529": [],
|
||||
"0/31/65528": [],
|
||||
"0/29/0": [
|
||||
{
|
||||
"0": 22,
|
||||
"1": 1
|
||||
}
|
||||
],
|
||||
"0/29/1": [65, 64, 63, 62, 60, 52, 51, 48, 44, 43, 40, 31, 29, 49, 42, 53],
|
||||
"0/29/2": [41],
|
||||
"0/29/3": [1],
|
||||
"0/29/65533": 3,
|
||||
"0/29/65532": 0,
|
||||
"0/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531],
|
||||
"0/29/65529": [],
|
||||
"0/29/65528": [],
|
||||
"0/49/0": 1,
|
||||
"0/49/1": [
|
||||
{
|
||||
"0": "p0jbsOzJRNw=",
|
||||
"1": true
|
||||
}
|
||||
],
|
||||
"0/49/4": true,
|
||||
"0/49/5": 0,
|
||||
"0/49/6": "p0jbsOzJRNw=",
|
||||
"0/49/7": null,
|
||||
"0/49/2": 10,
|
||||
"0/49/3": 20,
|
||||
"0/49/9": 10,
|
||||
"0/49/10": 5,
|
||||
"0/49/65533": 2,
|
||||
"0/49/65532": 2,
|
||||
"0/49/65531": [
|
||||
0, 1, 4, 5, 6, 7, 2, 3, 9, 10, 65532, 65533, 65528, 65529, 65531
|
||||
],
|
||||
"0/49/65529": [0, 3, 4, 6, 8],
|
||||
"0/49/65528": [1, 5, 7],
|
||||
"0/42/0": [],
|
||||
"0/42/1": true,
|
||||
"0/42/2": 1,
|
||||
"0/42/3": null,
|
||||
"0/42/65533": 1,
|
||||
"0/42/65532": 0,
|
||||
"0/42/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531],
|
||||
"0/42/65529": [0],
|
||||
"0/42/65528": [],
|
||||
"0/53/0": 25,
|
||||
"0/53/1": 5,
|
||||
"0/53/2": "MyHome",
|
||||
"0/53/3": 4660,
|
||||
"0/53/4": 12054125955590472924,
|
||||
"0/53/5": "QP0ADbgAoAAA",
|
||||
"0/53/7": [
|
||||
{
|
||||
"0": 7061919368606463488,
|
||||
"1": 17,
|
||||
"2": 3072,
|
||||
"3": 1729386,
|
||||
"4": 111166,
|
||||
"5": 3,
|
||||
"6": -41,
|
||||
"7": -42,
|
||||
"8": 0,
|
||||
"9": 0,
|
||||
"10": true,
|
||||
"11": true,
|
||||
"12": true,
|
||||
"13": false
|
||||
},
|
||||
{
|
||||
"0": 2202349555917590819,
|
||||
"1": 17,
|
||||
"2": 25600,
|
||||
"3": 421691,
|
||||
"4": 193083,
|
||||
"5": 3,
|
||||
"6": -66,
|
||||
"7": -66,
|
||||
"8": 58,
|
||||
"9": 2,
|
||||
"10": true,
|
||||
"11": true,
|
||||
"12": true,
|
||||
"13": false
|
||||
},
|
||||
{
|
||||
"0": 10563246212816049995,
|
||||
"1": 27,
|
||||
"2": 26624,
|
||||
"3": 444355,
|
||||
"4": 68004,
|
||||
"5": 3,
|
||||
"6": -47,
|
||||
"7": -49,
|
||||
"8": 0,
|
||||
"9": 0,
|
||||
"10": true,
|
||||
"11": true,
|
||||
"12": true,
|
||||
"13": false
|
||||
},
|
||||
{
|
||||
"0": 14318601490803184919,
|
||||
"1": 15,
|
||||
"2": 33792,
|
||||
"3": 1114210,
|
||||
"4": 116186,
|
||||
"5": 3,
|
||||
"6": -53,
|
||||
"7": -53,
|
||||
"8": 0,
|
||||
"9": 0,
|
||||
"10": true,
|
||||
"11": true,
|
||||
"12": true,
|
||||
"13": false
|
||||
},
|
||||
{
|
||||
"0": 6498271992183290326,
|
||||
"1": 13,
|
||||
"2": 40960,
|
||||
"3": 924853,
|
||||
"4": 849689,
|
||||
"5": 3,
|
||||
"6": -55,
|
||||
"7": -56,
|
||||
"8": 15,
|
||||
"9": 0,
|
||||
"10": true,
|
||||
"11": true,
|
||||
"12": true,
|
||||
"13": false
|
||||
},
|
||||
{
|
||||
"0": 4206032556233211940,
|
||||
"1": 15,
|
||||
"2": 53248,
|
||||
"3": 298977,
|
||||
"4": 107155,
|
||||
"5": 3,
|
||||
"6": -76,
|
||||
"7": -77,
|
||||
"8": 0,
|
||||
"9": 0,
|
||||
"10": true,
|
||||
"11": true,
|
||||
"12": true,
|
||||
"13": false
|
||||
},
|
||||
{
|
||||
"0": 3385988815368794773,
|
||||
"1": 11,
|
||||
"2": 57344,
|
||||
"3": 2237645,
|
||||
"4": 106995,
|
||||
"5": 2,
|
||||
"6": -81,
|
||||
"7": -81,
|
||||
"8": 75,
|
||||
"9": 8,
|
||||
"10": true,
|
||||
"11": true,
|
||||
"12": true,
|
||||
"13": false
|
||||
},
|
||||
{
|
||||
"0": 8265194500311707858,
|
||||
"1": 26,
|
||||
"2": 58368,
|
||||
"3": 257719,
|
||||
"4": 116620,
|
||||
"5": 3,
|
||||
"6": -60,
|
||||
"7": -61,
|
||||
"8": 0,
|
||||
"9": 0,
|
||||
"10": true,
|
||||
"11": true,
|
||||
"12": true,
|
||||
"13": false
|
||||
}
|
||||
],
|
||||
"0/53/8": [
|
||||
{
|
||||
"0": 7061919368606463488,
|
||||
"1": 3072,
|
||||
"2": 3,
|
||||
"3": 40,
|
||||
"4": 1,
|
||||
"5": 3,
|
||||
"6": 3,
|
||||
"7": 18,
|
||||
"8": true,
|
||||
"9": true
|
||||
},
|
||||
{
|
||||
"0": 2170162790398179662,
|
||||
"1": 9216,
|
||||
"2": 9,
|
||||
"3": 63,
|
||||
"4": 0,
|
||||
"5": 0,
|
||||
"6": 0,
|
||||
"7": 0,
|
||||
"8": true,
|
||||
"9": false
|
||||
},
|
||||
{
|
||||
"0": 0,
|
||||
"1": 12288,
|
||||
"2": 12,
|
||||
"3": 63,
|
||||
"4": 0,
|
||||
"5": 0,
|
||||
"6": 0,
|
||||
"7": 42,
|
||||
"8": true,
|
||||
"9": false
|
||||
},
|
||||
{
|
||||
"0": 0,
|
||||
"1": 14336,
|
||||
"2": 14,
|
||||
"3": 25,
|
||||
"4": 2,
|
||||
"5": 0,
|
||||
"6": 0,
|
||||
"7": 108,
|
||||
"8": true,
|
||||
"9": false
|
||||
},
|
||||
{
|
||||
"0": 2202349555917590819,
|
||||
"1": 25600,
|
||||
"2": 25,
|
||||
"3": 40,
|
||||
"4": 1,
|
||||
"5": 3,
|
||||
"6": 3,
|
||||
"7": 18,
|
||||
"8": true,
|
||||
"9": true
|
||||
},
|
||||
{
|
||||
"0": 10563246212816049995,
|
||||
"1": 26624,
|
||||
"2": 26,
|
||||
"3": 40,
|
||||
"4": 1,
|
||||
"5": 3,
|
||||
"6": 3,
|
||||
"7": 27,
|
||||
"8": true,
|
||||
"9": true
|
||||
},
|
||||
{
|
||||
"0": 14318601490803184919,
|
||||
"1": 33792,
|
||||
"2": 33,
|
||||
"3": 40,
|
||||
"4": 1,
|
||||
"5": 3,
|
||||
"6": 3,
|
||||
"7": 15,
|
||||
"8": true,
|
||||
"9": true
|
||||
},
|
||||
{
|
||||
"0": 6498271992183290326,
|
||||
"1": 40960,
|
||||
"2": 40,
|
||||
"3": 57,
|
||||
"4": 1,
|
||||
"5": 3,
|
||||
"6": 3,
|
||||
"7": 13,
|
||||
"8": true,
|
||||
"9": true
|
||||
},
|
||||
{
|
||||
"0": 4206032556233211940,
|
||||
"1": 53248,
|
||||
"2": 52,
|
||||
"3": 3,
|
||||
"4": 1,
|
||||
"5": 3,
|
||||
"6": 2,
|
||||
"7": 16,
|
||||
"8": true,
|
||||
"9": true
|
||||
},
|
||||
{
|
||||
"0": 3385988815368794773,
|
||||
"1": 57344,
|
||||
"2": 56,
|
||||
"3": 40,
|
||||
"4": 1,
|
||||
"5": 2,
|
||||
"6": 0,
|
||||
"7": 12,
|
||||
"8": true,
|
||||
"9": true
|
||||
},
|
||||
{
|
||||
"0": 8265194500311707858,
|
||||
"1": 58368,
|
||||
"2": 57,
|
||||
"3": 40,
|
||||
"4": 1,
|
||||
"5": 3,
|
||||
"6": 3,
|
||||
"7": 26,
|
||||
"8": true,
|
||||
"9": true
|
||||
}
|
||||
],
|
||||
"0/53/9": 1775826714,
|
||||
"0/53/10": 64,
|
||||
"0/53/11": 130,
|
||||
"0/53/12": 25,
|
||||
"0/53/13": 57,
|
||||
"0/53/59": {
|
||||
"0": 672,
|
||||
"1": 143
|
||||
},
|
||||
"0/53/60": "AB//4A==",
|
||||
"0/53/61": {
|
||||
"0": true,
|
||||
"1": false,
|
||||
"2": true,
|
||||
"3": true,
|
||||
"4": true,
|
||||
"5": true,
|
||||
"6": false,
|
||||
"7": true,
|
||||
"8": true,
|
||||
"9": true,
|
||||
"10": true,
|
||||
"11": true
|
||||
},
|
||||
"0/53/62": [],
|
||||
"0/53/63": null,
|
||||
"0/53/64": null,
|
||||
"0/53/65533": 3,
|
||||
"0/53/65532": 0,
|
||||
"0/53/65531": [
|
||||
0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 59, 60, 61, 62, 65532, 65533,
|
||||
65528, 65529, 65531
|
||||
],
|
||||
"0/53/65529": [],
|
||||
"0/53/65528": [],
|
||||
"1/29/0": [
|
||||
{
|
||||
"0": 43,
|
||||
"1": 1
|
||||
}
|
||||
],
|
||||
"1/29/1": [29, 3, 4, 514],
|
||||
"1/29/2": [],
|
||||
"1/29/3": [],
|
||||
"1/29/65533": 3,
|
||||
"1/29/65532": 0,
|
||||
"1/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531],
|
||||
"1/29/65529": [],
|
||||
"1/29/65528": [],
|
||||
"1/3/0": 0,
|
||||
"1/3/1": 2,
|
||||
"1/3/65533": 6,
|
||||
"1/3/65532": 0,
|
||||
"1/3/65531": [0, 1, 65532, 65533, 65528, 65529, 65531],
|
||||
"1/3/65529": [0, 64],
|
||||
"1/3/65528": [],
|
||||
"1/4/0": 128,
|
||||
"1/4/65533": 4,
|
||||
"1/4/65532": 1,
|
||||
"1/4/65531": [0, 65532, 65533, 65528, 65529, 65531],
|
||||
"1/4/65529": [0, 1, 2, 3, 4, 5],
|
||||
"1/4/65528": [0, 1, 2, 3],
|
||||
"1/514/0": 1,
|
||||
"1/514/1": 2,
|
||||
"1/514/2": 30,
|
||||
"1/514/3": 30,
|
||||
"1/514/65533": 5,
|
||||
"1/514/65532": 63,
|
||||
"1/514/65531": [
|
||||
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 65532, 65533, 65528, 65529, 65531
|
||||
],
|
||||
"1/514/65529": [0],
|
||||
"1/514/65528": [],
|
||||
"1/514/4": 10,
|
||||
"1/514/5": 3,
|
||||
"1/514/6": 3,
|
||||
"1/514/7": 1,
|
||||
"1/514/8": 0,
|
||||
"1/514/9": 3,
|
||||
"1/514/10": 0,
|
||||
"1/514/11": 0
|
||||
},
|
||||
"attribute_subscriptions": []
|
||||
}
|
||||
@@ -3782,6 +3782,56 @@
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_buttons[silabs_fan][button.sl_fan_identify-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.sl_fan_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Identify',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <ButtonDeviceClass.IDENTIFY: 'identify'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Identify',
|
||||
'platform': 'matter',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': '00000000000004D2-0000000000000063-MatterNodeDevice-1-IdentifyButton-3-1',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_buttons[silabs_fan][button.sl_fan_identify-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'identify',
|
||||
'friendly_name': 'SL_Fan Identify',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.sl_fan_identify',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_buttons[silabs_laundrywasher][button.laundrywasher_identify-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
||||
@@ -335,3 +335,75 @@
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_fans[silabs_fan][fan.sl_fan-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'preset_modes': list([
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'auto',
|
||||
'natural_wind',
|
||||
'sleep_wind',
|
||||
]),
|
||||
}),
|
||||
'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.sl_fan',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'matter',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': <FanEntityFeature: 63>,
|
||||
'translation_key': 'fan',
|
||||
'unique_id': '00000000000004D2-0000000000000063-MatterNodeDevice-1-MatterFan-514-0',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_fans[silabs_fan][fan.sl_fan-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'direction': 'forward',
|
||||
'friendly_name': 'SL_Fan',
|
||||
'oscillating': False,
|
||||
'percentage': 30,
|
||||
'percentage_step': 10.0,
|
||||
'preset_mode': 'low',
|
||||
'preset_modes': list([
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'auto',
|
||||
'natural_wind',
|
||||
'sleep_wind',
|
||||
]),
|
||||
'supported_features': <FanEntityFeature: 63>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'fan.sl_fan',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
"""Test motion conditions."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.const import ATTR_DEVICE_CLASS, CONF_ENTITY_ID, STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.components import (
|
||||
ConditionStateDescription,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
create_target_condition,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
set_or_remove_state,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]:
|
||||
"""Create multiple binary sensor entities associated with different targets."""
|
||||
return await target_entities(hass, "binary_sensor")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"condition",
|
||||
[
|
||||
"motion.is_detected",
|
||||
"motion.is_not_detected",
|
||||
],
|
||||
)
|
||||
async def test_motion_conditions_gated_by_labs_flag(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str
|
||||
) -> None:
|
||||
"""Test the motion conditions are gated by the labs flag."""
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities("binary_sensor"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("condition", "condition_options", "states"),
|
||||
[
|
||||
*parametrize_condition_states_any(
|
||||
condition="motion.is_detected",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "motion"},
|
||||
),
|
||||
*parametrize_condition_states_any(
|
||||
condition="motion.is_not_detected",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "motion"},
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_motion_binary_sensor_condition_behavior_any(
|
||||
hass: HomeAssistant,
|
||||
target_binary_sensors: dict[str, list[str]],
|
||||
condition_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
condition: str,
|
||||
condition_options: dict[str, Any],
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test motion condition for binary_sensor with 'any' behavior."""
|
||||
other_entity_ids = set(target_binary_sensors["included"]) - {entity_id}
|
||||
excluded_entity_ids = set(target_binary_sensors["excluded"]) - {entity_id}
|
||||
|
||||
for eid in target_binary_sensors["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
for eid in excluded_entity_ids:
|
||||
set_or_remove_state(hass, eid, states[0]["excluded"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
hass,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="any",
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
excluded_state = state["excluded"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
for excluded_entity_id in excluded_entity_ids:
|
||||
set_or_remove_state(hass, excluded_entity_id, excluded_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities("binary_sensor"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("condition", "condition_options", "states"),
|
||||
[
|
||||
*parametrize_condition_states_all(
|
||||
condition="motion.is_detected",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "motion"},
|
||||
),
|
||||
*parametrize_condition_states_all(
|
||||
condition="motion.is_not_detected",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "motion"},
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_motion_binary_sensor_condition_behavior_all(
|
||||
hass: HomeAssistant,
|
||||
target_binary_sensors: dict[str, list[str]],
|
||||
condition_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
condition: str,
|
||||
condition_options: dict[str, Any],
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test motion condition for binary_sensor with 'all' behavior."""
|
||||
other_entity_ids = set(target_binary_sensors["included"]) - {entity_id}
|
||||
excluded_entity_ids = set(target_binary_sensors["excluded"]) - {entity_id}
|
||||
|
||||
for eid in target_binary_sensors["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
for eid in excluded_entity_ids:
|
||||
set_or_remove_state(hass, eid, states[0]["excluded"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
hass,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="all",
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
excluded_state = state["excluded"]
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true_first_entity"]
|
||||
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
for excluded_entity_id in excluded_entity_ids:
|
||||
set_or_remove_state(hass, excluded_entity_id, excluded_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
|
||||
# --- Device class exclusion test ---
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"condition_key",
|
||||
"binary_sensor_matching",
|
||||
"binary_sensor_non_matching",
|
||||
),
|
||||
[
|
||||
(
|
||||
"motion.is_detected",
|
||||
STATE_ON,
|
||||
STATE_OFF,
|
||||
),
|
||||
(
|
||||
"motion.is_not_detected",
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_motion_condition_excludes_non_motion_device_class(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
binary_sensor_matching: str,
|
||||
binary_sensor_non_matching: str,
|
||||
) -> None:
|
||||
"""Test motion condition excludes entities without device_class motion."""
|
||||
entity_id_motion = "binary_sensor.test_motion"
|
||||
entity_id_occupancy = "binary_sensor.test_occupancy"
|
||||
|
||||
# Set matching states on all entities
|
||||
hass.states.async_set(
|
||||
entity_id_motion, binary_sensor_matching, {ATTR_DEVICE_CLASS: "motion"}
|
||||
)
|
||||
hass.states.async_set(
|
||||
entity_id_occupancy,
|
||||
binary_sensor_matching,
|
||||
{ATTR_DEVICE_CLASS: "occupancy"},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition_any = await create_target_condition(
|
||||
hass,
|
||||
condition=condition_key,
|
||||
target={CONF_ENTITY_ID: [entity_id_motion, entity_id_occupancy]},
|
||||
behavior="any",
|
||||
)
|
||||
|
||||
# Matching entity in matching state - condition should be True
|
||||
assert condition_any(hass) is True
|
||||
|
||||
# Set matching entity to non-matching state
|
||||
hass.states.async_set(
|
||||
entity_id_motion,
|
||||
binary_sensor_non_matching,
|
||||
{ATTR_DEVICE_CLASS: "motion"},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Wrong device class entity still in matching state, but should be excluded
|
||||
assert condition_any(hass) is False
|
||||
@@ -644,6 +644,31 @@ async def test_setting_device_tracker_location_via_abbr_reset_message(
|
||||
assert state.attributes["source_type"] == "gps"
|
||||
assert state.state == STATE_HOME
|
||||
|
||||
# Override the GPS state via a direct state update
|
||||
async_fire_mqtt_message(hass, "test-topic", "office")
|
||||
state = hass.states.get("device_tracker.test")
|
||||
assert state.state == "office"
|
||||
|
||||
# Test a GPS attributes update without a reset
|
||||
async_fire_mqtt_message(
|
||||
hass,
|
||||
"attributes-topic",
|
||||
'{"latitude":32.87336,"longitude": -117.22743, "gps_accuracy":1.5}',
|
||||
)
|
||||
|
||||
state = hass.states.get("device_tracker.test")
|
||||
assert state.state == "office"
|
||||
|
||||
# Reset the manual set location
|
||||
# This should calculate the location from GPS attributes
|
||||
async_fire_mqtt_message(hass, "test-topic", "reset")
|
||||
state = hass.states.get("device_tracker.test")
|
||||
assert state.attributes["latitude"] == 32.87336
|
||||
assert state.attributes["longitude"] == -117.22743
|
||||
assert state.attributes["gps_accuracy"] == 1.5
|
||||
assert state.attributes["source_type"] == "gps"
|
||||
assert state.state == STATE_HOME
|
||||
|
||||
|
||||
async def test_setting_blocked_attribute_via_mqtt_json_message(
|
||||
hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator
|
||||
|
||||
@@ -42,6 +42,7 @@ async def test_regular_hass_operations(hass: HomeAssistant, numato_fixture) -> N
|
||||
{ATTR_ENTITY_ID: "switch.numato_switch_mock_port5"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("switch.numato_switch_mock_port5").state == "on"
|
||||
assert numato_fixture.devices[0].values[5] == 1
|
||||
await hass.services.async_call(
|
||||
@@ -50,6 +51,7 @@ async def test_regular_hass_operations(hass: HomeAssistant, numato_fixture) -> N
|
||||
{ATTR_ENTITY_ID: "switch.numato_switch_mock_port6"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("switch.numato_switch_mock_port6").state == "on"
|
||||
assert numato_fixture.devices[0].values[6] == 1
|
||||
await hass.services.async_call(
|
||||
@@ -58,6 +60,7 @@ async def test_regular_hass_operations(hass: HomeAssistant, numato_fixture) -> N
|
||||
{ATTR_ENTITY_ID: "switch.numato_switch_mock_port5"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("switch.numato_switch_mock_port5").state == "off"
|
||||
assert numato_fixture.devices[0].values[5] == 0
|
||||
await hass.services.async_call(
|
||||
@@ -66,6 +69,7 @@ async def test_regular_hass_operations(hass: HomeAssistant, numato_fixture) -> N
|
||||
{ATTR_ENTITY_ID: "switch.numato_switch_mock_port6"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("switch.numato_switch_mock_port6").state == "off"
|
||||
assert numato_fixture.devices[0].values[6] == 0
|
||||
|
||||
|
||||
@@ -139,6 +139,8 @@ def mock_create_stream() -> Generator[AsyncMock]:
|
||||
"""Mock stream response."""
|
||||
|
||||
async def mock_generator(events, **kwargs):
|
||||
if isinstance(events, Exception):
|
||||
raise events
|
||||
response = Response(
|
||||
id="resp_A",
|
||||
created_at=1700000000,
|
||||
|
||||
@@ -20,6 +20,7 @@ from homeassistant.components.openai_conversation.const import (
|
||||
CONF_REASONING_EFFORT,
|
||||
CONF_REASONING_SUMMARY,
|
||||
CONF_RECOMMENDED,
|
||||
CONF_SERVICE_TIER,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_TOP_P,
|
||||
CONF_TTS_SPEED,
|
||||
@@ -314,6 +315,76 @@ async def test_subentry_reasoning_effort_list(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model", "service_tier_options"),
|
||||
[
|
||||
("gpt-5.4", ["auto", "flex", "default", "priority"]),
|
||||
("gpt-5.4-pro", ["auto", "flex", "default", "priority"]),
|
||||
("gpt-5.2", ["auto", "flex", "default", "priority"]),
|
||||
("gpt-5.1", ["auto", "flex", "default", "priority"]),
|
||||
("gpt-5", ["auto", "flex", "default", "priority"]),
|
||||
("gpt-5-mini", ["auto", "flex", "default", "priority"]),
|
||||
("gpt-5-nano", ["auto", "flex", "default"]),
|
||||
("o3", ["auto", "flex", "default", "priority"]),
|
||||
("o4-mini", ["auto", "flex", "default", "priority"]),
|
||||
("gpt-5.3-codex", ["auto", "default", "priority"]),
|
||||
("gpt-5.2-codex", ["auto", "default", "priority"]),
|
||||
("gpt-5.1-codex-max", ["auto", "default", "priority"]),
|
||||
("gpt-5-codex", ["auto", "default", "priority"]),
|
||||
("gpt-4.1", ["auto", "default", "priority"]),
|
||||
("gpt-4.1-mini", ["auto", "default", "priority"]),
|
||||
("gpt-4.1-nano", ["auto", "default", "priority"]),
|
||||
("gpt-4o", ["auto", "default", "priority"]),
|
||||
("gpt-4o-2024-05-13", ["auto", "default", "priority"]),
|
||||
("gpt-4o-mini", ["auto", "default", "priority"]),
|
||||
("gpt-5-chat-latest", []),
|
||||
("gpt-5.2-pro", []),
|
||||
("o3-mini", []),
|
||||
],
|
||||
)
|
||||
async def test_subentry_service_tier_list(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry,
|
||||
mock_init_component,
|
||||
model,
|
||||
service_tier_options,
|
||||
) -> None:
|
||||
"""Test the list of service tier options."""
|
||||
subentry = next(iter(mock_config_entry.subentries.values()))
|
||||
subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow(
|
||||
hass, subentry.subentry_id
|
||||
)
|
||||
assert subentry_flow["type"] is FlowResultType.FORM
|
||||
assert subentry_flow["step_id"] == "init"
|
||||
|
||||
# Configure initial step
|
||||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||||
subentry_flow["flow_id"],
|
||||
{
|
||||
CONF_RECOMMENDED: False,
|
||||
CONF_PROMPT: "Speak like a pirate",
|
||||
CONF_LLM_HASS_API: ["assist"],
|
||||
},
|
||||
)
|
||||
assert subentry_flow["type"] is FlowResultType.FORM
|
||||
assert subentry_flow["step_id"] == "advanced"
|
||||
|
||||
# Configure advanced step
|
||||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||||
subentry_flow["flow_id"],
|
||||
{
|
||||
CONF_CHAT_MODEL: model,
|
||||
},
|
||||
)
|
||||
assert subentry_flow["type"] is FlowResultType.FORM
|
||||
assert subentry_flow["step_id"] == "model"
|
||||
assert (
|
||||
subentry_flow["data_schema"].schema[CONF_SERVICE_TIER].config["options"]
|
||||
if subentry_flow["data_schema"].schema.get(CONF_SERVICE_TIER)
|
||||
else []
|
||||
) == service_tier_options
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("parameter", "error"),
|
||||
[
|
||||
@@ -493,6 +564,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
|
||||
CONF_WEB_SEARCH_CONTEXT_SIZE: "low",
|
||||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||||
CONF_WEB_SEARCH_INLINE_CITATIONS: True,
|
||||
CONF_SERVICE_TIER: "auto",
|
||||
CONF_CODE_INTERPRETER: False,
|
||||
},
|
||||
),
|
||||
@@ -503,6 +575,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
|
||||
CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
|
||||
CONF_TOP_P: RECOMMENDED_TOP_P,
|
||||
CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS,
|
||||
CONF_SERVICE_TIER: "auto",
|
||||
CONF_WEB_SEARCH: True,
|
||||
CONF_WEB_SEARCH_CONTEXT_SIZE: "low",
|
||||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||||
@@ -527,6 +600,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
|
||||
CONF_WEB_SEARCH_COUNTRY: "US",
|
||||
CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles",
|
||||
CONF_WEB_SEARCH_INLINE_CITATIONS: True,
|
||||
CONF_SERVICE_TIER: "auto",
|
||||
CONF_CODE_INTERPRETER: True,
|
||||
},
|
||||
(
|
||||
@@ -545,6 +619,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
|
||||
CONF_WEB_SEARCH_CONTEXT_SIZE: "low",
|
||||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||||
CONF_WEB_SEARCH_INLINE_CITATIONS: True,
|
||||
CONF_SERVICE_TIER: "default",
|
||||
CONF_CODE_INTERPRETER: True,
|
||||
},
|
||||
),
|
||||
@@ -555,6 +630,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
|
||||
CONF_CHAT_MODEL: "gpt-4o",
|
||||
CONF_TOP_P: 0.9,
|
||||
CONF_MAX_TOKENS: 1000,
|
||||
CONF_SERVICE_TIER: "default",
|
||||
CONF_WEB_SEARCH: True,
|
||||
CONF_WEB_SEARCH_CONTEXT_SIZE: "low",
|
||||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||||
@@ -574,6 +650,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
|
||||
CONF_REASONING_SUMMARY: "auto",
|
||||
CONF_VERBOSITY: "high",
|
||||
CONF_CODE_INTERPRETER: False,
|
||||
CONF_SERVICE_TIER: "flex",
|
||||
CONF_WEB_SEARCH: False,
|
||||
CONF_WEB_SEARCH_CONTEXT_SIZE: "low",
|
||||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||||
@@ -595,6 +672,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
|
||||
CONF_REASONING_SUMMARY: RECOMMENDED_REASONING_SUMMARY,
|
||||
CONF_CODE_INTERPRETER: False,
|
||||
CONF_VERBOSITY: "high",
|
||||
CONF_SERVICE_TIER: "flex",
|
||||
CONF_WEB_SEARCH: False,
|
||||
CONF_WEB_SEARCH_CONTEXT_SIZE: "low",
|
||||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||||
@@ -612,6 +690,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
|
||||
CONF_REASONING_SUMMARY: RECOMMENDED_REASONING_SUMMARY,
|
||||
CONF_CODE_INTERPRETER: False,
|
||||
CONF_VERBOSITY: "high",
|
||||
CONF_SERVICE_TIER: "flex",
|
||||
CONF_WEB_SEARCH: False,
|
||||
CONF_WEB_SEARCH_CONTEXT_SIZE: "low",
|
||||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||||
@@ -628,6 +707,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
|
||||
CONF_TOP_P: 0.9,
|
||||
CONF_MAX_TOKENS: 1000,
|
||||
CONF_CODE_INTERPRETER: True,
|
||||
CONF_SERVICE_TIER: "priority",
|
||||
CONF_WEB_SEARCH: True,
|
||||
CONF_WEB_SEARCH_CONTEXT_SIZE: "low",
|
||||
CONF_WEB_SEARCH_USER_LOCATION: True,
|
||||
@@ -660,6 +740,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
|
||||
CONF_TOP_P: 0.9,
|
||||
CONF_MAX_TOKENS: 1000,
|
||||
CONF_REASONING_EFFORT: "high",
|
||||
CONF_SERVICE_TIER: "auto",
|
||||
CONF_CODE_INTERPRETER: True,
|
||||
CONF_VERBOSITY: "low",
|
||||
CONF_WEB_SEARCH: False,
|
||||
@@ -684,6 +765,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
|
||||
CONF_CHAT_MODEL: "gpt-4o",
|
||||
CONF_TOP_P: 0.9,
|
||||
CONF_MAX_TOKENS: 1000,
|
||||
CONF_SERVICE_TIER: "auto",
|
||||
CONF_WEB_SEARCH: True,
|
||||
CONF_WEB_SEARCH_CONTEXT_SIZE: "low",
|
||||
CONF_WEB_SEARCH_USER_LOCATION: True,
|
||||
@@ -731,6 +813,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
|
||||
CONF_TOP_P: 0.9,
|
||||
CONF_MAX_TOKENS: 1000,
|
||||
CONF_REASONING_EFFORT: "low",
|
||||
CONF_SERVICE_TIER: "flex",
|
||||
CONF_CODE_INTERPRETER: True,
|
||||
CONF_VERBOSITY: "medium",
|
||||
},
|
||||
@@ -750,6 +833,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
|
||||
CONF_WEB_SEARCH_CONTEXT_SIZE: "high",
|
||||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||||
CONF_WEB_SEARCH_INLINE_CITATIONS: True,
|
||||
CONF_SERVICE_TIER: "priority",
|
||||
CONF_CODE_INTERPRETER: False,
|
||||
},
|
||||
),
|
||||
@@ -760,6 +844,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
|
||||
CONF_CHAT_MODEL: "gpt-4o",
|
||||
CONF_TOP_P: 0.9,
|
||||
CONF_MAX_TOKENS: 1000,
|
||||
CONF_SERVICE_TIER: "priority",
|
||||
CONF_WEB_SEARCH: True,
|
||||
CONF_WEB_SEARCH_CONTEXT_SIZE: "high",
|
||||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||||
@@ -780,6 +865,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
|
||||
CONF_REASONING_SUMMARY: "auto",
|
||||
CONF_CODE_INTERPRETER: True,
|
||||
CONF_VERBOSITY: "medium",
|
||||
CONF_SERVICE_TIER: "auto",
|
||||
CONF_WEB_SEARCH: True,
|
||||
CONF_WEB_SEARCH_CONTEXT_SIZE: "high",
|
||||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||||
@@ -959,6 +1045,7 @@ async def test_subentry_web_search_user_location(
|
||||
CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
|
||||
CONF_TOP_P: RECOMMENDED_TOP_P,
|
||||
CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS,
|
||||
CONF_SERVICE_TIER: "auto",
|
||||
CONF_WEB_SEARCH: True,
|
||||
CONF_WEB_SEARCH_CONTEXT_SIZE: "medium",
|
||||
CONF_WEB_SEARCH_USER_LOCATION: True,
|
||||
@@ -1088,6 +1175,7 @@ async def test_creating_ai_task_subentry_advanced(
|
||||
CONF_TEMPERATURE: 0.5,
|
||||
CONF_TOP_P: 0.9,
|
||||
CONF_CODE_INTERPRETER: False,
|
||||
CONF_SERVICE_TIER: "auto",
|
||||
}
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user