Compare commits

..

43 Commits

Author SHA1 Message Date
Denis Shulyaka
0167182e2e Add support for service tier for OpenAI integration (#165379)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-16 15:38:29 +01:00
Ariel Ebersberger
11411a880d Refactor trigger helpers (#165455)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-16 15:26:57 +01:00
J. Diego Rodríguez Royo
ce47abe1d3 Add climate entity for air conditioner to Home Connect (#155981)
Co-authored-by: Joostlek <joostlek@outlook.com>
Co-authored-by: emontnemery <erik@montnemery.com>
2026-03-16 15:19:57 +01:00
epenet
b58513c19a Use TuyaCoverAction enum in Tuya cover (#165690) 2026-03-16 15:08:49 +01:00
epenet
4e1dab6d8b Migrate remaining vacuum wrappers to Tuya library (#165688) 2026-03-16 15:06:03 +01:00
epenet
5ae8e1c319 Migrate remaining climate wrappers to Tuya library (#165687) 2026-03-16 15:03:15 +01:00
epenet
17bf6ca591 Migrate remaining alarm control panel wrappers to Tuya library (#165686) 2026-03-16 14:59:10 +01:00
epenet
256d30c38d Migrate remaining fan wrappers to Tuya library (#165685) 2026-03-16 14:56:26 +01:00
Jan Čermák
5d182394c2 Update zizmor to v1.23.1 (#165467) 2026-03-16 14:30:13 +01:00
epenet
011e6863d8 Bump tuya-device-handlers to 0.0.13 (#165684) 2026-03-16 14:11:26 +01:00
Anis Kadri
b902b590b1 Add UniFi Access binary sensors (#165569)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 14:03:46 +01:00
peteS-UK
960666e15b Improve discovery flow for Squeezebox (#153958) 2026-03-16 13:50:33 +01:00
Mike Degatano
1fb59c9f11 Remove code notary related unsupported reasons (#165417)
Co-authored-by: Stefan Agner <stefan@agner.ch>
2026-03-16 13:45:58 +01:00
Mike Degatano
332bf95e16 Bump aiohasupervisor to 0.4.1 (#165489)
Co-authored-by: Stefan Agner <stefan@agner.ch>
2026-03-16 13:11:48 +01:00
Joost Lekkerkerker
e35fc8267e Fix typing in nsw_fuel_station (#165679) 2026-03-16 12:41:53 +01:00
Joost Lekkerkerker
f8b4ffc0d7 Fix translation placeholders in Assist pipeline (#165676) 2026-03-16 12:37:47 +01:00
Mike Degatano
003ee5a699 Remove aiohasupervisor from pyproject.toml (#165512) 2026-03-16 11:56:10 +01:00
epenet
c91d805174 Use external library wrapper in Tuya vacuum (#165673) 2026-03-16 11:52:34 +01:00
epenet
c478d19ae3 Use external library wrapper in Tuya climate (#165672) 2026-03-16 11:46:59 +01:00
Samuel Xiao
09169b0f06 Switchbot Cloud: Fixed Circulator Fan on start error (#165241)
Co-authored-by: Ariel Ebersberger <ariel@ebersberger.io>
2026-03-16 11:45:21 +01:00
epenet
aa1dbee315 Use external library wrapper in Tuya cover (#165656) 2026-03-16 11:37:18 +01:00
TimL
daf89e5673 Bump Pysmlight to 0.3.0 (#165658) 2026-03-16 11:35:25 +01:00
Joshua Monta
85dc81c147 Update uhoo IQS to silver (#165665) 2026-03-16 11:31:53 +01:00
epenet
5acf24cb53 Use external library wrapper in Tuya alarm control panel (#165671) 2026-03-16 11:30:51 +01:00
Martin Hjelmare
79829a311c Fix emulated_kasa tests for Python 3.14.3 (#165667) 2026-03-16 11:19:06 +01:00
Martin Hjelmare
ce2c62ae28 Fix numato tests for Python 3.14.3 (#165669) 2026-03-16 11:17:29 +01:00
Martin Hjelmare
1cda3f47d6 Fix valve tests for Python 3.14.3 (#165668) 2026-03-16 11:16:27 +01:00
Nathan Spencer
e254716615 Remove deprecated entity creation code for Litter-Robot 4 devices (#165636) 2026-03-16 10:40:31 +01:00
epenet
1d410f4cbd Use external library wrapper in Tuya humidifer (#165654) 2026-03-16 10:39:54 +01:00
epenet
6616793e2b Use external library wrapper in Tuya light (#165653) 2026-03-16 10:39:43 +01:00
Joost Lekkerkerker
6766961327 Finish TRMNL docs (#165612) 2026-03-16 10:38:11 +01:00
Denis Shulyaka
dd6fc11d28 Bump python-telegram-bot to 22.6 (#165508)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-16 10:34:54 +01:00
Simone Chemelli
cb5b8b212c Bump aiocomelit to 2.0.1 (#165663) 2026-03-16 10:32:55 +01:00
epenet
66b96d096e Use external library wrapper in Tuya event (#165655) 2026-03-16 10:32:31 +01:00
epenet
e86160de36 Use external library wrapper in Tuya fan (#165464) 2026-03-16 10:24:00 +01:00
Simone Chemelli
7617007edd Update IQS to silver for Fritz (#162280) 2026-03-16 10:19:35 +01:00
epenet
3e065b31b3 Simplify Prana entity descriptions (#165660)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-16 10:12:16 +01:00
Simone Chemelli
5f909a6f3a Fix wifi switch status and add 100% coverage for Fritz (#164696) 2026-03-16 10:05:42 +01:00
Jan Bouwhuis
6117a20ec6 Fix MQTT device tracker overrides via JSON state attributes without reset (#165529) 2026-03-16 10:03:35 +01:00
Simone Chemelli
93bc05bb3f Fix switch set for Vodafone Station (#165273) 2026-03-16 10:00:52 +01:00
Thomas Kadauke
e7397ccaa7 fix: Increase WebSocket message size limit to 16MB in Hass.io ingress proxy (#164442)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 09:48:06 +01:00
Joshua Monta
91a43873a2 feat: implement reauthentication requirement (#165641) 2026-03-16 09:03:01 +01:00
Ludovic BOUÉ
469e06fb8c Add Matter certified Silabs fan example to fixtures (#165622) 2026-03-16 09:02:23 +01:00
116 changed files with 4348 additions and 2308 deletions

View File

@@ -72,7 +72,7 @@ jobs:
- name: Download Translations
run: python3 -m script.translations download
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
- name: Archive translations
shell: bash

View File

@@ -1400,7 +1400,7 @@ jobs:
with:
fail_ci_if_error: true
flags: full-suite
token: ${{ secrets.CODECOV_TOKEN }}
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
pytest-partial:
name: Run tests Python ${{ matrix.python-version }} (${{ matrix.group }})
@@ -1570,7 +1570,7 @@ jobs:
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
upload-test-results:
name: Upload test results to Codecov

View File

@@ -58,8 +58,8 @@ jobs:
# v1.7.0
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a
with:
app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }}
private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }}
app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
# The 90 day stale policy for issues
# Used for:

View File

@@ -33,6 +33,6 @@ jobs:
- name: Upload Translations
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
run: |
python3 -m script.translations upload

View File

@@ -142,7 +142,7 @@ jobs:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }}
wheels-key: ${{ secrets.WHEELS_KEY }} # zizmor: ignore[secrets-outside-env]
env-file: true
apk: "libffi-dev;openssl-dev;yaml-dev;nasm;zlib-ng-dev"
skip-binary: aiohttp;multidict;propcache;yarl;SQLAlchemy
@@ -200,7 +200,7 @@ jobs:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }}
wheels-key: ${{ secrets.WHEELS_KEY }} # zizmor: ignore[secrets-outside-env]
env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl

View File

@@ -18,7 +18,7 @@ repos:
exclude_types: [csv, json, html]
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: v1.22.0
rev: v1.23.1
hooks:
- id: zizmor
args:

View File

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

View File

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

View File

@@ -128,7 +128,6 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
"light",
"lock",
"media_player",
"motion",
"person",
"siren",
"switch",

View File

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

View File

@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["aiocomelit"],
"quality_scale": "platinum",
"requirements": ["aiocomelit==2.0.0"]
"requirements": ["aiocomelit==2.0.1"]
}

View File

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

View File

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

View File

@@ -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": [
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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] = {}

View File

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

View File

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

View File

@@ -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%]",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -191,5 +191,16 @@
}
]
}
},
"AirConditioner": {
"data": {
"settings": [
{
"key": "BSH.Common.Setting.PowerState",
"value": "BSH.Common.EnumType.PowerState.On",
"type": "BSH.Common.EnumType.PowerState"
}
]
}
}
}

View File

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

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

View File

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

View File

@@ -96,6 +96,7 @@ FIXTURES = [
"secuyou_smart_lock",
"silabs_dishwasher",
"silabs_evse_charging",
"silabs_fan",
"silabs_laundrywasher",
"silabs_light_switch",
"silabs_refrigerator",

View 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": []
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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