Compare commits

..

7 Commits

Author SHA1 Message Date
Paulus Schoutsen 9bd7ea78f0 Remove AITaskEntityFeature.GENERATE_STRUCTURED_DATA feature flag
The structured data generation functionality is now available to all
entities that support GENERATE_DATA. This simplifies the API by removing
an unnecessary feature flag while maintaining all functionality.

- Remove GENERATE_STRUCTURED_DATA from AITaskEntityFeature enum
- Remove feature check in task.py
- Update services.yaml to remove filter
- Update tests to reflect the change

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-04 13:22:48 +02:00
Paulus Schoutsen e42038742a Merge branch 'dev' into ai-task-structured-data 2025-07-04 13:11:00 +02:00
Allen Porter ff58c4e564 Merge branch 'ai-task-structured-data' of https://github.com/allenporter/home-assistant-core into ai-task-structured-data 2025-07-04 00:35:20 +00:00
Allen Porter 5ba71a4675 Forbid extra fields in the vol schema to ensure generated output is correct 2025-07-04 00:35:03 +00:00
Allen Porter 7fb7bc8e50 Update conftest.py to revert conftest function 2025-07-03 14:57:52 -07:00
Allen Porter afa30be64b Rename _validate_structure to _validate_schema 2025-07-03 21:32:10 +00:00
Allen Porter 789eb029fa Add AI task structured output 2025-07-03 21:26:20 +00:00
100 changed files with 1364 additions and 2442 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ on:
type: boolean
env:
CACHE_VERSION: 4
CACHE_VERSION: 3
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2025.8"
-1
View File
@@ -381,7 +381,6 @@ homeassistant.components.openai_conversation.*
homeassistant.components.openexchangerates.*
homeassistant.components.opensky.*
homeassistant.components.openuv.*
homeassistant.components.opower.*
homeassistant.components.oralb.*
homeassistant.components.otbr.*
homeassistant.components.overkiz.*
-2
View File
@@ -76,7 +76,6 @@ from .exceptions import HomeAssistantError
from .helpers import (
area_registry,
category_registry,
condition,
config_validation as cv,
device_registry,
entity,
@@ -453,7 +452,6 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> None:
create_eager_task(restore_state.async_load(hass)),
create_eager_task(hass.config_entries.async_initialize()),
create_eager_task(async_get_system_info(hass)),
create_eager_task(condition.async_setup(hass)),
create_eager_task(trigger.async_setup(hass)),
)
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "bronze",
"requirements": ["aioamazondevices==3.2.3"]
"requirements": ["aioamazondevices==3.2.2"]
}
@@ -56,7 +56,7 @@ SERVICE_UPLOAD = "upload"
ANDROIDTV_STATES = {
"off": MediaPlayerState.OFF,
"idle": MediaPlayerState.IDLE,
"standby": MediaPlayerState.IDLE,
"standby": MediaPlayerState.STANDBY,
"playing": MediaPlayerState.PLAYING,
"paused": MediaPlayerState.PAUSED,
}
@@ -191,7 +191,7 @@ class AppleTvMediaPlayer(
self._is_feature_available(FeatureName.PowerState)
and self.atv.power.power_state == PowerState.Off
):
return MediaPlayerState.OFF
return MediaPlayerState.STANDBY
if self._playing:
state = self._playing.device_state
if state in (DeviceState.Idle, DeviceState.Loading):
@@ -200,7 +200,7 @@ class AppleTvMediaPlayer(
return MediaPlayerState.PLAYING
if state in (DeviceState.Paused, DeviceState.Seeking, DeviceState.Stopped):
return MediaPlayerState.PAUSED
return MediaPlayerState.IDLE # Bad or unknown state?
return MediaPlayerState.STANDBY # Bad or unknown state?
return None
@callback
@@ -107,7 +107,7 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity):
"""Return the state of the device."""
media_state = self.client.play_state.state
if media_state == "NETWORK":
return MediaPlayerState.OFF
return MediaPlayerState.STANDBY
if self.client.state.power:
if media_state == "play":
return MediaPlayerState.PLAYING
@@ -94,7 +94,6 @@ async def _get_options_dict(handler: SchemaCommonFlowHandler | None) -> dict:
max=6,
mode=selector.NumberSelectorMode.BOX,
unit_of_measurement="decimals",
translation_key="round",
),
),
vol.Required(CONF_TIME_WINDOW): selector.DurationSelector(),
@@ -52,11 +52,6 @@
"h": "Hours",
"d": "Days"
}
},
"round": {
"unit_of_measurement": {
"decimals": "decimals"
}
}
}
}
@@ -10,12 +10,7 @@ from eheimdigital.device import EheimDigitalDevice
from eheimdigital.hub import EheimDigitalHub
import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
SOURCE_USER,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.helpers import selector
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -131,52 +126,3 @@ class EheimDigitalConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=CONFIG_SCHEMA,
errors=errors,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of the config entry."""
if user_input is None:
return self.async_show_form(
step_id=SOURCE_RECONFIGURE, data_schema=CONFIG_SCHEMA
)
self._async_abort_entries_match(user_input)
errors: dict[str, str] = {}
hub = EheimDigitalHub(
host=user_input[CONF_HOST],
session=async_get_clientsession(self.hass),
loop=self.hass.loop,
main_device_added_event=self.main_device_added_event,
)
try:
await hub.connect()
async with asyncio.timeout(2):
# This event gets triggered when the first message is received from
# the device, it contains the data necessary to create the main device.
# This removes the race condition where the main device is accessed
# before the response from the device is parsed.
await self.main_device_added_event.wait()
if TYPE_CHECKING:
# At this point the main device is always set
assert isinstance(hub.main, EheimDigitalDevice)
await self.async_set_unique_id(hub.main.mac_address)
await hub.close()
except (ClientError, TimeoutError):
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001
errors["base"] = "unknown"
LOGGER.exception("Unknown exception occurred")
else:
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
data_updates=user_input,
)
return self.async_show_form(
step_id=SOURCE_RECONFIGURE,
data_schema=CONFIG_SCHEMA,
errors=errors,
)
@@ -60,7 +60,7 @@ rules:
entity-translations: done
exception-translations: done
icon-translations: todo
reconfiguration-flow: done
reconfiguration-flow: todo
repair-issues: todo
stale-devices: done
@@ -4,14 +4,6 @@
"discovery_confirm": {
"description": "[%key:common::config_flow::description::confirm_setup%]"
},
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "[%key:component::eheimdigital::config::step::user::data_description::host%]"
}
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
@@ -23,9 +15,7 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "The identifier does not match the previous identifier"
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -126,7 +126,6 @@ class EnvoyEnchargeBinarySensorEntity(EnvoyBaseBinarySensorEntity):
name=f"Encharge {serial_number}",
sw_version=str(encharge_inventory[self._serial_number].firmware_version),
via_device=(DOMAIN, self.envoy_serial_num),
serial_number=serial_number,
)
@property
@@ -159,7 +158,6 @@ class EnvoyEnpowerBinarySensorEntity(EnvoyBaseBinarySensorEntity):
name=f"Enpower {enpower.serial_number}",
sw_version=str(enpower.firmware_version),
via_device=(DOMAIN, self.envoy_serial_num),
serial_number=enpower.serial_number,
)
@property
@@ -165,7 +165,6 @@ class EnvoyStorageSettingsNumberEntity(EnvoyBaseEntity, NumberEntity):
name=f"Enpower {self._serial_number}",
sw_version=str(enpower.firmware_version),
via_device=(DOMAIN, self.envoy_serial_num),
serial_number=self._serial_number,
)
else:
# If no enpower device assign numbers to Envoy itself
@@ -223,7 +223,6 @@ class EnvoyStorageSettingsSelectEntity(EnvoyBaseEntity, SelectEntity):
name=f"Enpower {self._serial_number}",
sw_version=str(enpower.firmware_version),
via_device=(DOMAIN, self.envoy_serial_num),
serial_number=self._serial_number,
)
else:
# If no enpower device assign selects to Envoy itself
@@ -1313,7 +1313,6 @@ class EnvoyInverterEntity(EnvoySensorBaseEntity):
manufacturer="Enphase",
model="Inverter",
via_device=(DOMAIN, self.envoy_serial_num),
serial_number=serial_number,
)
@property
@@ -1357,7 +1356,6 @@ class EnvoyEnchargeEntity(EnvoySensorBaseEntity):
name=f"Encharge {serial_number}",
sw_version=str(encharge_inventory[self._serial_number].firmware_version),
via_device=(DOMAIN, self.envoy_serial_num),
serial_number=serial_number,
)
@@ -1422,7 +1420,6 @@ class EnvoyEnpowerEntity(EnvoySensorBaseEntity):
name=f"Enpower {enpower_data.serial_number}",
sw_version=str(enpower_data.firmware_version),
via_device=(DOMAIN, self.envoy_serial_num),
serial_number=enpower_data.serial_number,
)
@property
@@ -138,7 +138,6 @@ class EnvoyEnpowerSwitchEntity(EnvoyBaseEntity, SwitchEntity):
name=f"Enpower {self._serial_number}",
sw_version=str(enpower.firmware_version),
via_device=(DOMAIN, self.envoy_serial_num),
serial_number=self._serial_number,
)
@property
@@ -236,7 +235,6 @@ class EnvoyStorageSettingsSwitchEntity(EnvoyBaseEntity, SwitchEntity):
name=f"Enpower {self._serial_number}",
sw_version=str(enpower.firmware_version),
via_device=(DOMAIN, self.envoy_serial_num),
serial_number=self._serial_number,
)
else:
# If no enpower device assign switches to Envoy itself
@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250702.1"]
"requirements": ["home-assistant-frontend==20250702.0"]
}
@@ -2,10 +2,6 @@
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_oauth2_flow import (
AUTH_CALLBACK_PATH,
MY_AUTH_CALLBACK_PATH,
)
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
@@ -18,14 +14,12 @@ async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationSe
async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]:
"""Return description placeholders for the credentials dialog."""
if "my" in hass.config.components:
redirect_url = MY_AUTH_CALLBACK_PATH
else:
ha_host = hass.config.external_url or "https://YOUR_DOMAIN:PORT"
redirect_url = f"{ha_host}{AUTH_CALLBACK_PATH}"
return {
"oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent",
"more_info_url": "https://www.home-assistant.io/integrations/google_assistant_sdk/",
"oauth_consent_url": (
"https://console.cloud.google.com/apis/credentials/consent"
),
"more_info_url": (
"https://www.home-assistant.io/integrations/google_assistant_sdk/"
),
"oauth_creds_url": "https://console.cloud.google.com/apis/credentials",
"redirect_url": redirect_url,
}
@@ -46,7 +46,7 @@
}
},
"application_credentials": {
"description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Assistant SDK. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n1. Add `{redirect_url}` under *Authorized redirect URI*."
"description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Assistant SDK. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type."
},
"services": {
"send_text_command": {
+12 -7
View File
@@ -95,16 +95,21 @@ def get_recurrence_rule(recurrence: rrule) -> str:
'DTSTART:YYYYMMDDTHHMMSS\nRRULE:FREQ=YEARLY;INTERVAL=2'
Args:
recurrence: An RRULE object.
Parameters
----------
recurrence : rrule
An RRULE object.
Returns:
Returns
-------
str
The recurrence rule portion of the RRULE string, starting with 'FREQ='.
Example:
>>> rule = get_recurrence_rule(task)
>>> print(rule)
'FREQ=YEARLY;INTERVAL=2'
Example
-------
>>> rule = get_recurrence_rule(task)
>>> print(rule)
'FREQ=YEARLY;INTERVAL=2'
"""
return str(recurrence).split("RRULE:")[1]
+1 -1
View File
@@ -64,7 +64,7 @@ def setup_bans(hass: HomeAssistant, app: Application, login_threshold: int) -> N
"""Initialize bans when app starts up."""
await app[KEY_BAN_MANAGER].async_load()
app.on_startup.append(ban_startup) # type: ignore[arg-type]
app.on_startup.append(ban_startup)
@middleware
@@ -3,6 +3,9 @@
"binary_sensor": {
"leaving_dock": {
"default": "mdi:debug-step-out"
},
"returning_to_dock": {
"default": "mdi:debug-step-into"
}
},
"button": {
@@ -45,26 +48,6 @@
"work_area_progress": {
"default": "mdi:collage"
}
},
"switch": {
"my_lawn_work_area": {
"default": "mdi:square-outline",
"state": {
"on": "mdi:square"
}
},
"work_area_work_area": {
"default": "mdi:square-outline",
"state": {
"on": "mdi:square"
}
},
"stay_out_zones": {
"default": "mdi:rhombus-outline",
"state": {
"on": "mdi:rhombus"
}
}
}
},
"services": {
@@ -8,5 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["aioautomower"],
"quality_scale": "silver",
"requirements": ["aioautomower==1.2.0"]
"requirements": ["aioautomower==1.0.1"]
}
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/hydrawise",
"iot_class": "cloud_polling",
"loggers": ["pydrawise"],
"requirements": ["pydrawise==2025.7.0"]
"requirements": ["pydrawise==2025.6.0"]
}
@@ -136,7 +136,7 @@ class LookinMedia(LookinPowerPushRemoteEntity, MediaPlayerEntity):
async def async_turn_off(self) -> None:
"""Turn the media player off."""
await self._async_send_command(self._power_off_command)
self._attr_state = MediaPlayerState.OFF
self._attr_state = MediaPlayerState.STANDBY
self.async_write_ha_state()
async def async_turn_on(self) -> None:
@@ -159,5 +159,7 @@ class LookinMedia(LookinPowerPushRemoteEntity, MediaPlayerEntity):
state = status[0]
mute = status[2]
self._attr_state = MediaPlayerState.ON if state == "1" else MediaPlayerState.OFF
self._attr_state = (
MediaPlayerState.ON if state == "1" else MediaPlayerState.STANDBY
)
self._attr_is_volume_muted = mute == "0"
@@ -54,7 +54,7 @@ class MatterBinarySensor(MatterEntity, BinarySensorEntity):
value = self.get_matter_attribute_value(self._entity_info.primary_attribute)
if value in (None, NullValue):
value = None
elif value_convert := self.entity_description.device_to_ha:
elif value_convert := self.entity_description.measurement_to_ha:
value = value_convert(value)
if TYPE_CHECKING:
value = cast(bool | None, value)
@@ -70,7 +70,7 @@ DISCOVERY_SCHEMAS = [
entity_description=MatterBinarySensorEntityDescription(
key="HueMotionSensor",
device_class=BinarySensorDeviceClass.MOTION,
device_to_ha=lambda x: (x & 1 == 1) if x is not None else None,
measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None,
),
entity_class=MatterBinarySensor,
required_attributes=(clusters.OccupancySensing.Attributes.Occupancy,),
@@ -83,7 +83,7 @@ DISCOVERY_SCHEMAS = [
key="OccupancySensor",
device_class=BinarySensorDeviceClass.OCCUPANCY,
# The first bit = if occupied
device_to_ha=lambda x: (x & 1 == 1) if x is not None else None,
measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None,
),
entity_class=MatterBinarySensor,
required_attributes=(clusters.OccupancySensing.Attributes.Occupancy,),
@@ -94,7 +94,7 @@ DISCOVERY_SCHEMAS = [
key="BatteryChargeLevel",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: x
measurement_to_ha=lambda x: x
!= clusters.PowerSource.Enums.BatChargeLevelEnum.kOk,
),
entity_class=MatterBinarySensor,
@@ -109,7 +109,7 @@ DISCOVERY_SCHEMAS = [
key="ContactSensor",
device_class=BinarySensorDeviceClass.DOOR,
# value is inverted on matter to what we expect
device_to_ha=lambda x: not x,
measurement_to_ha=lambda x: not x,
),
entity_class=MatterBinarySensor,
required_attributes=(clusters.BooleanState.Attributes.StateValue,),
@@ -153,7 +153,7 @@ DISCOVERY_SCHEMAS = [
entity_description=MatterBinarySensorEntityDescription(
key="LockDoorStateSensor",
device_class=BinarySensorDeviceClass.DOOR,
device_to_ha={
measurement_to_ha={
clusters.DoorLock.Enums.DoorStateEnum.kDoorOpen: True,
clusters.DoorLock.Enums.DoorStateEnum.kDoorJammed: True,
clusters.DoorLock.Enums.DoorStateEnum.kDoorForcedOpen: True,
@@ -168,7 +168,7 @@ DISCOVERY_SCHEMAS = [
platform=Platform.BINARY_SENSOR,
entity_description=MatterBinarySensorEntityDescription(
key="SmokeCoAlarmDeviceMutedSensor",
device_to_ha=lambda x: (
measurement_to_ha=lambda x: (
x == clusters.SmokeCoAlarm.Enums.MuteStateEnum.kMuted
),
translation_key="muted",
@@ -181,7 +181,7 @@ DISCOVERY_SCHEMAS = [
platform=Platform.BINARY_SENSOR,
entity_description=MatterBinarySensorEntityDescription(
key="SmokeCoAlarmEndfOfServiceSensor",
device_to_ha=lambda x: (
measurement_to_ha=lambda x: (
x == clusters.SmokeCoAlarm.Enums.EndOfServiceEnum.kExpired
),
translation_key="end_of_service",
@@ -195,7 +195,7 @@ DISCOVERY_SCHEMAS = [
platform=Platform.BINARY_SENSOR,
entity_description=MatterBinarySensorEntityDescription(
key="SmokeCoAlarmBatteryAlertSensor",
device_to_ha=lambda x: (
measurement_to_ha=lambda x: (
x != clusters.SmokeCoAlarm.Enums.AlarmStateEnum.kNormal
),
translation_key="battery_alert",
@@ -232,7 +232,7 @@ DISCOVERY_SCHEMAS = [
entity_description=MatterBinarySensorEntityDescription(
key="SmokeCoAlarmSmokeStateSensor",
device_class=BinarySensorDeviceClass.SMOKE,
device_to_ha=lambda x: (
measurement_to_ha=lambda x: (
x != clusters.SmokeCoAlarm.Enums.AlarmStateEnum.kNormal
),
),
@@ -244,7 +244,7 @@ DISCOVERY_SCHEMAS = [
entity_description=MatterBinarySensorEntityDescription(
key="SmokeCoAlarmInterconnectSmokeAlarmSensor",
device_class=BinarySensorDeviceClass.SMOKE,
device_to_ha=lambda x: (
measurement_to_ha=lambda x: (
x != clusters.SmokeCoAlarm.Enums.AlarmStateEnum.kNormal
),
translation_key="interconnected_smoke_alarm",
@@ -257,7 +257,7 @@ DISCOVERY_SCHEMAS = [
entity_description=MatterBinarySensorEntityDescription(
key="SmokeCoAlarmInterconnectCOAlarmSensor",
device_class=BinarySensorDeviceClass.CO,
device_to_ha=lambda x: (
measurement_to_ha=lambda x: (
x != clusters.SmokeCoAlarm.Enums.AlarmStateEnum.kNormal
),
translation_key="interconnected_co_alarm",
@@ -271,7 +271,7 @@ DISCOVERY_SCHEMAS = [
key="EnergyEvseChargingStatusSensor",
translation_key="evse_charging_status",
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
device_to_ha={
measurement_to_ha={
clusters.EnergyEvse.Enums.StateEnum.kNotPluggedIn: False,
clusters.EnergyEvse.Enums.StateEnum.kPluggedInNoDemand: False,
clusters.EnergyEvse.Enums.StateEnum.kPluggedInDemand: False,
@@ -291,7 +291,7 @@ DISCOVERY_SCHEMAS = [
key="EnergyEvsePlugStateSensor",
translation_key="evse_plug_state",
device_class=BinarySensorDeviceClass.PLUG,
device_to_ha={
measurement_to_ha={
clusters.EnergyEvse.Enums.StateEnum.kNotPluggedIn: False,
clusters.EnergyEvse.Enums.StateEnum.kPluggedInNoDemand: True,
clusters.EnergyEvse.Enums.StateEnum.kPluggedInDemand: True,
@@ -311,7 +311,7 @@ DISCOVERY_SCHEMAS = [
key="EnergyEvseSupplyStateSensor",
translation_key="evse_supply_charging_state",
device_class=BinarySensorDeviceClass.RUNNING,
device_to_ha={
measurement_to_ha={
clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabled: False,
clusters.EnergyEvse.Enums.SupplyStateEnum.kChargingEnabled: True,
clusters.EnergyEvse.Enums.SupplyStateEnum.kDischargingEnabled: False,
@@ -327,7 +327,7 @@ DISCOVERY_SCHEMAS = [
entity_description=MatterBinarySensorEntityDescription(
key="WaterHeaterManagementBoostStateSensor",
translation_key="boost_state",
device_to_ha=lambda x: (
measurement_to_ha=lambda x: (
x == clusters.WaterHeaterManagement.Enums.BoostStateEnum.kActive
),
),
@@ -342,7 +342,7 @@ DISCOVERY_SCHEMAS = [
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
# DeviceFault or SupplyFault bit enabled
device_to_ha={
measurement_to_ha={
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kDeviceFault: True,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSupplyFault: True,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSpeedLow: False,
@@ -366,7 +366,7 @@ DISCOVERY_SCHEMAS = [
key="PumpStatusRunning",
translation_key="pump_running",
device_class=BinarySensorDeviceClass.RUNNING,
device_to_ha=lambda x: (
measurement_to_ha=lambda x: (
x
== clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRunning
),
@@ -384,7 +384,7 @@ DISCOVERY_SCHEMAS = [
translation_key="dishwasher_alarm_inflow",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: (
measurement_to_ha=lambda x: (
x == clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kInflowError
),
),
@@ -399,7 +399,7 @@ DISCOVERY_SCHEMAS = [
translation_key="dishwasher_alarm_door",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: (
measurement_to_ha=lambda x: (
x == clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kDoorError
),
),
+2 -2
View File
@@ -59,8 +59,8 @@ class MatterEntityDescription(EntityDescription):
"""Describe the Matter entity."""
# convert the value from the primary attribute to the value used by HA
device_to_ha: Callable[[Any], Any] | None = None
ha_to_device: Callable[[Any], Any] | None = None
measurement_to_ha: Callable[[Any], Any] | None = None
ha_to_native_value: Callable[[Any], Any] | None = None
command_timeout: int | None = None
+21 -21
View File
@@ -55,7 +55,7 @@ class MatterRangeNumberEntityDescription(
):
"""Describe Matter Number Input entities with min and max values."""
ha_to_device: Callable[[Any], Any]
ha_to_native_value: Callable[[Any], Any]
# attribute descriptors to get the min and max value
min_attribute: type[ClusterAttributeDescriptor]
@@ -74,7 +74,7 @@ class MatterNumber(MatterEntity, NumberEntity):
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
sendvalue = int(value)
if value_convert := self.entity_description.ha_to_device:
if value_convert := self.entity_description.ha_to_native_value:
sendvalue = value_convert(value)
await self.write_attribute(
value=sendvalue,
@@ -84,7 +84,7 @@ class MatterNumber(MatterEntity, NumberEntity):
def _update_from_device(self) -> None:
"""Update from device."""
value = self.get_matter_attribute_value(self._entity_info.primary_attribute)
if value_convert := self.entity_description.device_to_ha:
if value_convert := self.entity_description.measurement_to_ha:
value = value_convert(value)
self._attr_native_value = value
@@ -96,7 +96,7 @@ class MatterRangeNumber(MatterEntity, NumberEntity):
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
send_value = self.entity_description.ha_to_device(value)
send_value = self.entity_description.ha_to_native_value(value)
# custom command defined to set the new value
await self.send_device_command(
self.entity_description.command(send_value),
@@ -106,7 +106,7 @@ class MatterRangeNumber(MatterEntity, NumberEntity):
def _update_from_device(self) -> None:
"""Update from device."""
value = self.get_matter_attribute_value(self._entity_info.primary_attribute)
if value_convert := self.entity_description.device_to_ha:
if value_convert := self.entity_description.measurement_to_ha:
value = value_convert(value)
self._attr_native_value = value
self._attr_native_min_value = (
@@ -133,7 +133,7 @@ class MatterLevelControlNumber(MatterEntity, NumberEntity):
async def async_set_native_value(self, value: float) -> None:
"""Set level value."""
send_value = int(value)
if value_convert := self.entity_description.ha_to_device:
if value_convert := self.entity_description.ha_to_native_value:
send_value = value_convert(value)
await self.send_device_command(
clusters.LevelControl.Commands.MoveToLevel(
@@ -145,7 +145,7 @@ class MatterLevelControlNumber(MatterEntity, NumberEntity):
def _update_from_device(self) -> None:
"""Update from device."""
value = self.get_matter_attribute_value(self._entity_info.primary_attribute)
if value_convert := self.entity_description.device_to_ha:
if value_convert := self.entity_description.measurement_to_ha:
value = value_convert(value)
self._attr_native_value = value
@@ -162,8 +162,8 @@ DISCOVERY_SCHEMAS = [
native_min_value=0,
mode=NumberMode.BOX,
# use 255 to indicate that the value should revert to the default
device_to_ha=lambda x: 255 if x is None else x,
ha_to_device=lambda x: None if x == 255 else int(x),
measurement_to_ha=lambda x: 255 if x is None else x,
ha_to_native_value=lambda x: None if x == 255 else int(x),
native_step=1,
native_unit_of_measurement=None,
),
@@ -180,8 +180,8 @@ DISCOVERY_SCHEMAS = [
translation_key="on_transition_time",
native_max_value=65534,
native_min_value=0,
device_to_ha=lambda x: None if x is None else x / 10,
ha_to_device=lambda x: round(x * 10),
measurement_to_ha=lambda x: None if x is None else x / 10,
ha_to_native_value=lambda x: round(x * 10),
native_step=0.1,
native_unit_of_measurement=UnitOfTime.SECONDS,
mode=NumberMode.BOX,
@@ -199,8 +199,8 @@ DISCOVERY_SCHEMAS = [
translation_key="off_transition_time",
native_max_value=65534,
native_min_value=0,
device_to_ha=lambda x: None if x is None else x / 10,
ha_to_device=lambda x: round(x * 10),
measurement_to_ha=lambda x: None if x is None else x / 10,
ha_to_native_value=lambda x: round(x * 10),
native_step=0.1,
native_unit_of_measurement=UnitOfTime.SECONDS,
mode=NumberMode.BOX,
@@ -218,8 +218,8 @@ DISCOVERY_SCHEMAS = [
translation_key="on_off_transition_time",
native_max_value=65534,
native_min_value=0,
device_to_ha=lambda x: None if x is None else x / 10,
ha_to_device=lambda x: round(x * 10),
measurement_to_ha=lambda x: None if x is None else x / 10,
ha_to_native_value=lambda x: round(x * 10),
native_step=0.1,
native_unit_of_measurement=UnitOfTime.SECONDS,
mode=NumberMode.BOX,
@@ -256,8 +256,8 @@ DISCOVERY_SCHEMAS = [
native_min_value=-50,
native_step=0.5,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_to_ha=lambda x: None if x is None else x / 10,
ha_to_device=lambda x: round(x * 10),
measurement_to_ha=lambda x: None if x is None else x / 10,
ha_to_native_value=lambda x: round(x * 10),
mode=NumberMode.BOX,
),
entity_class=MatterNumber,
@@ -275,10 +275,10 @@ DISCOVERY_SCHEMAS = [
native_max_value=100,
native_min_value=0.5,
native_step=0.5,
device_to_ha=(
measurement_to_ha=(
lambda x: None if x is None else x / 2 # Matter range (1-200)
),
ha_to_device=lambda x: round(x * 2), # HA range 0.5100.0%
ha_to_native_value=lambda x: round(x * 2), # HA range 0.5100.0%
mode=NumberMode.SLIDER,
),
entity_class=MatterLevelControlNumber,
@@ -326,8 +326,8 @@ DISCOVERY_SCHEMAS = [
targetTemperature=value
),
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_to_ha=lambda x: None if x is None else x / 100,
ha_to_device=lambda x: round(x * 100),
measurement_to_ha=lambda x: None if x is None else x / 100,
ha_to_native_value=lambda x: round(x * 100),
min_attribute=clusters.TemperatureControl.Attributes.MinTemperature,
max_attribute=clusters.TemperatureControl.Attributes.MaxTemperature,
mode=NumberMode.SLIDER,
+17 -17
View File
@@ -71,8 +71,8 @@ class MatterSelectEntityDescription(SelectEntityDescription, MatterEntityDescrip
class MatterMapSelectEntityDescription(MatterSelectEntityDescription):
"""Describe Matter select entities for MatterMapSelectEntityDescription."""
device_to_ha: Callable[[int], str | None]
ha_to_device: Callable[[str], int | None]
measurement_to_ha: Callable[[int], str | None]
ha_to_native_value: Callable[[str], int | None]
# list attribute: the attribute descriptor to get the list of values (= list of integers)
list_attribute: type[ClusterAttributeDescriptor]
@@ -97,7 +97,7 @@ class MatterAttributeSelectEntity(MatterEntity, SelectEntity):
async def async_select_option(self, option: str) -> None:
"""Change the selected mode."""
value_convert = self.entity_description.ha_to_device
value_convert = self.entity_description.ha_to_native_value
if TYPE_CHECKING:
assert value_convert is not None
await self.write_attribute(
@@ -109,7 +109,7 @@ class MatterAttributeSelectEntity(MatterEntity, SelectEntity):
"""Update from device."""
value: Nullable | int | None
value = self.get_matter_attribute_value(self._entity_info.primary_attribute)
value_convert = self.entity_description.device_to_ha
value_convert = self.entity_description.measurement_to_ha
if TYPE_CHECKING:
assert value_convert is not None
self._attr_current_option = value_convert(value)
@@ -132,7 +132,7 @@ class MatterMapSelectEntity(MatterAttributeSelectEntity):
self._attr_options = [
mapped_value
for value in available_values
if (mapped_value := self.entity_description.device_to_ha(value))
if (mapped_value := self.entity_description.measurement_to_ha(value))
]
# use base implementation from MatterAttributeSelectEntity to set the current option
super()._update_from_device()
@@ -333,13 +333,13 @@ DISCOVERY_SCHEMAS = [
entity_category=EntityCategory.CONFIG,
translation_key="startup_on_off",
options=["on", "off", "toggle", "previous"],
device_to_ha={
measurement_to_ha={
0: "off",
1: "on",
2: "toggle",
None: "previous",
}.get,
ha_to_device={
ha_to_native_value={
"off": 0,
"on": 1,
"toggle": 2,
@@ -358,12 +358,12 @@ DISCOVERY_SCHEMAS = [
entity_category=EntityCategory.CONFIG,
translation_key="sensitivity_level",
options=["high", "standard", "low"],
device_to_ha={
measurement_to_ha={
0: "high",
1: "standard",
2: "low",
}.get,
ha_to_device={
ha_to_native_value={
"high": 0,
"standard": 1,
"low": 2,
@@ -379,11 +379,11 @@ DISCOVERY_SCHEMAS = [
entity_category=EntityCategory.CONFIG,
translation_key="temperature_display_mode",
options=["Celsius", "Fahrenheit"],
device_to_ha={
measurement_to_ha={
0: "Celsius",
1: "Fahrenheit",
}.get,
ha_to_device={
ha_to_native_value={
"Celsius": 0,
"Fahrenheit": 1,
}.get,
@@ -432,8 +432,8 @@ DISCOVERY_SCHEMAS = [
key="MatterLaundryWasherNumberOfRinses",
translation_key="laundry_washer_number_of_rinses",
list_attribute=clusters.LaundryWasherControls.Attributes.SupportedRinses,
device_to_ha=NUMBER_OF_RINSES_STATE_MAP.get,
ha_to_device=NUMBER_OF_RINSES_STATE_MAP_REVERSE.get,
measurement_to_ha=NUMBER_OF_RINSES_STATE_MAP.get,
ha_to_native_value=NUMBER_OF_RINSES_STATE_MAP_REVERSE.get,
),
entity_class=MatterMapSelectEntity,
required_attributes=(
@@ -450,13 +450,13 @@ DISCOVERY_SCHEMAS = [
entity_category=EntityCategory.CONFIG,
translation_key="door_lock_sound_volume",
options=["silent", "low", "medium", "high"],
device_to_ha={
measurement_to_ha={
0: "silent",
1: "low",
3: "medium",
2: "high",
}.get,
ha_to_device={
ha_to_native_value={
"silent": 0,
"low": 1,
"medium": 3,
@@ -472,8 +472,8 @@ DISCOVERY_SCHEMAS = [
key="PumpConfigurationAndControlOperationMode",
translation_key="pump_operation_mode",
options=list(PUMP_OPERATION_MODE_MAP.values()),
device_to_ha=PUMP_OPERATION_MODE_MAP.get,
ha_to_device=PUMP_OPERATION_MODE_MAP_REVERSE.get,
measurement_to_ha=PUMP_OPERATION_MODE_MAP.get,
ha_to_native_value=PUMP_OPERATION_MODE_MAP_REVERSE.get,
),
entity_class=MatterAttributeSelectEntity,
required_attributes=(
+23 -23
View File
@@ -194,7 +194,7 @@ class MatterSensor(MatterEntity, SensorEntity):
value = self.get_matter_attribute_value(self._entity_info.primary_attribute)
if value in (None, NullValue):
value = None
elif value_convert := self.entity_description.device_to_ha:
elif value_convert := self.entity_description.measurement_to_ha:
value = value_convert(value)
self._attr_native_value = value
@@ -296,7 +296,7 @@ DISCOVERY_SCHEMAS = [
key="TemperatureSensor",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
device_to_ha=lambda x: x / 100,
measurement_to_ha=lambda x: x / 100,
state_class=SensorStateClass.MEASUREMENT,
),
entity_class=MatterSensor,
@@ -308,7 +308,7 @@ DISCOVERY_SCHEMAS = [
key="PressureSensor",
native_unit_of_measurement=UnitOfPressure.KPA,
device_class=SensorDeviceClass.PRESSURE,
device_to_ha=lambda x: x / 10,
measurement_to_ha=lambda x: x / 10,
state_class=SensorStateClass.MEASUREMENT,
),
entity_class=MatterSensor,
@@ -320,7 +320,7 @@ DISCOVERY_SCHEMAS = [
key="FlowSensor",
native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
translation_key="flow",
device_to_ha=lambda x: x / 10,
measurement_to_ha=lambda x: x / 10,
state_class=SensorStateClass.MEASUREMENT,
),
entity_class=MatterSensor,
@@ -332,7 +332,7 @@ DISCOVERY_SCHEMAS = [
key="HumiditySensor",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
device_to_ha=lambda x: x / 100,
measurement_to_ha=lambda x: x / 100,
state_class=SensorStateClass.MEASUREMENT,
),
entity_class=MatterSensor,
@@ -346,7 +346,7 @@ DISCOVERY_SCHEMAS = [
key="LightSensor",
native_unit_of_measurement=LIGHT_LUX,
device_class=SensorDeviceClass.ILLUMINANCE,
device_to_ha=lambda x: round(pow(10, ((x - 1) / 10000)), 1),
measurement_to_ha=lambda x: round(pow(10, ((x - 1) / 10000)), 1),
state_class=SensorStateClass.MEASUREMENT,
),
entity_class=MatterSensor,
@@ -360,7 +360,7 @@ DISCOVERY_SCHEMAS = [
device_class=SensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
# value has double precision
device_to_ha=lambda x: int(x / 2),
measurement_to_ha=lambda x: int(x / 2),
state_class=SensorStateClass.MEASUREMENT,
),
entity_class=MatterSensor,
@@ -402,7 +402,7 @@ DISCOVERY_SCHEMAS = [
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
options=[state for state in CHARGE_STATE_MAP.values() if state is not None],
device_to_ha=CHARGE_STATE_MAP.get,
measurement_to_ha=CHARGE_STATE_MAP.get,
),
entity_class=MatterSensor,
required_attributes=(clusters.PowerSource.Attributes.BatChargeState,),
@@ -589,7 +589,7 @@ DISCOVERY_SCHEMAS = [
state_class=None,
# convert to set first to remove the duplicate unknown value
options=[x for x in AIR_QUALITY_MAP.values() if x is not None],
device_to_ha=lambda x: AIR_QUALITY_MAP[x],
measurement_to_ha=lambda x: AIR_QUALITY_MAP[x],
),
entity_class=MatterSensor,
required_attributes=(clusters.AirQuality.Attributes.AirQuality,),
@@ -668,7 +668,7 @@ DISCOVERY_SCHEMAS = [
native_unit_of_measurement=UnitOfPower.WATT,
suggested_display_precision=2,
state_class=SensorStateClass.MEASUREMENT,
device_to_ha=lambda x: x / 1000,
measurement_to_ha=lambda x: x / 1000,
),
entity_class=MatterSensor,
required_attributes=(
@@ -685,7 +685,7 @@ DISCOVERY_SCHEMAS = [
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_display_precision=3,
state_class=SensorStateClass.TOTAL_INCREASING,
device_to_ha=lambda x: x / 1000,
measurement_to_ha=lambda x: x / 1000,
),
entity_class=MatterSensor,
required_attributes=(
@@ -702,7 +702,7 @@ DISCOVERY_SCHEMAS = [
native_unit_of_measurement=UnitOfPower.WATT,
suggested_display_precision=2,
state_class=SensorStateClass.MEASUREMENT,
device_to_ha=lambda x: x / 10,
measurement_to_ha=lambda x: x / 10,
),
entity_class=MatterSensor,
required_attributes=(NeoCluster.Attributes.Watt,),
@@ -731,7 +731,7 @@ DISCOVERY_SCHEMAS = [
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
suggested_display_precision=0,
state_class=SensorStateClass.MEASUREMENT,
device_to_ha=lambda x: x / 10,
measurement_to_ha=lambda x: x / 10,
),
entity_class=MatterSensor,
required_attributes=(NeoCluster.Attributes.Voltage,),
@@ -823,7 +823,7 @@ DISCOVERY_SCHEMAS = [
suggested_display_precision=3,
state_class=SensorStateClass.TOTAL_INCREASING,
# id 0 of the EnergyMeasurementStruct is the cumulative energy (in mWh)
device_to_ha=lambda x: x.energy,
measurement_to_ha=lambda x: x.energy,
),
entity_class=MatterSensor,
required_attributes=(
@@ -842,7 +842,7 @@ DISCOVERY_SCHEMAS = [
suggested_display_precision=3,
state_class=SensorStateClass.TOTAL_INCREASING,
# id 0 of the EnergyMeasurementStruct is the cumulative energy (in mWh)
device_to_ha=lambda x: x.energy,
measurement_to_ha=lambda x: x.energy,
),
entity_class=MatterSensor,
required_attributes=(
@@ -910,7 +910,7 @@ DISCOVERY_SCHEMAS = [
translation_key="contamination_state",
device_class=SensorDeviceClass.ENUM,
options=list(CONTAMINATION_STATE_MAP.values()),
device_to_ha=CONTAMINATION_STATE_MAP.get,
measurement_to_ha=CONTAMINATION_STATE_MAP.get,
),
entity_class=MatterSensor,
required_attributes=(clusters.SmokeCoAlarm.Attributes.ContaminationState,),
@@ -922,7 +922,7 @@ DISCOVERY_SCHEMAS = [
translation_key="expiry_date",
device_class=SensorDeviceClass.TIMESTAMP,
# raw value is epoch seconds
device_to_ha=datetime.fromtimestamp,
measurement_to_ha=datetime.fromtimestamp,
),
entity_class=MatterSensor,
required_attributes=(clusters.SmokeCoAlarm.Attributes.ExpiryDate,),
@@ -993,7 +993,7 @@ DISCOVERY_SCHEMAS = [
key="ThermostatLocalTemperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
device_to_ha=lambda x: x / 100,
measurement_to_ha=lambda x: x / 100,
state_class=SensorStateClass.MEASUREMENT,
),
entity_class=MatterSensor,
@@ -1044,7 +1044,7 @@ DISCOVERY_SCHEMAS = [
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
translation_key="window_covering_target_position",
device_to_ha=lambda x: round((10000 - x) / 100),
measurement_to_ha=lambda x: round((10000 - x) / 100),
native_unit_of_measurement=PERCENTAGE,
),
entity_class=MatterSensor,
@@ -1060,7 +1060,7 @@ DISCOVERY_SCHEMAS = [
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
options=list(EVSE_FAULT_STATE_MAP.values()),
device_to_ha=EVSE_FAULT_STATE_MAP.get,
measurement_to_ha=EVSE_FAULT_STATE_MAP.get,
),
entity_class=MatterSensor,
required_attributes=(clusters.EnergyEvse.Attributes.FaultState,),
@@ -1173,7 +1173,7 @@ DISCOVERY_SCHEMAS = [
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
options=list(ESA_STATE_MAP.values()),
device_to_ha=ESA_STATE_MAP.get,
measurement_to_ha=ESA_STATE_MAP.get,
),
entity_class=MatterSensor,
required_attributes=(clusters.DeviceEnergyManagement.Attributes.ESAState,),
@@ -1186,7 +1186,7 @@ DISCOVERY_SCHEMAS = [
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
options=list(DEM_OPT_OUT_STATE_MAP.values()),
device_to_ha=DEM_OPT_OUT_STATE_MAP.get,
measurement_to_ha=DEM_OPT_OUT_STATE_MAP.get,
),
entity_class=MatterSensor,
required_attributes=(clusters.DeviceEnergyManagement.Attributes.OptOutState,),
@@ -1200,7 +1200,7 @@ DISCOVERY_SCHEMAS = [
options=[
mode for mode in PUMP_CONTROL_MODE_MAP.values() if mode is not None
],
device_to_ha=PUMP_CONTROL_MODE_MAP.get,
measurement_to_ha=PUMP_CONTROL_MODE_MAP.get,
),
entity_class=MatterSensor,
required_attributes=(
+6 -6
View File
@@ -95,7 +95,7 @@ class MatterGenericCommandSwitch(MatterSwitch):
def _update_from_device(self) -> None:
"""Update from device."""
value = self.get_matter_attribute_value(self._entity_info.primary_attribute)
if value_convert := self.entity_description.device_to_ha:
if value_convert := self.entity_description.measurement_to_ha:
value = value_convert(value)
self._attr_is_on = value
@@ -141,7 +141,7 @@ class MatterNumericSwitch(MatterSwitch):
async def _async_set_native_value(self, value: bool) -> None:
"""Update the current value."""
if value_convert := self.entity_description.ha_to_device:
if value_convert := self.entity_description.ha_to_native_value:
send_value = value_convert(value)
await self.write_attribute(
value=send_value,
@@ -159,7 +159,7 @@ class MatterNumericSwitch(MatterSwitch):
def _update_from_device(self) -> None:
"""Update from device."""
value = self.get_matter_attribute_value(self._entity_info.primary_attribute)
if value_convert := self.entity_description.device_to_ha:
if value_convert := self.entity_description.measurement_to_ha:
value = value_convert(value)
self._attr_is_on = value
@@ -248,11 +248,11 @@ DISCOVERY_SCHEMAS = [
key="EveTrvChildLock",
entity_category=EntityCategory.CONFIG,
translation_key="child_lock",
device_to_ha={
measurement_to_ha={
0: False,
1: True,
}.get,
ha_to_device={
ha_to_native_value={
False: 0,
True: 1,
}.get,
@@ -275,7 +275,7 @@ DISCOVERY_SCHEMAS = [
),
off_command=clusters.EnergyEvse.Commands.Disable,
command_timeout=3000,
device_to_ha=EVSE_SUPPLY_STATE_MAP.get,
measurement_to_ha=EVSE_SUPPLY_STATE_MAP.get,
),
entity_class=MatterGenericCommandSwitch,
required_attributes=(
@@ -45,16 +45,6 @@ class MediaSourceItem:
identifier: str
target_media_player: str | None
@property
def media_source_id(self) -> str:
"""Return the media source ID."""
uri = URI_SCHEME
if self.domain:
uri += self.domain
if self.identifier:
uri += f"/{self.identifier}"
return uri
async def async_browse(self) -> BrowseMediaSource:
"""Browse this item."""
if self.domain is None:
@@ -134,7 +134,7 @@ class MediaroomDevice(MediaPlayerEntity):
state_map = {
State.OFF: MediaPlayerState.OFF,
State.STANDBY: MediaPlayerState.IDLE,
State.STANDBY: MediaPlayerState.STANDBY,
State.PLAYING_LIVE_TV: MediaPlayerState.PLAYING,
State.PLAYING_RECORDED_TV: MediaPlayerState.PLAYING,
State.PLAYING_TIMESHIFT_TV: MediaPlayerState.PLAYING,
@@ -155,7 +155,7 @@ class MediaroomDevice(MediaPlayerEntity):
self._channel = None
self._optimistic = optimistic
self._attr_state = (
MediaPlayerState.PLAYING if optimistic else MediaPlayerState.IDLE
MediaPlayerState.PLAYING if optimistic else MediaPlayerState.STANDBY
)
self._name = f"Mediaroom {device_id if device_id else host}"
self._available = True
@@ -254,7 +254,7 @@ class MediaroomDevice(MediaPlayerEntity):
try:
self.set_state(await self.stb.turn_off())
if self._optimistic:
self._attr_state = MediaPlayerState.IDLE
self._attr_state = MediaPlayerState.STANDBY
self._available = True
except PyMediaroomError:
self._available = False
@@ -6,24 +6,12 @@
"utility": "Utility name",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"utility": "The name of your utility provider",
"username": "The username for your utility account",
"password": "The password for your utility account"
}
},
"mfa": {
"description": "The TOTP secret below is not one of the 6-digit time-based numeric codes. It is a string of around 16 characters containing the shared secret that enables your authenticator app to generate the correct time-based code at the appropriate time. See the documentation.",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"totp_secret": "TOTP secret"
},
"data_description": {
"username": "[%key:component::opower::config::step::user::data_description::username%]",
"password": "[%key:component::opower::config::step::user::data_description::password%]",
"totp_secret": "The TOTP secret for your utility account, used for multi-factor authentication (MFA)."
}
},
"reauth_confirm": {
@@ -32,11 +20,6 @@
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"totp_secret": "[%key:component::opower::config::step::mfa::data::totp_secret%]"
},
"data_description": {
"username": "[%key:component::opower::config::step::user::data_description::username%]",
"password": "[%key:component::opower::config::step::user::data_description::password%]",
"totp_secret": "The TOTP secret for your utility account, used for multi-factor authentication (MFA)."
}
}
},
@@ -6,13 +6,7 @@ from collections.abc import Mapping
import platform
from typing import Any
from haphilipsjs import (
DEFAULT_API_VERSION,
ConnectionFailure,
GeneralFailure,
PairingFailure,
PhilipsTV,
)
from haphilipsjs import ConnectionFailure, PairingFailure, PhilipsTV
import voluptuous as vol
from homeassistant.config_entries import (
@@ -24,18 +18,16 @@ from homeassistant.config_entries import (
from homeassistant.const import (
CONF_API_VERSION,
CONF_HOST,
CONF_NAME,
CONF_PASSWORD,
CONF_PIN,
CONF_USERNAME,
)
from homeassistant.core import callback
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import selector
from homeassistant.helpers.schema_config_entry_flow import (
SchemaFlowFormStep,
SchemaOptionsFlowHandler,
)
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import LOGGER
from .const import CONF_ALLOW_NOTIFY, CONF_SYSTEM, CONST_APP_ID, CONST_APP_NAME, DOMAIN
@@ -62,6 +54,21 @@ OPTIONS_FLOW = {
}
async def _validate_input(
hass: HomeAssistant, host: str, api_version: int
) -> PhilipsTV:
"""Validate the user input allows us to connect."""
hub = PhilipsTV(host, api_version)
await hub.getSystem()
await hub.setTransport(hub.secured_transport)
if not hub.system:
raise ConnectionFailure("System data is empty")
return hub
class PhilipsJSConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Philips TV."""
@@ -74,38 +81,6 @@ class PhilipsJSConfigFlow(ConfigFlow, domain=DOMAIN):
self._hub: PhilipsTV | None = None
self._pair_state: Any = None
async def _async_attempt_prepare(
self, host: str, api_version: int, secured_transport: bool
) -> None:
hub = PhilipsTV(
host, api_version=api_version, secured_transport=secured_transport
)
await hub.getSystem()
await hub.setTransport(hub.secured_transport)
if not hub.system or not hub.name:
raise ConnectionFailure("System data or name is empty")
self._hub = hub
self._current[CONF_HOST] = host
self._current[CONF_SYSTEM] = hub.system
self._current[CONF_API_VERSION] = hub.api_version
self.context.update({"title_placeholders": {CONF_NAME: hub.name}})
if serialnumber := hub.system.get("serialnumber"):
await self.async_set_unique_id(serialnumber)
if self.source != SOURCE_REAUTH:
self._abort_if_unique_id_configured(
updates=self._current, reload_on_update=True
)
async def _async_attempt_add(self) -> ConfigFlowResult:
assert self._hub
if self._hub.pairing_type == "digest_auth_pairing":
return await self.async_step_pair()
return await self._async_create_current()
async def _async_create_current(self) -> ConfigFlowResult:
system = self._current[CONF_SYSTEM]
if self.source == SOURCE_REAUTH:
@@ -179,43 +154,6 @@ class PhilipsJSConfigFlow(ConfigFlow, domain=DOMAIN):
self._current[CONF_API_VERSION] = entry_data[CONF_API_VERSION]
return await self.async_step_user()
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
LOGGER.debug(
"Checking discovered device: {discovery_info.name} on {discovery_info.host}"
)
secured_transport = discovery_info.type == "_philipstv_s_rpc._tcp.local."
api_version = 6 if secured_transport else DEFAULT_API_VERSION
try:
await self._async_attempt_prepare(
discovery_info.host, api_version, secured_transport
)
except GeneralFailure:
LOGGER.debug("Failed to get system info from discovery", exc_info=True)
return self.async_abort(reason="discovery_failure")
return await self.async_step_zeroconf_confirm()
async def async_step_zeroconf_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initiated by zeroconf."""
if user_input is not None:
return await self._async_attempt_add()
name = self.context.get("title_placeholders", {CONF_NAME: "Philips TV"})[
CONF_NAME
]
return self.async_show_form(
step_id="zeroconf_confirm",
description_placeholders={CONF_NAME: name},
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -224,14 +162,28 @@ class PhilipsJSConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input:
self._current = user_input
try:
await self._async_attempt_prepare(
user_input[CONF_HOST], user_input[CONF_API_VERSION], False
hub = await _validate_input(
self.hass, user_input[CONF_HOST], user_input[CONF_API_VERSION]
)
except GeneralFailure as exc:
except ConnectionFailure as exc:
LOGGER.error(exc)
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return await self._async_attempt_add()
if serialnumber := hub.system.get("serialnumber"):
await self.async_set_unique_id(serialnumber)
if self.source != SOURCE_REAUTH:
self._abort_if_unique_id_configured()
self._current[CONF_SYSTEM] = hub.system
self._current[CONF_API_VERSION] = hub.api_version
self._hub = hub
if hub.pairing_type == "digest_auth_pairing":
return await self.async_step_pair()
return await self._async_create_current()
schema = self.add_suggested_values_to_schema(USER_SCHEMA, self._current)
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/philips_js",
"iot_class": "local_polling",
"loggers": ["haphilipsjs"],
"requirements": ["ha-philipsjs==3.2.2"],
"zeroconf": ["_philipstv_s_rpc._tcp.local.", "_philipstv_rpc._tcp.local."]
"requirements": ["ha-philipsjs==3.2.2"]
}
@@ -1,6 +1,5 @@
{
"config": {
"flow_title": "{name}",
"step": {
"user": {
"data": {
@@ -8,10 +7,6 @@
"api_version": "API Version"
}
},
"zeroconf_confirm": {
"title": "Discovered Philips TV",
"description": "Do you want to add the TV ({name}) to Home Assistant? It will turn on and a pairing code will be displayed on it that you will need to enter in the next screen."
},
"pair": {
"title": "Pair",
"description": "Enter the PIN displayed on your TV",
+2 -2
View File
@@ -191,7 +191,7 @@ class PS4Device(MediaPlayerEntity):
)
elif self.state != MediaPlayerState.IDLE:
self.idle()
elif self.state != MediaPlayerState.OFF:
elif self.state != MediaPlayerState.STANDBY:
self.state_standby()
elif self._retry > DEFAULT_RETRIES:
@@ -223,7 +223,7 @@ class PS4Device(MediaPlayerEntity):
def state_standby(self) -> None:
"""Set states for state standby."""
self.reset_title()
self._attr_state = MediaPlayerState.OFF
self._attr_state = MediaPlayerState.STANDBY
def state_unknown(self) -> None:
"""Set states for state unknown."""
@@ -50,8 +50,6 @@ class RadioMediaSource(MediaSource):
@property
def radios(self) -> RadioBrowser:
"""Return the radio browser."""
if not hasattr(self.entry, "runtime_data") or self.entry.runtime_data is None:
raise Unresolvable("Radio Browser integration not properly loaded")
return self.entry.runtime_data
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
@@ -142,7 +142,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity):
def state(self) -> MediaPlayerState | None:
"""Return the state of the device."""
if self.coordinator.data.state.standby:
return MediaPlayerState.OFF
return MediaPlayerState.STANDBY
if self.coordinator.data.app is None:
return None
@@ -308,21 +308,21 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity):
@roku_exception_handler()
async def async_media_pause(self) -> None:
"""Send pause command."""
if self.state not in {MediaPlayerState.OFF, MediaPlayerState.PAUSED}:
if self.state not in {MediaPlayerState.STANDBY, MediaPlayerState.PAUSED}:
await self.coordinator.roku.remote("play")
await self.coordinator.async_request_refresh()
@roku_exception_handler()
async def async_media_play(self) -> None:
"""Send play command."""
if self.state not in {MediaPlayerState.OFF, MediaPlayerState.PLAYING}:
if self.state not in {MediaPlayerState.STANDBY, MediaPlayerState.PLAYING}:
await self.coordinator.roku.remote("play")
await self.coordinator.async_request_refresh()
@roku_exception_handler()
async def async_media_play_pause(self) -> None:
"""Send play/pause command."""
if self.state != MediaPlayerState.OFF:
if self.state != MediaPlayerState.STANDBY:
await self.coordinator.roku.remote("play")
await self.coordinator.async_request_refresh()
+3 -4
View File
@@ -31,7 +31,7 @@ async def async_setup_entry(
if dev_id in event_entities:
return
# new player!
event_entity = RoonEventEntity(roon_server, player_data, config_entry.entry_id)
event_entity = RoonEventEntity(roon_server, player_data)
event_entities.add(dev_id)
async_add_entities([event_entity])
@@ -50,14 +50,13 @@ class RoonEventEntity(EventEntity):
_attr_event_types = ["volume_up", "volume_down", "mute_toggle"]
_attr_translation_key = "volume"
def __init__(self, server, player_data, entry_id):
def __init__(self, server, player_data):
"""Initialize the entity."""
self._server = server
self._player_data = player_data
player_name = player_data["display_name"]
self._attr_name = f"{player_name} roon volume"
self._attr_unique_id = self._player_data["dev_id"]
self._entry_id = entry_id
if self._player_data.get("source_controls"):
dev_model = self._player_data["source_controls"][0].get("display_name")
@@ -70,7 +69,7 @@ class RoonEventEntity(EventEntity):
name=cast(str | None, self.name),
manufacturer="RoonLabs",
model=dev_model,
via_device=(DOMAIN, self._entry_id),
via_device=(DOMAIN, self._server.roon_id),
)
def _roonapi_volume_callback(
@@ -72,7 +72,7 @@ async def async_setup_entry(
dev_id = player_data["dev_id"]
if dev_id not in media_players:
# new player!
media_player = RoonDevice(roon_server, player_data, config_entry.entry_id)
media_player = RoonDevice(roon_server, player_data)
media_players.add(dev_id)
async_add_entities([media_player])
else:
@@ -106,7 +106,7 @@ class RoonDevice(MediaPlayerEntity):
| MediaPlayerEntityFeature.PLAY_MEDIA
)
def __init__(self, server, player_data, entry_id):
def __init__(self, server, player_data):
"""Initialize Roon device object."""
self._remove_signal_status = None
self._server = server
@@ -125,7 +125,6 @@ class RoonDevice(MediaPlayerEntity):
self._attr_volume_level = 0
self._volume_fixed = True
self._volume_incremental = False
self._entry_id = entry_id
self.update_data(player_data)
async def async_added_to_hass(self) -> None:
@@ -167,7 +166,7 @@ class RoonDevice(MediaPlayerEntity):
name=cast(str | None, self.name),
manufacturer="RoonLabs",
model=dev_model,
via_device=(DOMAIN, self._entry_id),
via_device=(DOMAIN, self._server.roon_id),
)
def update_data(self, player_data=None):
@@ -343,7 +343,7 @@ class SnapcastClientDevice(SnapcastBaseDevice):
if self.is_volume_muted or self._current_group.muted:
return MediaPlayerState.IDLE
return STREAM_STATUS.get(self._current_group.stream_status)
return MediaPlayerState.OFF
return MediaPlayerState.STANDBY
@property
def extra_state_attributes(self) -> Mapping[str, Any]:
+3 -3
View File
@@ -374,7 +374,9 @@ class TelegramNotificationService:
}
if data is not None:
if ATTR_PARSER in data:
params[ATTR_PARSER] = data[ATTR_PARSER]
params[ATTR_PARSER] = self._parsers.get(
data[ATTR_PARSER], self.parse_mode
)
if ATTR_TIMEOUT in data:
params[ATTR_TIMEOUT] = data[ATTR_TIMEOUT]
if ATTR_DISABLE_NOTIF in data:
@@ -406,8 +408,6 @@ class TelegramNotificationService:
params[ATTR_REPLYMARKUP] = InlineKeyboardMarkup(
[_make_row_inline_keyboard(row) for row in keys]
)
if params[ATTR_PARSER] == PARSER_PLAIN_TEXT:
params[ATTR_PARSER] = None
return params
async def _send_msg(
@@ -159,6 +159,8 @@ class OptionsFlowHandler(OptionsFlow):
"""Manage the options."""
if user_input is not None:
if user_input[ATTR_PARSER] == PARSER_PLAIN_TEXT:
user_input[ATTR_PARSER] = None
return self.async_create_entry(data=user_input)
return self.async_show_form(
@@ -109,7 +109,6 @@ send_photo:
- "markdown"
- "markdownv2"
- "plain_text"
translation_key: "parse_mode"
disable_notification:
selector:
boolean:
@@ -262,7 +261,6 @@ send_animation:
- "markdown"
- "markdownv2"
- "plain_text"
translation_key: "parse_mode"
disable_notification:
selector:
boolean:
@@ -343,7 +341,6 @@ send_video:
- "markdown"
- "markdownv2"
- "plain_text"
translation_key: "parse_mode"
disable_notification:
selector:
boolean:
@@ -496,7 +493,6 @@ send_document:
- "markdown"
- "markdownv2"
- "plain_text"
translation_key: "parse_mode"
disable_notification:
selector:
boolean:
@@ -674,7 +670,6 @@ edit_message:
- "markdown"
- "markdownv2"
- "plain_text"
translation_key: "parse_mode"
disable_web_page_preview:
selector:
boolean:
@@ -31,7 +31,7 @@ from .const import (
EVENTS,
LOGGER,
)
from .helpers import get_device, get_first_geofence, get_geofence_ids
from .helpers import get_device, get_first_geofence
class TraccarServerCoordinatorDataDevice(TypedDict):
@@ -131,7 +131,7 @@ class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorDat
"device": device,
"geofence": get_first_geofence(
geofences,
get_geofence_ids(device, position),
position["geofenceIds"] or [],
),
"position": position,
"attributes": attr,
@@ -187,7 +187,7 @@ class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorDat
self.data[device_id]["attributes"] = attr
self.data[device_id]["geofence"] = get_first_geofence(
self._geofences,
get_geofence_ids(self.data[device_id]["device"], position),
position["geofenceIds"] or [],
)
update_devices.add(device_id)
@@ -2,7 +2,7 @@
from __future__ import annotations
from pytraccar import DeviceModel, GeofenceModel, PositionModel
from pytraccar import DeviceModel, GeofenceModel
def get_device(device_id: int, devices: list[DeviceModel]) -> DeviceModel | None:
@@ -22,17 +22,3 @@ def get_first_geofence(
(geofence for geofence in geofences if geofence["id"] in target),
None,
)
def get_geofence_ids(
device: DeviceModel,
position: PositionModel,
) -> list[int]:
"""Compatibility helper to return a list of geofence IDs."""
# For Traccar >=5.8 https://github.com/traccar/traccar/commit/30bafaed42e74863c5ca68a33c87f39d1e2de93d
if "geofenceIds" in position:
return position["geofenceIds"] or []
# For Traccar <5.8
if "geofenceIds" in device:
return device["geofenceIds"] or []
return []
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/venstar",
"iot_class": "local_polling",
"loggers": ["venstarcolortouch"],
"requirements": ["venstarcolortouch==0.21"]
"requirements": ["venstarcolortouch==0.19"]
}
@@ -35,10 +35,6 @@ from homeassistant.exceptions import (
Unauthorized,
)
from homeassistant.helpers import config_validation as cv, entity, template
from homeassistant.helpers.condition import (
async_get_all_descriptions as async_get_all_condition_descriptions,
async_subscribe_platform_events as async_subscribe_condition_platform_events,
)
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entityfilter import (
INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA,
@@ -80,7 +76,6 @@ from . import const, decorators, messages
from .connection import ActiveConnection
from .messages import construct_event_message, construct_result_message
ALL_CONDITION_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_condition_descriptions_json"
ALL_SERVICE_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_service_descriptions_json"
ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_trigger_descriptions_json"
@@ -106,7 +101,6 @@ def async_register_commands(
async_reg(hass, handle_ping)
async_reg(hass, handle_render_template)
async_reg(hass, handle_subscribe_bootstrap_integrations)
async_reg(hass, handle_subscribe_condition_platforms)
async_reg(hass, handle_subscribe_events)
async_reg(hass, handle_subscribe_trigger)
async_reg(hass, handle_subscribe_trigger_platforms)
@@ -507,53 +501,6 @@ def _send_handle_entities_init_response(
)
async def _async_get_all_condition_descriptions_json(hass: HomeAssistant) -> bytes:
"""Return JSON of descriptions (i.e. user documentation) for all condition."""
descriptions = await async_get_all_condition_descriptions(hass)
if ALL_CONDITION_DESCRIPTIONS_JSON_CACHE in hass.data:
cached_descriptions, cached_json_payload = hass.data[
ALL_CONDITION_DESCRIPTIONS_JSON_CACHE
]
# If the descriptions are the same, return the cached JSON payload
if cached_descriptions is descriptions:
return cast(bytes, cached_json_payload)
json_payload = json_bytes(
{
condition: description
for condition, description in descriptions.items()
if description is not None
}
)
hass.data[ALL_CONDITION_DESCRIPTIONS_JSON_CACHE] = (descriptions, json_payload)
return json_payload
@decorators.websocket_command({vol.Required("type"): "condition_platforms/subscribe"})
@decorators.async_response
async def handle_subscribe_condition_platforms(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle subscribe conditions command."""
async def on_new_conditions(new_conditions: set[str]) -> None:
"""Forward new conditions to websocket."""
descriptions = await async_get_all_condition_descriptions(hass)
new_condition_descriptions = {}
for condition in new_conditions:
if (description := descriptions[condition]) is not None:
new_condition_descriptions[condition] = description
if not new_condition_descriptions:
return
connection.send_event(msg["id"], new_condition_descriptions)
connection.subscriptions[msg["id"]] = async_subscribe_condition_platform_events(
hass, on_new_conditions
)
connection.send_result(msg["id"])
conditions_json = await _async_get_all_condition_descriptions_json(hass)
connection.send_message(construct_event_message(msg["id"], conditions_json))
async def _async_get_all_service_descriptions_json(hass: HomeAssistant) -> bytes:
"""Return JSON of descriptions (i.e. user documentation) for all service calls."""
descriptions = await async_get_all_service_descriptions(hass)
-10
View File
@@ -771,16 +771,6 @@ ZEROCONF = {
"domain": "onewire",
},
],
"_philipstv_rpc._tcp.local.": [
{
"domain": "philips_js",
},
],
"_philipstv_s_rpc._tcp.local.": [
{
"domain": "philips_js",
},
],
"_plexmediasvr._tcp.local.": [
{
"domain": "plex",
+5 -217
View File
@@ -5,17 +5,19 @@ from __future__ import annotations
import abc
import asyncio
from collections import deque
from collections.abc import Callable, Container, Coroutine, Generator, Iterable
from collections.abc import Callable, Container, Generator
from contextlib import contextmanager
from datetime import datetime, time as dt_time, timedelta
import functools as ft
import logging
import re
import sys
from typing import TYPE_CHECKING, Any, Protocol, cast
from typing import Any, Protocol, cast
import voluptuous as vol
from homeassistant.components import zone as zone_cmp
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_GPS_ACCURACY,
@@ -52,20 +54,11 @@ from homeassistant.exceptions import (
HomeAssistantError,
TemplateError,
)
from homeassistant.loader import (
Integration,
IntegrationNotFound,
async_get_integration,
async_get_integrations,
)
from homeassistant.loader import IntegrationNotFound, async_get_integration
from homeassistant.util import dt as dt_util
from homeassistant.util.async_ import run_callback_threadsafe
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.yaml import load_yaml_dict
from homeassistant.util.yaml.loader import JSON_TYPE
from . import config_validation as cv, entity_registry as er
from .integration_platform import async_process_integration_platforms
from .template import Template, render_complex
from .trace import (
TraceElement,
@@ -83,8 +76,6 @@ ASYNC_FROM_CONFIG_FORMAT = "async_{}_from_config"
FROM_CONFIG_FORMAT = "{}_from_config"
VALIDATE_CONFIG_FORMAT = "{}_validate_config"
_LOGGER = logging.getLogger(__name__)
_PLATFORM_ALIASES: dict[str | None, str | None] = {
"and": None,
"device": "device_automation",
@@ -103,99 +94,6 @@ INPUT_ENTITY_ID = re.compile(
)
CONDITION_DESCRIPTION_CACHE: HassKey[dict[str, dict[str, Any] | None]] = HassKey(
"condition_description_cache"
)
CONDITION_PLATFORM_SUBSCRIPTIONS: HassKey[
list[Callable[[set[str]], Coroutine[Any, Any, None]]]
] = HassKey("condition_platform_subscriptions")
CONDITIONS: HassKey[dict[str, str]] = HassKey("conditions")
# Basic schemas to sanity check the condition descriptions,
# full validation is done by hassfest.conditions
_FIELD_SCHEMA = vol.Schema(
{},
extra=vol.ALLOW_EXTRA,
)
_CONDITION_SCHEMA = vol.Schema(
{
vol.Optional("fields"): vol.Schema({str: _FIELD_SCHEMA}),
},
extra=vol.ALLOW_EXTRA,
)
def starts_with_dot(key: str) -> str:
"""Check if key starts with dot."""
if not key.startswith("."):
raise vol.Invalid("Key does not start with .")
return key
_CONDITIONS_SCHEMA = vol.Schema(
{
vol.Remove(vol.All(str, starts_with_dot)): object,
cv.slug: vol.Any(None, _CONDITION_SCHEMA),
}
)
async def async_setup(hass: HomeAssistant) -> None:
"""Set up the condition helper."""
hass.data[CONDITION_DESCRIPTION_CACHE] = {}
hass.data[CONDITION_PLATFORM_SUBSCRIPTIONS] = []
hass.data[CONDITIONS] = {}
await async_process_integration_platforms(
hass, "condition", _register_condition_platform, wait_for_platforms=True
)
@callback
def async_subscribe_platform_events(
hass: HomeAssistant,
on_event: Callable[[set[str]], Coroutine[Any, Any, None]],
) -> Callable[[], None]:
"""Subscribe to condition platform events."""
condition_platform_event_subscriptions = hass.data[CONDITION_PLATFORM_SUBSCRIPTIONS]
def remove_subscription() -> None:
condition_platform_event_subscriptions.remove(on_event)
condition_platform_event_subscriptions.append(on_event)
return remove_subscription
async def _register_condition_platform(
hass: HomeAssistant, integration_domain: str, platform: ConditionProtocol
) -> None:
"""Register a condition platform."""
new_conditions: set[str] = set()
if hasattr(platform, "async_get_conditions"):
for condition_key in await platform.async_get_conditions(hass):
hass.data[CONDITIONS][condition_key] = integration_domain
new_conditions.add(condition_key)
else:
_LOGGER.debug(
"Integration %s does not provide condition support, skipping",
integration_domain,
)
return
# We don't use gather here because gather adds additional overhead
# when wrapping each coroutine in a task, and we expect our listeners
# to call condition.async_get_all_descriptions which will only yield
# the first time it's called, after that it returns cached data.
for listener in hass.data[CONDITION_PLATFORM_SUBSCRIPTIONS]:
try:
await listener(new_conditions)
except Exception:
_LOGGER.exception("Error while notifying condition platform listener")
class Condition(abc.ABC):
"""Condition class."""
@@ -819,8 +717,6 @@ def time(
for the opposite. "(23:59 <= now < 00:01)" would be the same as
"not (00:01 <= now < 23:59)".
"""
from homeassistant.components.sensor import SensorDeviceClass # noqa: PLC0415
now = dt_util.now()
now_time = now.time()
@@ -928,8 +824,6 @@ def zone(
Async friendly.
"""
from homeassistant.components import zone as zone_cmp # noqa: PLC0415
if zone_ent is None:
raise ConditionErrorMessage("zone", "no zone specified")
@@ -1186,109 +1080,3 @@ def async_extract_devices(config: ConfigType | Template) -> set[str]:
referenced.add(device_id)
return referenced
def _load_conditions_file(hass: HomeAssistant, integration: Integration) -> JSON_TYPE:
"""Load conditions file for an integration."""
try:
return cast(
JSON_TYPE,
_CONDITIONS_SCHEMA(
load_yaml_dict(str(integration.file_path / "conditions.yaml"))
),
)
except FileNotFoundError:
_LOGGER.warning(
"Unable to find conditions.yaml for the %s integration", integration.domain
)
return {}
except (HomeAssistantError, vol.Invalid) as ex:
_LOGGER.warning(
"Unable to parse conditions.yaml for the %s integration: %s",
integration.domain,
ex,
)
return {}
def _load_conditions_files(
hass: HomeAssistant, integrations: Iterable[Integration]
) -> dict[str, JSON_TYPE]:
"""Load condition files for multiple integrations."""
return {
integration.domain: _load_conditions_file(hass, integration)
for integration in integrations
}
async def async_get_all_descriptions(
hass: HomeAssistant,
) -> dict[str, dict[str, Any] | None]:
"""Return descriptions (i.e. user documentation) for all conditions."""
descriptions_cache = hass.data[CONDITION_DESCRIPTION_CACHE]
conditions = hass.data[CONDITIONS]
# See if there are new conditions not seen before.
# Any condition that we saw before already has an entry in description_cache.
all_conditions = set(conditions)
previous_all_conditions = set(descriptions_cache)
# If the conditions are the same, we can return the cache
if previous_all_conditions == all_conditions:
return descriptions_cache
# Files we loaded for missing descriptions
new_conditions_descriptions: dict[str, JSON_TYPE] = {}
# We try to avoid making a copy in the event the cache is good,
# but now we must make a copy in case new conditions get added
# while we are loading the missing ones so we do not
# add the new ones to the cache without their descriptions
conditions = conditions.copy()
if missing_conditions := all_conditions.difference(descriptions_cache):
domains_with_missing_conditions = {
conditions[missing_condition] for missing_condition in missing_conditions
}
ints_or_excs = await async_get_integrations(
hass, domains_with_missing_conditions
)
integrations: list[Integration] = []
for domain, int_or_exc in ints_or_excs.items():
if type(int_or_exc) is Integration and int_or_exc.has_conditions:
integrations.append(int_or_exc)
continue
if TYPE_CHECKING:
assert isinstance(int_or_exc, Exception)
_LOGGER.debug(
"Failed to load conditions.yaml for integration: %s",
domain,
exc_info=int_or_exc,
)
if integrations:
new_conditions_descriptions = await hass.async_add_executor_job(
_load_conditions_files, hass, integrations
)
# Make a copy of the old cache and add missing descriptions to it
new_descriptions_cache = descriptions_cache.copy()
for missing_condition in missing_conditions:
domain = conditions[missing_condition]
if (
yaml_description := new_conditions_descriptions.get(domain, {}).get( # type: ignore[union-attr]
missing_condition
)
) is None:
_LOGGER.debug(
"No condition descriptions found for condition %s, skipping",
missing_condition,
)
new_descriptions_cache[missing_condition] = None
continue
description = {"fields": yaml_description.get("fields", {})}
new_descriptions_cache[missing_condition] = description
hass.data[CONDITION_DESCRIPTION_CACHE] = new_descriptions_cache
return new_descriptions_cache
@@ -1537,6 +1537,22 @@ def STATE_CONDITION_SCHEMA(value: Any) -> dict[str, Any]:
return key_dependency("for", "state")(validated)
SUN_CONDITION_SCHEMA = vol.All(
vol.Schema(
{
**CONDITION_BASE_SCHEMA,
vol.Required(CONF_CONDITION): "sun",
vol.Optional("before"): sun_event,
vol.Optional("before_offset"): time_period,
vol.Optional("after"): vol.All(
vol.Lower, vol.Any(SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE)
),
vol.Optional("after_offset"): time_period,
}
),
has_at_least_one_key("before", "after"),
)
TEMPLATE_CONDITION_SCHEMA = vol.Schema(
{
**CONDITION_BASE_SCHEMA,
+52 -45
View File
@@ -866,17 +866,19 @@ def async_track_state_change_filtered(
) -> _TrackStateChangeFiltered:
"""Track state changes with a TrackStates filter that can be updated.
Args:
hass:
Home assistant object.
track_states:
A TrackStates data class.
action:
Callable to call with results.
Parameters
----------
hass
Home assistant object.
track_states
A TrackStates data class.
action
Callable to call with results.
Returns:
Object used to update the listeners (async_update_listeners) with a new
TrackStates or cancel the tracking (async_remove).
Returns
-------
Object used to update the listeners (async_update_listeners) with a new
TrackStates or cancel the tracking (async_remove).
"""
tracker = _TrackStateChangeFiltered(hass, track_states, action)
@@ -905,26 +907,29 @@ def async_track_template(
exception, the listener will still be registered but will only
fire if the template result becomes true without an exception.
Action args:
entity_id:
ID of the entity that triggered the state change.
old_state:
The old state of the entity that changed.
new_state:
New state of the entity that changed.
Action arguments
----------------
entity_id
ID of the entity that triggered the state change.
old_state
The old state of the entity that changed.
new_state
New state of the entity that changed.
Args:
hass:
Home assistant object.
template:
The template to calculate.
action:
Callable to call with results. See above for arguments.
variables:
Variables to pass to the template.
Parameters
----------
hass
Home assistant object.
template
The template to calculate.
action
Callable to call with results. See above for arguments.
variables
Variables to pass to the template.
Returns:
Callable to unregister the listener.
Returns
-------
Callable to unregister the listener.
"""
job = HassJob(action, f"track template {template}")
@@ -1356,24 +1361,26 @@ def async_track_template_result(
Once the template returns to a non-error condition the result is sent
to the action as usual.
Args:
hass:
Home assistant object.
track_templates:
An iterable of TrackTemplate.
action:
Callable to call with results.
strict:
When set to True, raise on undefined variables.
log_fn:
If not None, template error messages will logging by calling log_fn
instead of the normal logging facility.
has_super_template:
When set to True, the first template will block rendering of other
templates if it doesn't render as True.
Parameters
----------
hass
Home assistant object.
track_templates
An iterable of TrackTemplate.
action
Callable to call with results.
strict
When set to True, raise on undefined variables.
log_fn
If not None, template error messages will logging by calling log_fn
instead of the normal logging facility.
has_super_template
When set to True, the first template will block rendering of other
templates if it doesn't render as True.
Returns:
Info object used to unregister the listener, and refresh the template.
Returns
-------
Info object used to unregister the listener, and refresh the template.
"""
tracker = TrackTemplateResultInfo(hass, track_templates, action, has_super_template)
-5
View File
@@ -193,11 +193,6 @@ def report_usage(
exclude_integrations=exclude_integrations
)
except MissingIntegrationFrame as err:
# We need to be careful with assigning the error here as it affects the
# cleanup of objects referenced from the stack trace as seen in
# https://github.com/home-assistant/core/pull/148021#discussion_r2182379834
# When core_behavior is ReportBehavior.ERROR, we will re-raise the error,
# so we can safely assign it to integration_frame_err.
if core_behavior is ReportBehavior.ERROR:
integration_frame_err = err
_report_usage_partial = functools.partial(
-2
View File
@@ -1066,7 +1066,6 @@ class NumberSelectorConfig(BaseSelectorConfig, total=False):
step: float | Literal["any"]
unit_of_measurement: str
mode: NumberSelectorMode
translation_key: str
class NumberSelectorMode(StrEnum):
@@ -1107,7 +1106,6 @@ class NumberSelector(Selector[NumberSelectorConfig]):
vol.Optional(CONF_MODE, default=NumberSelectorMode.SLIDER): vol.All(
vol.Coerce(NumberSelectorMode), lambda val: val.value
),
vol.Optional("translation_key"): str,
}
),
validate_slider,
-6
View File
@@ -67,7 +67,6 @@ _LOGGER = logging.getLogger(__name__)
#
BASE_PRELOAD_PLATFORMS = [
"backup",
"condition",
"config",
"config_flow",
"diagnostics",
@@ -858,11 +857,6 @@ class Integration:
# True.
return self.manifest.get("import_executor", True)
@cached_property
def has_conditions(self) -> bool:
"""Return if the integration has conditions."""
return "conditions.yaml" in self._top_level_files
@cached_property
def has_services(self) -> bool:
"""Return if the integration has services."""
+1 -1
View File
@@ -38,7 +38,7 @@ habluetooth==3.49.0
hass-nabucasa==0.105.0
hassil==2.2.3
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20250702.1
home-assistant-frontend==20250702.0
home-assistant-intents==2025.6.23
httpx==0.28.1
ifaddr==0.2.0
Generated
-10
View File
@@ -3566,16 +3566,6 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.opower.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.oralb.*]
check_untyped_defs = true
disallow_incomplete_defs = true
-1
View File
@@ -913,5 +913,4 @@ split-on-trailing-comma = false
max-complexity = 25
[tool.ruff.lint.pydocstyle]
convention = "google"
property-decorators = ["propcache.api.cached_property"]
+5 -5
View File
@@ -185,7 +185,7 @@ aioairzone-cloud==0.6.12
aioairzone==1.0.0
# homeassistant.components.alexa_devices
aioamazondevices==3.2.3
aioamazondevices==3.2.2
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -204,7 +204,7 @@ aioaseko==1.0.0
aioasuswrt==1.4.0
# homeassistant.components.husqvarna_automower
aioautomower==1.2.0
aioautomower==1.0.1
# homeassistant.components.azure_devops
aioazuredevops==2.2.1
@@ -1168,7 +1168,7 @@ hole==0.8.0
holidays==0.75
# homeassistant.components.frontend
home-assistant-frontend==20250702.1
home-assistant-frontend==20250702.0
# homeassistant.components.conversation
home-assistant-intents==2025.6.23
@@ -1929,7 +1929,7 @@ pydiscovergy==3.0.2
pydoods==1.0.2
# homeassistant.components.hydrawise
pydrawise==2025.7.0
pydrawise==2025.6.0
# homeassistant.components.android_ip_webcam
pydroid-ipcam==3.0.0
@@ -3041,7 +3041,7 @@ vehicle==2.2.2
velbus-aio==2025.5.0
# homeassistant.components.venstar
venstarcolortouch==0.21
venstarcolortouch==0.19
# homeassistant.components.vilfo
vilfo-api-client==0.5.0
+5 -5
View File
@@ -173,7 +173,7 @@ aioairzone-cloud==0.6.12
aioairzone==1.0.0
# homeassistant.components.alexa_devices
aioamazondevices==3.2.3
aioamazondevices==3.2.2
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -192,7 +192,7 @@ aioaseko==1.0.0
aioasuswrt==1.4.0
# homeassistant.components.husqvarna_automower
aioautomower==1.2.0
aioautomower==1.0.1
# homeassistant.components.azure_devops
aioazuredevops==2.2.1
@@ -1017,7 +1017,7 @@ hole==0.8.0
holidays==0.75
# homeassistant.components.frontend
home-assistant-frontend==20250702.1
home-assistant-frontend==20250702.0
# homeassistant.components.conversation
home-assistant-intents==2025.6.23
@@ -1610,7 +1610,7 @@ pydexcom==0.2.3
pydiscovergy==3.0.2
# homeassistant.components.hydrawise
pydrawise==2025.7.0
pydrawise==2025.6.0
# homeassistant.components.android_ip_webcam
pydroid-ipcam==3.0.0
@@ -2509,7 +2509,7 @@ vehicle==2.2.2
velbus-aio==2025.5.0
# homeassistant.components.venstar
venstarcolortouch==0.21
venstarcolortouch==0.19
# homeassistant.components.vilfo
vilfo-api-client==0.5.0
-2
View File
@@ -12,7 +12,6 @@ from . import (
application_credentials,
bluetooth,
codeowners,
conditions,
config_flow,
config_schema,
dependencies,
@@ -39,7 +38,6 @@ INTEGRATION_PLUGINS = [
application_credentials,
bluetooth,
codeowners,
conditions,
config_schema,
dependencies,
dhcp,
-225
View File
@@ -1,225 +0,0 @@
"""Validate conditions."""
from __future__ import annotations
import contextlib
import json
import pathlib
import re
from typing import Any
import voluptuous as vol
from voluptuous.humanize import humanize_error
from homeassistant.const import CONF_SELECTOR
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import condition, config_validation as cv, selector
from homeassistant.util.yaml import load_yaml_dict
from .model import Config, Integration
def exists(value: Any) -> Any:
"""Check if value exists."""
if value is None:
raise vol.Invalid("Value cannot be None")
return value
FIELD_SCHEMA = vol.Schema(
{
vol.Optional("example"): exists,
vol.Optional("default"): exists,
vol.Optional("required"): bool,
vol.Optional(CONF_SELECTOR): selector.validate_selector,
}
)
CONDITION_SCHEMA = vol.Any(
vol.Schema(
{
vol.Optional("fields"): vol.Schema({str: FIELD_SCHEMA}),
}
),
None,
)
CONDITIONS_SCHEMA = vol.Schema(
{
vol.Remove(vol.All(str, condition.starts_with_dot)): object,
cv.slug: CONDITION_SCHEMA,
}
)
NON_MIGRATED_INTEGRATIONS = {
"device_automation",
"sun",
}
def grep_dir(path: pathlib.Path, glob_pattern: str, search_pattern: str) -> bool:
"""Recursively go through a dir and it's children and find the regex."""
pattern = re.compile(search_pattern)
for fil in path.glob(glob_pattern):
if not fil.is_file():
continue
if pattern.search(fil.read_text()):
return True
return False
def validate_conditions(config: Config, integration: Integration) -> None: # noqa: C901
"""Validate conditions."""
try:
data = load_yaml_dict(str(integration.path / "conditions.yaml"))
except FileNotFoundError:
# Find if integration uses conditions
has_conditions = grep_dir(
integration.path,
"**/condition.py",
r"async_get_conditions",
)
if has_conditions and integration.domain not in NON_MIGRATED_INTEGRATIONS:
integration.add_error(
"conditions", "Registers conditions but has no conditions.yaml"
)
return
except HomeAssistantError:
integration.add_error("conditions", "Invalid conditions.yaml")
return
try:
conditions = CONDITIONS_SCHEMA(data)
except vol.Invalid as err:
integration.add_error(
"conditions", f"Invalid conditions.yaml: {humanize_error(data, err)}"
)
return
icons_file = integration.path / "icons.json"
icons = {}
if icons_file.is_file():
with contextlib.suppress(ValueError):
icons = json.loads(icons_file.read_text())
condition_icons = icons.get("conditions", {})
# Try loading translation strings
if integration.core:
strings_file = integration.path / "strings.json"
else:
# For custom integrations, use the en.json file
strings_file = integration.path / "translations/en.json"
strings = {}
if strings_file.is_file():
with contextlib.suppress(ValueError):
strings = json.loads(strings_file.read_text())
error_msg_suffix = "in the translations file"
if not integration.core:
error_msg_suffix = f"and is not {error_msg_suffix}"
# For each condition in the integration:
# 1. Check if the condition description is set, if not,
# check if it's in the strings file else add an error.
# 2. Check if the condition has an icon set in icons.json.
# raise an error if not.,
for condition_name, condition_schema in conditions.items():
if integration.core and condition_name not in condition_icons:
# This is enforced for Core integrations only
integration.add_error(
"conditions",
f"Condition {condition_name} has no icon in icons.json.",
)
if condition_schema is None:
continue
if "name" not in condition_schema and integration.core:
try:
strings["conditions"][condition_name]["name"]
except KeyError:
integration.add_error(
"conditions",
f"Condition {condition_name} has no name {error_msg_suffix}",
)
if "description" not in condition_schema and integration.core:
try:
strings["conditions"][condition_name]["description"]
except KeyError:
integration.add_error(
"conditions",
f"Condition {condition_name} has no description {error_msg_suffix}",
)
# The same check is done for the description in each of the fields of the
# condition schema.
for field_name, field_schema in condition_schema.get("fields", {}).items():
if "fields" in field_schema:
# This is a section
continue
if "name" not in field_schema and integration.core:
try:
strings["conditions"][condition_name]["fields"][field_name]["name"]
except KeyError:
integration.add_error(
"conditions",
(
f"Condition {condition_name} has a field {field_name} with no "
f"name {error_msg_suffix}"
),
)
if "description" not in field_schema and integration.core:
try:
strings["conditions"][condition_name]["fields"][field_name][
"description"
]
except KeyError:
integration.add_error(
"conditions",
(
f"Condition {condition_name} has a field {field_name} with no "
f"description {error_msg_suffix}"
),
)
if "selector" in field_schema:
with contextlib.suppress(KeyError):
translation_key = field_schema["selector"]["select"][
"translation_key"
]
try:
strings["selector"][translation_key]
except KeyError:
integration.add_error(
"conditions",
f"Condition {condition_name} has a field {field_name} with a selector with a translation key {translation_key} that is not in the translations file",
)
# The same check is done for the description in each of the sections of the
# condition schema.
for section_name, section_schema in condition_schema.get("fields", {}).items():
if "fields" not in section_schema:
# This is not a section
continue
if "name" not in section_schema and integration.core:
try:
strings["conditions"][condition_name]["sections"][section_name][
"name"
]
except KeyError:
integration.add_error(
"conditions",
f"Condition {condition_name} has a section {section_name} with no name {error_msg_suffix}",
)
def validate(integrations: dict[str, Integration], config: Config) -> None:
"""Handle dependencies for integrations."""
# check conditions.yaml is valid
for integration in integrations.values():
validate_conditions(config, integration)
-11
View File
@@ -120,16 +120,6 @@ CUSTOM_INTEGRATION_SERVICE_ICONS_SCHEMA = cv.schema_with_slug_keys(
)
CONDITION_ICONS_SCHEMA = cv.schema_with_slug_keys(
vol.Schema(
{
vol.Optional("condition"): icon_value_validator,
}
),
slug_validator=translation_key_validator,
)
TRIGGER_ICONS_SCHEMA = cv.schema_with_slug_keys(
vol.Schema(
{
@@ -176,7 +166,6 @@ def icon_schema(
schema = vol.Schema(
{
vol.Optional("conditions"): CONDITION_ICONS_SCHEMA,
vol.Optional("config"): DATA_ENTRY_ICONS_SCHEMA,
vol.Optional("issues"): vol.Schema(
{str: {"fix_flow": DATA_ENTRY_ICONS_SCHEMA}}
-20
View File
@@ -310,10 +310,6 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema:
translation_value_validator,
slug_validator=translation_key_validator,
),
vol.Optional("unit_of_measurement"): cv.schema_with_slug_keys(
translation_value_validator,
slug_validator=translation_key_validator,
),
vol.Optional("fields"): cv.schema_with_slug_keys(str),
},
slug_validator=vol.Any("_", cv.slug),
@@ -420,22 +416,6 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema:
},
slug_validator=translation_key_validator,
),
vol.Optional("conditions"): cv.schema_with_slug_keys(
{
vol.Required("name"): translation_value_validator,
vol.Required("description"): translation_value_validator,
vol.Required("description_configured"): translation_value_validator,
vol.Optional("fields"): cv.schema_with_slug_keys(
{
vol.Required("name"): str,
vol.Required("description"): translation_value_validator,
vol.Optional("example"): translation_value_validator,
},
slug_validator=translation_key_validator,
),
},
slug_validator=translation_key_validator,
),
vol.Optional("triggers"): cv.schema_with_slug_keys(
{
vol.Required("name"): translation_value_validator,
-2
View File
@@ -75,7 +75,6 @@ from homeassistant.core import (
from homeassistant.helpers import (
area_registry as ar,
category_registry as cr,
condition,
device_registry as dr,
entity,
entity_platform,
@@ -297,7 +296,6 @@ async def async_test_home_assistant(
# Load the registries
entity.async_setup(hass)
loader.async_setup(hass)
await condition.async_setup(hass)
await trigger.async_setup(hass)
# setup translation cache instead of calling translation.async_setup(hass)
+1 -1
View File
@@ -93,7 +93,7 @@ async def test_generate_data_service_structure_fields(
init_components: None,
mock_ai_task_entity: MockAITaskEntity,
) -> None:
"""Test the entity can generate structured data with a top level object schema."""
"""Test the entity can generate structured data with a top level object schemea."""
result = await hass.services.async_call(
"ai_task",
"generate_data",
@@ -54,9 +54,9 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_IDLE,
STATE_OFF,
STATE_PLAYING,
STATE_STANDBY,
STATE_UNAVAILABLE,
)
from homeassistant.core import HomeAssistant
@@ -163,7 +163,7 @@ async def test_reconnect(
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_IDLE
assert state.state == STATE_STANDBY
assert MSG_RECONNECT[patch_key] in caplog.record_tuples[2]
@@ -672,7 +672,7 @@ async def test_update_lock_not_acquired(hass: HomeAssistant) -> None:
await async_update_entity(hass, entity_id)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_IDLE
assert state.state == STATE_STANDBY
async def test_download(hass: HomeAssistant) -> None:
@@ -45,6 +45,7 @@ from homeassistant.const import (
STATE_ON,
STATE_PAUSED,
STATE_PLAYING,
STATE_STANDBY,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
@@ -155,8 +156,8 @@ async def test_entity_supported_features_with_control_bus(
@pytest.mark.parametrize(
("power_state", "play_state", "media_player_state"),
[
(True, "NETWORK", STATE_OFF),
(False, "NETWORK", STATE_OFF),
(True, "NETWORK", STATE_STANDBY),
(False, "NETWORK", STATE_STANDBY),
(False, "play", STATE_OFF),
(True, "play", STATE_PLAYING),
(True, "pause", STATE_PAUSED),
@@ -7,20 +7,12 @@ from aiohttp import ClientConnectionError
import pytest
from homeassistant.components.eheimdigital.const import DOMAIN
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
SOURCE_USER,
SOURCE_ZEROCONF,
)
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .conftest import init_integration
from tests.common import MockConfigEntry
ZEROCONF_DISCOVERY = ZeroconfServiceInfo(
ip_address=ip_address("192.0.2.1"),
ip_addresses=[ip_address("192.0.2.1")],
@@ -218,74 +210,3 @@ async def test_abort(hass: HomeAssistant, eheimdigital_hub_mock: AsyncMock) -> N
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "already_configured"
@patch("homeassistant.components.eheimdigital.config_flow.asyncio.Event", new=AsyncMock)
@pytest.mark.parametrize(
("side_effect", "error_value"),
[(ClientConnectionError(), "cannot_connect"), (Exception(), "unknown")],
)
async def test_reconfigure(
hass: HomeAssistant,
eheimdigital_hub_mock: AsyncMock,
mock_config_entry: MockConfigEntry,
side_effect: Exception,
error_value: str,
) -> None:
"""Test reconfigure flow."""
await init_integration(hass, mock_config_entry)
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == SOURCE_RECONFIGURE
eheimdigital_hub_mock.return_value.connect.side_effect = side_effect
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error_value}
eheimdigital_hub_mock.return_value.connect.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert (
mock_config_entry.unique_id
== eheimdigital_hub_mock.return_value.main.mac_address
)
@patch("homeassistant.components.eheimdigital.config_flow.asyncio.Event", new=AsyncMock)
async def test_reconfigure_different_device(
hass: HomeAssistant,
eheimdigital_hub_mock: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reconfigure flow."""
await init_integration(hass, mock_config_entry)
# Simulate a different device
eheimdigital_hub_mock.return_value.main.mac_address = "00:00:00:00:00:02"
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == SOURCE_RECONFIGURE
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unique_id_mismatch"
@@ -15,7 +15,7 @@ from homeassistant.components.emulated_roku.binding import (
ROKU_COMMAND_LAUNCH,
EmulatedRoku,
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.core import Event, HomeAssistant
async def test_events_fired_properly(hass: HomeAssistant) -> None:
@@ -43,7 +43,6 @@ async def test_events_fired_properly(hass: HomeAssistant) -> None:
return Mock(start=AsyncMock(), close=AsyncMock())
@callback
def listener(event: Event) -> None:
if event.data[ATTR_SOURCE_NAME] == random_name:
events.append(event)
@@ -307,7 +307,7 @@
'name': 'Inverter 1',
'name_by_user': None,
'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72',
'serial_number': '1',
'serial_number': None,
'suggested_area': None,
'sw_version': None,
}),
@@ -1186,7 +1186,7 @@
'name': 'Inverter 1',
'name_by_user': None,
'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72',
'serial_number': '1',
'serial_number': None,
'suggested_area': None,
'sw_version': None,
}),
@@ -2109,7 +2109,7 @@
'name': 'Inverter 1',
'name_by_user': None,
'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72',
'serial_number': '1',
'serial_number': None,
'suggested_area': None,
'sw_version': None,
}),
@@ -2805,7 +2805,7 @@
'name': 'Inverter 1',
'name_by_user': None,
'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72',
'serial_number': '1',
'serial_number': None,
'suggested_area': None,
'sw_version': None,
}),
@@ -1,36 +0,0 @@
"""Test the Google Assistant SDK application_credentials."""
import pytest
from homeassistant import setup
from homeassistant.components.google_assistant_sdk.application_credentials import (
async_get_description_placeholders,
)
from homeassistant.core import HomeAssistant
@pytest.mark.parametrize(
("additional_components", "external_url", "expected_redirect_uri"),
[
([], "https://example.com", "https://example.com/auth/external/callback"),
([], None, "https://YOUR_DOMAIN:PORT/auth/external/callback"),
(["my"], "https://example.com", "https://my.home-assistant.io/redirect/oauth"),
],
)
async def test_description_placeholders(
hass: HomeAssistant,
additional_components: list[str],
external_url: str | None,
expected_redirect_uri: str,
) -> None:
"""Test description placeholders."""
for component in additional_components:
assert await setup.async_setup_component(hass, component, {})
hass.config.external_url = external_url
placeholders = await async_get_description_placeholders(hass)
assert placeholders == {
"oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent",
"more_info_url": "https://www.home-assistant.io/integrations/google_assistant_sdk/",
"oauth_creds_url": "https://console.cloud.google.com/apis/credentials",
"redirect_url": expected_redirect_uri,
}
@@ -63,7 +63,6 @@
'stay_out_zones': True,
'work_areas': True,
}),
'messages': None,
'metadata': dict({
'connected': True,
'status_dateteime': '2023-06-05T00:00:00+00:00',
@@ -81,7 +80,7 @@
'work_area_name': 'Front lawn',
}),
'planner': dict({
'external_reason': 'ifttt',
'external_reason': 'ifttt_wildlife',
'next_start_datetime': '2023-06-05T19:00:00+02:00',
'override': dict({
'action': 'not_active',
@@ -1,80 +0,0 @@
"""Test constants for the media source component."""
import pytest
from homeassistant.components.media_source.const import URI_SCHEME_REGEX
@pytest.mark.parametrize(
("uri", "expected_domain", "expected_identifier"),
[
("media-source://", None, None),
("media-source://local_media", "local_media", None),
(
"media-source://local_media/some/path/file.mp3",
"local_media",
"some/path/file.mp3",
),
("media-source://a/b", "a", "b"),
(
"media-source://domain/file with spaces.mp4",
"domain",
"file with spaces.mp4",
),
(
"media-source://domain/file-with-dashes.mp3",
"domain",
"file-with-dashes.mp3",
),
("media-source://domain/file.with.dots.mp3", "domain", "file.with.dots.mp3"),
(
"media-source://domain/special!@#$%^&*()chars",
"domain",
"special!@#$%^&*()chars",
),
],
)
def test_valid_uri_patterns(
uri: str, expected_domain: str | None, expected_identifier: str | None
) -> None:
"""Test various valid URI patterns."""
match = URI_SCHEME_REGEX.match(uri)
assert match is not None
assert match.group("domain") == expected_domain
assert match.group("identifier") == expected_identifier
@pytest.mark.parametrize(
"uri",
[
"media-source:", # missing //
"media-source:/", # missing second /
"media-source:///", # extra /
"media-source://domain/", # trailing slash after domain
"invalid-scheme://domain", # wrong scheme
"media-source//domain", # missing :
"MEDIA-SOURCE://domain", # uppercase scheme
"media_source://domain", # underscore in scheme
"", # empty string
"media-source", # scheme only
"media-source://domain extra", # extra content
"prefix media-source://domain", # prefix content
"media-source://domain suffix", # suffix content
# Invalid domain names
"media-source://_test", # starts with underscore
"media-source://test_", # ends with underscore
"media-source://_test_", # starts and ends with underscore
"media-source://_", # single underscore
"media-source://test-123", # contains hyphen
"media-source://test.123", # contains dot
"media-source://test 123", # contains space
"media-source://TEST", # uppercase letters
"media-source://Test", # mixed case
# Identifier cannot start with slash
"media-source://domain//invalid", # identifier starts with slash
],
)
def test_invalid_uris(uri: str) -> None:
"""Test invalid URI formats."""
match = URI_SCHEME_REGEX.match(uri)
assert match is None, f"URI '{uri}' should be invalid"
@@ -2,7 +2,6 @@
from homeassistant.components.media_player import MediaClass, MediaType
from homeassistant.components.media_source import const, models
from homeassistant.core import HomeAssistant
async def test_browse_media_as_dict() -> None:
@@ -69,18 +68,3 @@ async def test_media_source_default_name() -> None:
"""Test MediaSource uses domain as default name."""
source = models.MediaSource(const.DOMAIN)
assert source.name == const.DOMAIN
async def test_media_source_item_media_source_id(hass: HomeAssistant) -> None:
"""Test MediaSourceItem media_source_id property."""
# Test with domain and identifier
item = models.MediaSourceItem(hass, "test_domain", "test/identifier", None)
assert item.media_source_id == "media-source://test_domain/test/identifier"
# Test with domain only
item = models.MediaSourceItem(hass, "test_domain", "", None)
assert item.media_source_id == "media-source://test_domain"
# Test with no domain (root)
item = models.MediaSourceItem(hass, None, "", None)
assert item.media_source_id == "media-source://"
-1
View File
@@ -5,7 +5,6 @@ MOCK_NAME = "Philips TV"
MOCK_USERNAME = "mock_user"
MOCK_PASSWORD = "mock_password"
MOCK_HOSTNAME = "mock_hostname"
MOCK_SYSTEM = {
"menulanguage": "English",
-1
View File
@@ -38,7 +38,6 @@ def mock_tv():
tv.application = None
tv.applications = {}
tv.system = MOCK_SYSTEM
tv.name = MOCK_NAME
tv.api_version = 1
tv.api_version_detected = None
tv.on = True
+16 -76
View File
@@ -1,6 +1,5 @@
"""Test the Philips TV config flow."""
from ipaddress import ip_address
from unittest.mock import ANY
from haphilipsjs import PairingFailure
@@ -10,13 +9,10 @@ from homeassistant import config_entries
from homeassistant.components.philips_js.const import CONF_ALLOW_NOTIFY, DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import (
MOCK_CONFIG,
MOCK_CONFIG_PAIRED,
MOCK_HOSTNAME,
MOCK_NAME,
MOCK_PASSWORD,
MOCK_SYSTEM,
MOCK_SYSTEM_UNPAIRED,
@@ -37,7 +33,6 @@ async def mock_tv_pairable(mock_tv):
mock_tv.api_version = 6
mock_tv.api_version_detected = 6
mock_tv.secured_transport = True
mock_tv.name = MOCK_NAME
mock_tv.pairRequest.return_value = {}
mock_tv.pairGrant.return_value = MOCK_USERNAME, MOCK_PASSWORD
@@ -107,6 +102,21 @@ async def test_form_cannot_connect(hass: HomeAssistant, mock_tv) -> None:
assert result["errors"] == {"base": "cannot_connect"}
async def test_form_unexpected_error(hass: HomeAssistant, mock_tv) -> None:
"""Test we handle unexpected exceptions."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
mock_tv.getSystem.side_effect = Exception("Unexpected exception")
result = await hass.config_entries.flow.async_configure(
result["flow_id"], MOCK_USERINPUT
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "unknown"}
async def test_pairing(hass: HomeAssistant, mock_tv_pairable, mock_setup_entry) -> None:
"""Test we get the form."""
mock_tv = mock_tv_pairable
@@ -133,13 +143,7 @@ async def test_pairing(hass: HomeAssistant, mock_tv_pairable, mock_setup_entry)
)
assert result == {
"context": {
"source": "user",
"unique_id": "ABCDEFGHIJKLF",
"title_placeholders": {
"name": "Philips TV",
},
},
"context": {"source": "user", "unique_id": "ABCDEFGHIJKLF"},
"flow_id": ANY,
"type": "create_entry",
"description": None,
@@ -254,67 +258,3 @@ async def test_options_flow(hass: HomeAssistant) -> None:
assert result["type"] is FlowResultType.CREATE_ENTRY
assert config_entry.options == {CONF_ALLOW_NOTIFY: True}
@pytest.mark.parametrize(
("secured_transport", "discovery_type"),
[(True, "_philipstv_s_rpc._tcp.local."), (False, "_philipstv_rpc._tcp.local.")],
)
async def test_zeroconf_discovery(
hass: HomeAssistant, mock_tv_pairable, secured_transport, discovery_type
) -> None:
"""Test we can setup from zeroconf discovery."""
mock_tv_pairable.secured_transport = secured_transport
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname=MOCK_HOSTNAME,
name=MOCK_NAME,
port=None,
properties={},
type=discovery_type,
),
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] is None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
mock_tv_pairable.setTransport.assert_called_with(secured_transport)
mock_tv_pairable.pairRequest.assert_called()
async def test_zeroconf_probe_failed(
hass: HomeAssistant,
mock_tv_pairable,
) -> None:
"""Test we can setup from zeroconf discovery."""
mock_tv_pairable.system = None
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname=MOCK_HOSTNAME,
name=MOCK_NAME,
port=None,
properties={},
type="_philipstv_s_rpc._tcp.local.",
),
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "discovery_failure"
+3 -3
View File
@@ -33,8 +33,8 @@ from homeassistant.const import (
CONF_REGION,
CONF_TOKEN,
STATE_IDLE,
STATE_OFF,
STATE_PLAYING,
STATE_STANDBY,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant
@@ -188,7 +188,7 @@ async def test_state_standby_is_set(hass: HomeAssistant) -> None:
await mock_ddp_response(hass, MOCK_STATUS_STANDBY)
assert hass.states.get(mock_entity_id).state == STATE_OFF
assert hass.states.get(mock_entity_id).state == STATE_STANDBY
async def test_state_playing_is_set(hass: HomeAssistant) -> None:
@@ -308,7 +308,7 @@ async def test_device_info_is_set_from_status_correctly(
mock_d_entries = device_registry.devices
mock_entry = device_registry.async_get_device(identifiers={(DOMAIN, MOCK_HOST_ID)})
assert mock_state == STATE_OFF
assert mock_state == STATE_STANDBY
assert len(mock_d_entries) == 1
assert mock_entry.name == MOCK_HOST_NAME
@@ -1,139 +0,0 @@
"""Test the Radio Browser media source."""
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from radios import RadioBrowser
from homeassistant.components.media_source import MediaSourceItem, Unresolvable
from homeassistant.components.radio_browser.const import DOMAIN
from homeassistant.components.radio_browser.media_source import (
RadioMediaSource,
async_get_media_source,
)
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@pytest.fixture
def mock_radio_browser() -> AsyncMock:
"""Mock RadioBrowser."""
radio_browser = AsyncMock(spec=RadioBrowser)
# Mock station without full Station object to avoid constructor complexity
mock_station = AsyncMock()
mock_station.uuid = "test-uuid"
mock_station.name = "Test Station"
mock_station.url = "https://example.com/stream"
mock_station.codec = "MP3"
mock_station.favicon = "https://example.com/favicon.ico"
radio_browser.station.return_value = mock_station
return radio_browser
async def test_media_source_without_runtime_data(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test media source raises error when runtime_data is missing."""
mock_config_entry.add_to_hass(hass)
# Don't set runtime_data to simulate the error condition
media_source = RadioMediaSource(hass, mock_config_entry)
with pytest.raises(
Unresolvable, match="Radio Browser integration not properly loaded"
):
_ = media_source.radios
async def test_media_source_with_none_runtime_data(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test media source raises error when runtime_data is None."""
mock_config_entry.add_to_hass(hass)
mock_config_entry.runtime_data = None
media_source = RadioMediaSource(hass, mock_config_entry)
with pytest.raises(
Unresolvable, match="Radio Browser integration not properly loaded"
):
_ = media_source.radios
async def test_media_source_with_runtime_data(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_radio_browser: AsyncMock,
) -> None:
"""Test media source works correctly with runtime_data."""
mock_config_entry.add_to_hass(hass)
mock_config_entry.runtime_data = mock_radio_browser
media_source = RadioMediaSource(hass, mock_config_entry)
# Should not raise an error
radios = media_source.radios
assert radios is mock_radio_browser
async def test_async_get_media_source(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_radio_browser: AsyncMock,
) -> None:
"""Test async_get_media_source function."""
mock_config_entry.add_to_hass(hass)
mock_config_entry.runtime_data = mock_radio_browser
media_source = await async_get_media_source(hass)
assert isinstance(media_source, RadioMediaSource)
assert media_source.entry is mock_config_entry
assert media_source.radios is mock_radio_browser
async def test_async_resolve_media_with_missing_runtime_data(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test async_resolve_media raises error when runtime_data is missing."""
mock_config_entry.add_to_hass(hass)
# Don't set runtime_data to simulate the error condition
media_source = RadioMediaSource(hass, mock_config_entry)
item = MediaSourceItem(
hass=hass,
domain=DOMAIN,
identifier="test-uuid",
target_media_player=None,
)
with pytest.raises(
Unresolvable, match="Radio Browser integration not properly loaded"
):
await media_source.async_resolve_media(item)
async def test_async_browse_media_with_missing_runtime_data(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test async_browse_media raises error when runtime_data is missing."""
mock_config_entry.add_to_hass(hass)
# Don't set runtime_data to simulate the error condition
media_source = RadioMediaSource(hass, mock_config_entry)
item = MediaSourceItem(
hass=hass,
domain=DOMAIN,
identifier="",
target_media_player=None,
)
with pytest.raises(
Unresolvable, match="Radio Browser integration not properly loaded"
):
await media_source.async_browse_media(item)
+2 -2
View File
@@ -52,10 +52,10 @@ from homeassistant.const import (
SERVICE_VOLUME_MUTE,
SERVICE_VOLUME_UP,
STATE_IDLE,
STATE_OFF,
STATE_ON,
STATE_PAUSED,
STATE_PLAYING,
STATE_STANDBY,
STATE_UNAVAILABLE,
)
from homeassistant.core import HomeAssistant
@@ -112,7 +112,7 @@ async def test_idle_setup(
"""Test setup with idle device."""
state = hass.states.get(MAIN_ENTITY_ID)
assert state
assert state.state == STATE_OFF
assert state.state == STATE_STANDBY
@pytest.mark.parametrize("mock_device", ["roku/rokutv-7820x.json"], indirect=True)
@@ -63,7 +63,7 @@ async def test_options_flow(
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"][ATTR_PARSER] == PARSER_PLAIN_TEXT
assert result["data"][ATTR_PARSER] is None
async def test_reconfigure_flow_broadcast(
@@ -50,7 +50,6 @@ from homeassistant.components.telegram_bot.const import (
ATTR_VERIFY_SSL,
CONF_CONFIG_ENTRY_ID,
DOMAIN,
PARSER_PLAIN_TEXT,
PLATFORM_BROADCAST,
SECTION_ADVANCED_SETTINGS,
SERVICE_ANSWER_CALLBACK_QUERY,
@@ -184,7 +183,6 @@ async def test_send_message(
(
{
ATTR_MESSAGE: "test_message",
ATTR_PARSER: PARSER_PLAIN_TEXT,
ATTR_KEYBOARD_INLINE: "command1:/cmd1,/cmd2,mock_link:https://mock_link",
},
InlineKeyboardMarkup(
@@ -201,7 +199,6 @@ async def test_send_message(
(
{
ATTR_MESSAGE: "test_message",
ATTR_PARSER: PARSER_PLAIN_TEXT,
ATTR_KEYBOARD_INLINE: [
[["command1", "/cmd1"]],
[["mock_link", "https://mock_link"]],
@@ -253,7 +250,7 @@ async def test_send_message_with_inline_keyboard(
mock_send_message.assert_called_once_with(
12345678,
"test_message",
parse_mode=None,
parse_mode=ParseMode.MARKDOWN,
disable_web_page_preview=None,
disable_notification=False,
reply_to_message_id=None,
+386
View File
@@ -1 +1,387 @@
"""Tests for the Wallbox integration."""
from http import HTTPStatus
import requests
import requests_mock
from homeassistant.components.wallbox.const import (
CHARGER_ADDED_ENERGY_KEY,
CHARGER_ADDED_RANGE_KEY,
CHARGER_CHARGING_POWER_KEY,
CHARGER_CHARGING_SPEED_KEY,
CHARGER_CURRENCY_KEY,
CHARGER_CURRENT_VERSION_KEY,
CHARGER_DATA_KEY,
CHARGER_ECO_SMART_KEY,
CHARGER_ECO_SMART_MODE_KEY,
CHARGER_ECO_SMART_STATUS_KEY,
CHARGER_ENERGY_PRICE_KEY,
CHARGER_FEATURES_KEY,
CHARGER_LOCKED_UNLOCKED_KEY,
CHARGER_MAX_AVAILABLE_POWER_KEY,
CHARGER_MAX_CHARGING_CURRENT_KEY,
CHARGER_MAX_ICP_CURRENT_KEY,
CHARGER_NAME_KEY,
CHARGER_PART_NUMBER_KEY,
CHARGER_PLAN_KEY,
CHARGER_POWER_BOOST_KEY,
CHARGER_SERIAL_NUMBER_KEY,
CHARGER_SOFTWARE_KEY,
CHARGER_STATUS_ID_KEY,
)
from homeassistant.core import HomeAssistant
from .const import ERROR, REFRESH_TOKEN_TTL, STATUS, TTL, USER_ID
from tests.common import MockConfigEntry
test_response = {
CHARGER_CHARGING_POWER_KEY: 0,
CHARGER_STATUS_ID_KEY: 193,
CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0,
CHARGER_CHARGING_SPEED_KEY: 0,
CHARGER_ADDED_RANGE_KEY: 150,
CHARGER_ADDED_ENERGY_KEY: 44.697,
CHARGER_NAME_KEY: "WallboxName",
CHARGER_DATA_KEY: {
CHARGER_MAX_CHARGING_CURRENT_KEY: 24,
CHARGER_ENERGY_PRICE_KEY: 0.4,
CHARGER_LOCKED_UNLOCKED_KEY: False,
CHARGER_SERIAL_NUMBER_KEY: "20000",
CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E",
CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"},
CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"},
CHARGER_MAX_ICP_CURRENT_KEY: 20,
CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]},
CHARGER_ECO_SMART_KEY: {
CHARGER_ECO_SMART_STATUS_KEY: False,
CHARGER_ECO_SMART_MODE_KEY: 0,
},
},
}
test_response_bidir = {
CHARGER_CHARGING_POWER_KEY: 0,
CHARGER_STATUS_ID_KEY: 193,
CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0,
CHARGER_CHARGING_SPEED_KEY: 0,
CHARGER_ADDED_RANGE_KEY: 150,
CHARGER_ADDED_ENERGY_KEY: 44.697,
CHARGER_NAME_KEY: "WallboxName",
CHARGER_DATA_KEY: {
CHARGER_MAX_CHARGING_CURRENT_KEY: 24,
CHARGER_ENERGY_PRICE_KEY: 0.4,
CHARGER_LOCKED_UNLOCKED_KEY: False,
CHARGER_SERIAL_NUMBER_KEY: "20000",
CHARGER_PART_NUMBER_KEY: "QSP1-0-2-4-9-002-E",
CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"},
CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"},
CHARGER_MAX_ICP_CURRENT_KEY: 20,
CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]},
CHARGER_ECO_SMART_KEY: {
CHARGER_ECO_SMART_STATUS_KEY: False,
CHARGER_ECO_SMART_MODE_KEY: 0,
},
},
}
test_response_eco_mode = {
CHARGER_CHARGING_POWER_KEY: 0,
CHARGER_STATUS_ID_KEY: 193,
CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0,
CHARGER_CHARGING_SPEED_KEY: 0,
CHARGER_ADDED_RANGE_KEY: 150,
CHARGER_ADDED_ENERGY_KEY: 44.697,
CHARGER_NAME_KEY: "WallboxName",
CHARGER_DATA_KEY: {
CHARGER_MAX_CHARGING_CURRENT_KEY: 24,
CHARGER_ENERGY_PRICE_KEY: 0.4,
CHARGER_LOCKED_UNLOCKED_KEY: False,
CHARGER_SERIAL_NUMBER_KEY: "20000",
CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E",
CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"},
CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"},
CHARGER_MAX_ICP_CURRENT_KEY: 20,
CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]},
CHARGER_ECO_SMART_KEY: {
CHARGER_ECO_SMART_STATUS_KEY: True,
CHARGER_ECO_SMART_MODE_KEY: 0,
},
},
}
test_response_full_solar = {
CHARGER_CHARGING_POWER_KEY: 0,
CHARGER_STATUS_ID_KEY: 193,
CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0,
CHARGER_CHARGING_SPEED_KEY: 0,
CHARGER_ADDED_RANGE_KEY: 150,
CHARGER_ADDED_ENERGY_KEY: 44.697,
CHARGER_NAME_KEY: "WallboxName",
CHARGER_DATA_KEY: {
CHARGER_MAX_CHARGING_CURRENT_KEY: 24,
CHARGER_ENERGY_PRICE_KEY: 0.4,
CHARGER_LOCKED_UNLOCKED_KEY: False,
CHARGER_SERIAL_NUMBER_KEY: "20000",
CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E",
CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"},
CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"},
CHARGER_MAX_ICP_CURRENT_KEY: 20,
CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]},
CHARGER_ECO_SMART_KEY: {
CHARGER_ECO_SMART_STATUS_KEY: True,
CHARGER_ECO_SMART_MODE_KEY: 1,
},
},
}
test_response_no_power_boost = {
CHARGER_CHARGING_POWER_KEY: 0,
CHARGER_STATUS_ID_KEY: 193,
CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0,
CHARGER_CHARGING_SPEED_KEY: 0,
CHARGER_ADDED_RANGE_KEY: 150,
CHARGER_ADDED_ENERGY_KEY: 44.697,
CHARGER_NAME_KEY: "WallboxName",
CHARGER_DATA_KEY: {
CHARGER_MAX_CHARGING_CURRENT_KEY: 24,
CHARGER_ENERGY_PRICE_KEY: 0.4,
CHARGER_LOCKED_UNLOCKED_KEY: False,
CHARGER_SERIAL_NUMBER_KEY: "20000",
CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E",
CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"},
CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"},
CHARGER_MAX_ICP_CURRENT_KEY: 20,
CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: []},
},
}
http_404_error = requests.exceptions.HTTPError()
http_404_error.response = requests.Response()
http_404_error.response.status_code = HTTPStatus.NOT_FOUND
http_429_error = requests.exceptions.HTTPError()
http_429_error.response = requests.Response()
http_429_error.response.status_code = HTTPStatus.TOO_MANY_REQUESTS
authorisation_response = {
"data": {
"attributes": {
"token": "fakekeyhere",
"refresh_token": "refresh_fakekeyhere",
USER_ID: 12345,
TTL: 145656758,
REFRESH_TOKEN_TTL: 145756758,
ERROR: "false",
STATUS: 200,
}
}
}
authorisation_response_unauthorised = {
"data": {
"attributes": {
"token": "fakekeyhere",
"refresh_token": "refresh_fakekeyhere",
USER_ID: 12345,
TTL: 145656758,
REFRESH_TOKEN_TTL: 145756758,
ERROR: "false",
STATUS: 404,
}
}
}
invalid_reauth_response = {
"jwt": "fakekeyhere",
"refresh_token": "refresh_fakekeyhere",
"user_id": 12345,
"ttl": 145656758,
"refresh_token_ttl": 145756758,
"error": False,
"status": 200,
}
http_403_error = requests.exceptions.HTTPError()
http_403_error.response = requests.Response()
http_403_error.response.status_code = HTTPStatus.FORBIDDEN
http_404_error = requests.exceptions.HTTPError()
http_404_error.response = requests.Response()
http_404_error.response.status_code = HTTPStatus.NOT_FOUND
async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None:
"""Test wallbox sensor class setup."""
with requests_mock.Mocker() as mock_request:
mock_request.get(
"https://user-api.wall-box.com/users/signin",
json=authorisation_response,
status_code=HTTPStatus.OK,
)
mock_request.get(
"https://api.wall-box.com/chargers/status/12345",
json=test_response,
status_code=HTTPStatus.OK,
)
mock_request.put(
"https://api.wall-box.com/v2/charger/12345",
json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20},
status_code=HTTPStatus.OK,
)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
async def setup_integration_no_eco_mode(
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test wallbox sensor class setup."""
with requests_mock.Mocker() as mock_request:
mock_request.get(
"https://user-api.wall-box.com/users/signin",
json=authorisation_response,
status_code=HTTPStatus.OK,
)
mock_request.get(
"https://api.wall-box.com/chargers/status/12345",
json=test_response_no_power_boost,
status_code=HTTPStatus.OK,
)
mock_request.put(
"https://api.wall-box.com/v2/charger/12345",
json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20},
status_code=HTTPStatus.OK,
)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
async def setup_integration_select(
hass: HomeAssistant, entry: MockConfigEntry, response
) -> None:
"""Test wallbox sensor class setup."""
with requests_mock.Mocker() as mock_request:
mock_request.get(
"https://user-api.wall-box.com/users/signin",
json=authorisation_response,
status_code=HTTPStatus.OK,
)
mock_request.get(
"https://api.wall-box.com/chargers/status/12345",
json=response,
status_code=HTTPStatus.OK,
)
mock_request.put(
"https://api.wall-box.com/v2/charger/12345",
json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20},
status_code=HTTPStatus.OK,
)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
async def setup_integration_bidir(hass: HomeAssistant, entry: MockConfigEntry) -> None:
"""Test wallbox sensor class setup."""
with requests_mock.Mocker() as mock_request:
mock_request.get(
"https://user-api.wall-box.com/users/signin",
json=authorisation_response,
status_code=HTTPStatus.OK,
)
mock_request.get(
"https://api.wall-box.com/chargers/status/12345",
json=test_response_bidir,
status_code=HTTPStatus.OK,
)
mock_request.put(
"https://api.wall-box.com/v2/charger/12345",
json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20},
status_code=HTTPStatus.OK,
)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
async def setup_integration_connection_error(
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test wallbox sensor class setup with a connection error."""
with requests_mock.Mocker() as mock_request:
mock_request.get(
"https://user-api.wall-box.com/users/signin",
json=authorisation_response,
status_code=HTTPStatus.FORBIDDEN,
)
mock_request.get(
"https://api.wall-box.com/chargers/status/12345",
json=test_response,
status_code=HTTPStatus.FORBIDDEN,
)
mock_request.put(
"https://api.wall-box.com/v2/charger/12345",
json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20},
status_code=HTTPStatus.FORBIDDEN,
)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
async def setup_integration_read_only(
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test wallbox sensor class setup for read only."""
with requests_mock.Mocker() as mock_request:
mock_request.get(
"https://user-api.wall-box.com/users/signin",
json=authorisation_response,
status_code=HTTPStatus.OK,
)
mock_request.get(
"https://api.wall-box.com/chargers/status/12345",
json=test_response,
status_code=HTTPStatus.OK,
)
mock_request.put(
"https://api.wall-box.com/v2/charger/12345",
json=test_response,
status_code=HTTPStatus.FORBIDDEN,
)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
async def setup_integration_platform_not_ready(
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test wallbox sensor class setup for read only."""
with requests_mock.Mocker() as mock_request:
mock_request.get(
"https://user-api.wall-box.com/users/signin",
json=authorisation_response,
status_code=HTTPStatus.OK,
)
mock_request.get(
"https://api.wall-box.com/chargers/status/12345",
json=test_response,
status_code=HTTPStatus.OK,
)
mock_request.put(
"https://api.wall-box.com/v2/charger/12345",
json=test_response,
status_code=HTTPStatus.NOT_FOUND,
)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
+1 -251
View File
@@ -1,220 +1,13 @@
"""Test fixtures for the Wallbox integration."""
from http import HTTPStatus
from unittest.mock import MagicMock, Mock, patch
import pytest
import requests
from homeassistant.components.wallbox.const import (
CHARGER_ADDED_ENERGY_KEY,
CHARGER_ADDED_RANGE_KEY,
CHARGER_CHARGING_POWER_KEY,
CHARGER_CHARGING_SPEED_KEY,
CHARGER_CURRENCY_KEY,
CHARGER_CURRENT_VERSION_KEY,
CHARGER_DATA_KEY,
CHARGER_DATA_POST_L1_KEY,
CHARGER_DATA_POST_L2_KEY,
CHARGER_ECO_SMART_KEY,
CHARGER_ECO_SMART_MODE_KEY,
CHARGER_ECO_SMART_STATUS_KEY,
CHARGER_ENERGY_PRICE_KEY,
CHARGER_FEATURES_KEY,
CHARGER_LOCKED_UNLOCKED_KEY,
CHARGER_MAX_AVAILABLE_POWER_KEY,
CHARGER_MAX_CHARGING_CURRENT_KEY,
CHARGER_MAX_CHARGING_CURRENT_POST_KEY,
CHARGER_MAX_ICP_CURRENT_KEY,
CHARGER_NAME_KEY,
CHARGER_PART_NUMBER_KEY,
CHARGER_PLAN_KEY,
CHARGER_POWER_BOOST_KEY,
CHARGER_SERIAL_NUMBER_KEY,
CHARGER_SOFTWARE_KEY,
CHARGER_STATUS_ID_KEY,
CONF_STATION,
DOMAIN,
)
from homeassistant.components.wallbox.const import CONF_STATION, DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from .const import ERROR, REFRESH_TOKEN_TTL, STATUS, TTL, USER_ID
from tests.common import MockConfigEntry
test_response = {
CHARGER_CHARGING_POWER_KEY: 0,
CHARGER_STATUS_ID_KEY: 193,
CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0,
CHARGER_CHARGING_SPEED_KEY: 0,
CHARGER_ADDED_RANGE_KEY: 150,
CHARGER_ADDED_ENERGY_KEY: 44.697,
CHARGER_NAME_KEY: "WallboxName",
CHARGER_DATA_KEY: {
CHARGER_MAX_CHARGING_CURRENT_KEY: 24,
CHARGER_ENERGY_PRICE_KEY: 0.4,
CHARGER_LOCKED_UNLOCKED_KEY: False,
CHARGER_SERIAL_NUMBER_KEY: "20000",
CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E",
CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"},
CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"},
CHARGER_MAX_ICP_CURRENT_KEY: 20,
CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]},
CHARGER_ECO_SMART_KEY: {
CHARGER_ECO_SMART_STATUS_KEY: False,
CHARGER_ECO_SMART_MODE_KEY: 0,
},
},
}
test_response_bidir = {
CHARGER_CHARGING_POWER_KEY: 0,
CHARGER_STATUS_ID_KEY: 193,
CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0,
CHARGER_CHARGING_SPEED_KEY: 0,
CHARGER_ADDED_RANGE_KEY: 150,
CHARGER_ADDED_ENERGY_KEY: 44.697,
CHARGER_NAME_KEY: "WallboxName",
CHARGER_DATA_KEY: {
CHARGER_MAX_CHARGING_CURRENT_KEY: 24,
CHARGER_ENERGY_PRICE_KEY: 0.4,
CHARGER_LOCKED_UNLOCKED_KEY: False,
CHARGER_SERIAL_NUMBER_KEY: "20000",
CHARGER_PART_NUMBER_KEY: "QSP1-0-2-4-9-002-E",
CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"},
CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"},
CHARGER_MAX_ICP_CURRENT_KEY: 20,
CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]},
CHARGER_ECO_SMART_KEY: {
CHARGER_ECO_SMART_STATUS_KEY: False,
CHARGER_ECO_SMART_MODE_KEY: 0,
},
},
}
test_response_eco_mode = {
CHARGER_CHARGING_POWER_KEY: 0,
CHARGER_STATUS_ID_KEY: 193,
CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0,
CHARGER_CHARGING_SPEED_KEY: 0,
CHARGER_ADDED_RANGE_KEY: 150,
CHARGER_ADDED_ENERGY_KEY: 44.697,
CHARGER_NAME_KEY: "WallboxName",
CHARGER_DATA_KEY: {
CHARGER_MAX_CHARGING_CURRENT_KEY: 24,
CHARGER_ENERGY_PRICE_KEY: 0.4,
CHARGER_LOCKED_UNLOCKED_KEY: False,
CHARGER_SERIAL_NUMBER_KEY: "20000",
CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E",
CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"},
CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"},
CHARGER_MAX_ICP_CURRENT_KEY: 20,
CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]},
CHARGER_ECO_SMART_KEY: {
CHARGER_ECO_SMART_STATUS_KEY: True,
CHARGER_ECO_SMART_MODE_KEY: 0,
},
},
}
test_response_full_solar = {
CHARGER_CHARGING_POWER_KEY: 0,
CHARGER_STATUS_ID_KEY: 193,
CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0,
CHARGER_CHARGING_SPEED_KEY: 0,
CHARGER_ADDED_RANGE_KEY: 150,
CHARGER_ADDED_ENERGY_KEY: 44.697,
CHARGER_NAME_KEY: "WallboxName",
CHARGER_DATA_KEY: {
CHARGER_MAX_CHARGING_CURRENT_KEY: 24,
CHARGER_ENERGY_PRICE_KEY: 0.4,
CHARGER_LOCKED_UNLOCKED_KEY: False,
CHARGER_SERIAL_NUMBER_KEY: "20000",
CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E",
CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"},
CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"},
CHARGER_MAX_ICP_CURRENT_KEY: 20,
CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]},
CHARGER_ECO_SMART_KEY: {
CHARGER_ECO_SMART_STATUS_KEY: True,
CHARGER_ECO_SMART_MODE_KEY: 1,
},
},
}
test_response_no_power_boost = {
CHARGER_CHARGING_POWER_KEY: 0,
CHARGER_STATUS_ID_KEY: 193,
CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0,
CHARGER_CHARGING_SPEED_KEY: 0,
CHARGER_ADDED_RANGE_KEY: 150,
CHARGER_ADDED_ENERGY_KEY: 44.697,
CHARGER_NAME_KEY: "WallboxName",
CHARGER_DATA_KEY: {
CHARGER_MAX_CHARGING_CURRENT_KEY: 24,
CHARGER_ENERGY_PRICE_KEY: 0.4,
CHARGER_LOCKED_UNLOCKED_KEY: False,
CHARGER_SERIAL_NUMBER_KEY: "20000",
CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E",
CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"},
CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"},
CHARGER_MAX_ICP_CURRENT_KEY: 20,
CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: []},
},
}
http_403_error = requests.exceptions.HTTPError()
http_403_error.response = requests.Response()
http_403_error.response.status_code = HTTPStatus.FORBIDDEN
http_404_error = requests.exceptions.HTTPError()
http_404_error.response = requests.Response()
http_404_error.response.status_code = HTTPStatus.NOT_FOUND
http_429_error = requests.exceptions.HTTPError()
http_429_error.response = requests.Response()
http_429_error.response.status_code = HTTPStatus.TOO_MANY_REQUESTS
authorisation_response = {
"data": {
"attributes": {
"token": "fakekeyhere",
"refresh_token": "refresh_fakekeyhere",
USER_ID: 12345,
TTL: 145656758,
REFRESH_TOKEN_TTL: 145756758,
ERROR: "false",
STATUS: 200,
}
}
}
authorisation_response_unauthorised = {
"data": {
"attributes": {
"token": "fakekeyhere",
"refresh_token": "refresh_fakekeyhere",
USER_ID: 12345,
TTL: 145656758,
REFRESH_TOKEN_TTL: 145756758,
ERROR: "false",
STATUS: 404,
}
}
}
invalid_reauth_response = {
"jwt": "fakekeyhere",
"refresh_token": "refresh_fakekeyhere",
"user_id": 12345,
"ttl": 145656758,
"refresh_token_ttl": 145756758,
"error": False,
"status": 200,
}
@pytest.fixture
def entry(hass: HomeAssistant) -> MockConfigEntry:
@@ -230,46 +23,3 @@ def entry(hass: HomeAssistant) -> MockConfigEntry:
)
entry.add_to_hass(hass)
return entry
@pytest.fixture
def mock_wallbox():
"""Patch Wallbox class for tests."""
with patch("homeassistant.components.wallbox.Wallbox") as mock:
wallbox = MagicMock()
wallbox.authenticate = Mock(return_value=authorisation_response)
wallbox.lockCharger = Mock(
return_value={
CHARGER_DATA_POST_L1_KEY: {
CHARGER_DATA_POST_L2_KEY: {CHARGER_LOCKED_UNLOCKED_KEY: True}
}
}
)
wallbox.unlockCharger = Mock(
return_value={
CHARGER_DATA_POST_L1_KEY: {
CHARGER_DATA_POST_L2_KEY: {CHARGER_LOCKED_UNLOCKED_KEY: True}
}
}
)
wallbox.setEnergyCost = Mock(return_value={CHARGER_ENERGY_PRICE_KEY: 0.25})
wallbox.setMaxChargingCurrent = Mock(
return_value={
CHARGER_DATA_POST_L1_KEY: {
CHARGER_DATA_POST_L2_KEY: {
CHARGER_MAX_CHARGING_CURRENT_POST_KEY: True
}
}
}
)
wallbox.setIcpMaxCurrent = Mock(return_value={CHARGER_MAX_ICP_CURRENT_KEY: 25})
wallbox.getChargerStatus = Mock(return_value=test_response)
mock.return_value = wallbox
yield wallbox
async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None:
"""Test wallbox sensor class setup."""
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
+30 -27
View File
@@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .conftest import (
from . import (
authorisation_response,
authorisation_response_unauthorised,
http_403_error,
@@ -38,7 +38,7 @@ test_response = {
}
async def test_show_set_form(hass: HomeAssistant, mock_wallbox) -> None:
async def test_show_set_form(hass: HomeAssistant) -> None:
"""Test that the setup form is served."""
flow = config_flow.WallboxConfigFlow()
flow.hass = hass
@@ -53,6 +53,7 @@ async def test_form_cannot_authenticate(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with (
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
@@ -72,8 +73,8 @@ async def test_form_cannot_authenticate(hass: HomeAssistant) -> None:
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "invalid_auth"}
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "invalid_auth"}
async def test_form_cannot_connect(hass: HomeAssistant) -> None:
@@ -81,6 +82,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with (
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
@@ -100,8 +102,8 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None:
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "cannot_connect"}
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "cannot_connect"}
async def test_form_validate_input(hass: HomeAssistant) -> None:
@@ -109,14 +111,15 @@ async def test_form_validate_input(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with (
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
return_value=authorisation_response,
new=Mock(return_value=authorisation_response),
),
patch(
"homeassistant.components.wallbox.Wallbox.pauseChargingSession",
return_value=test_response,
"homeassistant.components.wallbox.Wallbox.getChargerStatus",
new=Mock(return_value=test_response),
),
):
result2 = await hass.config_entries.flow.async_configure(
@@ -132,20 +135,20 @@ async def test_form_validate_input(hass: HomeAssistant) -> None:
assert result2["data"]["station"] == "12345"
async def test_form_reauth(
hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox
) -> None:
async def test_form_reauth(hass: HomeAssistant, entry: MockConfigEntry) -> None:
"""Test we handle reauth flow."""
await setup_integration(hass, entry)
assert entry.state is ConfigEntryState.LOADED
with (
patch.object(
mock_wallbox,
"authenticate",
return_value=authorisation_response_unauthorised,
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
new=Mock(return_value=authorisation_response_unauthorised),
),
patch(
"homeassistant.components.wallbox.Wallbox.getChargerStatus",
new=Mock(return_value=test_response),
),
patch.object(mock_wallbox, "getChargerStatus", return_value=test_response),
):
result = await entry.start_reauth_flow(hass)
@@ -158,27 +161,27 @@ async def test_form_reauth(
},
)
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "reauth_successful"
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "reauth_successful"
await hass.async_block_till_done()
await hass.config_entries.async_unload(entry.entry_id)
async def test_form_reauth_invalid(
hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox
) -> None:
async def test_form_reauth_invalid(hass: HomeAssistant, entry: MockConfigEntry) -> None:
"""Test we handle reauth invalid flow."""
await setup_integration(hass, entry)
assert entry.state is ConfigEntryState.LOADED
with (
patch.object(
mock_wallbox,
"authenticate",
return_value=authorisation_response_unauthorised,
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
new=Mock(return_value=authorisation_response_unauthorised),
),
patch(
"homeassistant.components.wallbox.Wallbox.getChargerStatus",
new=Mock(return_value=test_response),
),
patch.object(mock_wallbox, "getChargerStatus", return_value=test_response),
):
result = await entry.start_reauth_flow(hass)
+86 -29
View File
@@ -1,23 +1,27 @@
"""Test Wallbox Init Component."""
from unittest.mock import patch
from unittest.mock import Mock, patch
from homeassistant.components.wallbox.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from .conftest import (
from . import (
authorisation_response,
http_403_error,
http_429_error,
setup_integration,
test_response_no_power_boost,
setup_integration_connection_error,
setup_integration_no_eco_mode,
setup_integration_read_only,
test_response,
)
from tests.common import MockConfigEntry
async def test_wallbox_setup_unload_entry(
hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test Wallbox Unload."""
@@ -29,27 +33,37 @@ async def test_wallbox_setup_unload_entry(
async def test_wallbox_unload_entry_connection_error(
hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test Wallbox Unload Connection Error."""
with patch.object(mock_wallbox, "authenticate", side_effect=http_403_error):
await setup_integration(hass, entry)
assert entry.state is ConfigEntryState.SETUP_ERROR
assert await hass.config_entries.async_unload(entry.entry_id)
assert entry.state is ConfigEntryState.NOT_LOADED
await setup_integration_connection_error(hass, entry)
assert entry.state is ConfigEntryState.SETUP_ERROR
assert await hass.config_entries.async_unload(entry.entry_id)
assert entry.state is ConfigEntryState.NOT_LOADED
async def test_wallbox_refresh_failed_connection_error_auth(
hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test Wallbox setup with connection error."""
await setup_integration(hass, entry)
assert entry.state is ConfigEntryState.LOADED
with patch.object(mock_wallbox, "authenticate", side_effect=http_429_error):
with (
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
new=Mock(side_effect=http_429_error),
),
patch(
"homeassistant.components.wallbox.Wallbox.pauseChargingSession",
new=Mock(return_value=test_response),
),
):
wallbox = hass.data[DOMAIN][entry.entry_id]
await wallbox.async_refresh()
assert await hass.config_entries.async_unload(entry.entry_id)
@@ -57,7 +71,7 @@ async def test_wallbox_refresh_failed_connection_error_auth(
async def test_wallbox_refresh_failed_invalid_auth(
hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test Wallbox setup with authentication error."""
@@ -65,8 +79,14 @@ async def test_wallbox_refresh_failed_invalid_auth(
assert entry.state is ConfigEntryState.LOADED
with (
patch.object(mock_wallbox, "authenticate", side_effect=http_403_error),
patch.object(mock_wallbox, "pauseChargingSession", side_effect=http_403_error),
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
new=Mock(side_effect=http_403_error),
),
patch(
"homeassistant.components.wallbox.Wallbox.pauseChargingSession",
new=Mock(side_effect=http_403_error),
),
):
wallbox = hass.data[DOMAIN][entry.entry_id]
@@ -77,14 +97,23 @@ async def test_wallbox_refresh_failed_invalid_auth(
async def test_wallbox_refresh_failed_http_error(
hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test Wallbox setup with authentication error."""
await setup_integration(hass, entry)
assert entry.state is ConfigEntryState.LOADED
with patch.object(mock_wallbox, "getChargerStatus", side_effect=http_403_error):
with (
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
new=Mock(return_value=authorisation_response),
),
patch(
"homeassistant.components.wallbox.Wallbox.getChargerStatus",
new=Mock(side_effect=http_403_error),
),
):
wallbox = hass.data[DOMAIN][entry.entry_id]
await wallbox.async_refresh()
@@ -94,14 +123,23 @@ async def test_wallbox_refresh_failed_http_error(
async def test_wallbox_refresh_failed_too_many_requests(
hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test Wallbox setup with authentication error."""
await setup_integration(hass, entry)
assert entry.state is ConfigEntryState.LOADED
with patch.object(mock_wallbox, "getChargerStatus", side_effect=http_429_error):
with (
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
new=Mock(return_value=authorisation_response),
),
patch(
"homeassistant.components.wallbox.Wallbox.getChargerStatus",
new=Mock(side_effect=http_429_error),
),
):
wallbox = hass.data[DOMAIN][entry.entry_id]
await wallbox.async_refresh()
@@ -111,14 +149,23 @@ async def test_wallbox_refresh_failed_too_many_requests(
async def test_wallbox_refresh_failed_connection_error(
hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test Wallbox setup with connection error."""
await setup_integration(hass, entry)
assert entry.state is ConfigEntryState.LOADED
with patch.object(mock_wallbox, "pauseChargingSession", side_effect=http_403_error):
with (
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
new=Mock(return_value=authorisation_response),
),
patch(
"homeassistant.components.wallbox.Wallbox.pauseChargingSession",
new=Mock(side_effect=http_403_error),
),
):
wallbox = hass.data[DOMAIN][entry.entry_id]
await wallbox.async_refresh()
@@ -127,15 +174,25 @@ async def test_wallbox_refresh_failed_connection_error(
assert entry.state is ConfigEntryState.NOT_LOADED
async def test_wallbox_refresh_failed_read_only(
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test Wallbox setup for read-only user."""
await setup_integration_read_only(hass, entry)
assert entry.state is ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(entry.entry_id)
assert entry.state is ConfigEntryState.NOT_LOADED
async def test_wallbox_setup_load_entry_no_eco_mode(
hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test Wallbox Unload."""
with patch.object(
mock_wallbox, "getChargerStatus", return_value=test_response_no_power_boost
):
await setup_integration(hass, entry)
assert entry.state is ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(entry.entry_id)
assert entry.state is ConfigEntryState.NOT_LOADED
await setup_integration_no_eco_mode(hass, entry)
assert entry.state is ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(entry.entry_id)
assert entry.state is ConfigEntryState.NOT_LOADED
+115 -47
View File
@@ -1,24 +1,28 @@
"""Test Wallbox Lock component."""
from unittest.mock import patch
from unittest.mock import Mock, patch
import pytest
from homeassistant.components.lock import SERVICE_LOCK, SERVICE_UNLOCK
from homeassistant.components.wallbox.coordinator import InvalidAuth
from homeassistant.components.wallbox.const import CHARGER_LOCKED_UNLOCKED_KEY
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .conftest import http_403_error, http_404_error, http_429_error, setup_integration
from . import (
authorisation_response,
http_403_error,
http_404_error,
http_429_error,
setup_integration,
)
from .const import MOCK_LOCK_ENTITY_ID
from tests.common import MockConfigEntry
async def test_wallbox_lock_class(
hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox
) -> None:
async def test_wallbox_lock_class(hass: HomeAssistant, entry: MockConfigEntry) -> None:
"""Test wallbox lock class."""
await setup_integration(hass, entry)
@@ -27,35 +31,60 @@ async def test_wallbox_lock_class(
assert state
assert state.state == "unlocked"
await hass.services.async_call(
"lock",
SERVICE_LOCK,
{
ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID,
},
blocking=True,
)
with (
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
new=Mock(return_value=authorisation_response),
),
patch(
"homeassistant.components.wallbox.Wallbox.lockCharger",
new=Mock(
return_value={"data": {"chargerData": {CHARGER_LOCKED_UNLOCKED_KEY: 1}}}
),
),
patch(
"homeassistant.components.wallbox.Wallbox.unlockCharger",
new=Mock(
return_value={"data": {"chargerData": {CHARGER_LOCKED_UNLOCKED_KEY: 0}}}
),
),
):
await hass.services.async_call(
"lock",
SERVICE_LOCK,
{
ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID,
},
blocking=True,
)
await hass.services.async_call(
"lock",
SERVICE_UNLOCK,
{
ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID,
},
blocking=True,
)
await hass.services.async_call(
"lock",
SERVICE_UNLOCK,
{
ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID,
},
blocking=True,
)
async def test_wallbox_lock_class_error_handling(
hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox
async def test_wallbox_lock_class_connection_error(
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test wallbox lock class connection error."""
await setup_integration(hass, entry)
with (
patch.object(mock_wallbox, "lockCharger", side_effect=http_404_error),
pytest.raises(HomeAssistantError),
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
new=Mock(return_value=authorisation_response),
),
patch(
"homeassistant.components.wallbox.Wallbox.lockCharger",
new=Mock(side_effect=ConnectionError),
),
pytest.raises(ConnectionError),
):
await hass.services.async_call(
"lock",
@@ -67,8 +96,42 @@ async def test_wallbox_lock_class_error_handling(
)
with (
patch.object(mock_wallbox, "lockCharger", side_effect=http_404_error),
patch.object(mock_wallbox, "unlockCharger", side_effect=http_404_error),
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
new=Mock(return_value=authorisation_response),
),
patch(
"homeassistant.components.wallbox.Wallbox.lockCharger",
new=Mock(side_effect=ConnectionError),
),
patch(
"homeassistant.components.wallbox.Wallbox.unlockCharger",
new=Mock(side_effect=ConnectionError),
),
pytest.raises(ConnectionError),
):
await hass.services.async_call(
"lock",
SERVICE_UNLOCK,
{
ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID,
},
blocking=True,
)
with (
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
new=Mock(return_value=authorisation_response),
),
patch(
"homeassistant.components.wallbox.Wallbox.lockCharger",
new=Mock(side_effect=http_429_error),
),
patch(
"homeassistant.components.wallbox.Wallbox.unlockCharger",
new=Mock(side_effect=http_429_error),
),
pytest.raises(HomeAssistantError),
):
await hass.services.async_call(
@@ -80,8 +143,18 @@ async def test_wallbox_lock_class_error_handling(
blocking=True,
)
with (
patch.object(mock_wallbox, "lockCharger", side_effect=http_404_error),
patch.object(mock_wallbox, "unlockCharger", side_effect=http_404_error),
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
new=Mock(return_value=authorisation_response),
),
patch(
"homeassistant.components.wallbox.Wallbox.lockCharger",
new=Mock(side_effect=http_403_error),
),
patch(
"homeassistant.components.wallbox.Wallbox.unlockCharger",
new=Mock(side_effect=http_403_error),
),
pytest.raises(HomeAssistantError),
):
await hass.services.async_call(
@@ -92,24 +165,19 @@ async def test_wallbox_lock_class_error_handling(
},
blocking=True,
)
with (
patch.object(mock_wallbox, "lockCharger", side_effect=http_403_error),
patch.object(mock_wallbox, "unlockCharger", side_effect=http_403_error),
pytest.raises(InvalidAuth),
):
await hass.services.async_call(
"lock",
SERVICE_UNLOCK,
{
ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID,
},
blocking=True,
)
with (
patch.object(mock_wallbox, "lockCharger", side_effect=http_429_error),
patch.object(mock_wallbox, "unlockCharger", side_effect=http_429_error),
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
new=Mock(return_value=authorisation_response),
),
patch(
"homeassistant.components.wallbox.Wallbox.lockCharger",
new=Mock(side_effect=http_404_error),
),
patch(
"homeassistant.components.wallbox.Wallbox.unlockCharger",
new=Mock(side_effect=http_404_error),
),
pytest.raises(HomeAssistantError),
):
await hass.services.async_call(
+287 -131
View File
@@ -1,22 +1,28 @@
"""Test Wallbox Switch component."""
from unittest.mock import patch
from unittest.mock import Mock, patch
import pytest
from homeassistant.components.input_number import ATTR_VALUE, SERVICE_SET_VALUE
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN
from homeassistant.components.wallbox.const import (
CHARGER_ENERGY_PRICE_KEY,
CHARGER_MAX_CHARGING_CURRENT_KEY,
CHARGER_MAX_ICP_CURRENT_KEY,
)
from homeassistant.components.wallbox.coordinator import InvalidAuth
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .conftest import (
from . import (
authorisation_response,
http_403_error,
http_404_error,
http_429_error,
setup_integration,
test_response_bidir,
setup_integration_bidir,
)
from .const import (
MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID,
@@ -26,87 +32,105 @@ from .const import (
from tests.common import MockConfigEntry
mock_wallbox = Mock()
mock_wallbox.authenticate = Mock(return_value=authorisation_response)
mock_wallbox.setEnergyCost = Mock(return_value={CHARGER_ENERGY_PRICE_KEY: 1.1})
mock_wallbox.setMaxChargingCurrent = Mock(
return_value={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}
)
mock_wallbox.setIcpMaxCurrent = Mock(return_value={CHARGER_MAX_ICP_CURRENT_KEY: 10})
async def test_wallbox_number_power_class(
hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox
async def test_wallbox_number_class(
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test wallbox sensor class."""
await setup_integration(hass, entry)
state = hass.states.get(MOCK_NUMBER_ENTITY_ID)
assert state.attributes["min"] == 6
assert state.attributes["max"] == 25
with (
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
new=Mock(return_value=authorisation_response),
),
patch(
"homeassistant.components.wallbox.Wallbox.setMaxChargingCurrent",
new=Mock(
return_value={"data": {"chargerData": {"maxChargingCurrent": 20}}}
),
),
):
state = hass.states.get(MOCK_NUMBER_ENTITY_ID)
assert state.attributes["min"] == 6
assert state.attributes["max"] == 25
await hass.services.async_call(
"number",
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ID,
ATTR_VALUE: 20,
},
blocking=True,
)
await hass.services.async_call(
"number",
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ID,
ATTR_VALUE: 20,
},
blocking=True,
)
async def test_wallbox_number_power_class_bidir(
hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox
async def test_wallbox_number_class_bidir(
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test wallbox sensor class."""
with patch.object(
mock_wallbox, "getChargerStatus", return_value=test_response_bidir
):
await setup_integration(hass, entry)
state = hass.states.get(MOCK_NUMBER_ENTITY_ID)
assert state.attributes["min"] == -25
assert state.attributes["max"] == 25
await setup_integration_bidir(hass, entry)
state = hass.states.get(MOCK_NUMBER_ENTITY_ID)
assert state.attributes["min"] == -25
assert state.attributes["max"] == 25
async def test_wallbox_number_energy_class(
hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox
) -> None:
"""Test wallbox sensor class."""
await setup_integration(hass, entry)
await hass.services.async_call(
"number",
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID,
ATTR_VALUE: 1.1,
},
blocking=True,
)
async def test_wallbox_number_icp_power_class(
hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox
) -> None:
"""Test wallbox sensor class."""
await setup_integration(hass, entry)
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID,
ATTR_VALUE: 10,
},
blocking=True,
)
async def test_wallbox_number_power_class_error_handling(
hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test wallbox sensor class."""
await setup_integration(hass, entry)
with (
patch.object(mock_wallbox, "setMaxChargingCurrent", side_effect=http_404_error),
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
new=Mock(return_value=authorisation_response),
),
patch(
"homeassistant.components.wallbox.Wallbox.setEnergyCost",
new=Mock(return_value={CHARGER_ENERGY_PRICE_KEY: 1.1}),
),
):
await hass.services.async_call(
"number",
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID,
ATTR_VALUE: 1.1,
},
blocking=True,
)
async def test_wallbox_number_class_connection_error(
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test wallbox sensor class."""
await setup_integration(hass, entry)
with (
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
new=Mock(return_value=authorisation_response),
),
patch(
"homeassistant.components.wallbox.Wallbox.setMaxChargingCurrent",
new=Mock(side_effect=http_404_error),
),
pytest.raises(HomeAssistantError),
):
await hass.services.async_call(
@@ -119,8 +143,23 @@ async def test_wallbox_number_power_class_error_handling(
blocking=True,
)
async def test_wallbox_number_class_too_many_requests(
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test wallbox sensor class."""
await setup_integration(hass, entry)
with (
patch.object(mock_wallbox, "setMaxChargingCurrent", side_effect=http_429_error),
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
new=Mock(return_value=authorisation_response),
),
patch(
"homeassistant.components.wallbox.Wallbox.setMaxChargingCurrent",
new=Mock(side_effect=http_429_error),
),
pytest.raises(HomeAssistantError),
):
await hass.services.async_call(
@@ -133,8 +172,167 @@ async def test_wallbox_number_power_class_error_handling(
blocking=True,
)
async def test_wallbox_number_class_energy_price_update_failed(
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test wallbox sensor class."""
await setup_integration(hass, entry)
with (
patch.object(mock_wallbox, "setMaxChargingCurrent", side_effect=http_403_error),
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
new=Mock(return_value=authorisation_response),
),
patch(
"homeassistant.components.wallbox.Wallbox.setEnergyCost",
new=Mock(side_effect=http_429_error),
),
pytest.raises(HomeAssistantError),
):
await hass.services.async_call(
"number",
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID,
ATTR_VALUE: 1.1,
},
blocking=True,
)
async def test_wallbox_number_class_energy_price_update_connection_error(
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test wallbox sensor class."""
await setup_integration(hass, entry)
with (
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
new=Mock(return_value=authorisation_response),
),
patch(
"homeassistant.components.wallbox.Wallbox.setEnergyCost",
new=Mock(side_effect=http_404_error),
),
pytest.raises(HomeAssistantError),
):
await hass.services.async_call(
"number",
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID,
ATTR_VALUE: 1.1,
},
blocking=True,
)
async def test_wallbox_number_class_energy_price_auth_error(
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test wallbox sensor class."""
await setup_integration(hass, entry)
with (
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
new=Mock(return_value=authorisation_response),
),
patch(
"homeassistant.components.wallbox.Wallbox.setEnergyCost",
new=Mock(side_effect=http_429_error),
),
pytest.raises(HomeAssistantError),
):
await hass.services.async_call(
"number",
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID,
ATTR_VALUE: 1.1,
},
blocking=True,
)
async def test_wallbox_number_class_icp_energy(
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test wallbox sensor class."""
await setup_integration(hass, entry)
with (
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
new=Mock(return_value=authorisation_response),
),
patch(
"homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent",
new=Mock(return_value={"icp_max_current": 20}),
),
):
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID,
ATTR_VALUE: 10,
},
blocking=True,
)
async def test_wallbox_number_class_icp_energy_auth_error(
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test wallbox sensor class."""
await setup_integration(hass, entry)
with (
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
new=Mock(return_value=authorisation_response),
),
patch(
"homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent",
new=Mock(side_effect=http_403_error),
),
pytest.raises(InvalidAuth),
):
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID,
ATTR_VALUE: 10,
},
blocking=True,
)
async def test_wallbox_number_class_energy_auth_error(
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test wallbox sensor class."""
await setup_integration(hass, entry)
with (
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
new=Mock(return_value=authorisation_response),
),
patch(
"homeassistant.components.wallbox.Wallbox.setMaxChargingCurrent",
new=Mock(side_effect=http_403_error),
),
pytest.raises(InvalidAuth),
):
await hass.services.async_call(
@@ -148,93 +346,51 @@ async def test_wallbox_number_power_class_error_handling(
)
async def test_wallbox_number_energy_class_error_handling(
hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox
async def test_wallbox_number_class_icp_energy_connection_error(
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test wallbox sensor class."""
await setup_integration(hass, entry)
with (
patch.object(mock_wallbox, "setEnergyCost", side_effect=http_429_error),
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
new=Mock(return_value=authorisation_response),
),
patch(
"homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent",
new=Mock(side_effect=http_404_error),
),
pytest.raises(HomeAssistantError),
):
await hass.services.async_call(
"number",
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID,
ATTR_VALUE: 1.1,
},
blocking=True,
)
with (
patch.object(mock_wallbox, "setEnergyCost", side_effect=http_404_error),
pytest.raises(HomeAssistantError),
):
await hass.services.async_call(
"number",
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID,
ATTR_VALUE: 1.1,
},
blocking=True,
)
with (
patch.object(mock_wallbox, "setEnergyCost", side_effect=http_429_error),
pytest.raises(HomeAssistantError),
):
await hass.services.async_call(
"number",
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID,
ATTR_VALUE: 1.1,
ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID,
ATTR_VALUE: 10,
},
blocking=True,
)
async def test_wallbox_number_icp_power_class_error_handling(
hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox
async def test_wallbox_number_class_icp_energy_too_many_request(
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test wallbox sensor class."""
await setup_integration(hass, entry)
with (
patch.object(mock_wallbox, "setIcpMaxCurrent", side_effect=http_403_error),
pytest.raises(InvalidAuth),
):
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID,
ATTR_VALUE: 10,
},
blocking=True,
)
with (
patch.object(mock_wallbox, "setIcpMaxCurrent", side_effect=http_404_error),
pytest.raises(HomeAssistantError),
):
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID,
ATTR_VALUE: 10,
},
blocking=True,
)
with (
patch.object(mock_wallbox, "setIcpMaxCurrent", side_effect=http_429_error),
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
new=Mock(return_value=authorisation_response),
),
patch(
"homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent",
new=Mock(side_effect=http_429_error),
),
pytest.raises(HomeAssistantError),
):
await hass.services.async_call(
+73 -53
View File
@@ -1,6 +1,6 @@
"""Test Wallbox Select component."""
from unittest.mock import patch
from unittest.mock import Mock, patch
import pytest
@@ -9,14 +9,15 @@ from homeassistant.components.select import (
DOMAIN as SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
)
from homeassistant.components.wallbox.const import EcoSmartMode
from homeassistant.components.wallbox.const import CHARGER_STATUS_ID_KEY, EcoSmartMode
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, HomeAssistantError
from .conftest import (
from . import (
authorisation_response,
http_404_error,
http_429_error,
setup_integration,
setup_integration_select,
test_response,
test_response_eco_mode,
test_response_full_solar,
@@ -33,13 +34,44 @@ TEST_OPTIONS = [
]
@pytest.fixture
def mock_authenticate():
"""Fixture to patch Wallbox methods."""
with patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
new=Mock(return_value=authorisation_response),
):
yield
@pytest.mark.parametrize(("mode", "response"), TEST_OPTIONS)
async def test_wallbox_select_solar_charging_class(
hass: HomeAssistant, entry: MockConfigEntry, mode, response, mock_wallbox
hass: HomeAssistant, entry: MockConfigEntry, mode, response, mock_authenticate
) -> None:
"""Test wallbox select class."""
with patch.object(mock_wallbox, "getChargerStatus", return_value=response):
await setup_integration(hass, entry)
if mode == EcoSmartMode.OFF:
response = test_response
elif mode == EcoSmartMode.ECO_MODE:
response = test_response_eco_mode
elif mode == EcoSmartMode.FULL_SOLAR:
response = test_response_full_solar
with (
patch(
"homeassistant.components.wallbox.Wallbox.enableEcoSmart",
new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}),
),
patch(
"homeassistant.components.wallbox.Wallbox.disableEcoSmart",
new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}),
),
patch(
"homeassistant.components.wallbox.Wallbox.getChargerStatus",
new=Mock(return_value=response),
),
):
await setup_integration_select(hass, entry, response)
await hass.services.async_call(
SELECT_DOMAIN,
@@ -56,35 +88,43 @@ async def test_wallbox_select_solar_charging_class(
async def test_wallbox_select_no_power_boost_class(
hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test wallbox select class."""
with patch.object(
mock_wallbox, "getChargerStatus", return_value=test_response_no_power_boost
):
await setup_integration(hass, entry)
await setup_integration_select(hass, entry, test_response_no_power_boost)
state = hass.states.get(MOCK_SELECT_ENTITY_ID)
assert state is None
state = hass.states.get(MOCK_SELECT_ENTITY_ID)
assert state is None
@pytest.mark.parametrize(("mode", "response"), TEST_OPTIONS)
@pytest.mark.parametrize("error", [http_404_error, ConnectionError])
async def test_wallbox_select_class_error(
hass: HomeAssistant,
entry: MockConfigEntry,
mode,
response,
mock_wallbox,
error,
mock_authenticate,
) -> None:
"""Test wallbox select class connection error."""
await setup_integration(hass, entry)
await setup_integration_select(hass, entry, response)
with (
patch.object(mock_wallbox, "getChargerStatus", return_value=response),
patch.object(mock_wallbox, "disableEcoSmart", side_effect=http_404_error),
patch.object(mock_wallbox, "enableEcoSmart", side_effect=http_404_error),
patch(
"homeassistant.components.wallbox.Wallbox.disableEcoSmart",
new=Mock(side_effect=error),
),
patch(
"homeassistant.components.wallbox.Wallbox.enableEcoSmart",
new=Mock(side_effect=error),
),
patch(
"homeassistant.components.wallbox.Wallbox.getChargerStatus",
new=Mock(return_value=test_response_eco_mode),
),
pytest.raises(HomeAssistantError),
):
await hass.services.async_call(
@@ -104,45 +144,25 @@ async def test_wallbox_select_too_many_requests_error(
entry: MockConfigEntry,
mode,
response,
mock_wallbox,
mock_authenticate,
) -> None:
"""Test wallbox select class connection error."""
await setup_integration(hass, entry)
await setup_integration_select(hass, entry, response)
with (
patch.object(mock_wallbox, "getChargerStatus", return_value=response),
patch.object(mock_wallbox, "disableEcoSmart", side_effect=http_429_error),
patch.object(mock_wallbox, "enableEcoSmart", side_effect=http_429_error),
pytest.raises(HomeAssistantError),
):
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{
ATTR_ENTITY_ID: MOCK_SELECT_ENTITY_ID,
ATTR_OPTION: mode,
},
blocking=True,
)
@pytest.mark.parametrize(("mode", "response"), TEST_OPTIONS)
async def test_wallbox_select_connection_error(
hass: HomeAssistant,
entry: MockConfigEntry,
mode,
response,
mock_wallbox,
) -> None:
"""Test wallbox select class connection error."""
await setup_integration(hass, entry)
with (
patch.object(mock_wallbox, "getChargerStatus", return_value=response),
patch.object(mock_wallbox, "disableEcoSmart", side_effect=ConnectionError),
patch.object(mock_wallbox, "enableEcoSmart", side_effect=ConnectionError),
patch(
"homeassistant.components.wallbox.Wallbox.disableEcoSmart",
new=Mock(side_effect=http_429_error),
),
patch(
"homeassistant.components.wallbox.Wallbox.enableEcoSmart",
new=Mock(side_effect=http_429_error),
),
patch(
"homeassistant.components.wallbox.Wallbox.getChargerStatus",
new=Mock(return_value=test_response_eco_mode),
),
pytest.raises(HomeAssistantError),
):
await hass.services.async_call(
+2 -2
View File
@@ -3,7 +3,7 @@
from homeassistant.const import CONF_UNIT_OF_MEASUREMENT, UnitOfPower
from homeassistant.core import HomeAssistant
from .conftest import setup_integration
from . import setup_integration
from .const import (
MOCK_SENSOR_CHARGING_POWER_ID,
MOCK_SENSOR_CHARGING_SPEED_ID,
@@ -14,7 +14,7 @@ from tests.common import MockConfigEntry
async def test_wallbox_sensor_class(
hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test wallbox sensor class."""
+70 -23
View File
@@ -1,22 +1,29 @@
"""Test Wallbox Lock component."""
from unittest.mock import patch
from unittest.mock import Mock, patch
import pytest
from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON
from homeassistant.components.wallbox.const import CHARGER_STATUS_ID_KEY
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .conftest import http_404_error, http_429_error, setup_integration
from . import (
authorisation_response,
http_404_error,
http_429_error,
setup_integration,
test_response,
)
from .const import MOCK_SWITCH_ENTITY_ID
from tests.common import MockConfigEntry
async def test_wallbox_switch_class(
hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test wallbox switch class."""
@@ -26,34 +33,59 @@ async def test_wallbox_switch_class(
assert state
assert state.state == "on"
await hass.services.async_call(
"switch",
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID,
},
blocking=True,
)
with (
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
new=Mock(return_value=authorisation_response),
),
patch(
"homeassistant.components.wallbox.Wallbox.pauseChargingSession",
new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}),
),
patch(
"homeassistant.components.wallbox.Wallbox.resumeChargingSession",
new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}),
),
patch(
"homeassistant.components.wallbox.Wallbox.getChargerStatus",
new=Mock(return_value=test_response),
),
):
await hass.services.async_call(
"switch",
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID,
},
blocking=True,
)
await hass.services.async_call(
"switch",
SERVICE_TURN_OFF,
{
ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID,
},
blocking=True,
)
await hass.services.async_call(
"switch",
SERVICE_TURN_OFF,
{
ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID,
},
blocking=True,
)
async def test_wallbox_switch_class_error_handling(
hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox
async def test_wallbox_switch_class_connection_error(
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test wallbox switch class connection error."""
await setup_integration(hass, entry)
with (
patch.object(mock_wallbox, "resumeChargingSession", side_effect=http_404_error),
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
new=Mock(return_value=authorisation_response),
),
patch(
"homeassistant.components.wallbox.Wallbox.resumeChargingSession",
new=Mock(side_effect=http_404_error),
),
pytest.raises(HomeAssistantError),
):
# Test behavior when a connection error occurs
@@ -66,8 +98,23 @@ async def test_wallbox_switch_class_error_handling(
blocking=True,
)
async def test_wallbox_switch_class_too_many_requests(
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
"""Test wallbox switch class connection error."""
await setup_integration(hass, entry)
with (
patch.object(mock_wallbox, "resumeChargingSession", side_effect=http_429_error),
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
new=Mock(return_value=authorisation_response),
),
patch(
"homeassistant.components.wallbox.Wallbox.resumeChargingSession",
new=Mock(side_effect=http_429_error),
),
pytest.raises(HomeAssistantError),
):
# Test behavior when a connection error occurs
@@ -19,7 +19,6 @@ from homeassistant.components.websocket_api.auth import (
TYPE_AUTH_REQUIRED,
)
from homeassistant.components.websocket_api.commands import (
ALL_CONDITION_DESCRIPTIONS_JSON_CACHE,
ALL_SERVICE_DESCRIPTIONS_JSON_CACHE,
ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE,
)
@@ -711,91 +710,6 @@ async def test_get_services(
assert hass.data[ALL_SERVICE_DESCRIPTIONS_JSON_CACHE] is old_cache
@patch("annotatedyaml.loader.load_yaml")
@patch.object(Integration, "has_conditions", return_value=True)
async def test_subscribe_conditions(
mock_has_conditions: Mock,
mock_load_yaml: Mock,
hass: HomeAssistant,
websocket_client: MockHAClientWebSocket,
) -> None:
"""Test condition_platforms/subscribe command."""
sun_condition_descriptions = """
sun: {}
"""
device_automation_condition_descriptions = """
device: {}
"""
def _load_yaml(fname, secrets=None):
if fname.endswith("device_automation/conditions.yaml"):
condition_descriptions = device_automation_condition_descriptions
elif fname.endswith("sun/conditions.yaml"):
condition_descriptions = sun_condition_descriptions
else:
raise FileNotFoundError
with io.StringIO(condition_descriptions) as file:
return parse_yaml(file)
mock_load_yaml.side_effect = _load_yaml
assert await async_setup_component(hass, "sun", {})
assert await async_setup_component(hass, "system_health", {})
await hass.async_block_till_done()
assert ALL_CONDITION_DESCRIPTIONS_JSON_CACHE not in hass.data
await websocket_client.send_json_auto_id({"type": "condition_platforms/subscribe"})
# Test start subscription with initial event
msg = await websocket_client.receive_json()
assert msg == {"id": 1, "result": None, "success": True, "type": "result"}
msg = await websocket_client.receive_json()
assert msg == {"event": {"sun": {"fields": {}}}, "id": 1, "type": "event"}
old_cache = hass.data[ALL_CONDITION_DESCRIPTIONS_JSON_CACHE]
# Test we receive an event when a new platform is loaded, if it has descriptions
assert await async_setup_component(hass, "calendar", {})
assert await async_setup_component(hass, "device_automation", {})
await hass.async_block_till_done()
msg = await websocket_client.receive_json()
assert msg == {
"event": {"device": {"fields": {}}},
"id": 1,
"type": "event",
}
# Initiate a second subscription to check the cache is updated because of the new
# condition
await websocket_client.send_json_auto_id({"type": "condition_platforms/subscribe"})
msg = await websocket_client.receive_json()
assert msg == {"id": 2, "result": None, "success": True, "type": "result"}
msg = await websocket_client.receive_json()
assert msg == {
"event": {"device": {"fields": {}}, "sun": {"fields": {}}},
"id": 2,
"type": "event",
}
assert hass.data[ALL_CONDITION_DESCRIPTIONS_JSON_CACHE] is not old_cache
# Initiate a third subscription to check the cache is not updated because no new
# condition was added
old_cache = hass.data[ALL_CONDITION_DESCRIPTIONS_JSON_CACHE]
await websocket_client.send_json_auto_id({"type": "condition_platforms/subscribe"})
msg = await websocket_client.receive_json()
assert msg == {"id": 3, "result": None, "success": True, "type": "result"}
msg = await websocket_client.receive_json()
assert msg == {
"event": {"device": {"fields": {}}, "sun": {"fields": {}}},
"id": 3,
"type": "event",
}
assert hass.data[ALL_CONDITION_DESCRIPTIONS_JSON_CACHE] is old_cache
@patch("annotatedyaml.loader.load_yaml")
@patch.object(Integration, "has_triggers", return_value=True)
async def test_subscribe_triggers(
+1 -1
View File
@@ -1724,7 +1724,7 @@ async def async_test_recorder(
wait_recorder: bool = True,
wait_recorder_setup: bool = True,
) -> AsyncGenerator[recorder.Recorder]:
"""Setup and return recorder instance."""
"""Setup and return recorder instance.""" # noqa: D401
await _async_init_recorder_component(
hass,
config,
+1 -287
View File
@@ -1,21 +1,14 @@
"""Test the condition helper."""
from datetime import timedelta
import io
from typing import Any
from unittest.mock import AsyncMock, Mock, patch
from freezegun import freeze_time
import pytest
from pytest_unordered import unordered
import voluptuous as vol
from homeassistant.components.device_automation import (
DOMAIN as DOMAIN_DEVICE_AUTOMATION,
)
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.components.sun import DOMAIN as DOMAIN_SUN
from homeassistant.components.system_health import DOMAIN as DOMAIN_SYSTEM_HEALTH
from homeassistant.const import (
ATTR_DEVICE_CLASS,
CONF_CONDITION,
@@ -34,12 +27,10 @@ from homeassistant.helpers import (
)
from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import Integration, async_get_integration
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from homeassistant.util.yaml.loader import parse_yaml
from tests.common import MockModule, MockPlatform, mock_integration, mock_platform
from tests.common import MockModule, mock_integration, mock_platform
def assert_element(trace_element, expected_element, path):
@@ -2526,280 +2517,3 @@ async def test_or_condition_with_disabled_condition(hass: HomeAssistant) -> None
"conditions/1/entity_id/0": [{"result": {"result": True, "state": 100.0}}],
}
)
@pytest.mark.parametrize(
"sun_condition_descriptions",
[
"""
sun:
fields:
after:
example: sunrise
selector:
select:
options:
- sunrise
- sunset
after_offset:
selector:
time: null
before:
example: sunrise
selector:
select:
options:
- sunrise
- sunset
before_offset:
selector:
time: null
""",
"""
.sunrise_sunset_selector: &sunrise_sunset_selector
example: sunrise
selector:
select:
options:
- sunrise
- sunset
.offset_selector: &offset_selector
selector:
time: null
sun:
fields:
after: *sunrise_sunset_selector
after_offset: *offset_selector
before: *sunrise_sunset_selector
before_offset: *offset_selector
""",
],
)
async def test_async_get_all_descriptions(
hass: HomeAssistant, sun_condition_descriptions: str
) -> None:
"""Test async_get_all_descriptions."""
device_automation_condition_descriptions = """
device: {}
"""
assert await async_setup_component(hass, DOMAIN_SUN, {})
assert await async_setup_component(hass, DOMAIN_SYSTEM_HEALTH, {})
await hass.async_block_till_done()
def _load_yaml(fname, secrets=None):
if fname.endswith("device_automation/conditions.yaml"):
condition_descriptions = device_automation_condition_descriptions
elif fname.endswith("sun/conditions.yaml"):
condition_descriptions = sun_condition_descriptions
with io.StringIO(condition_descriptions) as file:
return parse_yaml(file)
with (
patch(
"homeassistant.helpers.condition._load_conditions_files",
side_effect=condition._load_conditions_files,
) as proxy_load_conditions_files,
patch(
"annotatedyaml.loader.load_yaml",
side_effect=_load_yaml,
),
patch.object(Integration, "has_conditions", return_value=True),
):
descriptions = await condition.async_get_all_descriptions(hass)
# Test we only load conditions.yaml for integrations with conditions,
# system_health has no conditions
assert proxy_load_conditions_files.mock_calls[0][1][1] == unordered(
[
await async_get_integration(hass, DOMAIN_SUN),
]
)
# system_health does not have conditions and should not be in descriptions
assert descriptions == {
DOMAIN_SUN: {
"fields": {
"after": {
"example": "sunrise",
"selector": {"select": {"options": ["sunrise", "sunset"]}},
},
"after_offset": {"selector": {"time": None}},
"before": {
"example": "sunrise",
"selector": {"select": {"options": ["sunrise", "sunset"]}},
},
"before_offset": {"selector": {"time": None}},
}
}
}
# Verify the cache returns the same object
assert await condition.async_get_all_descriptions(hass) is descriptions
# Load the device_automation integration and check a new cache object is created
assert await async_setup_component(hass, DOMAIN_DEVICE_AUTOMATION, {})
await hass.async_block_till_done()
with (
patch(
"annotatedyaml.loader.load_yaml",
side_effect=_load_yaml,
),
patch.object(Integration, "has_conditions", return_value=True),
):
new_descriptions = await condition.async_get_all_descriptions(hass)
assert new_descriptions is not descriptions
assert new_descriptions == {
"device": {
"fields": {},
},
DOMAIN_SUN: {
"fields": {
"after": {
"example": "sunrise",
"selector": {"select": {"options": ["sunrise", "sunset"]}},
},
"after_offset": {"selector": {"time": None}},
"before": {
"example": "sunrise",
"selector": {"select": {"options": ["sunrise", "sunset"]}},
},
"before_offset": {"selector": {"time": None}},
}
},
}
# Verify the cache returns the same object
assert await condition.async_get_all_descriptions(hass) is new_descriptions
@pytest.mark.parametrize(
("yaml_error", "expected_message"),
[
(
FileNotFoundError("Blah"),
"Unable to find conditions.yaml for the sun integration",
),
(
HomeAssistantError("Test error"),
"Unable to parse conditions.yaml for the sun integration: Test error",
),
],
)
async def test_async_get_all_descriptions_with_yaml_error(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
yaml_error: Exception,
expected_message: str,
) -> None:
"""Test async_get_all_descriptions."""
assert await async_setup_component(hass, DOMAIN_SUN, {})
await hass.async_block_till_done()
def _load_yaml_dict(fname, secrets=None):
raise yaml_error
with (
patch(
"homeassistant.helpers.condition.load_yaml_dict",
side_effect=_load_yaml_dict,
),
patch.object(Integration, "has_conditions", return_value=True),
):
descriptions = await condition.async_get_all_descriptions(hass)
assert descriptions == {DOMAIN_SUN: None}
assert expected_message in caplog.text
async def test_async_get_all_descriptions_with_bad_description(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test async_get_all_descriptions."""
sun_service_descriptions = """
sun:
fields: not_a_dict
"""
assert await async_setup_component(hass, DOMAIN_SUN, {})
await hass.async_block_till_done()
def _load_yaml(fname, secrets=None):
with io.StringIO(sun_service_descriptions) as file:
return parse_yaml(file)
with (
patch(
"annotatedyaml.loader.load_yaml",
side_effect=_load_yaml,
),
patch.object(Integration, "has_conditions", return_value=True),
):
descriptions = await condition.async_get_all_descriptions(hass)
assert descriptions == {DOMAIN_SUN: None}
assert (
"Unable to parse conditions.yaml for the sun integration: "
"expected a dictionary for dictionary value @ data['sun']['fields']"
) in caplog.text
async def test_invalid_condition_platform(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test invalid condition platform."""
mock_integration(hass, MockModule("test", async_setup=AsyncMock(return_value=True)))
mock_platform(hass, "test.condition", MockPlatform())
await async_setup_component(hass, "test", {})
assert (
"Integration test does not provide condition support, skipping" in caplog.text
)
@patch("annotatedyaml.loader.load_yaml")
@patch.object(Integration, "has_conditions", return_value=True)
async def test_subscribe_conditions(
mock_has_conditions: Mock,
mock_load_yaml: Mock,
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test condition.async_subscribe_platform_events."""
sun_condition_descriptions = """
sun: {}
"""
def _load_yaml(fname, secrets=None):
if fname.endswith("sun/conditions.yaml"):
condition_descriptions = sun_condition_descriptions
else:
raise FileNotFoundError
with io.StringIO(condition_descriptions) as file:
return parse_yaml(file)
mock_load_yaml.side_effect = _load_yaml
async def broken_subscriber(_):
"""Simulate a broken subscriber."""
raise Exception("Boom!") # noqa: TRY002
condition_events = []
async def good_subscriber(new_conditions: set[str]):
"""Simulate a working subscriber."""
condition_events.append(new_conditions)
condition.async_subscribe_platform_events(hass, broken_subscriber)
condition.async_subscribe_platform_events(hass, good_subscriber)
assert await async_setup_component(hass, "sun", {})
assert condition_events == [{"sun"}]
assert "Error while notifying condition platform listener" in caplog.text
-35
View File
@@ -2713,41 +2713,6 @@ async def test_platform_state(
assert hass.states.get("test.test") is None
async def test_platform_state_no_platform(hass: HomeAssistant) -> None:
"""Test platform state for entities which are not added by an entity platform."""
class MockEntity(entity.Entity):
entity_id = "test.test"
def async_set_state(self, state: str) -> None:
self._attr_state = state
self.async_write_ha_state()
ent = MockEntity()
ent.hass = hass
assert hass.states.get("test.test") is None
# The attempt to write when in state NOT_ADDED should be allowed
assert ent._platform_state == entity.EntityPlatformState.NOT_ADDED
ent.async_set_state("not_added")
assert hass.states.get("test.test").state == "not_added"
# The attempt to write when in state ADDING should be allowed
ent._platform_state = entity.EntityPlatformState.ADDING
ent.async_set_state("adding")
assert hass.states.get("test.test").state == "adding"
# The attempt to write when in state ADDED should be allowed
ent._platform_state = entity.EntityPlatformState.ADDED
ent.async_set_state("added")
assert hass.states.get("test.test").state == "added"
# The attempt to write when in state REMOVED should be ignored
ent._platform_state = entity.EntityPlatformState.REMOVED
ent.async_set_state("removed")
assert hass.states.get("test.test").state == "added"
async def test_platform_state_fail_to_add(
hass: HomeAssistant, entity_registry: er.EntityRegistry
) -> None:
+1 -7
View File
@@ -396,13 +396,7 @@ def test_assist_pipeline_selector_schema(
({"min": -100, "max": 100, "step": 5}, (), ()),
({"min": -20, "max": -10, "mode": "box"}, (), ()),
(
{
"min": 0,
"max": 100,
"unit_of_measurement": "seconds",
"mode": "slider",
"translation_key": "foo",
},
{"min": 0, "max": 100, "unit_of_measurement": "seconds", "mode": "slider"},
(),
(),
),