Compare commits

..

29 Commits

Author SHA1 Message Date
abmantis 0190dc16a6 Merge branch 'dev' of github.com:home-assistant/core into automation_components_diag_entities 2026-04-29 11:08:30 +01:00
Simone Chemelli f4637db26d Add routine management to Alexa Devices (#166291) 2026-04-29 11:45:03 +02:00
Erik Montnemery b4bfe6b80b Rename timer last_action to last_transition (#169430) 2026-04-29 11:35:36 +02:00
Andrej Walilko 278f25ec6e Redact sensitive api creds before logging message in websocket api (#169326)
Co-authored-by: Andrej Walilko <awalilko@liquidweb.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-04-29 11:15:05 +02:00
Robert Resch 39d3bc3e53 Bump deebot-client to 18.2.0 (#169003) 2026-04-29 11:13:14 +02:00
Yabing Yi bb41a2df9f Fix logbook spam by including image domain in ALWAYS_CONTINUOUS_DOMAINS (#169240)
Co-authored-by: Claude Code <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-04-29 10:58:13 +02:00
Petar Petrov 284242b90e Copy unit_of_measurement onto energy inverted power sensor (#169427) 2026-04-29 10:56:08 +02:00
Erik Montnemery a95c216983 Unload scripts created by websocket command execute_script (#169368)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2026-04-29 10:24:24 +02:00
Simone Chemelli d41a3ae0cd Use defaults for device class UPTIME in Shelly (#169148) 2026-04-29 10:12:18 +02:00
J. Nick Koston 0dfbe3ef84 Expose async_clear_advertisement_history in the bluetooth API (#169191) 2026-04-29 10:11:27 +02:00
Franck Nijhof 71fc725d75 Extract state template functions into a state Jinja2 extension (#169034) 2026-04-29 10:03:38 +02:00
Shay Levy d41c9aee52 Bump aioshelly to 13.24.1 (#169426)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2026-04-29 10:53:38 +03:00
epenet 8091f511b8 Reject manifest dependencies on core integrations in hassfest (#169425) 2026-04-29 09:52:46 +02:00
Franck Nijhof a7baedc22b Add error and alert sensors to Fumis integration (#169307) 2026-04-29 09:51:22 +02:00
Franck Nijhof 05bfb3a52e Add number platform to Fumis integration (#169100) 2026-04-29 09:39:15 +02:00
Robert Resch 2a5b95ba4d Require hass in Template (#169292)
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 09:26:32 +02:00
Steve Easley 3dd972cc7a Fix jvcprojector entities going unavailable on transient command errors (#168985) 2026-04-29 09:21:53 +02:00
Marc Mueller acd9dd218a Protect CI cache save against cancellation (#168310) 2026-04-29 09:20:37 +02:00
J. Diego Rodríguez Royo 6552cf8f7a Keep options values when chaging or starting program on Home Connect (#168575) 2026-04-29 09:19:41 +02:00
Artur Pragacz e4e4785225 Clean up entity_service_call tests (#169170)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-29 09:17:45 +02:00
G Johansson d531ce8d1d Use async_on_create_entry in bayesian (#169218)
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 09:14:58 +02:00
Stefan Agner 0224928655 Bump python-otbr-api to 2.10.0 (#169370)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2026-04-29 09:10:26 +02:00
abmantis 9b29b07329 Rename test sensor entity 2026-04-28 15:58:39 +01:00
abmantis 59711ba797 Extract triggers/conditions/services for non-primary entities 2026-04-27 18:36:36 +01:00
abmantis 999d987108 Allow targeting non-primary entities in conditions 2026-04-27 14:47:04 +01:00
abmantis f660ddddea Add target selector tests for primary_entities_only field 2026-04-27 12:53:31 +01:00
abmantis 47579a9ac7 Merge branch 'dev' of github.com:home-assistant/core into non_primary_entity_trigger 2026-04-24 15:28:38 +01:00
abmantis c65c502e2f Allow targeting non-primary entities in triggers 2026-04-23 17:59:56 +01:00
abmantis 13e28210aa Allow extracting non-primary entities in websocket command 2026-04-22 23:19:29 +01:00
77 changed files with 5345 additions and 2586 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ env:
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"
# Base image version from https://github.com/home-assistant/docker
BASE_IMAGE_VERSION: "2026.02.0"
BASE_IMAGE_VERSION: "2026.01.0"
ARCHITECTURES: '["amd64", "aarch64"]'
permissions: {}
+29 -3
View File
@@ -366,7 +366,7 @@ jobs:
echo "key=uv-${UV_CACHE_VERSION}-${uv_version}-${HA_SHORT_VERSION}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: venv
key: >-
@@ -374,7 +374,8 @@ jobs:
needs.info.outputs.python_cache_key }}
- name: Restore uv wheel cache
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
id: cache-uv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ env.UV_CACHE_DIR }}
key: >-
@@ -398,6 +399,7 @@ jobs:
if: |
steps.cache-venv.outputs.cache-hit != 'true'
|| steps.cache-apt-check.outputs.cache-hit != 'true'
id: install-os-deps
timeout-minutes: 10
env:
APT_CACHE_HIT: ${{ steps.cache-apt-check.outputs.cache-hit }}
@@ -431,7 +433,10 @@ jobs:
sudo chmod -R 755 ${APT_CACHE_BASE}
fi
- name: Save apt cache
if: steps.cache-apt-check.outputs.cache-hit != 'true'
if: |
always()
&& steps.cache-apt-check.outputs.cache-hit != 'true'
&& steps.install-os-deps.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
@@ -441,6 +446,7 @@ jobs:
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true'
id: create-venv
run: |
python -m venv venv
. venv/bin/activate
@@ -471,6 +477,26 @@ jobs:
- name: Check dirty
run: |
./script/check_dirty
- name: Save uv wheel cache
if: |
(success() && steps.cache-venv.outputs.cache-hit != 'true')
|| (always()
&& steps.create-venv.outcome == 'success'
&& steps.cache-uv.outputs.cache-matched-key == '')
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ env.UV_CACHE_DIR }}
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
steps.generate-uv-key.outputs.key }}
- name: Save base Python virtual environment
if: always() && steps.create-venv.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: venv
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
hassfest:
name: Check hassfest
+1 -1
View File
@@ -1 +1 @@
3.14.3
3.14.2
@@ -11,6 +11,7 @@ from .services import async_setup_services
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.NOTIFY,
Platform.SENSOR,
Platform.SWITCH,
@@ -0,0 +1,55 @@
"""Support for buttons."""
from homeassistant.components.button import ButtonEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import slugify
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
from .entity import AmazonServiceEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: AmazonConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up button entities for Alexa Devices."""
coordinator = entry.runtime_data
known_routines: set[str] = set()
def _check_routines() -> None:
current_routines = set(coordinator.api.routines)
new_routines = current_routines - known_routines
if new_routines:
known_routines.update(new_routines)
async_add_entities(
AmazonRoutineButton(coordinator, routine) for routine in new_routines
)
_check_routines()
entry.async_on_unload(coordinator.async_add_listener(_check_routines))
class AmazonRoutineButton(AmazonServiceEntity, ButtonEntity):
"""Button entity for Alexa routine."""
_attr_has_entity_name = True
def __init__(self, coordinator: AmazonDevicesCoordinator, routine: str) -> None:
"""Initialize the routine button entity."""
self._coordinator = coordinator
self._routine = routine
super().__init__(
coordinator,
EntityDescription(key=slugify(routine), name=routine),
)
async def async_press(self) -> None:
"""Handle button press action."""
await self._coordinator.api.call_routine(self._routine)
@@ -12,12 +12,13 @@ from aioamazondevices.structures import AmazonDevice
from aiohttp import ClientSession
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import slugify
from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN
@@ -64,6 +65,13 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
for identifier_domain, identifier in device.identifiers
if identifier_domain == DOMAIN
}
self.previous_routines: set[str] = {
routine.unique_id
for routine in er.async_entries_for_config_entry(
er.async_get(hass), entry.entry_id
)
if routine.domain == Platform.BUTTON
}
async def _async_update_data(self) -> dict[str, AmazonDevice]:
"""Update device data."""
@@ -92,8 +100,13 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
current_devices = set(data.keys())
if stale_devices := self.previous_devices - current_devices:
await self._async_remove_device_stale(stale_devices)
self.previous_devices = current_devices
current_routines = {slugify(routine) for routine in self.api.routines}
if stale_routines := self.previous_routines - current_routines:
await self._async_remove_routine_stale(stale_routines)
self.previous_routines = current_routines
return data
async def _async_remove_device_stale(
@@ -116,3 +129,23 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
async def _async_remove_routine_stale(
self,
stale_routines: set[str],
) -> None:
"""Remove stale routine."""
entity_registry = er.async_get(self.hass)
for routine in stale_routines:
_LOGGER.debug(
"Detected change in routines: routine %s removed",
routine,
)
entity_id = entity_registry.async_get_entity_id(
Platform.BUTTON,
DOMAIN,
f"{slugify(self.config_entry.unique_id)}-{slugify(routine)}",
)
if entity_id:
entity_registry.async_remove(entity_id)
@@ -2,9 +2,10 @@
from aioamazondevices.structures import AmazonDevice
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
from .const import DOMAIN
from .coordinator import AmazonDevicesCoordinator
@@ -50,3 +51,32 @@ class AmazonEntity(CoordinatorEntity[AmazonDevicesCoordinator]):
and self._serial_num in self.coordinator.data
and self.device.online
)
class AmazonServiceEntity(CoordinatorEntity[AmazonDevicesCoordinator]):
"""Defines Alexa Devices entity for service device."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AmazonDevicesCoordinator,
description: EntityDescription,
) -> None:
"""Initialize the service entity."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, service_device_id(coordinator))},
manufacturer="Amazon",
entry_type=DeviceEntryType.SERVICE,
)
self.entity_description = description
self._attr_unique_id = (
f"{slugify(coordinator.config_entry.unique_id)}-{description.key}"
)
def service_device_id(coordinator: AmazonDevicesCoordinator) -> str:
"""Return service device id."""
return slugify(f"{coordinator.config_entry.unique_id}_service_device")
+19 -5
View File
@@ -30,19 +30,33 @@ BATTERY_PERCENTAGE_DOMAIN_SPECS = {
CONDITIONS: dict[str, type[Condition]] = {
"is_low": make_entity_state_condition(
BATTERY_DOMAIN_SPECS, STATE_ON, support_duration=True
BATTERY_DOMAIN_SPECS,
STATE_ON,
support_duration=True,
primary_entities_only=False,
),
"is_not_low": make_entity_state_condition(
BATTERY_DOMAIN_SPECS, STATE_OFF, support_duration=True
BATTERY_DOMAIN_SPECS,
STATE_OFF,
support_duration=True,
primary_entities_only=False,
),
"is_charging": make_entity_state_condition(
BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON, support_duration=True
BATTERY_CHARGING_DOMAIN_SPECS,
STATE_ON,
support_duration=True,
primary_entities_only=False,
),
"is_not_charging": make_entity_state_condition(
BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF, support_duration=True
BATTERY_CHARGING_DOMAIN_SPECS,
STATE_OFF,
support_duration=True,
primary_entities_only=False,
),
"is_level": make_entity_numerical_condition(
BATTERY_PERCENTAGE_DOMAIN_SPECS, PERCENTAGE
BATTERY_PERCENTAGE_DOMAIN_SPECS,
PERCENTAGE,
primary_entities_only=False,
),
}
@@ -3,6 +3,7 @@
entity:
- domain: binary_sensor
device_class: battery
primary_entities_only: false
fields:
behavior: &condition_behavior
required: true
@@ -42,6 +43,7 @@ is_charging:
entity:
- domain: binary_sensor
device_class: battery_charging
primary_entities_only: false
fields:
behavior: *condition_behavior
for: *condition_for
@@ -51,6 +53,7 @@ is_not_charging:
entity:
- domain: binary_sensor
device_class: battery_charging
primary_entities_only: false
fields:
behavior: *condition_behavior
for: *condition_for
@@ -60,6 +63,7 @@ is_level:
entity:
- domain: sensor
device_class: battery
primary_entities_only: false
fields:
behavior: *condition_behavior
threshold:
@@ -33,11 +33,13 @@ from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN
from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN
from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN
from homeassistant.config_entries import (
SOURCE_USER,
ConfigEntry,
ConfigFlowResult,
ConfigSubentry,
ConfigSubentryData,
ConfigSubentryFlow,
FlowType,
SubentryFlowContext,
SubentryFlowResult,
)
from homeassistant.const import (
@@ -62,7 +64,6 @@ from homeassistant.helpers.schema_config_entry_flow import (
from .binary_sensor import above_greater_than_below, no_overlapping
from .const import (
CONF_OBSERVATIONS,
CONF_P_GIVEN_F,
CONF_P_GIVEN_T,
CONF_PRIOR,
@@ -373,26 +374,6 @@ def _validate_observation_subentry(
return user_input
async def _validate_subentry_from_config_entry(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]:
# Standard behavior is to merge the result with the options.
# In this case, we want to add a subentry so we update the options directly.
observations: list[dict[str, Any]] = handler.options.setdefault(
CONF_OBSERVATIONS, []
)
if handler.parent_handler.cur_step is not None:
user_input[CONF_PLATFORM] = handler.parent_handler.cur_step["step_id"]
user_input = _validate_observation_subentry(
user_input[CONF_PLATFORM],
user_input,
other_subentries=handler.options[CONF_OBSERVATIONS],
)
observations.append(user_input)
return {}
async def _get_description_placeholders(
handler: SchemaCommonFlowHandler,
) -> dict[str, str]:
@@ -420,48 +401,12 @@ async def _get_description_placeholders(
}
async def _get_observation_menu_options(handler: SchemaCommonFlowHandler) -> list[str]:
"""Return the menu options for the observation selector."""
options = [typ.value for typ in ObservationTypes]
if handler.options.get(CONF_OBSERVATIONS):
options.append("finish")
return options
CONFIG_FLOW: dict[str, SchemaFlowMenuStep | SchemaFlowFormStep] = {
str(USER): SchemaFlowFormStep(
CONFIG_SCHEMA,
validate_user_input=_validate_user,
next_step=str(OBSERVATION_SELECTOR),
description_placeholders=_get_description_placeholders,
),
str(OBSERVATION_SELECTOR): SchemaFlowMenuStep(
_get_observation_menu_options,
),
str(ObservationTypes.STATE): SchemaFlowFormStep(
STATE_SUBSCHEMA,
next_step=str(OBSERVATION_SELECTOR),
validate_user_input=_validate_subentry_from_config_entry,
# Prevent the name of the bayesian sensor from being used as the suggested
# name of the observations
suggested_values=None,
description_placeholders=_get_description_placeholders,
),
str(ObservationTypes.NUMERIC_STATE): SchemaFlowFormStep(
NUMERIC_STATE_SUBSCHEMA,
next_step=str(OBSERVATION_SELECTOR),
validate_user_input=_validate_subentry_from_config_entry,
suggested_values=None,
description_placeholders=_get_description_placeholders,
),
str(ObservationTypes.TEMPLATE): SchemaFlowFormStep(
TEMPLATE_SUBSCHEMA,
next_step=str(OBSERVATION_SELECTOR),
validate_user_input=_validate_subentry_from_config_entry,
suggested_values=None,
description_placeholders=_get_description_placeholders,
),
"finish": SchemaFlowFormStep(),
)
}
@@ -497,27 +442,17 @@ class BayesianConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
name: str = options[CONF_NAME]
return name
@callback
def async_create_entry(
self,
data: Mapping[str, Any],
**kwargs: Any,
) -> ConfigFlowResult:
"""Finish config flow and create a config entry."""
data = dict(data)
observations = data.pop(CONF_OBSERVATIONS)
subentries: list[ConfigSubentryData] = [
ConfigSubentryData(
data=observation,
title=observation[CONF_NAME],
subentry_type="observation",
unique_id=None,
)
for observation in observations
]
self.async_config_flow_finished(data)
return super().async_create_entry(data=data, subentries=subentries, **kwargs)
async def async_on_create_entry(self, result: ConfigFlowResult) -> ConfigFlowResult:
"""Start subentry flow when config entry has been created."""
subentry_result = await self.hass.config_entries.subentries.async_init(
(result["result"].entry_id, "observation"),
context=SubentryFlowContext(source=SOURCE_USER),
)
result["next_flow"] = (
FlowType.CONFIG_SUBENTRIES_FLOW,
subentry_result["flow_id"],
)
return result
class ObservationSubentryFlowHandler(ConfigSubentryFlow):
@@ -58,6 +58,7 @@ from .api import (
async_address_present,
async_ble_device_from_address,
async_clear_address_from_match_history,
async_clear_advertisement_history,
async_current_scanners,
async_discovered_service_info,
async_get_advertisement_callback,
@@ -116,6 +117,7 @@ __all__ = [
"async_address_present",
"async_ble_device_from_address",
"async_clear_address_from_match_history",
"async_clear_advertisement_history",
"async_current_scanners",
"async_discovered_service_info",
"async_get_advertisement_callback",
+13
View File
@@ -207,6 +207,19 @@ def async_clear_address_from_match_history(hass: HomeAssistant, address: str) ->
_get_manager(hass).async_clear_address_from_match_history(address)
@hass_callback
def async_clear_advertisement_history(hass: HomeAssistant, address: str) -> None:
"""Clear cached advertisement history for a device.
Causes the next advertisement from this address to be treated as new
data, bypassing the change-detection guard in the Bluetooth manager.
Intended for devices that emit static advertisements as a wake-up
signal, for example, devices that require an active GATT connection
to read sensor data and whose advertisement payload never changes.
"""
_get_manager(hass).async_clear_advertisement_history(address)
@hass_callback
def async_register_scanner(
hass: HomeAssistant,
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.11", "deebot-client==18.1.0"]
"requirements": ["py-sucks==0.9.11", "deebot-client==18.2.0"]
}
+6 -5
View File
@@ -715,6 +715,9 @@ class EnergyPowerSensor(SensorEntity):
self._attr_native_value = None
return
self._attr_native_unit_of_measurement = source_state.attributes.get(
ATTR_UNIT_OF_MEASUREMENT
)
self._attr_native_value = value * -1
elif self._is_combined:
@@ -763,13 +766,11 @@ class EnergyPowerSensor(SensorEntity):
# Check first sensor
if source_entry := entity_reg.async_get(self._source_sensors[0]):
device_id = source_entry.device_id
# For combined mode, always use Watts because we may have different source units; for inverted mode, copy source unit
# Combined mode always emits Watts because we convert
# heterogeneous source units internally. For inverted mode the
# unit is copied from the source state in _update_state.
if self._is_combined:
self._attr_native_unit_of_measurement = UnitOfPower.WATT
else:
self._attr_native_unit_of_measurement = (
source_entry.unit_of_measurement
)
# Get source name from registry
source_name = source_entry.name or source_entry.original_name
# Assign power sensor to same device as source sensor(s)
@@ -11,6 +11,7 @@ PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CLIMATE,
Platform.NUMBER,
Platform.SENSOR,
]
+51
View File
@@ -5,13 +5,64 @@
"default": "mdi:clock-sync"
}
},
"number": {
"fan_speed": {
"default": "mdi:fan"
},
"power_level": {
"default": "mdi:fire"
}
},
"sensor": {
"alert": {
"default": "mdi:alert",
"state": {
"airflow_malfunction": "mdi:fan-off",
"door_open": "mdi:door-open",
"flue_gas_warning": "mdi:thermometer-alert",
"low_battery": "mdi:battery-alert",
"low_fuel": "mdi:gauge-empty",
"none": "mdi:check-circle",
"service_due": "mdi:wrench-clock",
"speed_sensor_failure": "mdi:fan-alert"
}
},
"combustion_chamber_temperature": {
"default": "mdi:thermometer-high"
},
"detailed_stove_status": {
"default": "mdi:fireplace"
},
"error": {
"default": "mdi:alert-circle",
"state": {
"chimney_alarm": "mdi:broom",
"chimney_dirty": "mdi:broom",
"door_alarm": "mdi:door-open",
"fire_error": "mdi:fire-alert",
"flue_gas_overtemp": "mdi:thermometer-high",
"fuel_ignition_timeout": "mdi:fire-off",
"gas_alarm": "mdi:alert-circle",
"general_error": "mdi:alert-circle",
"grate_error": "mdi:alert-circle",
"ignition_failed": "mdi:fire-alert",
"mfdoor_alarm": "mdi:door-open",
"no_pellet_alarm": "mdi:gauge-empty",
"none": "mdi:check-circle",
"ntc1_alarm": "mdi:thermometer-alert",
"ntc2_alarm": "mdi:thermometer-alert",
"ntc3_alarm": "mdi:thermometer-alert",
"pressure_alarm": "mdi:gauge-empty",
"pressure_sensor_off": "mdi:gauge-empty",
"safety_switch": "mdi:shield-alert",
"sensor_t01_t02": "mdi:thermometer-alert",
"sensor_t01_t03": "mdi:thermometer-alert",
"sensor_t02": "mdi:thermometer-alert",
"sensor_t03_t05": "mdi:thermometer-alert",
"sensor_t04": "mdi:thermometer-alert",
"tc1_alarm": "mdi:thermometer-alert"
}
},
"fan_1_speed": {
"default": "mdi:fan"
},
+97
View File
@@ -0,0 +1,97 @@
"""Support for Fumis number entities."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from fumis import Fumis, FumisInfo
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import FumisConfigEntry, FumisDataUpdateCoordinator
from .entity import FumisEntity
from .helpers import fumis_exception_handler
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class FumisNumberEntityDescription(NumberEntityDescription):
"""Describes a Fumis number entity."""
has_fn: Callable[[FumisInfo], bool] = lambda _: True
value_fn: Callable[[FumisInfo], float | None]
set_fn: Callable[[Fumis, float], Awaitable[Any]]
NUMBERS: tuple[FumisNumberEntityDescription, ...] = (
FumisNumberEntityDescription(
key="fan_speed",
translation_key="fan_speed",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
native_min_value=0,
native_max_value=5,
native_step=1,
has_fn=lambda data: len(data.controller.fans) > 0,
value_fn=lambda data: (
data.controller.fans[0].speed if data.controller.fans else None
),
set_fn=lambda client, value: client.set_fan_speed(int(value)),
),
FumisNumberEntityDescription(
key="power_level",
translation_key="power_level",
native_min_value=1,
native_max_value=5,
native_step=1,
value_fn=lambda data: data.controller.power.set_power,
set_fn=lambda client, value: client.set_power(int(value)),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: FumisConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Fumis number entities based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
FumisNumberEntity(coordinator=coordinator, description=description)
for description in NUMBERS
if description.has_fn(coordinator.data)
)
class FumisNumberEntity(FumisEntity, NumberEntity):
"""Defines a Fumis number entity."""
entity_description: FumisNumberEntityDescription
def __init__(
self,
coordinator: FumisDataUpdateCoordinator,
description: FumisNumberEntityDescription,
) -> None:
"""Initialize the Fumis number entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}"
@property
def native_value(self) -> float | None:
"""Return the current value."""
return self.entity_description.value_fn(self.coordinator.data)
@fumis_exception_handler
async def async_set_native_value(self, value: float) -> None:
"""Set a new value."""
await self.entity_description.set_fn(self.coordinator.client, value)
await self.coordinator.async_request_refresh()
+62 -1
View File
@@ -5,8 +5,9 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any
from fumis import FumisInfo, StoveState, StoveStatus
from fumis import FumisInfo, StoveAlert, StoveError, StoveState, StoveStatus
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -34,15 +35,52 @@ from .entity import FumisEntity
PARALLEL_UPDATES = 0
def _code_to_state(code: StoveAlert | StoveError | None) -> str | None:
"""Convert a stove alert or error code to a sensor state value.
Returns "none" when there is no active alert/error, None when the code
is unknown, or the enum member name in lowercase for known codes.
"""
if code is None:
return "none"
if code.name == "UNKNOWN":
return None
return code.name.lower()
def _code_to_attr(code: StoveAlert | StoveError | None) -> dict[str, str | None]:
"""Convert a stove alert or error code to extra state attributes."""
if code is None or code.name == "UNKNOWN":
return {"code": None}
return {"code": code.value}
@dataclass(frozen=True, kw_only=True)
class FumisSensorEntityDescription(SensorEntityDescription):
"""Describes a Fumis sensor entity."""
attr_fn: Callable[[FumisInfo], dict[str, Any]] | None = None
has_fn: Callable[[FumisInfo], bool] = lambda _: True
value_fn: Callable[[FumisInfo], datetime | float | int | str | None]
SENSORS: tuple[FumisSensorEntityDescription, ...] = (
FumisSensorEntityDescription(
key="alert",
translation_key="alert",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
options=[
"none",
*(
alert.name.lower()
for alert in StoveAlert
if alert != StoveAlert.UNKNOWN
),
],
value_fn=lambda data: _code_to_state(data.controller.stove_alert),
attr_fn=lambda data: _code_to_attr(data.controller.stove_alert),
),
FumisSensorEntityDescription(
key="combustion_chamber_temperature",
translation_key="combustion_chamber_temperature",
@@ -69,6 +107,22 @@ SENSORS: tuple[FumisSensorEntityDescription, ...] = (
else data.controller.stove_status.name.lower()
),
),
FumisSensorEntityDescription(
key="error",
translation_key="error",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
options=[
"none",
*(
error.name.lower()
for error in StoveError
if error != StoveError.UNKNOWN
),
],
value_fn=lambda data: _code_to_state(data.controller.stove_error),
attr_fn=lambda data: _code_to_attr(data.controller.stove_error),
),
FumisSensorEntityDescription(
key="fan_1_speed",
translation_key="fan_1_speed",
@@ -267,6 +321,13 @@ class FumisSensorEntity(FumisEntity, SensorEntity):
self.entity_description = description
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}"
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return additional state attributes."""
if self.entity_description.attr_fn is None:
return None
return self.entity_description.attr_fn(self.coordinator.data)
@property
def native_value(self) -> datetime | float | int | str | None:
"""Return the sensor value."""
@@ -58,7 +58,28 @@
"name": "Sync clock"
}
},
"number": {
"fan_speed": {
"name": "Fan speed"
},
"power_level": {
"name": "Power level"
}
},
"sensor": {
"alert": {
"name": "Alert",
"state": {
"airflow_malfunction": "Airflow sensor malfunction",
"door_open": "Door open",
"flue_gas_warning": "Flue gas temperature warning",
"low_battery": "Low battery",
"low_fuel": "Low fuel level",
"none": "No alert",
"service_due": "Service due",
"speed_sensor_failure": "Speed sensor failure"
}
},
"combustion_chamber_temperature": {
"name": "Combustion chamber"
},
@@ -81,6 +102,36 @@
"wood_start": "Wood start"
}
},
"error": {
"name": "Error",
"state": {
"chimney_alarm": "Chimney alarm",
"chimney_dirty": "Chimney or burning pot dirty",
"door_alarm": "Door alarm",
"fire_error": "Fire error",
"flue_gas_overtemp": "Flue gas overtemperature",
"fuel_ignition_timeout": "Fuel ignition timeout",
"gas_alarm": "Gas alarm",
"general_error": "General error",
"grate_error": "Grate error",
"ignition_failed": "Ignition failed",
"mfdoor_alarm": "MFDoor alarm",
"no_pellet_alarm": "No pellet alarm",
"none": "No error",
"ntc1_alarm": "NTC1 alarm",
"ntc2_alarm": "NTC2 alarm",
"ntc3_alarm": "NTC3 alarm",
"pressure_alarm": "Pressure alarm",
"pressure_sensor_off": "Pressure sensor off",
"safety_switch": "Safety switch tripped",
"sensor_t01_t02": "Sensor T01/T02 malfunction",
"sensor_t01_t03": "Sensor T01/T03 malfunction",
"sensor_t02": "Sensor T02 malfunction",
"sensor_t03_t05": "Sensor T03/T05 malfunction",
"sensor_t04": "Sensor T04 malfunction",
"tc1_alarm": "TC1 alarm"
}
},
"fan_1_speed": {
"name": "Fan 1 speed"
},
@@ -637,16 +637,19 @@ class HomeConnectApplianceCoordinator(DataUpdateCoordinator[HomeConnectAppliance
options.update(await self.get_options_definitions(resolved_program_key))
for option in options.values():
option_value = option.constraints.default if option.constraints else None
if option_value is not None:
option_event_key = EventKey(option.key)
option_event_key = EventKey(option.key)
if (
option_event_key not in events
and option.constraints
and (option_default_value := option.constraints.default) is not None
):
events[option_event_key] = Event(
option_event_key,
option.key.value,
0,
"",
"",
option_value,
option_default_value,
option.name,
unit=option.unit,
)
@@ -7,7 +7,12 @@ from datetime import timedelta
import logging
from typing import TYPE_CHECKING, Any
from jvcprojector import JvcProjector, JvcProjectorTimeoutError, command as cmd
from jvcprojector import (
JvcProjector,
JvcProjectorCommandError,
JvcProjectorTimeoutError,
command as cmd,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -144,7 +149,16 @@ class JvcProjectorDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]):
self, command: type[Command], new_state: dict[type[Command], str]
) -> str | None:
"""Update state with the current value of a command."""
value = await self.device.get(command)
try:
value = await self.device.get(command)
except JvcProjectorCommandError as err:
_LOGGER.warning("Command %s failed: %s", command.name, err)
cached = self.state.get(command)
if command is cmd.Power and cached is None:
raise UpdateFailed(
f"Failed to fetch {command.name} and no cached value is available"
) from err
return cached
if value != self.state.get(command):
new_state[command] = value
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["jvcprojector"],
"requirements": ["pyjvcprojector==2.0.5"]
"requirements": ["pyjvcprojector==2.0.6"]
}
+2 -2
View File
@@ -11,9 +11,9 @@ from homeassistant.const import EVENT_CALL_SERVICE, EVENT_LOGBOOK_ENTRY
# Domains that are always continuous
#
# These are hard coded here to avoid importing
# the entire counter and proximity integrations
# the entire counter, image, and proximity integrations
# to get the name of the domain.
ALWAYS_CONTINUOUS_DOMAINS = {"counter", "proximity"}
ALWAYS_CONTINUOUS_DOMAINS = {"counter", "image", "proximity"}
# Domains that are continuous if there is a UOM set on the entity
CONDITIONALLY_CONTINUOUS_DOMAINS = {SENSOR_DOMAIN}
+1 -1
View File
@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/otbr",
"integration_type": "service",
"iot_class": "local_polling",
"requirements": ["python-otbr-api==2.9.0"]
"requirements": ["python-otbr-api==2.10.0"]
}
@@ -3,7 +3,6 @@
"name": "Recovery Mode",
"codeowners": ["@home-assistant/core"],
"config_flow": false,
"dependencies": ["persistent_notification"],
"documentation": "https://www.home-assistant.io/integrations/recovery_mode",
"integration_type": "system",
"quality_scale": "internal"
@@ -17,7 +17,7 @@
"iot_class": "local_push",
"loggers": ["aioshelly"],
"quality_scale": "platinum",
"requirements": ["aioshelly==13.24.0"],
"requirements": ["aioshelly==13.24.1"],
"zeroconf": [
{
"name": "shelly*",
@@ -467,7 +467,6 @@ REST_SENSORS: Final = {
),
"uptime": RestSensorDescription(
key="uptime",
translation_key="last_restart",
value=lambda status, _: utcnow() - timedelta(seconds=status["uptime"]),
device_class=SensorDeviceClass.UPTIME,
entity_registry_enabled_default=False,
@@ -1243,7 +1242,6 @@ RPC_SENSORS: Final = {
"uptime": RpcSensorDescription(
key="sys",
sub_key="uptime",
translation_key="last_restart",
device_class=SensorDeviceClass.UPTIME,
value=lambda status, _: utcnow() - timedelta(seconds=status),
entity_registry_enabled_default=False,
@@ -424,9 +424,6 @@
"lamp_life": {
"name": "Lamp life"
},
"last_restart": {
"name": "Last restart"
},
"left_slot_level": {
"name": "Left slot level"
},
@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/thread",
"integration_type": "service",
"iot_class": "local_polling",
"requirements": ["python-otbr-api==2.9.0", "pyroute2==0.7.5"],
"requirements": ["python-otbr-api==2.10.0", "pyroute2==0.7.5"],
"single_config_entry": true,
"zeroconf": ["_meshcop._udp.local."]
}
+6 -6
View File
@@ -41,7 +41,7 @@ ATTR_REMAINING = "remaining"
ATTR_FINISHES_AT = "finishes_at"
ATTR_RESTORE = "restore"
ATTR_FINISHED_AT = "finished_at"
ATTR_LAST_ACTION = "last_action"
ATTR_LAST_TRANSITION = "last_transition"
CONF_DURATION = "duration"
CONF_RESTORE = "restore"
@@ -203,7 +203,7 @@ class Timer(collection.CollectionEntity, RestoreEntity):
def __init__(self, config: ConfigType) -> None:
"""Initialize a timer."""
self._config: dict = config
self._last_action: str | None = None
self._last_transition: str | None = None
self._state: str = STATUS_IDLE
self._configured_duration = cv.time_period_str(config[CONF_DURATION])
self._running_duration: timedelta = self._configured_duration
@@ -251,7 +251,7 @@ class Timer(collection.CollectionEntity, RestoreEntity):
attrs: dict[str, Any] = {
ATTR_DURATION: _format_timedelta(self._running_duration),
ATTR_EDITABLE: self.editable,
ATTR_LAST_ACTION: self._last_action,
ATTR_LAST_TRANSITION: self._last_transition,
}
if self._end is not None:
attrs[ATTR_FINISHES_AT] = self._end.isoformat()
@@ -277,7 +277,7 @@ class Timer(collection.CollectionEntity, RestoreEntity):
# Begin restoring state
self._state = state.state
self._last_action = state.attributes.get(ATTR_LAST_ACTION)
self._last_transition = state.attributes.get(ATTR_LAST_TRANSITION)
# Nothing more to do if the timer is idle
if self._state == STATUS_IDLE:
@@ -353,7 +353,7 @@ class Timer(collection.CollectionEntity, RestoreEntity):
self._end += duration
self._remaining = new_remaining
# We don't use _fire_event_and_write_state here because we don't want to
# update last_action
# update last_transition
self.async_write_ha_state()
self.hass.bus.async_fire(EVENT_TIMER_CHANGED, {ATTR_ENTITY_ID: self.entity_id})
self._listener = async_track_point_in_utc_time(
@@ -437,7 +437,7 @@ class Timer(collection.CollectionEntity, RestoreEntity):
self, event: str, *, extra_attrs: dict[str, Any] | None = None
) -> None:
"""Fire the event and write state."""
self._last_action = event.partition(".")[2]
self._last_transition = event.partition(".")[2]
self.async_write_ha_state()
event_data = {ATTR_ENTITY_ID: self.entity_id}
if extra_attrs:
@@ -63,6 +63,16 @@
"finishes_at": {
"name": "Finishes at"
},
"last_transition": {
"name": "Last transition",
"state": {
"cancelled": "Cancelled",
"finished": "Finished",
"paused": "Paused",
"restarted": "Restarted",
"started": "Started"
}
},
"remaining": {
"name": "Remaining"
},
@@ -10,7 +10,7 @@ from typing import Any, Self
from homeassistant.const import CONF_TARGET
from homeassistant.core import HomeAssistant
from homeassistant.helpers import target as target_helpers
from homeassistant.helpers import entity_registry as er, target as target_helpers
from homeassistant.helpers.condition import (
async_get_all_descriptions as async_get_all_condition_descriptions,
)
@@ -92,12 +92,14 @@ class _AutomationComponentLookupData:
component: str
filters: list[_EntityFilter]
primary_entities_only: bool = True
@classmethod
def create(cls, component: str, target_description: dict[str, Any]) -> Self:
"""Build automation component lookup data from target description."""
filters: list[_EntityFilter] = []
primary_entities_only = target_description.get("primary_entities_only", True)
entity_filters_config = target_description.get("entity", [])
for entity_filter_config in entity_filters_config:
entity_filter = _EntityFilter(
@@ -110,14 +112,28 @@ class _AutomationComponentLookupData:
)
filters.append(entity_filter)
return cls(component=component, filters=filters)
return cls(
component=component,
filters=filters,
primary_entities_only=primary_entities_only,
)
def matches(
self, hass: HomeAssistant, entity_id: str, domain: str, integration: str
self,
hass: HomeAssistant,
entity_id: str,
domain: str,
integration: str,
check_entity_category: bool,
) -> bool:
"""Return if entity matches ANY of the filters."""
if not self.filters:
return True
if check_entity_category and self.primary_entities_only:
entry = er.async_get(hass).async_get(entity_id)
if entry is None or entry.entity_category is not None:
return False
return any(
f.matches(hass, entity_id, domain, integration) for f in self.filters
)
@@ -220,6 +236,7 @@ def _async_get_automation_components_for_target(
hass,
target_helpers.TargetSelection(target_selection),
expand_group=expand_group,
primary_entities_only=False,
)
_LOGGER.debug("Extracted entities for lookup: %s", extracted)
@@ -230,6 +247,7 @@ def _async_get_automation_components_for_target(
"Automation components per domain: %s", lookup_table.domain_components
)
check_entity_category = len(extracted.indirectly_referenced) > 0
entity_infos = entity_sources(hass)
matched_components: set[str] = set()
for entity_id in extracted.referenced | extracted.indirectly_referenced:
@@ -253,7 +271,11 @@ def _async_get_automation_components_for_target(
if component_data.component in matched_components:
continue
if component_data.matches(
hass, entity_id, entity_domain, entity_integration
hass,
entity_id,
entity_domain,
entity_integration,
check_entity_category,
):
matched_components.add(component_data.component)
@@ -1076,6 +1076,8 @@ async def handle_execute_script(
translation_placeholders=err.translation_placeholders,
)
return
finally:
script_obj.async_unload()
connection.send_result(
msg["id"],
{
@@ -13,6 +13,7 @@ from homeassistant.auth.models import RefreshToken, User
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, Unauthorized
from homeassistant.helpers.http import current_request
from homeassistant.helpers.redact import async_redact_data
from homeassistant.util.json import JsonValueType
from . import const, messages
@@ -32,6 +33,15 @@ current_connection = ContextVar["ActiveConnection | None"](
"current_connection", default=None
)
REDACT_KEYS = {
"access_token",
"password",
"api_password",
"refresh_token",
"token",
"auth_token",
}
type MessageHandler = Callable[[HomeAssistant, ActiveConnection, dict[str, Any]], None]
type BinaryHandler = Callable[[HomeAssistant, ActiveConnection, bytes], None]
@@ -201,6 +211,7 @@ class ActiveConnection:
or type(type_) is not str
)
):
msg = async_redact_data(msg, REDACT_KEYS)
self.logger.error("Received invalid command: %s", msg)
id_ = msg.get("id") if isinstance(msg, dict) else 0
self.send_message(
@@ -264,6 +275,7 @@ class ActiveConnection:
self, msg: bytes | str | dict[str, Any] | Callable[[], str]
) -> None:
"""Send a message when the connection is closed."""
msg = async_redact_data(msg, REDACT_KEYS)
self.logger.debug("Tried to send message %s on closed connection", msg)
@callback
@@ -277,6 +289,8 @@ class ActiveConnection:
translation_key: str | None = None
translation_placeholders: dict[str, Any] | None = None
msg = async_redact_data(msg, REDACT_KEYS)
if isinstance(err, Unauthorized):
code = const.ERR_UNAUTHORIZED
err_message = "Unauthorized"
+13 -1
View File
@@ -16,6 +16,7 @@ import sys
from typing import (
TYPE_CHECKING,
Any,
ClassVar,
Final,
Literal,
Never,
@@ -448,6 +449,9 @@ class EntityConditionBase(Condition):
_domain_specs: Mapping[str, DomainSpec]
_schema: vol.Schema = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL
# When True, indirect target expansion (via device/area/floor) skips
# entities with an entity_category.
_primary_entities_only: ClassVar[bool] = True
@override
@classmethod
@@ -615,7 +619,10 @@ class EntityConditionBase(Condition):
def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool:
"""Test state condition."""
targeted_entities = async_extract_referenced_entity_ids(
self._hass, self._target_selection, expand_group=False
self._hass,
self._target_selection,
expand_group=False,
primary_entities_only=self._primary_entities_only,
)
referenced_entity_ids = targeted_entities.referenced.union(
targeted_entities.indirectly_referenced
@@ -663,6 +670,7 @@ def make_entity_state_condition(
states: str | bool | set[str | bool],
*,
support_duration: bool = False,
primary_entities_only: bool = True,
) -> type[EntityStateConditionBase]:
"""Create a condition for entity state changes to specific state(s).
@@ -686,6 +694,7 @@ def make_entity_state_condition(
else ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL
)
_states = states_set
_primary_entities_only = primary_entities_only
return CustomCondition
@@ -793,6 +802,8 @@ class EntityNumericalConditionBase(EntityConditionBase):
def make_entity_numerical_condition(
domain_specs: Mapping[str, DomainSpec] | str,
valid_unit: str | None | UndefinedType = UNDEFINED,
*,
primary_entities_only: bool = True,
) -> type[EntityNumericalConditionBase]:
"""Create a condition for numerical state comparisons."""
specs = _normalize_domain_specs(domain_specs)
@@ -802,6 +813,7 @@ def make_entity_numerical_condition(
_domain_specs = specs
_valid_unit = valid_unit
_primary_entities_only = primary_entities_only
return CustomCondition
+1 -9
View File
@@ -759,15 +759,7 @@ def dynamic_template(value: Any) -> template_helper.Template:
if not template_helper.is_template_string(str(value)):
raise vol.Invalid("template value does not contain a dynamic template")
if not (hass := _async_get_hass_or_none()):
from .frame import ReportBehavior, report_usage # noqa: PLC0415
report_usage(
(
"validates schema outside the event loop, "
"which will stop working in HA Core 2025.10"
),
core_behavior=ReportBehavior.LOG,
)
raise vol.Invalid("Validates schema outside the event loop")
template_value = template_helper.Template(str(value), hass)
+1 -9
View File
@@ -36,7 +36,7 @@ from homeassistant.core import (
callback,
split_entity_id,
)
from homeassistant.exceptions import HomeAssistantError, TemplateError
from homeassistant.exceptions import TemplateError
from homeassistant.util import dt as dt_util
from homeassistant.util.async_ import run_callback_threadsafe
from homeassistant.util.event_type import EventType
@@ -990,14 +990,6 @@ class TrackTemplateResultInfo:
self._last_result: dict[Template, bool | str | TemplateError] = {}
for track_template_ in track_templates:
if track_template_.template.hass:
continue
raise HomeAssistantError(
"Calls async_track_template_result with template without hass"
)
self._rate_limit = KeyedRateLimit(hass)
self._info: dict[Template, RenderInfo] = {}
self._track_state_changes: _TrackStateChangeFiltered | None = None
+12 -349
View File
@@ -5,40 +5,27 @@ from __future__ import annotations
from ast import literal_eval
import asyncio
import collections.abc
from collections.abc import Callable, Iterable
from collections.abc import Callable
from datetime import timedelta
from functools import lru_cache, partial, wraps
from functools import lru_cache, partial
import logging
import pathlib
import re
import sys
from types import CodeType
from typing import TYPE_CHECKING, Any, Concatenate, Literal, NoReturn, Self, overload
from typing import TYPE_CHECKING, Any, Literal, Self, overload
import weakref
import jinja2
from jinja2 import pass_context, pass_eval_context
from jinja2.runtime import AsyncLoopContext, LoopContext
from jinja2.sandbox import ImmutableSandboxedEnvironment
from jinja2.utils import Namespace
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_LATITUDE,
ATTR_LONGITUDE,
ATTR_PERSONS,
EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STOP,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
UnitOfLength,
)
from homeassistant.core import HomeAssistant, State, callback, valid_entity_id
from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import location as loc_helper
from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.typing import TemplateVarsType
from homeassistant.util import convert, location as location_util
from homeassistant.util.async_ import run_callback_threadsafe
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads
@@ -62,9 +49,6 @@ from .states import (
StateTranslated,
TemplateState as TemplateState,
TemplateStateFromEntityId as TemplateStateFromEntityId,
_collect_state,
_get_state,
_resolve_state,
)
if TYPE_CHECKING:
@@ -267,27 +251,11 @@ class Template:
"template",
)
def __init__(self, template: str, hass: HomeAssistant | None = None) -> None:
"""Instantiate a template.
Note: A valid hass instance should always be passed in. The hass parameter
will be non optional in Home Assistant Core 2025.10.
"""
from homeassistant.helpers.frame import ( # noqa: PLC0415
ReportBehavior,
report_usage,
)
def __init__(self, template: str, hass: HomeAssistant) -> None:
"""Instantiate a template."""
if not isinstance(template, str):
raise TypeError("Expected template to be a string")
if not hass:
report_usage(
"creates a template object without passing hass",
core_behavior=ReportBehavior.LOG,
breaks_in_ha_version="2025.10",
)
self.template: str = template.strip()
self._compiled_code: CodeType | None = None
self._compiled: jinja2.Template | None = None
@@ -302,8 +270,6 @@ class Template:
@property
def _env(self) -> TemplateEnvironment:
if self.hass is None:
return _NO_HASS_ENV
# Bypass cache if a custom log function is specified
if self._log_fn is not None:
return TemplateEnvironment(
@@ -631,217 +597,6 @@ class Template:
return f"Template<template=({self.template}) renders={self._renders}>"
def expand(hass: HomeAssistant, *args: Any) -> Iterable[State]:
"""Expand out any groups and zones into entity states."""
# circular import.
from homeassistant.helpers import entity as entity_helper # noqa: PLC0415
search = list(args)
found = {}
sources = entity_helper.entity_sources(hass)
while search:
entity = search.pop()
if isinstance(entity, str):
entity_id = entity
if (entity := _get_state(hass, entity)) is None:
continue
elif isinstance(entity, State):
entity_id = entity.entity_id
elif isinstance(entity, collections.abc.Iterable):
search += entity
continue
else:
# ignore other types
continue
if entity_id in found:
continue
domain = entity.domain
if domain == "group" or (
(source := sources.get(entity_id)) and source["domain"] == "group"
):
# Collect state will be called in here since it's wrapped
if group_entities := entity.attributes.get(ATTR_ENTITY_ID):
search += group_entities
elif domain == "zone":
if zone_entities := entity.attributes.get(ATTR_PERSONS):
search += zone_entities
else:
_collect_state(hass, entity_id)
found[entity_id] = entity
return list(found.values())
def closest(hass: HomeAssistant, *args: Any) -> State | None:
"""Find closest entity.
Closest to home:
closest(states)
closest(states.device_tracker)
closest('group.children')
closest(states.group.children)
Closest to a point:
closest(23.456, 23.456, 'group.children')
closest('zone.school', 'group.children')
closest(states.zone.school, 'group.children')
As a filter:
states | closest
states.device_tracker | closest
['group.children', states.device_tracker] | closest
'group.children' | closest(23.456, 23.456)
states.device_tracker | closest('zone.school')
'group.children' | closest(states.zone.school)
"""
if len(args) == 1:
latitude = hass.config.latitude
longitude = hass.config.longitude
entities = args[0]
elif len(args) == 2:
point_state = _resolve_state(hass, args[0])
if point_state is None:
_LOGGER.warning("Closest:Unable to find state %s", args[0])
return None
if not loc_helper.has_location(point_state):
_LOGGER.warning(
"Closest:State does not contain valid location: %s", point_state
)
return None
latitude = point_state.attributes[ATTR_LATITUDE]
longitude = point_state.attributes[ATTR_LONGITUDE]
entities = args[1]
else:
latitude_arg = convert(args[0], float)
longitude_arg = convert(args[1], float)
if latitude_arg is None or longitude_arg is None:
_LOGGER.warning(
"Closest:Received invalid coordinates: %s, %s", args[0], args[1]
)
return None
latitude = latitude_arg
longitude = longitude_arg
entities = args[2]
states = expand(hass, entities)
# state will already be wrapped here
return loc_helper.closest(latitude, longitude, states)
def closest_filter(hass: HomeAssistant, *args: Any) -> State | None:
"""Call closest as a filter. Need to reorder arguments."""
new_args = list(args[1:])
new_args.append(args[0])
return closest(hass, *new_args)
def distance(hass: HomeAssistant, *args: Any) -> float | None:
"""Calculate distance.
Will calculate distance from home to a point or between points.
Points can be passed in using state objects or lat/lng coordinates.
"""
locations: list[tuple[float, float]] = []
to_process = list(args)
while to_process:
value = to_process.pop(0)
if isinstance(value, str) and not valid_entity_id(value):
point_state = None
else:
point_state = _resolve_state(hass, value)
if point_state is None:
# We expect this and next value to be lat&lng
if not to_process:
_LOGGER.warning(
"Distance:Expected latitude and longitude, got %s", value
)
return None
value_2 = to_process.pop(0)
latitude_to_process = convert(value, float)
longitude_to_process = convert(value_2, float)
if latitude_to_process is None or longitude_to_process is None:
_LOGGER.warning(
"Distance:Unable to process latitude and longitude: %s, %s",
value,
value_2,
)
return None
latitude = latitude_to_process
longitude = longitude_to_process
else:
if not loc_helper.has_location(point_state):
_LOGGER.warning(
"Distance:State does not contain valid location: %s", point_state
)
return None
latitude = point_state.attributes[ATTR_LATITUDE]
longitude = point_state.attributes[ATTR_LONGITUDE]
locations.append((latitude, longitude))
if len(locations) == 1:
return hass.config.distance(*locations[0])
return hass.config.units.length(
location_util.distance(*locations[0] + locations[1]), UnitOfLength.METERS
)
def is_state(hass: HomeAssistant, entity_id: str, state: str | list[str]) -> bool:
"""Test if a state is a specific value."""
state_obj = _get_state(hass, entity_id)
return state_obj is not None and (
state_obj.state == state
or (isinstance(state, list) and state_obj.state in state)
)
def is_state_attr(hass: HomeAssistant, entity_id: str, name: str, value: Any) -> bool:
"""Test if a state's attribute is a specific value."""
if (state_obj := _get_state(hass, entity_id)) is not None:
attr = state_obj.attributes.get(name, _SENTINEL)
if attr is _SENTINEL:
return False
return bool(attr == value)
return False
def state_attr(hass: HomeAssistant, entity_id: str, name: str) -> Any:
"""Get a specific attribute from a state."""
if (state_obj := _get_state(hass, entity_id)) is not None:
return state_obj.attributes.get(name)
return None
def has_value(hass: HomeAssistant, entity_id: str) -> bool:
"""Test if an entity has a valid value."""
state_obj = _get_state(hass, entity_id)
return state_obj is not None and (
state_obj.state not in [STATE_UNAVAILABLE, STATE_UNKNOWN]
)
def make_logging_undefined(
strict: bool | None, log_fn: Callable[[int, str], None] | None
) -> type[jinja2.Undefined]:
@@ -992,106 +747,17 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.add_extension(
"homeassistant.helpers.template.extensions.SerializationExtension"
)
self.add_extension("homeassistant.helpers.template.extensions.StateExtension")
self.add_extension("homeassistant.helpers.template.extensions.StringExtension")
self.add_extension(
"homeassistant.helpers.template.extensions.TypeCastExtension"
)
self.add_extension("homeassistant.helpers.template.extensions.VersionExtension")
if hass is None:
return
# This environment has access to hass, attach its loader to enable imports.
self.loader = _get_hass_loader(hass)
# We mark these as a context functions to ensure they get
# evaluated fresh with every execution, rather than executed
# at compile time and the value stored. The context itself
# can be discarded, we only need to get at the hass object.
def hassfunction[**_P, _R](
func: Callable[Concatenate[HomeAssistant, _P], _R],
jinja_context: Callable[
[Callable[Concatenate[Any, _P], _R]],
Callable[Concatenate[Any, _P], _R],
] = pass_context,
) -> Callable[Concatenate[Any, _P], _R]:
"""Wrap function that depend on hass."""
@wraps(func)
def wrapper(_: Any, *args: _P.args, **kwargs: _P.kwargs) -> _R:
return func(hass, *args, **kwargs)
return jinja_context(wrapper)
if limited:
def unsupported(name: str) -> Callable[[], NoReturn]:
def warn_unsupported(*args: Any, **kwargs: Any) -> NoReturn:
raise TemplateError(
f"Use of '{name}' is not supported in limited templates"
)
return warn_unsupported
hass_globals = [
"closest",
"distance",
"expand",
"has_value",
"is_state_attr",
"is_state",
"state_attr",
"state_attr_translated",
"state_translated",
"states",
]
hass_filters = [
"closest",
"expand",
"has_value",
"state_attr",
"state_attr_translated",
"state_translated",
"states",
]
hass_tests = [
"has_value",
"is_state_attr",
"is_state",
]
for glob in hass_globals:
self.globals[glob] = unsupported(glob)
for filt in hass_filters:
self.filters[filt] = unsupported(filt)
for test in hass_tests:
self.tests[test] = unsupported(test)
return
self.globals["closest"] = hassfunction(closest)
self.globals["distance"] = hassfunction(distance)
self.globals["expand"] = hassfunction(expand)
self.globals["has_value"] = hassfunction(has_value)
self.filters["closest"] = hassfunction(closest_filter)
self.filters["expand"] = self.globals["expand"]
self.filters["has_value"] = self.globals["has_value"]
self.tests["has_value"] = hassfunction(has_value, pass_eval_context)
# State extensions
self.globals["is_state_attr"] = hassfunction(is_state_attr)
self.globals["is_state"] = hassfunction(is_state)
self.globals["state_attr"] = hassfunction(state_attr)
self.globals["state_attr_translated"] = StateAttrTranslated(hass)
self.globals["state_translated"] = StateTranslated(hass)
self.globals["states"] = AllStates(hass)
self.filters["state_attr"] = self.globals["state_attr"]
self.filters["state_attr_translated"] = self.globals["state_attr_translated"]
self.filters["state_translated"] = self.globals["state_translated"]
self.filters["states"] = self.globals["states"]
self.tests["is_state_attr"] = hassfunction(is_state_attr, pass_eval_context)
self.tests["is_state"] = hassfunction(is_state, pass_eval_context)
if hass is not None:
# This environment has access to hass, attach its loader
# to enable imports.
self.loader = _get_hass_loader(hass)
def is_safe_callable(self, obj):
"""Test if callback is safe."""
@@ -1160,6 +826,3 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
compiled = super().compile(source)
self.template_cache[source] = compiled
return compiled
_NO_HASS_ENV = TemplateEnvironment(None)
@@ -15,6 +15,7 @@ from .labels import LabelExtension
from .math import MathExtension
from .regex import RegexExtension
from .serialization import SerializationExtension
from .state import StateExtension
from .string import StringExtension
from .type_cast import TypeCastExtension
from .version import VersionExtension
@@ -35,6 +36,7 @@ __all__ = [
"MathExtension",
"RegexExtension",
"SerializationExtension",
"StateExtension",
"StringExtension",
"TypeCastExtension",
"VersionExtension",
@@ -32,6 +32,9 @@ class TemplateFunction:
True # Whether this function is available in limited environments
)
requires_hass: bool = False # Whether this function requires hass to be available
pass_context: bool = (
True # Whether to wrap with pass_context when requires_hass is True
)
def _pass_context[**_P, _R](
@@ -91,7 +94,7 @@ class BaseTemplateExtension(Extension):
func = template_func.func
if template_func.requires_hass:
if template_func.requires_hass and template_func.pass_context:
# We wrap these as a context functions to ensure they get
# evaluated fresh with every execution, rather than executed
# at compile time and the value stored.
@@ -0,0 +1,355 @@
"""State functions for Home Assistant templates."""
from __future__ import annotations
import collections.abc
from collections.abc import Iterable
import logging
from typing import TYPE_CHECKING, Any
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_LATITUDE,
ATTR_LONGITUDE,
ATTR_PERSONS,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
UnitOfLength,
)
from homeassistant.core import State, valid_entity_id
from homeassistant.helpers import location as loc_helper
from homeassistant.helpers.template.states import (
AllStates,
StateAttrTranslated,
StateTranslated,
_collect_state,
_get_state,
_resolve_state,
)
from homeassistant.util import convert, location as location_util
from .base import BaseTemplateExtension, TemplateFunction
if TYPE_CHECKING:
from homeassistant.helpers.template import TemplateEnvironment
_LOGGER = logging.getLogger(__name__)
_SENTINEL = object()
class StateExtension(BaseTemplateExtension):
"""Jinja2 extension for state functions."""
def __init__(self, environment: TemplateEnvironment) -> None:
"""Initialize the state extension."""
# Build the class-based instance functions only when hass is available.
# These use pass_context=False because they are callable class instances
# that should not be wrapped by _pass_context.
class_functions: list[TemplateFunction] = []
if (hass := environment.hass) is not None:
class_functions = [
TemplateFunction(
"state_attr_translated",
StateAttrTranslated(hass),
as_global=True,
as_filter=True,
requires_hass=True,
limited_ok=False,
pass_context=False,
),
TemplateFunction(
"state_translated",
StateTranslated(hass),
as_global=True,
as_filter=True,
requires_hass=True,
limited_ok=False,
pass_context=False,
),
TemplateFunction(
"states",
AllStates(hass),
as_global=True,
as_filter=True,
requires_hass=True,
limited_ok=False,
pass_context=False,
),
]
super().__init__(
environment,
functions=[
TemplateFunction(
"closest",
self.closest,
as_global=True,
requires_hass=True,
limited_ok=False,
),
TemplateFunction(
"closest",
self.closest_filter,
as_filter=True,
requires_hass=True,
limited_ok=False,
),
TemplateFunction(
"distance",
self.distance,
as_global=True,
requires_hass=True,
limited_ok=False,
),
TemplateFunction(
"expand",
self.expand,
as_global=True,
as_filter=True,
requires_hass=True,
limited_ok=False,
),
TemplateFunction(
"has_value",
self.has_value,
as_global=True,
as_filter=True,
as_test=True,
requires_hass=True,
limited_ok=False,
),
TemplateFunction(
"is_state",
self.is_state,
as_global=True,
as_test=True,
requires_hass=True,
limited_ok=False,
),
TemplateFunction(
"is_state_attr",
self.is_state_attr,
as_global=True,
as_test=True,
requires_hass=True,
limited_ok=False,
),
TemplateFunction(
"state_attr",
self.state_attr,
as_global=True,
as_filter=True,
requires_hass=True,
limited_ok=False,
),
*class_functions,
],
)
def expand(self, *args: Any) -> Iterable[State]:
"""Expand out any groups and zones into entity states."""
# circular import.
from homeassistant.helpers import entity as entity_helper # noqa: PLC0415
hass = self.hass
search = list(args)
found = {}
sources = entity_helper.entity_sources(hass)
while search:
entity = search.pop()
if isinstance(entity, str):
entity_id = entity
if (entity := _get_state(hass, entity)) is None:
continue
elif isinstance(entity, State):
entity_id = entity.entity_id
elif isinstance(entity, collections.abc.Iterable):
search += entity
continue
else:
# ignore other types
continue
if entity_id in found:
continue
domain = entity.domain
if domain == "group" or (
(source := sources.get(entity_id)) and source["domain"] == "group"
):
# Collect state will be called in here since it's wrapped
if group_entities := entity.attributes.get(ATTR_ENTITY_ID):
search += group_entities
elif domain == "zone":
if zone_entities := entity.attributes.get(ATTR_PERSONS):
search += zone_entities
else:
_collect_state(hass, entity_id)
found[entity_id] = entity
return list(found.values())
def closest(self, *args: Any) -> State | None:
"""Find closest entity.
Closest to home:
closest(states)
closest(states.device_tracker)
closest('group.children')
closest(states.group.children)
Closest to a point:
closest(23.456, 23.456, 'group.children')
closest('zone.school', 'group.children')
closest(states.zone.school, 'group.children')
As a filter:
states | closest
states.device_tracker | closest
['group.children', states.device_tracker] | closest
'group.children' | closest(23.456, 23.456)
states.device_tracker | closest('zone.school')
'group.children' | closest(states.zone.school)
"""
hass = self.hass
if len(args) == 1:
latitude = hass.config.latitude
longitude = hass.config.longitude
entities = args[0]
elif len(args) == 2:
point_state = _resolve_state(hass, args[0])
if point_state is None:
_LOGGER.warning("Closest:Unable to find state %s", args[0])
return None
if not loc_helper.has_location(point_state):
_LOGGER.warning(
"Closest:State does not contain valid location: %s", point_state
)
return None
latitude = point_state.attributes[ATTR_LATITUDE]
longitude = point_state.attributes[ATTR_LONGITUDE]
entities = args[1]
else:
latitude_arg = convert(args[0], float)
longitude_arg = convert(args[1], float)
if latitude_arg is None or longitude_arg is None:
_LOGGER.warning(
"Closest:Received invalid coordinates: %s, %s", args[0], args[1]
)
return None
latitude = latitude_arg
longitude = longitude_arg
entities = args[2]
states = self.expand(entities)
# state will already be wrapped here
return loc_helper.closest(latitude, longitude, states)
def closest_filter(self, *args: Any) -> State | None:
"""Call closest as a filter. Need to reorder arguments."""
new_args = list(args[1:])
new_args.append(args[0])
return self.closest(*new_args)
def distance(self, *args: Any) -> float | None:
"""Calculate distance.
Will calculate distance from home to a point or between points.
Points can be passed in using state objects or lat/lng coordinates.
"""
hass = self.hass
locations: list[tuple[float, float]] = []
to_process = list(args)
while to_process:
value = to_process.pop(0)
if isinstance(value, str) and not valid_entity_id(value):
point_state = None
else:
point_state = _resolve_state(hass, value)
if point_state is None:
# We expect this and next value to be lat&lng
if not to_process:
_LOGGER.warning(
"Distance:Expected latitude and longitude, got %s", value
)
return None
value_2 = to_process.pop(0)
latitude_to_process = convert(value, float)
longitude_to_process = convert(value_2, float)
if latitude_to_process is None or longitude_to_process is None:
_LOGGER.warning(
"Distance:Unable to process latitude and longitude: %s, %s",
value,
value_2,
)
return None
latitude = latitude_to_process
longitude = longitude_to_process
else:
if not loc_helper.has_location(point_state):
_LOGGER.warning(
"Distance:State does not contain valid location: %s",
point_state,
)
return None
latitude = point_state.attributes[ATTR_LATITUDE]
longitude = point_state.attributes[ATTR_LONGITUDE]
locations.append((latitude, longitude))
if len(locations) == 1:
return hass.config.distance(*locations[0])
return hass.config.units.length(
location_util.distance(*locations[0] + locations[1]), UnitOfLength.METERS
)
def is_state(self, entity_id: str, state: str | list[str]) -> bool:
"""Test if a state is a specific value."""
state_obj = _get_state(self.hass, entity_id)
return state_obj is not None and (
state_obj.state == state
or (isinstance(state, list) and state_obj.state in state)
)
def is_state_attr(self, entity_id: str, name: str, value: Any) -> bool:
"""Test if a state's attribute is a specific value."""
if (state_obj := _get_state(self.hass, entity_id)) is not None:
attr = state_obj.attributes.get(name, _SENTINEL)
if attr is _SENTINEL:
return False
return bool(attr == value)
return False
def state_attr(self, entity_id: str, name: str) -> Any:
"""Get a specific attribute from a state."""
if (state_obj := _get_state(self.hass, entity_id)) is not None:
return state_obj.attributes.get(name)
return None
def has_value(self, entity_id: str) -> bool:
"""Test if an entity has a valid value."""
state_obj = _get_state(self.hass, entity_id)
return state_obj is not None and (
state_obj.state not in [STATE_UNAVAILABLE, STATE_UNKNOWN]
)
+4 -4
View File
@@ -399,7 +399,7 @@ aiorussound==5.0.1
aioruuvigateway==0.1.0
# homeassistant.components.shelly
aioshelly==13.24.0
aioshelly==13.24.1
# homeassistant.components.skybell
aioskybell==22.7.0
@@ -794,7 +794,7 @@ debugpy==1.8.17
decora-wifi==1.4
# homeassistant.components.ecovacs
deebot-client==18.1.0
deebot-client==18.2.0
# homeassistant.components.ihc
# homeassistant.components.ohmconnect
@@ -2222,7 +2222,7 @@ pyitachip2ir==0.0.7
pyituran==0.1.5
# homeassistant.components.jvc_projector
pyjvcprojector==2.0.5
pyjvcprojector==2.0.6
# homeassistant.components.kaleidescape
pykaleidescape==1.1.5
@@ -2651,7 +2651,7 @@ python-opensky==1.0.1
# homeassistant.components.otbr
# homeassistant.components.thread
python-otbr-api==2.9.0
python-otbr-api==2.10.0
# homeassistant.components.overseerr
python-overseerr==0.9.0
+4 -4
View File
@@ -384,7 +384,7 @@ aiorussound==5.0.1
aioruuvigateway==0.1.0
# homeassistant.components.shelly
aioshelly==13.24.0
aioshelly==13.24.1
# homeassistant.components.skybell
aioskybell==22.7.0
@@ -706,7 +706,7 @@ debugpy==1.8.17
decora-wifi==1.4
# homeassistant.components.ecovacs
deebot-client==18.1.0
deebot-client==18.2.0
# homeassistant.components.ihc
# homeassistant.components.ohmconnect
@@ -1905,7 +1905,7 @@ pyisy==3.4.1
pyituran==0.1.5
# homeassistant.components.jvc_projector
pyjvcprojector==2.0.5
pyjvcprojector==2.0.6
# homeassistant.components.kaleidescape
pykaleidescape==1.1.5
@@ -2259,7 +2259,7 @@ python-opensky==1.0.1
# homeassistant.components.otbr
# homeassistant.components.thread
python-otbr-api==2.9.0
python-otbr-api==2.10.0
# homeassistant.components.overseerr
python-overseerr==0.9.0
+12 -2
View File
@@ -13,6 +13,10 @@ from homeassistant.requirements import DISCOVERY_INTEGRATIONS
from . import ast_parse_module
from .model import Config, Integration
# Duplicated from homeassistant.bootstrap to avoid importing bootstrap (and its
# eager component pre-imports) into hassfest. Kept in sync via test_dependencies.
CORE_INTEGRATIONS = {"homeassistant", "persistent_notification"}
class ImportCollector(ast.NodeVisitor):
"""Collect all integrations referenced."""
@@ -86,6 +90,7 @@ class ImportCollector(ast.NodeVisitor):
ALLOWED_USED_COMPONENTS = {
*CORE_INTEGRATIONS,
*{platform.value for platform in Platform},
# Internal integrations
"alert",
@@ -95,7 +100,6 @@ ALLOWED_USED_COMPONENTS = {
"device_automation",
"frontend",
"group",
"homeassistant",
"input_boolean",
"input_button",
"input_datetime",
@@ -106,7 +110,6 @@ ALLOWED_USED_COMPONENTS = {
"media_source",
"onboarding",
"panel_custom",
"persistent_notification",
"person",
"script",
"shopping_list",
@@ -332,6 +335,13 @@ def _validate_dependencies(
"dependencies", f"Dependency {dep} does not exist"
)
if dep in CORE_INTEGRATIONS:
integration.add_error(
"dependencies",
f"Dependency {dep} is a core integration and is "
"unconditionally loaded",
)
def validate(
integrations: dict[str, Integration],
+1 -1
View File
@@ -2,7 +2,7 @@
# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
FROM python:3.14.3-alpine
FROM python:3.14.2-alpine
ENV \
UV_SYSTEM_PYTHON=true \
+11 -4
View File
@@ -13,7 +13,13 @@ from homeassistant.components.alexa_devices.const import (
)
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from .const import TEST_DEVICE_1, TEST_DEVICE_1_SN, TEST_PASSWORD, TEST_USERNAME
from .const import (
TEST_DEVICE_1,
TEST_DEVICE_1_SN,
TEST_PASSWORD,
TEST_USER_ID,
TEST_USERNAME,
)
from tests.common import MockConfigEntry
@@ -44,12 +50,13 @@ def mock_amazon_devices_client() -> Generator[AsyncMock]:
client = mock_client.return_value
client.login = AsyncMock()
client.login.login_mode_interactive.return_value = {
"customer_info": {"user_id": TEST_USERNAME},
"customer_info": {"user_id": TEST_USER_ID},
CONF_SITE: "https://www.amazon.com",
}
client.get_devices_data.return_value = {
TEST_DEVICE_1_SN: deepcopy(TEST_DEVICE_1)
}
client.routines = ["Test Routine"]
client.send_sound_notification = AsyncMock()
yield client
@@ -59,7 +66,7 @@ def mock_config_entry() -> MockConfigEntry:
"""Mock a config entry."""
return MockConfigEntry(
domain=DOMAIN,
title="Amazon Test Account",
title=TEST_USERNAME,
data={
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
@@ -68,7 +75,7 @@ def mock_config_entry() -> MockConfigEntry:
CONF_SITE: "https://www.amazon.com",
},
},
unique_id=TEST_USERNAME,
unique_id=TEST_USER_ID,
version=1,
minor_version=3,
)
+1
View File
@@ -12,6 +12,7 @@ from aioamazondevices.structures import AmazonDevice, AmazonDeviceSensor, Amazon
TEST_CODE = "023123"
TEST_PASSWORD = "fake_password"
TEST_USERNAME = "fake_email@gmail.com"
TEST_USER_ID = "amzn1.account.fake_user_id"
TEST_DEVICE_1_SN = "echo_test_serial_number"
TEST_DEVICE_1_ID = "echo_test_device_id"
@@ -0,0 +1,51 @@
# serializer version: 1
# name: test_all_entities[button.fake_email_gmail_com_test_routine-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.fake_email_gmail_com_test_routine',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Test Routine',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Test Routine',
'platform': 'alexa_devices',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'amzn1_account_fake_user_id-test_routine',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[button.fake_email_gmail_com_test_routine-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'fake_email@gmail.com Test Routine',
}),
'context': <ANY>,
'entity_id': 'button.fake_email_gmail_com_test_routine',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
@@ -97,7 +97,7 @@
'subentries': list([
]),
'title': '**REDACTED**',
'unique_id': 'fake_email@gmail.com',
'unique_id': 'amzn1.account.fake_user_id',
'version': 1,
}),
})
@@ -0,0 +1,96 @@
"""Test Alexa Devices button entities."""
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.alexa_devices.coordinator import SCAN_INTERVAL
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.util import slugify
from . import setup_integration
from .const import TEST_USERNAME
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test all entities."""
with patch("homeassistant.components.alexa_devices.PLATFORMS", [Platform.BUTTON]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_pressing_routine_button(
hass: HomeAssistant,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test routine run button."""
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: f"button.{slugify(TEST_USERNAME)}_test_routine"},
blocking=True,
)
mock_amazon_devices_client.call_routine.assert_called_once()
@pytest.mark.parametrize(
("initial_routine", "updated_routines"),
[
(["Test Routine"], ["Test Routine", "New Routine"]), # Add a routine
(["Test Routine", "New Routine"], ["Test Routine"]), # Remove a routine
(["Test Routine"], []), # Remove all routines
],
)
async def test_dynamic_entities(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
initial_routine: list[str],
updated_routines: list[str],
) -> None:
"""Test entities are dynamically created and deleted."""
mock_amazon_devices_client.routines = initial_routine
await setup_integration(hass, mock_config_entry)
# Check initial routine(s) exist
for routine in initial_routine:
entity_id = f"button.{slugify(TEST_USERNAME)}_{slugify(routine)}"
assert hass.states.get(entity_id) is not None
mock_amazon_devices_client.routines = updated_routines
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
# After update, check which routines should exist
for routine in updated_routines:
entity_id = f"button.{slugify(TEST_USERNAME)}_{slugify(routine)}"
assert hass.states.get(entity_id) is not None
# Check routines that were removed no longer exist
for routine in set(initial_routine) - set(updated_routines):
entity_id = f"button.{slugify(TEST_USERNAME)}_{slugify(routine)}"
assert hass.states.get(entity_id) is None
@@ -19,7 +19,7 @@ from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .const import TEST_CODE, TEST_PASSWORD, TEST_USERNAME
from .const import TEST_CODE, TEST_PASSWORD, TEST_USER_ID, TEST_USERNAME
from tests.common import MockConfigEntry
@@ -51,11 +51,11 @@ async def test_full_flow(
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
CONF_LOGIN_DATA: {
"customer_info": {"user_id": TEST_USERNAME},
"customer_info": {"user_id": TEST_USER_ID},
CONF_SITE: "https://www.amazon.com",
},
}
assert result["result"].unique_id == TEST_USERNAME
assert result["result"].unique_id == TEST_USER_ID
mock_amazon_devices_client.login.login_mode_interactive.assert_called_once_with(
"023123"
)
@@ -170,7 +170,7 @@ async def test_reauth_successful(
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: "other_fake_password",
CONF_LOGIN_DATA: {
"customer_info": {"user_id": TEST_USERNAME},
"customer_info": {"user_id": TEST_USER_ID},
CONF_SITE: "https://www.amazon.com",
},
}
@@ -228,7 +228,7 @@ async def test_reauth_not_successful(
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: "fake_password",
CONF_LOGIN_DATA: {
"customer_info": {"user_id": TEST_USERNAME},
"customer_info": {"user_id": TEST_USER_ID},
CONF_SITE: "https://www.amazon.com",
},
}
@@ -268,7 +268,7 @@ async def test_reconfigure_successful(
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: new_password,
CONF_LOGIN_DATA: {
"customer_info": {"user_id": TEST_USERNAME},
"customer_info": {"user_id": TEST_USER_ID},
CONF_SITE: "https://www.amazon.com",
},
}
@@ -327,7 +327,7 @@ async def test_reconfigure_fails(
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
CONF_LOGIN_DATA: {
"customer_info": {"user_id": TEST_USERNAME},
"customer_info": {"user_id": TEST_USER_ID},
CONF_SITE: "https://www.amazon.com",
},
}
+2 -2
View File
@@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from . import setup_integration
from .const import TEST_DEVICE_1_SN, TEST_PASSWORD, TEST_USERNAME
from .const import TEST_DEVICE_1_SN, TEST_PASSWORD, TEST_USER_ID, TEST_USERNAME
from tests.common import MockConfigEntry
@@ -109,7 +109,7 @@ async def test_migrate_entry(
CONF_PASSWORD: TEST_PASSWORD,
**(extra_data),
},
unique_id=TEST_USERNAME,
unique_id=TEST_USER_ID,
version=1,
minor_version=minor_version,
)
+7 -2
View File
@@ -9,6 +9,7 @@ from homeassistant.const import (
ATTR_UNIT_OF_MEASUREMENT,
STATE_OFF,
STATE_ON,
EntityCategory,
)
from homeassistant.core import HomeAssistant
@@ -32,13 +33,17 @@ _BATTERY_UNIT_ATTRS = {ATTR_UNIT_OF_MEASUREMENT: "%"}
@pytest.fixture
async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple binary sensor entities associated with different targets."""
return await target_entities(hass, "binary_sensor")
return await target_entities(
hass, "binary_sensor", entity_category=EntityCategory.DIAGNOSTIC
)
@pytest.fixture
async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple sensor entities associated with different targets."""
return await target_entities(hass, "sensor")
return await target_entities(
hass, "sensor", entity_category=EntityCategory.DIAGNOSTIC
)
@pytest.mark.parametrize(
+66 -123
View File
@@ -10,7 +10,6 @@ import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components.bayesian.config_flow import (
OBSERVATION_SELECTOR,
USER,
ObservationTypes,
OptionsFlowSteps,
@@ -27,6 +26,7 @@ from homeassistant.config_entries import (
ConfigEntry,
ConfigSubentry,
ConfigSubentryDataWithId,
FlowType,
)
from homeassistant.const import (
CONF_ABOVE,
@@ -72,10 +72,9 @@ async def test_config_flow_step_user(hass: HomeAssistant) -> None:
)
await hass.async_block_till_done()
# We move on to the next step - the observation selector
assert result1["step_id"] == OBSERVATION_SELECTOR
assert result1["type"] is FlowResultType.MENU
assert result1["flow_id"] is not None
assert result1["type"] == FlowResultType.CREATE_ENTRY
assert result1["result"].title == "Office occupied"
assert result1["next_flow"][0] == FlowType.CONFIG_SUBENTRIES_FLOW
async def test_subentry_flow(hass: HomeAssistant) -> None:
@@ -254,13 +253,15 @@ async def test_single_state_observation(hass: HomeAssistant) -> None:
)
await hass.async_block_till_done()
# Confirm the next step is the menu
assert result["step_id"] == OBSERVATION_SELECTOR
assert result["type"] is FlowResultType.MENU
assert result["flow_id"] is not None
assert result["menu_options"] == ["state", "numeric_state", "template"]
assert result["type"] == FlowResultType.CREATE_ENTRY
entry_id = result["result"].entry_id
sub_flow_id = result["next_flow"][1]
result = await hass.config_entries.flow.async_configure(
# Confirm the next step is the menu
result = hass.config_entries.subentries.async_get(sub_flow_id)
assert result["flow_id"] is not None
result = await hass.config_entries.subentries.async_configure(
result["flow_id"], {"next_step_id": str(ObservationTypes.STATE)}
)
await hass.async_block_till_done()
@@ -268,7 +269,7 @@ async def test_single_state_observation(hass: HomeAssistant) -> None:
assert result["step_id"] == str(ObservationTypes.STATE)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{
CONF_ENTITY_ID: "sensor.kitchen_occupancy",
@@ -279,22 +280,9 @@ async def test_single_state_observation(hass: HomeAssistant) -> None:
},
)
assert result["step_id"] == OBSERVATION_SELECTOR
assert result["type"] is FlowResultType.MENU
assert result["flow_id"] is not None
assert result["menu_options"] == [
"state",
"numeric_state",
"template",
"finish",
]
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "finish"}
)
assert result["type"] == FlowResultType.CREATE_ENTRY
await hass.async_block_till_done()
entry_id = result["result"].entry_id
config_entry = hass.config_entries.async_get_entry(entry_id)
assert config_entry is not None
assert type(config_entry) is ConfigEntry
@@ -341,22 +329,20 @@ async def test_single_numeric_state_observation(hass: HomeAssistant) -> None:
CONF_PRIOR: 20,
},
)
assert result["type"] == FlowResultType.CREATE_ENTRY
config_entry = result["result"]
sub_flow_id = result["next_flow"][1]
await hass.async_block_till_done()
# Confirm the next step is the menu
assert result["step_id"] == OBSERVATION_SELECTOR
assert result["type"] is FlowResultType.MENU
assert result["flow_id"] is not None
# select numeric state observation
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": str(ObservationTypes.NUMERIC_STATE)}
result = await hass.config_entries.subentries.async_configure(
sub_flow_id, {"next_step_id": str(ObservationTypes.NUMERIC_STATE)}
)
await hass.async_block_till_done()
assert result["step_id"] == str(ObservationTypes.NUMERIC_STATE)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{
CONF_ENTITY_ID: "sensor.outside_temperature",
@@ -367,21 +353,8 @@ async def test_single_numeric_state_observation(hass: HomeAssistant) -> None:
CONF_NAME: "20 - 35 outside",
},
)
assert result["step_id"] == OBSERVATION_SELECTOR
assert result["type"] is FlowResultType.MENU
assert result["flow_id"] is not None
assert result["menu_options"] == [
"state",
"numeric_state",
"template",
"finish",
]
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "finish"}
)
await hass.async_block_till_done()
config_entry = result["result"]
assert config_entry.options == {
CONF_NAME: "Nice day",
CONF_PROBABILITY_THRESHOLD: 0.51,
@@ -427,20 +400,19 @@ async def test_multi_numeric_state_observation(hass: HomeAssistant) -> None:
)
await hass.async_block_till_done()
# Confirm the next step is the menu
assert result["step_id"] == OBSERVATION_SELECTOR
assert result["type"] is FlowResultType.MENU
assert result["flow_id"] is not None
assert result["type"] == FlowResultType.CREATE_ENTRY
config_entry = result["result"]
sub_flow_id = result["next_flow"][1]
# select numeric state observation
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": str(ObservationTypes.NUMERIC_STATE)}
result = await hass.config_entries.subentries.async_configure(
sub_flow_id, {"next_step_id": str(ObservationTypes.NUMERIC_STATE)}
)
await hass.async_block_till_done()
assert result["step_id"] == str(ObservationTypes.NUMERIC_STATE)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{
CONF_ENTITY_ID: "sensor.outside_temperature",
@@ -451,18 +423,17 @@ async def test_multi_numeric_state_observation(hass: HomeAssistant) -> None:
CONF_NAME: "20 - 35 outside",
},
)
# Confirm the next step is the menu
assert result["step_id"] == OBSERVATION_SELECTOR
assert result["type"] is FlowResultType.MENU
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": str(ObservationTypes.NUMERIC_STATE)}
)
await hass.async_block_till_done()
# This should fail as overlapping ranges for the same entity are not allowed
current_step = result["step_id"]
result = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.subentries.async_init(
(config_entry.entry_id, "observation"),
context={"source": config_entries.SOURCE_USER},
)
result = await hass.config_entries.subentries.async_configure(
result["flow_id"], {"next_step_id": str(ObservationTypes.NUMERIC_STATE)}
)
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{
CONF_ENTITY_ID: "sensor.outside_temperature",
@@ -475,11 +446,10 @@ async def test_multi_numeric_state_observation(hass: HomeAssistant) -> None:
)
await hass.async_block_till_done()
assert result["errors"] == {"base": "overlapping_ranges"}
assert result["step_id"] == current_step
# This should fail as above should always be less than below
current_step = result["step_id"]
result = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{
CONF_ENTITY_ID: "sensor.outside_temperature",
@@ -495,7 +465,7 @@ async def test_multi_numeric_state_observation(hass: HomeAssistant) -> None:
assert result["errors"] == {"base": "above_below"}
# This should work
result = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{
CONF_ENTITY_ID: "sensor.outside_temperature",
@@ -506,22 +476,8 @@ async def test_multi_numeric_state_observation(hass: HomeAssistant) -> None:
CONF_NAME: "35 - 40 outside",
},
)
assert result["step_id"] == OBSERVATION_SELECTOR
assert result["type"] is FlowResultType.MENU
assert result["flow_id"] is not None
assert result["menu_options"] == [
"state",
"numeric_state",
"template",
"finish",
]
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "finish"}
)
await hass.async_block_till_done()
config_entry = result["result"]
assert config_entry.version == 1
assert config_entry.options == {
CONF_NAME: "Nice day",
@@ -582,20 +538,19 @@ async def test_single_template_observation(hass: HomeAssistant) -> None:
)
await hass.async_block_till_done()
# Confirm the next step is the menu
assert result["step_id"] == OBSERVATION_SELECTOR
assert result["type"] is FlowResultType.MENU
assert result["flow_id"] is not None
assert result["type"] == FlowResultType.CREATE_ENTRY
config_entry = result["result"]
sub_flow_id = result["next_flow"][1]
# Select template observation
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": str(ObservationTypes.TEMPLATE)}
result = await hass.config_entries.subentries.async_configure(
sub_flow_id, {"next_step_id": str(ObservationTypes.TEMPLATE)}
)
await hass.async_block_till_done()
assert result["step_id"] == str(ObservationTypes.TEMPLATE)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{
CONF_VALUE_TEMPLATE: "{{is_state('device_tracker.paulus','not_home') and ((as_timestamp(now()) - as_timestamp(states.device_tracker.paulus.last_changed)) > 300)}}",
@@ -604,21 +559,7 @@ async def test_single_template_observation(hass: HomeAssistant) -> None:
CONF_NAME: "Not seen in last 5 minutes",
},
)
assert result["step_id"] == OBSERVATION_SELECTOR
assert result["type"] is FlowResultType.MENU
assert result["flow_id"] is not None
assert result["menu_options"] == [
"state",
"numeric_state",
"template",
"finish",
]
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "finish"}
)
await hass.async_block_till_done()
config_entry = result["result"]
assert config_entry.version == 1
assert config_entry.options == {
CONF_NAME: "Paulus Home",
@@ -1087,13 +1028,12 @@ async def test_invalid_configs(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
assert result.get("errors") is None
# Confirm the next step is the menu
assert result["step_id"] == OBSERVATION_SELECTOR
assert result["type"] is FlowResultType.MENU
assert result["flow_id"] is not None
assert result["type"] == FlowResultType.CREATE_ENTRY
config_entry = result["result"]
sub_flow_id = result["next_flow"][1]
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": str(ObservationTypes.STATE)}
result = await hass.config_entries.subentries.async_configure(
sub_flow_id, {"next_step_id": str(ObservationTypes.STATE)}
)
await hass.async_block_till_done()
@@ -1102,7 +1042,7 @@ async def test_invalid_configs(hass: HomeAssistant) -> None:
# Observations with a probability of 0 will create certainties
with pytest.raises(vol.Invalid) as excinfo:
result = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{
CONF_ENTITY_ID: "sensor.work_laptop",
@@ -1117,7 +1057,7 @@ async def test_invalid_configs(hass: HomeAssistant) -> None:
# Observations with a probability of 1 will create certainties
with pytest.raises(vol.Invalid) as excinfo:
result = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{
CONF_ENTITY_ID: "sensor.work_laptop",
@@ -1133,7 +1073,7 @@ async def test_invalid_configs(hass: HomeAssistant) -> None:
# Observations with equal probabilities have no effect
# Try with a ObservationTypes.STATE observation
current_step = result["step_id"]
result = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{
CONF_ENTITY_ID: "sensor.work_laptop",
@@ -1148,7 +1088,7 @@ async def test_invalid_configs(hass: HomeAssistant) -> None:
assert result["errors"] == {"base": "equal_probabilities"}
# now submit a valid result
result = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{
CONF_ENTITY_ID: "sensor.work_laptop",
@@ -1159,13 +1099,15 @@ async def test_invalid_configs(hass: HomeAssistant) -> None:
},
)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.subentries.async_init(
(config_entry.entry_id, "observation"),
context={"source": config_entries.SOURCE_USER},
)
result = await hass.config_entries.subentries.async_configure(
result["flow_id"], {"next_step_id": str(ObservationTypes.NUMERIC_STATE)}
)
await hass.async_block_till_done()
current_step = result["step_id"]
result = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{
CONF_ENTITY_ID: "sensor.office_illuminance_lux",
@@ -1176,10 +1118,9 @@ async def test_invalid_configs(hass: HomeAssistant) -> None:
},
)
await hass.async_block_till_done()
assert result["step_id"] == current_step
assert result["errors"] == {"base": "equal_probabilities"}
result = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{
CONF_ENTITY_ID: "sensor.office_illuminance_lux",
@@ -1191,13 +1132,15 @@ async def test_invalid_configs(hass: HomeAssistant) -> None:
)
await hass.async_block_till_done()
# Try with a ObservationTypes.TEMPLATE observation
result = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.subentries.async_init(
(config_entry.entry_id, "observation"),
context={"source": config_entries.SOURCE_USER},
)
result = await hass.config_entries.subentries.async_configure(
result["flow_id"], {"next_step_id": str(ObservationTypes.TEMPLATE)}
)
await hass.async_block_till_done()
current_step = result["step_id"]
result = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{
CONF_VALUE_TEMPLATE: "{{ is_state('device_tracker.paulus', 'not_home') }}",
+51 -1
View File
@@ -9,12 +9,15 @@ from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import (
MONOTONIC_TIME,
BaseHaRemoteScanner,
BluetoothChange,
BluetoothScanningMode,
BluetoothServiceInfo,
HaBluetoothConnector,
async_clear_advertisement_history,
async_scanner_by_source,
async_scanner_devices_by_address,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from . import (
FakeRemoteScanner,
@@ -23,6 +26,7 @@ from . import (
_get_manager,
generate_advertisement_data,
generate_ble_device,
inject_advertisement,
)
@@ -228,3 +232,49 @@ async def test_async_current_scanners(hass: HomeAssistant) -> None:
# Verify we're back to the initial scanner
final_scanners = bluetooth.async_current_scanners(hass)
assert len(final_scanners) == initial_scanner_count
@pytest.mark.usefixtures("enable_bluetooth")
async def test_clear_advertisement_history(hass: HomeAssistant) -> None:
"""Test clearing advertisement history bypasses the dedup guard."""
callbacks: list[tuple[BluetoothServiceInfo, BluetoothChange]] = []
@callback
def _fake_subscriber(
service_info: BluetoothServiceInfo, change: BluetoothChange
) -> None:
callbacks.append((service_info, change))
cancel = bluetooth.async_register_callback(
hass,
_fake_subscriber,
{"address": "44:44:33:11:23:45"},
BluetoothScanningMode.ACTIVE,
)
switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand")
switchbot_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"},
)
inject_advertisement(hass, switchbot_device, switchbot_adv)
await hass.async_block_till_done()
# Identical advertisement is deduplicated by the manager
inject_advertisement(hass, switchbot_device, switchbot_adv)
await hass.async_block_till_done()
assert len(callbacks) == 1
# Clearing the advertisement history makes the next identical
# advertisement be treated as new data
async_clear_advertisement_history(hass, "44:44:33:11:23:45")
inject_advertisement(hass, switchbot_device, switchbot_adv)
await hass.async_block_till_done()
assert len(callbacks) == 2
cancel()
+86
View File
@@ -1427,6 +1427,92 @@ async def test_power_sensor_manager_creation(
state = hass.states.get("sensor.battery_power_inverted")
assert state is not None
assert float(state.state) == -100.0
assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfPower.WATT
async def test_power_sensor_inverted_propagates_unit(
recorder_mock: Recorder, hass: HomeAssistant
) -> None:
"""Test inverted power sensor copies unit from the source state."""
assert await async_setup_component(hass, "energy", {"energy": {}})
manager = await async_get_manager(hass)
manager.data = manager.default_preferences()
# Use a non-default unit to prove we copy from the source rather than
# hard-coding Watts.
hass.states.async_set(
"sensor.battery_power",
"1.5",
{ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT},
)
await hass.async_block_till_done()
await manager.async_update(
{
"energy_sources": [
{
"type": "battery",
"stat_energy_from": "sensor.battery_energy_from",
"stat_energy_to": "sensor.battery_energy_to",
"power_config": {
"stat_rate_inverted": "sensor.battery_power",
},
}
],
}
)
await hass.async_block_till_done()
state = hass.states.get("sensor.battery_power_inverted")
assert state is not None
assert float(state.state) == -1.5
assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfPower.KILO_WATT
# Source switches to Watts — the inverted sensor should follow.
hass.states.async_set(
"sensor.battery_power",
"200.0",
{ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT},
)
await hass.async_block_till_done()
state = hass.states.get("sensor.battery_power_inverted")
assert state is not None
assert float(state.state) == -200.0
assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfPower.WATT
async def test_power_sensor_inverted_source_without_unit(
recorder_mock: Recorder, hass: HomeAssistant
) -> None:
"""Test inverted sensor reports no unit when source has none."""
assert await async_setup_component(hass, "energy", {"energy": {}})
manager = await async_get_manager(hass)
manager.data = manager.default_preferences()
hass.states.async_set("sensor.battery_power", "100.0")
await hass.async_block_till_done()
await manager.async_update(
{
"energy_sources": [
{
"type": "battery",
"stat_energy_from": "sensor.battery_energy_from",
"stat_energy_to": "sensor.battery_energy_to",
"power_config": {
"stat_rate_inverted": "sensor.battery_power",
},
}
],
}
)
await hass.async_block_till_done()
state = hass.states.get("sensor.battery_power_inverted")
assert state is not None
assert float(state.state) == -100.0
assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes
async def test_power_sensor_manager_cleanup(
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,119 @@
# serializer version: 1
# name: test_numbers[number][number.clou_duo_fan_speed-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'max': 5,
'min': 0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.clou_duo_fan_speed',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Fan speed',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Fan speed',
'platform': 'fumis',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'fan_speed',
'unique_id': 'aa:bb:cc:dd:ee:ff_fan_speed',
'unit_of_measurement': None,
})
# ---
# name: test_numbers[number][number.clou_duo_fan_speed-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Clou Duo Fan speed',
'max': 5,
'min': 0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'context': <ANY>,
'entity_id': 'number.clou_duo_fan_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_numbers[number][number.clou_duo_power_level-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'max': 5,
'min': 1,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': None,
'entity_id': 'number.clou_duo_power_level',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Power level',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Power level',
'platform': 'fumis',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'power_level',
'unique_id': 'aa:bb:cc:dd:ee:ff_power_level',
'unit_of_measurement': None,
})
# ---
# name: test_numbers[number][number.clou_duo_power_level-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Clou Duo Power level',
'max': 5,
'min': 1,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'context': <ANY>,
'entity_id': 'number.clou_duo_power_level',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '5',
})
# ---
@@ -1,4 +1,77 @@
# serializer version: 1
# name: test_sensors[sensor][sensor.clou_duo_alert-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'options': list([
'none',
'low_fuel',
'service_due',
'flue_gas_warning',
'low_battery',
'speed_sensor_failure',
'door_open',
'airflow_malfunction',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.clou_duo_alert',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Alert',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Alert',
'platform': 'fumis',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'alert',
'unique_id': 'aa:bb:cc:dd:ee:ff_alert',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[sensor][sensor.clou_duo_alert-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'code': None,
'device_class': 'enum',
'friendly_name': 'Clou Duo Alert',
'options': list([
'none',
'low_fuel',
'service_due',
'flue_gas_warning',
'low_battery',
'speed_sensor_failure',
'door_open',
'airflow_malfunction',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.clou_duo_alert',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'none',
})
# ---
# name: test_sensors[sensor][sensor.clou_duo_burning_time-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -255,6 +328,113 @@
'state': 'combustion',
})
# ---
# name: test_sensors[sensor][sensor.clou_duo_error-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'options': list([
'none',
'ignition_failed',
'chimney_dirty',
'sensor_t02',
'sensor_t03_t05',
'sensor_t04',
'safety_switch',
'pressure_sensor_off',
'sensor_t01_t02',
'sensor_t01_t03',
'flue_gas_overtemp',
'fuel_ignition_timeout',
'general_error',
'mfdoor_alarm',
'fire_error',
'chimney_alarm',
'grate_error',
'ntc2_alarm',
'ntc3_alarm',
'door_alarm',
'pressure_alarm',
'ntc1_alarm',
'tc1_alarm',
'gas_alarm',
'no_pellet_alarm',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.clou_duo_error',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Error',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Error',
'platform': 'fumis',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'error',
'unique_id': 'aa:bb:cc:dd:ee:ff_error',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[sensor][sensor.clou_duo_error-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'code': None,
'device_class': 'enum',
'friendly_name': 'Clou Duo Error',
'options': list([
'none',
'ignition_failed',
'chimney_dirty',
'sensor_t02',
'sensor_t03_t05',
'sensor_t04',
'safety_switch',
'pressure_sensor_off',
'sensor_t01_t02',
'sensor_t01_t03',
'flue_gas_overtemp',
'fuel_ignition_timeout',
'general_error',
'mfdoor_alarm',
'fire_error',
'chimney_alarm',
'grate_error',
'ntc2_alarm',
'ntc3_alarm',
'door_alarm',
'pressure_alarm',
'ntc1_alarm',
'tc1_alarm',
'gas_alarm',
'no_pellet_alarm',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.clou_duo_error',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'none',
})
# ---
# name: test_sensors[sensor][sensor.clou_duo_fan_1_speed-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -1089,3 +1269,70 @@
'state': '27.0',
})
# ---
# name: test_sensors_active_error_and_alert[info_error_alert-sensor]
StateSnapshot({
'attributes': ReadOnlyDict({
'code': 'E101',
'device_class': 'enum',
'friendly_name': 'Clou Duo Error',
'options': list([
'none',
'ignition_failed',
'chimney_dirty',
'sensor_t02',
'sensor_t03_t05',
'sensor_t04',
'safety_switch',
'pressure_sensor_off',
'sensor_t01_t02',
'sensor_t01_t03',
'flue_gas_overtemp',
'fuel_ignition_timeout',
'general_error',
'mfdoor_alarm',
'fire_error',
'chimney_alarm',
'grate_error',
'ntc2_alarm',
'ntc3_alarm',
'door_alarm',
'pressure_alarm',
'ntc1_alarm',
'tc1_alarm',
'gas_alarm',
'no_pellet_alarm',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.clou_duo_error',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'ignition_failed',
})
# ---
# name: test_sensors_active_error_and_alert[info_error_alert-sensor].1
StateSnapshot({
'attributes': ReadOnlyDict({
'code': 'A001',
'device_class': 'enum',
'friendly_name': 'Clou Duo Alert',
'options': list([
'none',
'low_fuel',
'service_due',
'flue_gas_warning',
'low_battery',
'speed_sensor_failure',
'door_open',
'airflow_malfunction',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.clou_duo_alert',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'low_fuel',
})
# ---
+126
View File
@@ -0,0 +1,126 @@
"""Tests for the Fumis number entities."""
from unittest.mock import MagicMock
from fumis import FumisConnectionError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.fumis.const import DOMAIN
from homeassistant.components.number import (
ATTR_VALUE,
DOMAIN as NUMBER_DOMAIN,
SERVICE_SET_VALUE,
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from .const import UNIQUE_ID
from tests.common import MockConfigEntry, snapshot_platform
pytestmark = pytest.mark.parametrize(
"init_integration", [Platform.NUMBER], indirect=True
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_numbers(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the Fumis number entities."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.usefixtures("init_integration")
async def test_set_power_level(
hass: HomeAssistant,
mock_fumis: MagicMock,
) -> None:
"""Test setting the power level."""
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: "number.clou_duo_power_level", ATTR_VALUE: 3},
blocking=True,
)
mock_fumis.set_power.assert_called_once_with(3)
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_set_fan_speed(
hass: HomeAssistant,
mock_fumis: MagicMock,
) -> None:
"""Test setting the fan speed."""
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: "number.clou_duo_fan_speed", ATTR_VALUE: 2},
blocking=True,
)
mock_fumis.set_fan_speed.assert_called_once_with(2)
@pytest.mark.usefixtures("init_integration")
async def test_number_error_handling(
hass: HomeAssistant,
mock_fumis: MagicMock,
) -> None:
"""Test error handling for number actions."""
mock_fumis.set_power.side_effect = FumisConnectionError
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: "number.clou_duo_power_level", ATTR_VALUE: 3},
blocking=True,
)
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == "communication_error"
@pytest.mark.parametrize(
"unique_id",
[
f"{UNIQUE_ID}_fan_speed",
],
)
@pytest.mark.usefixtures("init_integration")
async def test_numbers_disabled_by_default(
entity_registry: er.EntityRegistry,
unique_id: str,
) -> None:
"""Test number entities that are disabled by default."""
entry = entity_registry.async_get_entity_id("number", "fumis", unique_id)
assert entry is not None, f"Entity with unique_id {unique_id} not found"
assert (entity_entry := entity_registry.async_get(entry))
assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
@pytest.mark.parametrize("device_fixture", ["info_minimal"])
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_numbers_conditional_creation(
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test fan_speed number is not created when data is missing."""
entity_entries = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
unique_ids = {entry.unique_id for entry in entity_entries}
# Fan speed should NOT exist with the minimal fixture
assert f"{UNIQUE_ID}_fan_speed" not in unique_ids
# Power level should still exist
assert f"{UNIQUE_ID}_power_level" in unique_ids
+29
View File
@@ -66,6 +66,33 @@ async def test_sensors_unknown_status(
assert state.state == STATE_UNKNOWN
@pytest.mark.parametrize("device_fixture", ["info_error_alert"])
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_sensors_active_error_and_alert(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test error and alert sensors with active codes."""
error_entity_id = entity_registry.async_get_entity_id(
"sensor", "fumis", f"{UNIQUE_ID}_error"
)
assert error_entity_id is not None
assert (state := hass.states.get(error_entity_id))
assert state == snapshot
assert state.state == "ignition_failed"
assert state.attributes["code"] == "E101"
alert_entity_id = entity_registry.async_get_entity_id(
"sensor", "fumis", f"{UNIQUE_ID}_alert"
)
assert alert_entity_id is not None
assert (state := hass.states.get(alert_entity_id))
assert state == snapshot
assert state.state == "low_fuel"
assert state.attributes["code"] == "A001"
@pytest.mark.parametrize("device_fixture", ["info_minimal"])
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_sensors_conditional_creation(
@@ -93,7 +120,9 @@ async def test_sensors_conditional_creation(
# These should still exist
for key in (
"alert",
"detailed_stove_status",
"error",
"power_output",
"stove_status",
"wifi_rssi",
@@ -28,7 +28,15 @@ from aiohomeconnect.model.error import (
TooManyRequestsError,
UnauthorizedError,
)
from aiohomeconnect.model.program import Option, OptionKey, Program, ProgramKey
from aiohomeconnect.model.program import (
Option,
OptionKey,
Program,
ProgramDefinition,
ProgramDefinitionConstraints,
ProgramDefinitionOption,
ProgramKey,
)
from freezegun.api import FrozenDateTimeFactory
import pytest
@@ -986,3 +994,99 @@ async def test_fetch_base_program_options_when_favorite_program_event(
client.get_available_program.assert_awaited_once_with(
appliance.ha_id, program_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50
)
@pytest.mark.parametrize("appliance", ["Dishwasher"], indirect=True)
@pytest.mark.parametrize(
"event_key",
[
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
],
)
async def test_option_values_kept_after_changing_program(
hass: HomeAssistant,
client: MagicMock,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
appliance: HomeAppliance,
event_key: EventKey,
) -> None:
"""Test that when a program is changed, the options are kept and defaults are not used."""
appliance_ha_id = appliance.ha_id
entity_id = "switch.dishwasher_half_load"
client.get_available_program = AsyncMock(
return_value=ProgramDefinition(
ProgramKey.DISHCARE_DISHWASHER_AUTO_1,
options=[
ProgramDefinitionOption(
OptionKey.DISHCARE_DISHWASHER_HALF_LOAD,
"Boolean",
constraints=ProgramDefinitionConstraints(default=False),
)
],
)
)
assert await integration_setup(client)
assert config_entry.state is ConfigEntryState.LOADED
assert not hass.states.is_state(entity_id, "on")
await client.add_events(
[
EventMessage(
appliance_ha_id,
EventType.NOTIFY,
data=ArrayOfEvents(
[
Event(
key=EventKey.DISHCARE_DISHWASHER_OPTION_HALF_LOAD,
raw_key=EventKey.DISHCARE_DISHWASHER_OPTION_HALF_LOAD.value,
timestamp=0,
level="",
handling="",
value=True,
),
]
),
)
]
)
await hass.async_block_till_done()
assert hass.states.is_state(entity_id, "on")
client.get_available_program = AsyncMock(
return_value=ProgramDefinition(
ProgramKey.DISHCARE_DISHWASHER_ECO_50,
options=[
ProgramDefinitionOption(
OptionKey.DISHCARE_DISHWASHER_HALF_LOAD,
"Boolean",
constraints=ProgramDefinitionConstraints(default=False),
)
],
)
)
await client.add_events(
[
EventMessage(
appliance_ha_id,
EventType.NOTIFY,
data=ArrayOfEvents(
[
Event(
key=event_key,
raw_key=event_key.value,
timestamp=0,
level="",
handling="",
value=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value,
),
]
),
)
]
)
await hass.async_block_till_done()
assert hass.states.is_state(entity_id, "on")
@@ -3,7 +3,11 @@
from datetime import timedelta
from unittest.mock import AsyncMock
from jvcprojector import JvcProjectorTimeoutError, command as cmd
from jvcprojector import (
JvcProjectorCommandError,
JvcProjectorTimeoutError,
command as cmd,
)
import pytest
from homeassistant.components.jvc_projector.coordinator import (
@@ -11,6 +15,7 @@ from homeassistant.components.jvc_projector.coordinator import (
INTERVAL_SLOW,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.util.dt import utcnow
@@ -58,3 +63,42 @@ async def test_coordinator_setup_connect_error(
) -> None:
"""Test coordinator connect error."""
assert mock_integration.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.parametrize(
"mock_device",
[{"fixture_override": {cmd.Power: JvcProjectorCommandError}}],
indirect=True,
)
async def test_coordinator_setup_power_command_error(
hass: HomeAssistant,
mock_device: AsyncMock,
mock_integration: MockConfigEntry,
) -> None:
"""Test coordinator fails setup when Power command errors with no cached value."""
assert mock_integration.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.parametrize(
"mock_device",
[{"fixture_override": {cmd.Input: JvcProjectorCommandError}}],
indirect=True,
)
async def test_coordinator_command_error_keeps_other_entities_available(
hass: HomeAssistant,
mock_device: AsyncMock,
mock_integration: MockConfigEntry,
) -> None:
"""Test a failing command does not take every entity offline."""
assert mock_integration.state is ConfigEntryState.LOADED
coordinator = mock_integration.runtime_data
assert coordinator.last_update_success is True
power = hass.states.get("sensor.jvc_projector_status")
assert power is not None
assert power.state == "on"
light_time = hass.states.get("sensor.jvc_projector_light_time")
assert light_time is not None
assert light_time.state != STATE_UNAVAILABLE
@@ -2559,6 +2559,7 @@ async def test_subscribe_all_entities_are_continuous(
entity_id, state, {ATTR_UNIT_OF_MEASUREMENT: "any"}
)
hass.states.async_set("counter.any", state)
hass.states.async_set("image.any", state)
hass.states.async_set("proximity.any", state)
# We will compare event subscriptions after closing the websocket connection,
@@ -2573,7 +2574,7 @@ async def test_subscribe_all_entities_are_continuous(
"id": 7,
"type": "logbook/event_stream",
"start_time": now.isoformat(),
"entity_ids": ["sensor.uom", "counter.any", "proximity.any"],
"entity_ids": ["sensor.uom", "counter.any", "image.any", "proximity.any"],
}
)
@@ -2908,6 +2909,7 @@ async def test_subscribe_all_entities_are_continuous_with_device(
entity_id, state, {ATTR_UNIT_OF_MEASUREMENT: "any"}
)
hass.states.async_set("counter.any", state)
hass.states.async_set("image.any", state)
hass.states.async_set("proximity.any", state)
hass.bus.async_fire("mock_event", {"device_id": device.id})
hass.bus.async_fire("mock_event", {"device_id": device2.id})
@@ -2924,7 +2926,7 @@ async def test_subscribe_all_entities_are_continuous_with_device(
"id": 7,
"type": "logbook/event_stream",
"start_time": now.isoformat(),
"entity_ids": ["sensor.uom", "counter.any", "proximity.any"],
"entity_ids": ["sensor.uom", "counter.any", "image.any", "proximity.any"],
"device_ids": [device.id, device2.id],
}
)
@@ -3113,6 +3115,11 @@ async def test_live_stream_with_changed_state_change(
{},
0, # Counter is an always continuous domain
),
(
"image.map0",
{},
0, # Image is an always continuous domain
),
(
"zone.home",
{},
+25
View File
@@ -1,10 +1,13 @@
"""Test fixtures for the Open Thread Border Router integration."""
from collections.abc import Generator
from http import HTTPStatus
import re
from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from python_otbr_api import KeyFormat
from homeassistant.components import otbr
from homeassistant.core import HomeAssistant
@@ -19,6 +22,7 @@ from . import (
)
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
@pytest.fixture(name="enable_compute_pskc")
@@ -44,6 +48,27 @@ def dataset_fixture() -> Any:
return DATASET_CH16
@pytest.fixture(name="key_format")
def key_format_fixture() -> KeyFormat:
"""Override to control the OTBR JSON key format probe outcome."""
return KeyFormat.PASCAL_CASE
@pytest.fixture(autouse=True)
def mock_api_actions(
aioclient_mock: AiohttpClientMocker, key_format: KeyFormat
) -> None:
"""Mock the /api/actions probe used by python_otbr_api to detect key format.
The probe was added in python_otbr_api 2.10.0: it returns 200 for OTBRs
that speak camelCase and 404 for older PascalCase OTBRs.
"""
status = (
HTTPStatus.OK if key_format == KeyFormat.CAMEL_CASE else HTTPStatus.NOT_FOUND
)
aioclient_mock.get(re.compile(r".*/api/actions$"), status=status)
@pytest.fixture(name="get_active_dataset_tlvs")
def get_active_dataset_tlvs_fixture(dataset: Any) -> Generator[AsyncMock]:
"""Mock get_active_dataset_tlvs."""
+39 -19
View File
@@ -2,12 +2,14 @@
import asyncio
from http import HTTPStatus
import re
from typing import Any
from unittest.mock import AsyncMock, Mock, patch
import aiohttp
import pytest
import python_otbr_api
from python_otbr_api import KeyFormat
from homeassistant.components import otbr
from homeassistant.components.homeassistant_hardware import (
@@ -58,6 +60,25 @@ HASSIO_DATA_OTBR = HassioServiceInfo(
)
def _expected_dataset_body(pan_id: int, key_format: KeyFormat) -> dict[str, Any]:
"""Return the expected JSON body for a default-channel dataset PUT.
python_otbr_api emits camelCase by default and rewrites to PascalCase only
when the /api/actions probe returns 404.
"""
if key_format == KeyFormat.PASCAL_CASE:
return {
"Channel": 15,
"NetworkName": f"ha-thread-{pan_id:04x}",
"PanId": pan_id,
}
return {
"channel": 15,
"networkName": f"ha-thread-{pan_id:04x}",
"panId": pan_id,
}
@pytest.fixture(name="otbr_addon_info")
def otbr_addon_info_fixture(addon_info: AsyncMock, addon_installed) -> AsyncMock:
"""Mock Supervisor otbr add-on info."""
@@ -153,6 +174,7 @@ async def test_user_flow_additional_entry_fail_get_address(
# Do a user flow
aioclient_mock.clear_requests()
aioclient_mock.get(re.compile(r".*/api/actions$"), status=HTTPStatus.NOT_FOUND)
aioclient_mock.get(f"{url1}/node/ba-id", json=TEST_BORDER_AGENT_ID.hex())
aioclient_mock.get(f"{url2}/node/ba-id", status=HTTPStatus.NOT_FOUND)
await _finish_user_flow(hass)
@@ -239,9 +261,12 @@ async def test_user_flow_additional_entry_same_address(
assert result["errors"] == {"base": "already_configured"}
@pytest.mark.parametrize("key_format", [KeyFormat.PASCAL_CASE, KeyFormat.CAMEL_CASE])
@pytest.mark.usefixtures("get_border_agent_id")
async def test_user_flow_router_not_setup(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
key_format: KeyFormat,
) -> None:
"""Test the user flow when the border router has no dataset.
@@ -278,12 +303,9 @@ async def test_user_flow_router_not_setup(
# Check we create a dataset and enable the router
assert aioclient_mock.mock_calls[-2][0] == "PUT"
assert aioclient_mock.mock_calls[-2][1].path == "/node/dataset/active"
pan_id = aioclient_mock.mock_calls[-2][2]["PanId"]
assert aioclient_mock.mock_calls[-2][2] == {
"Channel": 15,
"NetworkName": f"ha-thread-{pan_id:04x}",
"PanId": pan_id,
}
body = aioclient_mock.mock_calls[-2][2]
pan_id = body["PanId" if key_format == KeyFormat.PASCAL_CASE else "panId"]
assert body == _expected_dataset_body(pan_id, key_format)
assert aioclient_mock.mock_calls[-1][0] == "PUT"
assert aioclient_mock.mock_calls[-1][1].path == "/node/state"
@@ -671,12 +693,14 @@ async def test_hassio_discovery_flow_2x_addons_same_ext_address(
assert config_entry.unique_id == HASSIO_DATA.uuid
@pytest.mark.parametrize("key_format", [KeyFormat.PASCAL_CASE, KeyFormat.CAMEL_CASE])
@pytest.mark.usefixtures("get_border_agent_id")
async def test_hassio_discovery_flow_router_not_setup(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
multiprotocol_addon_manager_mock,
otbr_addon_info,
key_format: KeyFormat,
) -> None:
"""Test the hassio discovery flow when the border router has no dataset.
@@ -704,12 +728,9 @@ async def test_hassio_discovery_flow_router_not_setup(
# Check we create a dataset and enable the router
assert aioclient_mock.mock_calls[-2][0] == "PUT"
assert aioclient_mock.mock_calls[-2][1].path == "/node/dataset/active"
pan_id = aioclient_mock.mock_calls[-2][2]["PanId"]
assert aioclient_mock.mock_calls[-2][2] == {
"Channel": 15,
"NetworkName": f"ha-thread-{pan_id:04x}",
"PanId": pan_id,
}
body = aioclient_mock.mock_calls[-2][2]
pan_id = body["PanId" if key_format == KeyFormat.PASCAL_CASE else "panId"]
assert body == _expected_dataset_body(pan_id, key_format)
assert aioclient_mock.mock_calls[-1][0] == "PUT"
assert aioclient_mock.mock_calls[-1][1].path == "/node/state"
@@ -788,12 +809,14 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred(
assert config_entry.unique_id == HASSIO_DATA.uuid
@pytest.mark.parametrize("key_format", [KeyFormat.PASCAL_CASE, KeyFormat.CAMEL_CASE])
@pytest.mark.usefixtures("get_border_agent_id")
async def test_hassio_discovery_flow_router_not_setup_has_preferred_2(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
multiprotocol_addon_manager_mock,
otbr_addon_info,
key_format: KeyFormat,
) -> None:
"""Test the hassio discovery flow when the border router has no dataset.
@@ -824,12 +847,9 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred_2(
# Check we create a dataset and enable the router
assert aioclient_mock.mock_calls[-2][0] == "PUT"
assert aioclient_mock.mock_calls[-2][1].path == "/node/dataset/active"
pan_id = aioclient_mock.mock_calls[-2][2]["PanId"]
assert aioclient_mock.mock_calls[-2][2] == {
"Channel": 15,
"NetworkName": f"ha-thread-{pan_id:04x}",
"PanId": pan_id,
}
body = aioclient_mock.mock_calls[-2][2]
pan_id = body["PanId" if key_format == KeyFormat.PASCAL_CASE else "panId"]
assert body == _expected_dataset_body(pan_id, key_format)
assert aioclient_mock.mock_calls[-1][0] == "PUT"
assert aioclient_mock.mock_calls[-1][1].path == "/node/state"
@@ -443,57 +443,6 @@
'state': 'living_room',
})
# ---
# name: test_device[cury_gen4][sensor.test_name_last_restart-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_name_last_restart',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Last restart',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
'original_icon': None,
'original_name': 'Last restart',
'platform': 'shelly',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'last_restart',
'unique_id': '123456789ABC-sys-uptime',
'unit_of_measurement': None,
})
# ---
# name: test_device[cury_gen4][sensor.test_name_last_restart-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'uptime',
'friendly_name': 'Test name Last restart',
}),
'context': <ANY>,
'entity_id': 'sensor.test_name_last_restart',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2025-05-26T12:31:05+00:00',
})
# ---
# name: test_device[cury_gen4][sensor.test_name_left_slot_level-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -757,6 +706,57 @@
'state': '-49',
})
# ---
# name: test_device[cury_gen4][sensor.test_name_uptime-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_name_uptime',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Uptime',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
'original_icon': None,
'original_name': 'Uptime',
'platform': 'shelly',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '123456789ABC-sys-uptime',
'unit_of_measurement': None,
})
# ---
# name: test_device[cury_gen4][sensor.test_name_uptime-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'uptime',
'friendly_name': 'Test name Uptime',
}),
'context': <ANY>,
'entity_id': 'sensor.test_name_uptime',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2025-05-26T12:31:05+00:00',
})
# ---
# name: test_device[cury_gen4][switch.test_name_away_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -1354,57 +1354,6 @@
'state': 'off',
})
# ---
# name: test_device[duo_bulb_gen3][sensor.test_name_last_restart-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_name_last_restart',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Last restart',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
'original_icon': None,
'original_name': 'Last restart',
'platform': 'shelly',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'last_restart',
'unique_id': '123456789ABC-sys-uptime',
'unit_of_measurement': None,
})
# ---
# name: test_device[duo_bulb_gen3][sensor.test_name_last_restart-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'uptime',
'friendly_name': 'Test name Last restart',
}),
'context': <ANY>,
'entity_id': 'sensor.test_name_last_restart',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2025-05-24T02:30:09+00:00',
})
# ---
# name: test_device[duo_bulb_gen3][sensor.test_name_living_room_lamp_energy-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -1579,6 +1528,57 @@
'state': '-50',
})
# ---
# name: test_device[duo_bulb_gen3][sensor.test_name_uptime-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_name_uptime',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Uptime',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
'original_icon': None,
'original_name': 'Uptime',
'platform': 'shelly',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '123456789ABC-sys-uptime',
'unit_of_measurement': None,
})
# ---
# name: test_device[duo_bulb_gen3][sensor.test_name_uptime-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'uptime',
'friendly_name': 'Test name Uptime',
}),
'context': <ANY>,
'entity_id': 'sensor.test_name_uptime',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2025-05-24T02:30:09+00:00',
})
# ---
# name: test_device[duo_bulb_gen3][update.test_name_beta_firmware-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -3563,57 +3563,6 @@
'state': '231.2',
})
# ---
# name: test_device[power_strip_gen4][sensor.test_name_last_restart-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_name_last_restart',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Last restart',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
'original_icon': None,
'original_name': 'Last restart',
'platform': 'shelly',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'last_restart',
'unique_id': '123456789ABC-sys-uptime',
'unit_of_measurement': None,
})
# ---
# name: test_device[power_strip_gen4][sensor.test_name_last_restart-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'uptime',
'friendly_name': 'Test name Last restart',
}),
'context': <ANY>,
'entity_id': 'sensor.test_name_last_restart',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2025-04-02T18:10:04+00:00',
})
# ---
# name: test_device[power_strip_gen4][sensor.test_name_output_0_current-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -4499,6 +4448,57 @@
'state': '-68',
})
# ---
# name: test_device[power_strip_gen4][sensor.test_name_uptime-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_name_uptime',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Uptime',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
'original_icon': None,
'original_name': 'Uptime',
'platform': 'shelly',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '123456789ABC-sys-uptime',
'unit_of_measurement': None,
})
# ---
# name: test_device[power_strip_gen4][sensor.test_name_uptime-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'uptime',
'friendly_name': 'Test name Uptime',
}),
'context': <ANY>,
'entity_id': 'sensor.test_name_uptime',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2025-04-02T18:10:04+00:00',
})
# ---
# name: test_device[power_strip_gen4][switch.switch_1_name-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -5143,57 +5143,6 @@
'state': 'twilight',
})
# ---
# name: test_device[presence_gen4][sensor.test_name_last_restart-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_name_last_restart',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Last restart',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
'original_icon': None,
'original_name': 'Last restart',
'platform': 'shelly',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'last_restart',
'unique_id': '123456789ABC-sys-uptime',
'unit_of_measurement': None,
})
# ---
# name: test_device[presence_gen4][sensor.test_name_last_restart-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'uptime',
'friendly_name': 'Test name Last restart',
}),
'context': <ANY>,
'entity_id': 'sensor.test_name_last_restart',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2025-05-26T15:55:14+00:00',
})
# ---
# name: test_device[presence_gen4][sensor.test_name_my_zone_detected_objects-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -5411,6 +5360,57 @@
'state': '-60',
})
# ---
# name: test_device[presence_gen4][sensor.test_name_uptime-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_name_uptime',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Uptime',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
'original_icon': None,
'original_name': 'Uptime',
'platform': 'shelly',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '123456789ABC-sys-uptime',
'unit_of_measurement': None,
})
# ---
# name: test_device[presence_gen4][sensor.test_name_uptime-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'uptime',
'friendly_name': 'Test name Uptime',
}),
'context': <ANY>,
'entity_id': 'sensor.test_name_uptime',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2025-05-26T15:55:14+00:00',
})
# ---
# name: test_device[presence_gen4][update.test_name_beta_firmware-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -6305,57 +6305,6 @@
'state': 'twilight',
})
# ---
# name: test_device[wall_display_xl][sensor.test_name_last_restart-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_name_last_restart',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Last restart',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
'original_icon': None,
'original_name': 'Last restart',
'platform': 'shelly',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'last_restart',
'unique_id': '123456789ABC-sys-uptime',
'unit_of_measurement': None,
})
# ---
# name: test_device[wall_display_xl][sensor.test_name_last_restart-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'uptime',
'friendly_name': 'Test name Last restart',
}),
'context': <ANY>,
'entity_id': 'sensor.test_name_last_restart',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2025-05-15T21:33:41+00:00',
})
# ---
# name: test_device[wall_display_xl][sensor.test_name_signal_strength-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -6469,6 +6418,57 @@
'state': '-275.149993896484',
})
# ---
# name: test_device[wall_display_xl][sensor.test_name_uptime-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_name_uptime',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Uptime',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
'original_icon': None,
'original_name': 'Uptime',
'platform': 'shelly',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '123456789ABC-sys-uptime',
'unit_of_measurement': None,
})
# ---
# name: test_device[wall_display_xl][sensor.test_name_uptime-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'uptime',
'friendly_name': 'Test name Uptime',
}),
'context': <ANY>,
'entity_id': 'sensor.test_name_uptime',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2025-05-15T21:33:41+00:00',
})
# ---
# name: test_device[wall_display_xl][switch.test_name-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -7334,57 +7334,6 @@
'state': '50.0',
})
# ---
# name: test_shelly_2pm_gen3_cover[sensor.test_name_last_restart-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_name_last_restart',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Last restart',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
'original_icon': None,
'original_name': 'Last restart',
'platform': 'shelly',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'last_restart',
'unique_id': '123456789ABC-sys-uptime',
'unit_of_measurement': None,
})
# ---
# name: test_shelly_2pm_gen3_cover[sensor.test_name_last_restart-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'uptime',
'friendly_name': 'Test name Last restart',
}),
'context': <ANY>,
'entity_id': 'sensor.test_name_last_restart',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2025-05-26T15:57:39+00:00',
})
# ---
# name: test_shelly_2pm_gen3_cover[sensor.test_name_power-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -7556,6 +7505,57 @@
'state': '36.4',
})
# ---
# name: test_shelly_2pm_gen3_cover[sensor.test_name_uptime-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_name_uptime',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Uptime',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
'original_icon': None,
'original_name': 'Uptime',
'platform': 'shelly',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '123456789ABC-sys-uptime',
'unit_of_measurement': None,
})
# ---
# name: test_shelly_2pm_gen3_cover[sensor.test_name_uptime-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'uptime',
'friendly_name': 'Test name Uptime',
}),
'context': <ANY>,
'entity_id': 'sensor.test_name_uptime',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2025-05-26T15:57:39+00:00',
})
# ---
# name: test_shelly_2pm_gen3_cover[sensor.test_name_voltage-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -8403,57 +8403,6 @@
'state': 'unknown',
})
# ---
# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_last_restart-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_name_last_restart',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Last restart',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
'original_icon': None,
'original_name': 'Last restart',
'platform': 'shelly',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'last_restart',
'unique_id': '123456789ABC-sys-uptime',
'unit_of_measurement': None,
})
# ---
# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_last_restart-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'uptime',
'friendly_name': 'Test name Last restart',
}),
'context': <ANY>,
'entity_id': 'sensor.test_name_last_restart',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2025-05-26T16:02:17+00:00',
})
# ---
# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_output_0_current-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -9455,6 +9404,57 @@
'state': '-52',
})
# ---
# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_uptime-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_name_uptime',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Uptime',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
'original_icon': None,
'original_name': 'Uptime',
'platform': 'shelly',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '123456789ABC-sys-uptime',
'unit_of_measurement': None,
})
# ---
# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_uptime-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'uptime',
'friendly_name': 'Test name Uptime',
}),
'context': <ANY>,
'entity_id': 'sensor.test_name_uptime',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2025-05-26T16:02:17+00:00',
})
# ---
# name: test_shelly_2pm_gen3_no_relay_names[switch.test_name_output_0-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -10072,57 +10072,6 @@
'state': '0.0',
})
# ---
# name: test_shelly_pro_3em[sensor.test_name_last_restart-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_name_last_restart',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Last restart',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
'original_icon': None,
'original_name': 'Last restart',
'platform': 'shelly',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'last_restart',
'unique_id': '123456789ABC-sys-uptime',
'unit_of_measurement': None,
})
# ---
# name: test_shelly_pro_3em[sensor.test_name_last_restart-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'uptime',
'friendly_name': 'Test name Last restart',
}),
'context': <ANY>,
'entity_id': 'sensor.test_name_last_restart',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2025-05-20T20:42:37+00:00',
})
# ---
# name: test_shelly_pro_3em[sensor.test_name_neutral_current-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -11750,6 +11699,57 @@
'state': '46.3',
})
# ---
# name: test_shelly_pro_3em[sensor.test_name_uptime-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_name_uptime',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Uptime',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
'original_icon': None,
'original_name': 'Uptime',
'platform': 'shelly',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '123456789ABC-sys-uptime',
'unit_of_measurement': None,
})
# ---
# name: test_shelly_pro_3em[sensor.test_name_uptime-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'uptime',
'friendly_name': 'Test name Uptime',
}),
'context': <ANY>,
'entity_id': 'sensor.test_name_uptime',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2025-05-20T20:42:37+00:00',
})
# ---
# name: test_shelly_pro_3em[update.test_name_beta_firmware-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
+78 -78
View File
@@ -11,7 +11,7 @@ import pytest
from homeassistant.components.timer import (
ATTR_DURATION,
ATTR_FINISHES_AT,
ATTR_LAST_ACTION,
ATTR_LAST_TRANSITION,
ATTR_REMAINING,
ATTR_RESTORE,
CONF_DURATION,
@@ -136,7 +136,7 @@ async def test_config_options(hass: HomeAssistant) -> None:
assert state_1.attributes == {
ATTR_DURATION: "0:00:00",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
ATTR_LAST_TRANSITION: None,
}
assert state_2.state == STATUS_IDLE
@@ -145,14 +145,14 @@ async def test_config_options(hass: HomeAssistant) -> None:
ATTR_EDITABLE: False,
ATTR_FRIENDLY_NAME: "Hello World",
ATTR_ICON: "mdi:work",
ATTR_LAST_ACTION: None,
ATTR_LAST_TRANSITION: None,
}
assert state_3.state == STATUS_IDLE
assert state_3.attributes == {
ATTR_DURATION: str(cv.time_period(DEFAULT_DURATION)),
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
ATTR_LAST_TRANSITION: None,
}
@@ -169,7 +169,7 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
assert state.attributes == {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
ATTR_LAST_TRANSITION: None,
}
results: list[tuple[Event, State | None]] = []
@@ -196,7 +196,7 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
"expected_state": STATUS_ACTIVE,
"expected_extra_attributes": {
ATTR_FINISHES_AT: finish_10,
ATTR_LAST_ACTION: "started",
ATTR_LAST_TRANSITION: "started",
ATTR_REMAINING: "0:00:10",
},
"expected_event": EVENT_TIMER_STARTED,
@@ -206,7 +206,7 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
"call_data": {},
"expected_state": STATUS_PAUSED,
"expected_extra_attributes": {
ATTR_LAST_ACTION: "paused",
ATTR_LAST_TRANSITION: "paused",
ATTR_REMAINING: "0:00:10",
},
"expected_event": EVENT_TIMER_PAUSED,
@@ -217,7 +217,7 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
"expected_state": STATUS_ACTIVE,
"expected_extra_attributes": {
ATTR_FINISHES_AT: finish_10,
ATTR_LAST_ACTION: "restarted",
ATTR_LAST_TRANSITION: "restarted",
ATTR_REMAINING: "0:00:10",
},
"expected_event": EVENT_TIMER_RESTARTED,
@@ -226,14 +226,14 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
"call": SERVICE_CANCEL,
"call_data": {},
"expected_state": STATUS_IDLE,
"expected_extra_attributes": {ATTR_LAST_ACTION: "cancelled"},
"expected_extra_attributes": {ATTR_LAST_TRANSITION: "cancelled"},
"expected_event": EVENT_TIMER_CANCELLED,
},
{
"call": SERVICE_CANCEL,
"call_data": {},
"expected_state": STATUS_IDLE,
"expected_extra_attributes": {ATTR_LAST_ACTION: "cancelled"},
"expected_extra_attributes": {ATTR_LAST_TRANSITION: "cancelled"},
"expected_event": None,
},
{
@@ -242,7 +242,7 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
"expected_state": STATUS_ACTIVE,
"expected_extra_attributes": {
ATTR_FINISHES_AT: finish_10,
ATTR_LAST_ACTION: "started",
ATTR_LAST_TRANSITION: "started",
ATTR_REMAINING: "0:00:10",
},
"expected_event": EVENT_TIMER_STARTED,
@@ -251,14 +251,14 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
"call": SERVICE_FINISH,
"call_data": {},
"expected_state": STATUS_IDLE,
"expected_extra_attributes": {ATTR_LAST_ACTION: "finished"},
"expected_extra_attributes": {ATTR_LAST_TRANSITION: "finished"},
"expected_event": EVENT_TIMER_FINISHED,
},
{
"call": SERVICE_FINISH,
"call_data": {},
"expected_state": STATUS_IDLE,
"expected_extra_attributes": {ATTR_LAST_ACTION: "finished"},
"expected_extra_attributes": {ATTR_LAST_TRANSITION: "finished"},
"expected_event": None,
},
{
@@ -267,7 +267,7 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
"expected_state": STATUS_ACTIVE,
"expected_extra_attributes": {
ATTR_FINISHES_AT: finish_10,
ATTR_LAST_ACTION: "started",
ATTR_LAST_TRANSITION: "started",
ATTR_REMAINING: "0:00:10",
},
"expected_event": EVENT_TIMER_STARTED,
@@ -277,7 +277,7 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
"call_data": {},
"expected_state": STATUS_PAUSED,
"expected_extra_attributes": {
ATTR_LAST_ACTION: "paused",
ATTR_LAST_TRANSITION: "paused",
ATTR_REMAINING: "0:00:10",
},
"expected_event": EVENT_TIMER_PAUSED,
@@ -286,7 +286,7 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
"call": SERVICE_CANCEL,
"call_data": {},
"expected_state": STATUS_IDLE,
"expected_extra_attributes": {ATTR_LAST_ACTION: "cancelled"},
"expected_extra_attributes": {ATTR_LAST_TRANSITION: "cancelled"},
"expected_event": EVENT_TIMER_CANCELLED,
},
{
@@ -295,7 +295,7 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
"expected_state": STATUS_ACTIVE,
"expected_extra_attributes": {
ATTR_FINISHES_AT: finish_10,
ATTR_LAST_ACTION: "started",
ATTR_LAST_TRANSITION: "started",
ATTR_REMAINING: "0:00:10",
},
"expected_event": EVENT_TIMER_STARTED,
@@ -306,7 +306,7 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
"expected_state": STATUS_ACTIVE,
"expected_extra_attributes": {
ATTR_FINISHES_AT: finish_5,
ATTR_LAST_ACTION: "started", # Change does not set last_action
ATTR_LAST_TRANSITION: "started", # Change does not set last_transition
ATTR_REMAINING: "0:00:05",
},
"expected_event": EVENT_TIMER_CHANGED,
@@ -317,7 +317,7 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
"expected_state": STATUS_ACTIVE,
"expected_extra_attributes": {
ATTR_FINISHES_AT: finish_5,
ATTR_LAST_ACTION: "restarted",
ATTR_LAST_TRANSITION: "restarted",
ATTR_REMAINING: "0:00:05",
},
"expected_event": EVENT_TIMER_RESTARTED,
@@ -327,7 +327,7 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
"call_data": {},
"expected_state": STATUS_PAUSED,
"expected_extra_attributes": {
ATTR_LAST_ACTION: "paused",
ATTR_LAST_TRANSITION: "paused",
ATTR_REMAINING: "0:00:05",
},
"expected_event": EVENT_TIMER_PAUSED,
@@ -336,7 +336,7 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
"call": SERVICE_FINISH,
"call_data": {},
"expected_state": STATUS_IDLE,
"expected_extra_attributes": {ATTR_LAST_ACTION: "finished"},
"expected_extra_attributes": {ATTR_LAST_TRANSITION: "finished"},
"expected_event": EVENT_TIMER_FINISHED,
},
]
@@ -393,7 +393,7 @@ async def test_start_service(hass: HomeAssistant) -> None:
assert state.attributes == {
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:10",
ATTR_LAST_ACTION: None,
ATTR_LAST_TRANSITION: None,
}
await hass.services.async_call(
@@ -407,7 +407,7 @@ async def test_start_service(hass: HomeAssistant) -> None:
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:10",
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
ATTR_LAST_ACTION: "started",
ATTR_LAST_TRANSITION: "started",
ATTR_REMAINING: "0:00:10",
}
@@ -421,7 +421,7 @@ async def test_start_service(hass: HomeAssistant) -> None:
assert state.attributes == {
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:10",
ATTR_LAST_ACTION: "cancelled",
ATTR_LAST_TRANSITION: "cancelled",
}
with pytest.raises(HomeAssistantError):
@@ -446,7 +446,7 @@ async def test_start_service(hass: HomeAssistant) -> None:
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:15",
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=15)).isoformat(),
ATTR_LAST_ACTION: "started",
ATTR_LAST_TRANSITION: "started",
ATTR_REMAINING: "0:00:15",
}
@@ -485,7 +485,7 @@ async def test_start_service(hass: HomeAssistant) -> None:
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:15",
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=12)).isoformat(),
ATTR_LAST_ACTION: "started", # Change does not set last_action
ATTR_LAST_TRANSITION: "started", # Change does not set last_transition
ATTR_REMAINING: "0:00:12",
}
@@ -502,7 +502,7 @@ async def test_start_service(hass: HomeAssistant) -> None:
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:15",
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=14)).isoformat(),
ATTR_LAST_ACTION: "started", # Change does not set last_action
ATTR_LAST_TRANSITION: "started", # Change does not set last_transition
ATTR_REMAINING: "0:00:14",
}
@@ -516,7 +516,7 @@ async def test_start_service(hass: HomeAssistant) -> None:
assert state.attributes == {
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:10",
ATTR_LAST_ACTION: "cancelled",
ATTR_LAST_TRANSITION: "cancelled",
}
with pytest.raises(
@@ -536,7 +536,7 @@ async def test_start_service(hass: HomeAssistant) -> None:
assert state.attributes == {
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:10",
ATTR_LAST_ACTION: "cancelled", # Change does not set last_action
ATTR_LAST_TRANSITION: "cancelled", # Change does not set last_transition
}
@@ -555,7 +555,7 @@ async def test_wait_till_timer_expires(
assert state.attributes == {
ATTR_DURATION: "0:00:20",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
ATTR_LAST_TRANSITION: None,
}
results = []
@@ -583,7 +583,7 @@ async def test_wait_till_timer_expires(
ATTR_DURATION: "0:00:20",
ATTR_EDITABLE: False,
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=20)).isoformat(),
ATTR_LAST_ACTION: "started",
ATTR_LAST_TRANSITION: "started",
ATTR_REMAINING: "0:00:20",
}
@@ -605,7 +605,7 @@ async def test_wait_till_timer_expires(
ATTR_DURATION: "0:00:20",
ATTR_EDITABLE: False,
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=15)).isoformat(),
ATTR_LAST_ACTION: "started",
ATTR_LAST_TRANSITION: "started",
ATTR_REMAINING: "0:00:15",
}
@@ -623,7 +623,7 @@ async def test_wait_till_timer_expires(
ATTR_DURATION: "0:00:20",
ATTR_EDITABLE: False,
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=5)).isoformat(),
ATTR_LAST_ACTION: "started",
ATTR_LAST_TRANSITION: "started",
ATTR_REMAINING: "0:00:15",
}
@@ -637,7 +637,7 @@ async def test_wait_till_timer_expires(
assert state.attributes == {
ATTR_DURATION: "0:00:20",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: "finished",
ATTR_LAST_TRANSITION: "finished",
}
assert results[-1].event_type == EVENT_TIMER_FINISHED
@@ -656,7 +656,7 @@ async def test_no_initial_state_and_no_restore_state(hass: HomeAssistant) -> Non
assert state.attributes == {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
ATTR_LAST_TRANSITION: None,
}
@@ -703,7 +703,7 @@ async def test_config_reload(
assert state_1.attributes == {
ATTR_DURATION: "0:00:00",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
ATTR_LAST_TRANSITION: None,
}
assert state_2.state == STATUS_IDLE
@@ -712,7 +712,7 @@ async def test_config_reload(
ATTR_EDITABLE: False,
ATTR_FRIENDLY_NAME: "Hello World",
ATTR_ICON: "mdi:work",
ATTR_LAST_ACTION: None,
ATTR_LAST_TRANSITION: None,
}
with patch(
@@ -763,14 +763,14 @@ async def test_config_reload(
ATTR_EDITABLE: False,
ATTR_FRIENDLY_NAME: "Hello World reloaded",
ATTR_ICON: "mdi:work-reloaded",
ATTR_LAST_ACTION: None,
ATTR_LAST_TRANSITION: None,
}
assert state_3.state == STATUS_IDLE
assert state_3.attributes == {
ATTR_DURATION: "0:00:00",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
ATTR_LAST_TRANSITION: None,
}
@@ -787,7 +787,7 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
assert state.attributes == {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
ATTR_LAST_TRANSITION: None,
}
results = []
@@ -814,7 +814,7 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
ATTR_LAST_ACTION: "started",
ATTR_LAST_TRANSITION: "started",
ATTR_REMAINING: "0:00:10",
}
@@ -832,7 +832,7 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
ATTR_LAST_ACTION: "restarted",
ATTR_LAST_TRANSITION: "restarted",
ATTR_REMAINING: "0:00:10",
}
@@ -849,7 +849,7 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
assert state.attributes == {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: "paused",
ATTR_LAST_TRANSITION: "paused",
ATTR_REMAINING: "0:00:10",
}
@@ -867,7 +867,7 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
ATTR_LAST_ACTION: "restarted",
ATTR_LAST_TRANSITION: "restarted",
ATTR_REMAINING: "0:00:10",
}
@@ -888,7 +888,7 @@ async def test_state_changed_when_timer_restarted(hass: HomeAssistant) -> None:
assert state.attributes == {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
ATTR_LAST_TRANSITION: None,
}
results = []
@@ -911,7 +911,7 @@ async def test_state_changed_when_timer_restarted(hass: HomeAssistant) -> None:
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
ATTR_LAST_ACTION: "started",
ATTR_LAST_TRANSITION: "started",
ATTR_REMAINING: "0:00:10",
}
@@ -929,7 +929,7 @@ async def test_state_changed_when_timer_restarted(hass: HomeAssistant) -> None:
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
ATTR_LAST_ACTION: "restarted",
ATTR_LAST_TRANSITION: "restarted",
ATTR_REMAINING: "0:00:10",
}
@@ -938,10 +938,10 @@ async def test_state_changed_when_timer_restarted(hass: HomeAssistant) -> None:
@pytest.mark.freeze_time("2023-06-05 17:47:50")
async def test_last_action_after_restarted_timer_expires(
async def test_last_transition_after_restarted_timer_expires(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> None:
"""Test that last_action changes from restarted to finished when timer expires."""
"""Test that last_transition changes from restarted to finished when timer expires."""
hass.set_state(CoreState.starting)
await async_setup_component(hass, DOMAIN, {DOMAIN: {"test1": {CONF_DURATION: 10}}})
@@ -960,7 +960,7 @@ async def test_last_action_after_restarted_timer_expires(
state = hass.states.get("timer.test1")
assert state.state == STATUS_ACTIVE
assert state.attributes[ATTR_LAST_ACTION] == "restarted"
assert state.attributes[ATTR_LAST_TRANSITION] == "restarted"
# Let the timer expire
freezer.tick(15)
@@ -969,19 +969,19 @@ async def test_last_action_after_restarted_timer_expires(
state = hass.states.get("timer.test1")
assert state.state == STATUS_IDLE
assert state.attributes[ATTR_LAST_ACTION] == "finished"
assert state.attributes[ATTR_LAST_TRANSITION] == "finished"
@pytest.mark.freeze_time("2023-06-05 17:47:50")
async def test_last_action_persists_across_config_update(
async def test_last_transition_persists_across_config_update(
hass: HomeAssistant,
) -> None:
"""Test that last_action is preserved when the timer config is updated."""
"""Test that last_transition is preserved when the timer config is updated."""
hass.set_state(CoreState.starting)
await async_setup_component(hass, DOMAIN, {DOMAIN: {"test1": {CONF_DURATION: 10}}})
# Start and cancel to set last_action to "cancelled"
# Start and cancel to set last_transition to "cancelled"
await hass.services.async_call(
DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1"}, blocking=True
)
@@ -992,9 +992,9 @@ async def test_last_action_persists_across_config_update(
state = hass.states.get("timer.test1")
assert state.state == STATUS_IDLE
assert state.attributes[ATTR_LAST_ACTION] == "cancelled"
assert state.attributes[ATTR_LAST_TRANSITION] == "cancelled"
# Reload with a new duration — last_action should persist
# Reload with a new duration — last_transition should persist
with patch(
"homeassistant.config.load_yaml_config_file",
autospec=True,
@@ -1006,7 +1006,7 @@ async def test_last_action_persists_across_config_update(
state = hass.states.get("timer.test1")
assert state.state == STATUS_IDLE
assert state.attributes[ATTR_DURATION] == "0:00:20"
assert state.attributes[ATTR_LAST_ACTION] == "cancelled"
assert state.attributes[ATTR_LAST_TRANSITION] == "cancelled"
async def test_load_from_storage(hass: HomeAssistant, storage_setup) -> None:
@@ -1018,7 +1018,7 @@ async def test_load_from_storage(hass: HomeAssistant, storage_setup) -> None:
ATTR_DURATION: "0:00:00",
ATTR_EDITABLE: True,
ATTR_FRIENDLY_NAME: "timer from storage",
ATTR_LAST_ACTION: None,
ATTR_LAST_TRANSITION: None,
}
@@ -1032,7 +1032,7 @@ async def test_editable_state_attribute(hass: HomeAssistant, storage_setup) -> N
ATTR_DURATION: "0:00:00",
ATTR_EDITABLE: True,
ATTR_FRIENDLY_NAME: "timer from storage",
ATTR_LAST_ACTION: None,
ATTR_LAST_TRANSITION: None,
}
state = hass.states.get(f"{DOMAIN}.from_yaml")
@@ -1040,7 +1040,7 @@ async def test_editable_state_attribute(hass: HomeAssistant, storage_setup) -> N
assert state.attributes == {
ATTR_DURATION: "0:00:00",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
ATTR_LAST_TRANSITION: None,
}
@@ -1115,7 +1115,7 @@ async def test_update(
ATTR_DURATION: "0:00:00",
ATTR_EDITABLE: True,
ATTR_FRIENDLY_NAME: "timer from storage",
ATTR_LAST_ACTION: None,
ATTR_LAST_TRANSITION: None,
}
assert (
entity_registry.async_get_entity_id(DOMAIN, DOMAIN, timer_id) == timer_entity_id
@@ -1151,7 +1151,7 @@ async def test_update(
ATTR_DURATION: "0:00:33",
ATTR_EDITABLE: True,
ATTR_FRIENDLY_NAME: "timer from storage",
ATTR_LAST_ACTION: None,
ATTR_LAST_TRANSITION: None,
ATTR_RESTORE: True,
}
@@ -1191,7 +1191,7 @@ async def test_ws_create(
ATTR_DURATION: "0:00:42",
ATTR_EDITABLE: True,
ATTR_FRIENDLY_NAME: "New Timer",
ATTR_LAST_ACTION: None,
ATTR_LAST_TRANSITION: None,
}
assert (
entity_registry.async_get_entity_id(DOMAIN, DOMAIN, timer_id) == timer_entity_id
@@ -1217,13 +1217,13 @@ async def test_setup_no_config(hass: HomeAssistant, hass_admin_user: MockUser) -
assert count_start == len(hass.states.async_entity_ids())
@pytest.mark.parametrize("last_action", [None, "cancelled", "finished"])
async def test_restore_idle(hass: HomeAssistant, last_action: str | None) -> None:
@pytest.mark.parametrize("last_transition", [None, "cancelled", "finished"])
async def test_restore_idle(hass: HomeAssistant, last_transition: str | None) -> None:
"""Test entity restore logic when timer is idle."""
utc_now = utcnow()
attrs: dict[str, Any] = {ATTR_DURATION: "0:00:30"}
if last_action is not None:
attrs[ATTR_LAST_ACTION] = last_action
if last_transition is not None:
attrs[ATTR_LAST_TRANSITION] = last_transition
stored_state = StoredState(
State("timer.test", STATUS_IDLE, attrs),
None,
@@ -1252,7 +1252,7 @@ async def test_restore_idle(hass: HomeAssistant, last_action: str | None) -> Non
# Idle timers reset to the configured duration, not the stored one
ATTR_DURATION: "0:01:00",
ATTR_EDITABLE: True,
ATTR_LAST_ACTION: last_action,
ATTR_LAST_TRANSITION: last_transition,
ATTR_RESTORE: True,
}
@@ -1267,7 +1267,7 @@ async def test_restore_paused(hass: HomeAssistant) -> None:
STATUS_PAUSED,
{
ATTR_DURATION: "0:00:30",
ATTR_LAST_ACTION: "paused",
ATTR_LAST_TRANSITION: "paused",
ATTR_REMAINING: "0:00:15",
},
),
@@ -1296,16 +1296,16 @@ async def test_restore_paused(hass: HomeAssistant) -> None:
assert entity.extra_state_attributes == {
ATTR_DURATION: "0:00:30",
ATTR_EDITABLE: True,
ATTR_LAST_ACTION: "paused",
ATTR_LAST_TRANSITION: "paused",
ATTR_REMAINING: "0:00:15",
ATTR_RESTORE: True,
}
@pytest.mark.freeze_time("2023-06-05 17:47:50")
@pytest.mark.parametrize("last_action", [None, "started", "restarted"])
@pytest.mark.parametrize("last_transition", [None, "started", "restarted"])
async def test_restore_active_resume(
hass: HomeAssistant, last_action: str | None
hass: HomeAssistant, last_transition: str | None
) -> None:
"""Test entity restore logic when timer is active and end time is after startup."""
events = async_capture_events(hass, EVENT_TIMER_RESTARTED)
@@ -1320,7 +1320,7 @@ async def test_restore_active_resume(
{
ATTR_DURATION: "0:00:30",
ATTR_FINISHES_AT: finish.isoformat(),
ATTR_LAST_ACTION: last_action,
ATTR_LAST_TRANSITION: last_transition,
},
),
None,
@@ -1355,16 +1355,16 @@ async def test_restore_active_resume(
ATTR_DURATION: "0:00:30",
ATTR_EDITABLE: True,
ATTR_FINISHES_AT: finish.isoformat(),
ATTR_LAST_ACTION: "restarted",
ATTR_LAST_TRANSITION: "restarted",
ATTR_REMAINING: "0:00:15",
ATTR_RESTORE: True,
}
assert len(events) == 1
@pytest.mark.parametrize("last_action", [None, "started", "restarted"])
@pytest.mark.parametrize("last_transition", [None, "started", "restarted"])
async def test_restore_active_finished_outside_grace(
hass: HomeAssistant, last_action: str | None
hass: HomeAssistant, last_transition: str | None
) -> None:
"""Test entity restore logic: timer is active, ended while Home Assistant was stopped."""
events = async_capture_events(hass, EVENT_TIMER_FINISHED)
@@ -1379,7 +1379,7 @@ async def test_restore_active_finished_outside_grace(
{
ATTR_DURATION: "0:00:30",
ATTR_FINISHES_AT: finish.isoformat(),
ATTR_LAST_ACTION: last_action,
ATTR_LAST_TRANSITION: last_transition,
},
),
None,
@@ -1411,7 +1411,7 @@ async def test_restore_active_finished_outside_grace(
assert entity.extra_state_attributes == {
ATTR_DURATION: "0:01:00",
ATTR_EDITABLE: True,
ATTR_LAST_ACTION: "finished",
ATTR_LAST_TRANSITION: "finished",
ATTR_RESTORE: True,
}
assert len(events) == 1
@@ -177,6 +177,24 @@ async def target_entities(
switch_platform.config_entry = config_entry
await switch_platform.async_add_entities([device1_switch, area_device_switch])
area_device_diagnostic_sensor = MockEntity(
entity_id="sensor.test7",
unique_id="test7",
device_info=dr.DeviceInfo(identifiers=area_device.identifiers),
entity_category=EntityCategory.DIAGNOSTIC,
)
label2_device_config_sensor = MockEntity(
entity_id="sensor.potato",
unique_id="potato",
device_info=dr.DeviceInfo(identifiers=label2_device.identifiers),
entity_category=EntityCategory.CONFIG,
)
sensor_platform = MockEntityPlatform(hass, domain="sensor", platform_name="test")
sensor_platform.config_entry = config_entry
await sensor_platform.async_add_entities(
[area_device_diagnostic_sensor, label2_device_config_sensor]
)
component1_light = MockEntity(
entity_id="light.component1_light", unique_id="component1_light"
)
@@ -246,6 +264,8 @@ async def target_entities(
"light.test6",
"switch.test2",
"switch.test5",
"sensor.test7",
"sensor.potato",
"light.component1_light",
"light.component1_flash_light",
"light.component1_effect_flash_light",
@@ -3795,7 +3815,11 @@ async def test_get_triggers_conditions_for_target(
Mock(
**{
f"async_get_{automation_component}s": AsyncMock(
return_value={"match_all": Mock, "other_integration_lights": Mock}
return_value={
"match_all": Mock,
"other_integration_lights": Mock,
"non_primary_sensor": Mock,
}
)
}
),
@@ -3873,6 +3897,12 @@ async def test_get_triggers_conditions_for_target(
- light.LightEntityFeature.EFFECT
- integration: test
domain: light
non_primary_sensor:
target:
entity:
domain: sensor
primary_entities_only: false
"""
def _load_yaml(fname, secrets=None):
@@ -3978,6 +4008,7 @@ async def test_get_triggers_conditions_for_target(
"component1",
"component1.light_message",
"component2.match_all",
"component2.non_primary_sensor",
"component2.other_integration_lights",
"light.turned_on",
"sensor.turned_on",
@@ -3990,6 +4021,7 @@ async def test_get_triggers_conditions_for_target(
{"area_id": ["kitchen", "living_room"]},
[
"component2.match_all",
"component2.non_primary_sensor",
"component2.other_integration_lights",
"light.turned_on",
"switch.turned_on",
@@ -4003,10 +4035,23 @@ async def test_get_triggers_conditions_for_target(
"light.turned_on",
"component1",
"component2.match_all",
"component2.non_primary_sensor",
"component2.other_integration_lights",
"switch.turned_on",
],
)
# Test direct targeting of a non-primary entity - even
# primary_entities_only=True components match
await assert_command(
{"entity_id": ["sensor.test7"]},
[
"component2.match_all",
"component2.non_primary_sensor",
"sensor.turned_on",
],
)
# Test mixed target types
await assert_command(
{
@@ -4019,6 +4064,7 @@ async def test_get_triggers_conditions_for_target(
"component1",
"component1.light_message",
"component2.match_all",
"component2.non_primary_sensor",
"component2.other_integration_lights",
"light.turned_on",
"sensor.turned_on",
@@ -4107,6 +4153,12 @@ async def test_get_services_for_target(
- light.LightEntityFeature.EFFECT
- integration: test
domain: light
non_primary_sensor:
target:
entity:
domain: sensor
primary_entities_only: false
"""
def _load_yaml(fname, secrets=None):
@@ -4145,6 +4197,7 @@ async def test_get_services_for_target(
hass.services.async_register(
"component2", "other_integration_lights", lambda call: None
)
hass.services.async_register("component2", "non_primary_sensor", lambda call: None)
await hass.async_block_till_done()
async def assert_services(
@@ -4226,6 +4279,7 @@ async def test_get_services_for_target(
[
"component1.light_message",
"component2.match_all",
"component2.non_primary_sensor",
"component2.other_integration_lights",
"light.turn_on",
"sensor.turn_on",
@@ -4238,6 +4292,7 @@ async def test_get_services_for_target(
{"area_id": ["kitchen", "living_room"]},
[
"component2.match_all",
"component2.non_primary_sensor",
"component2.other_integration_lights",
"light.turn_on",
"switch.turn_on",
@@ -4250,10 +4305,23 @@ async def test_get_services_for_target(
[
"light.turn_on",
"component2.match_all",
"component2.non_primary_sensor",
"component2.other_integration_lights",
"switch.turn_on",
],
)
# Test direct targeting of a non-primary entity - even
# primary_entities_only=True components match
await assert_services(
{"entity_id": ["sensor.test7"]},
[
"component2.match_all",
"component2.non_primary_sensor",
"sensor.turn_on",
],
)
# Test mixed target types
await assert_services(
{
@@ -4265,6 +4333,7 @@ async def test_get_services_for_target(
[
"component1.light_message",
"component2.match_all",
"component2.non_primary_sensor",
"component2.other_integration_lights",
"light.turn_on",
"sensor.turn_on",
@@ -4432,3 +4501,29 @@ async def test_get_automation_component_lookup_table_cache(
_get_automation_component_lookup_table(hass, "services", services)
is service_result1
)
@pytest.mark.parametrize(
("side_effect", "expect_success"),
[(Exception("error"), False), (None, True)],
)
async def test_execute_script_unloads_script(
hass: HomeAssistant,
websocket_client: MockHAClientWebSocket,
side_effect: Exception | None,
expect_success: bool,
) -> None:
"""Test that execute_script unloads the script after execution."""
with patch("homeassistant.helpers.script.Script", autospec=True) as script_mock:
script_mock.return_value.async_run.return_value = None
script_mock.return_value.async_run.side_effect = side_effect
await websocket_client.send_json_auto_id(
{
"type": "execute_script",
"sequence": [{"service": "domain_test.test_service"}],
}
)
msg = await websocket_client.receive_json()
assert msg["success"] == expect_success
script_mock.return_value.async_unload.assert_called_once()
@@ -12,6 +12,7 @@ from homeassistant import exceptions
from homeassistant.components import websocket_api
from homeassistant.components.websocket_api.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers.redact import REDACTED
from tests.common import MockUser
@@ -137,3 +138,46 @@ async def test_binary_handler_registration() -> None:
# Verify we reuse an unsubscribed prefix
prefix, unsub = connection.async_register_binary_handler(None)
assert prefix == 15
async def test_credential_redaction(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test credential redaction."""
send_messages = []
user = MockUser()
refresh_token = Mock()
hass.data[DOMAIN] = {}
test_input = ["valid detail information", "secretpassword", "api-token-12345"]
connection = websocket_api.ActiveConnection(
logging.getLogger(__name__),
hass,
send_messages.append,
user,
refresh_token,
remote=None,
)
msg = {
"id": 5,
"detail": test_input[0],
"password": test_input[1],
"token": test_input[2],
}
connection.async_handle_exception(msg, vol.Invalid("bad input"))
assert len(send_messages) == 1
error_message = send_messages[0]["error"]["message"]
assert test_input[0] in error_message
assert test_input[1] not in error_message
assert test_input[2] not in error_message
assert REDACTED in error_message
msg = {"type": "auth", "access_token": test_input[2]}
connection.async_handle(msg)
assert len(send_messages) == 2
assert send_messages[1]["error"]["message"] == "Message incorrectly formatted."
assert test_input[2] not in caplog.text
assert REDACTED in caplog.text
+53 -1
View File
@@ -4,7 +4,14 @@ import ast
import pytest
from script.hassfest.dependencies import ImportCollector
from script.hassfest.dependencies import (
CORE_INTEGRATIONS,
ImportCollector,
_validate_dependencies,
)
from script.hassfest.model import Config
from . import get_integration
@pytest.fixture
@@ -90,3 +97,48 @@ import homeassistant.components.renamed_absolute as hue
"child_import_field",
"renamed_absolute",
}
def test_dependency_on_core_integration_rejected(config: Config) -> None:
"""Test that depending on a core integration is rejected."""
consumer = get_integration("consumer", config)
consumer.manifest["dependencies"] = ["persistent_notification"]
integrations = {
"consumer": consumer,
"persistent_notification": get_integration("persistent_notification", config),
}
_validate_dependencies(integrations)
assert len(consumer.errors) == 1
assert (
"Dependency persistent_notification is a core integration"
in consumer.errors[0].error
)
def test_dependency_on_non_core_integration_allowed(config: Config) -> None:
"""Test that depending on a non-core integration is not rejected."""
consumer = get_integration("consumer", config)
consumer.manifest["dependencies"] = ["other"]
integrations = {
"consumer": consumer,
"other": get_integration("other", config),
}
_validate_dependencies(integrations)
assert consumer.errors == []
def test_core_integrations_in_sync_with_bootstrap() -> None:
"""Test the duplicated CORE_INTEGRATIONS stays aligned with bootstrap."""
# Imported here so the rest of the hassfest tests are not slowed down
# by bootstrap's eager component pre-imports.
from homeassistant.bootstrap import ( # noqa: PLC0415
CORE_INTEGRATIONS as bootstrap_core_integrations,
)
assert bootstrap_core_integrations == CORE_INTEGRATIONS
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4 -6
View File
@@ -2015,8 +2015,7 @@ async def test_numeric_state_using_input_number(hass: HomeAssistant) -> None:
)
@pytest.mark.usefixtures("hass")
async def test_extract_entities() -> None:
async def test_extract_entities(hass: HomeAssistant) -> None:
"""Test extracting entities."""
assert condition.async_extract_entities(
{
@@ -2072,7 +2071,7 @@ async def test_extract_entities() -> None:
"entity_id": ["sensor.temperature_9", "sensor.temperature_10"],
"below": 110,
},
Template("{{ is_state('light.example', 'on') }}"),
Template("{{ is_state('light.example', 'on') }}", hass),
],
}
) == {
@@ -2089,8 +2088,7 @@ async def test_extract_entities() -> None:
}
@pytest.mark.usefixtures("hass")
async def test_extract_devices() -> None:
async def test_extract_devices(hass: HomeAssistant) -> None:
"""Test extracting devices."""
assert condition.async_extract_devices(
{
@@ -2133,7 +2131,7 @@ async def test_extract_devices() -> None:
},
],
},
Template("{{ is_state('light.example', 'on') }}"),
Template("{{ is_state('light.example', 'on') }}", hass),
],
}
) == {"abcd", "qwer", "abcd_not", "qwer_not", "abcd_or", "qwer_or"}
-30
View File
@@ -747,36 +747,6 @@ def test_dynamic_template(hass: HomeAssistant) -> None:
schema(value)
async def test_dynamic_template_no_hass(hass: HomeAssistant) -> None:
"""Test dynamic template validator."""
schema = vol.Schema(cv.dynamic_template)
for value in (
None,
1,
"{{ partial_print }",
"{% if True %}Hello",
["test"],
"just a string",
# Filter added as an extension by Home Assistant
"{{ ['group.foo']|expand|map(attribute='entity_id')|list }}",
):
with pytest.raises(vol.Invalid):
await hass.async_add_executor_job(schema, value)
options = (
"{{ beer }}",
"{% if 1 == 1 %}Hello{% else %}World{% endif %}",
# Function 'expand' added as an extension by Home Assistant, no error
# because non existing functions are not detected by Jinja2
"{{ expand('group.foo')|map(attribute='entity_id')|list }}",
# Non existing function 'no_such_function' is not detected by Jinja2
"{{ no_such_function('group.foo')|map(attribute='entity_id')|list }}",
)
for value in options:
await hass.async_add_executor_job(schema, value)
@pytest.mark.usefixtures("hass")
def test_template_complex() -> None:
"""Test template_complex validator."""
+1 -25
View File
@@ -22,7 +22,7 @@ from homeassistant.core import (
HomeAssistant,
callback,
)
from homeassistant.exceptions import HomeAssistantError, TemplateError
from homeassistant.exceptions import TemplateError
from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED
from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED
from homeassistant.helpers.event import (
@@ -4973,27 +4973,3 @@ async def test_async_track_state_report_change_event(hass: HomeAssistant) -> Non
"light.bowl": ["on", "on", "off", "off"],
"light.top": ["on", "on", "off", "off"],
}
async def test_async_track_template_no_hass_fails(hass: HomeAssistant) -> None:
"""Test async_track_template with a template without hass now fails."""
message = "Calls async_track_template_result with template without hass"
with pytest.raises(HomeAssistantError, match=message):
async_track_template(hass, Template("blah"), lambda x, y, z: None)
async_track_template(hass, Template("blah", hass), lambda x, y, z: None)
async def test_async_track_template_result_no_hass_fails(hass: HomeAssistant) -> None:
"""Test async_track_template_result with a template without hass now fails."""
message = "Calls async_track_template_result with template without hass"
with pytest.raises(HomeAssistantError, match=message):
async_track_template_result(
hass, [TrackTemplate(Template("blah"), None)], lambda x, y, z: None
)
async_track_template_result(
hass, [TrackTemplate(Template("blah", hass), None)], lambda x, y, z: None
)
+50 -59
View File
@@ -1,13 +1,13 @@
"""Test service helpers."""
import asyncio
from collections.abc import Callable, Iterable
from collections.abc import Callable, Generator, Iterable
from copy import deepcopy
import dataclasses
import io
import threading
from typing import Any
from unittest.mock import AsyncMock, Mock, patch
from unittest.mock import AsyncMock, Mock, call as mock_call, patch
import pytest
from pytest_unordered import unordered
@@ -48,6 +48,7 @@ from homeassistant.helpers import (
entity_registry as er,
service,
)
from homeassistant.helpers.entity import Entity
from homeassistant.loader import (
Integration,
async_get_integration,
@@ -75,17 +76,6 @@ SUPPORT_B = 2
SUPPORT_C = 4
@pytest.fixture
def mock_handle_entity_call():
"""Mock service platform call."""
with patch(
"homeassistant.helpers.service._handle_single_entity_call",
new_callable=AsyncMock,
return_value=None,
) as mock_call:
yield mock_call
@pytest.fixture
def mock_entities(hass: HomeAssistant) -> dict[str, MockEntity]:
"""Return mock entities in an ordered dict."""
@@ -127,6 +117,18 @@ def mock_entities(hass: HomeAssistant) -> dict[str, MockEntity]:
return entities
@pytest.fixture
def mock_entities_method() -> Generator[AsyncMock]:
"""Patch test_method on the base Entity class."""
mock = AsyncMock()
async def _stub(self: Entity, **kwargs: Any) -> None:
await mock(self, **kwargs)
with patch.object(Entity, "test_method", _stub, create=True):
yield mock
@pytest.fixture
def floor_area_mock(hass: HomeAssistant) -> None:
"""Mock including floor and area info."""
@@ -1686,7 +1688,9 @@ async def test_call_with_sync_attr(hass: HomeAssistant, mock_entities) -> None:
async def test_call_single_entity_uses_parallel_updates(
hass: HomeAssistant, mock_handle_entity_call, mock_entities
hass: HomeAssistant,
mock_entities: dict[str, MockEntity],
mock_entities_method: AsyncMock,
) -> None:
"""Check that single entity calls go through async_request_call."""
entity = mock_entities["light.kitchen"]
@@ -1698,7 +1702,7 @@ async def test_call_single_entity_uses_parallel_updates(
service_call = service.entity_service_call(
hass,
mock_entities,
Mock(),
"test_method",
ServiceCall(
hass,
"test_domain",
@@ -1710,13 +1714,13 @@ async def test_call_single_entity_uses_parallel_updates(
# Give the event loop a chance to progress; the call should be blocked
await asyncio.sleep(0)
assert mock_handle_entity_call.await_count == 0
mock_entities_method.assert_not_called()
# Release the semaphore so the call can proceed
entity.parallel_updates.release()
await task
assert mock_handle_entity_call.await_count == 1
mock_entities_method.assert_called_once_with(entity)
async def test_call_context_user_not_exist(hass: HomeAssistant) -> None:
@@ -1738,7 +1742,9 @@ async def test_call_context_user_not_exist(hass: HomeAssistant) -> None:
async def test_call_context_target_all(
hass: HomeAssistant, mock_handle_entity_call, mock_entities
hass: HomeAssistant,
mock_entities: dict[str, MockEntity],
mock_entities_method: AsyncMock,
) -> None:
"""Check we only target allowed entities if targeting all."""
with patch(
@@ -1753,7 +1759,7 @@ async def test_call_context_target_all(
await service.entity_service_call(
hass,
mock_entities,
Mock(),
"test_method",
ServiceCall(
hass,
"test_domain",
@@ -1763,12 +1769,13 @@ async def test_call_context_target_all(
),
)
assert len(mock_handle_entity_call.mock_calls) == 1
assert mock_handle_entity_call.mock_calls[0][1][1].entity_id == "light.kitchen"
mock_entities_method.assert_called_once_with(mock_entities["light.kitchen"])
async def test_call_context_target_specific(
hass: HomeAssistant, mock_handle_entity_call, mock_entities
hass: HomeAssistant,
mock_entities: dict[str, MockEntity],
mock_entities_method: AsyncMock,
) -> None:
"""Check targeting specific entities."""
with patch(
@@ -1782,7 +1789,7 @@ async def test_call_context_target_specific(
await service.entity_service_call(
hass,
mock_entities,
Mock(),
"test_method",
ServiceCall(
hass,
"test_domain",
@@ -1792,12 +1799,12 @@ async def test_call_context_target_specific(
),
)
assert len(mock_handle_entity_call.mock_calls) == 1
assert mock_handle_entity_call.mock_calls[0][1][1].entity_id == "light.kitchen"
mock_entities_method.assert_called_once_with(mock_entities["light.kitchen"])
async def test_call_context_target_specific_no_auth(
hass: HomeAssistant, mock_handle_entity_call, mock_entities
hass: HomeAssistant,
mock_entities: dict[str, MockEntity],
) -> None:
"""Check targeting specific entities without auth."""
with (
@@ -1810,7 +1817,7 @@ async def test_call_context_target_specific_no_auth(
await service.entity_service_call(
hass,
mock_entities,
Mock(),
"test_method",
ServiceCall(
hass,
"test_domain",
@@ -1825,32 +1832,35 @@ async def test_call_context_target_specific_no_auth(
async def test_call_no_context_target_all(
hass: HomeAssistant, mock_handle_entity_call, mock_entities
hass: HomeAssistant,
mock_entities: dict[str, MockEntity],
mock_entities_method: AsyncMock,
) -> None:
"""Check we target all if no user context given."""
await service.entity_service_call(
hass,
mock_entities,
Mock(),
"test_method",
ServiceCall(
hass, "test_domain", "test_service", data={"entity_id": ENTITY_MATCH_ALL}
),
)
assert len(mock_handle_entity_call.mock_calls) == 4
assert [call[1][1] for call in mock_handle_entity_call.mock_calls] == list(
mock_entities.values()
assert mock_entities_method.call_args_list == unordered(
mock_call(entity) for entity in mock_entities.values()
)
async def test_call_no_context_target_specific(
hass: HomeAssistant, mock_handle_entity_call, mock_entities
hass: HomeAssistant,
mock_entities: dict[str, MockEntity],
mock_entities_method: AsyncMock,
) -> None:
"""Check we can target specified entities."""
await service.entity_service_call(
hass,
mock_entities,
Mock(),
"test_method",
ServiceCall(
hass,
"test_domain",
@@ -1859,42 +1869,23 @@ async def test_call_no_context_target_specific(
),
)
assert len(mock_handle_entity_call.mock_calls) == 1
assert mock_handle_entity_call.mock_calls[0][1][1].entity_id == "light.kitchen"
async def test_call_with_match_all(
hass: HomeAssistant,
mock_handle_entity_call,
mock_entities,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Check we only target allowed entities if targeting all."""
await service.entity_service_call(
hass,
mock_entities,
Mock(),
ServiceCall(hass, "test_domain", "test_service", {"entity_id": "all"}),
)
assert len(mock_handle_entity_call.mock_calls) == 4
assert [call[1][1] for call in mock_handle_entity_call.mock_calls] == list(
mock_entities.values()
)
mock_entities_method.assert_called_once_with(mock_entities["light.kitchen"])
async def test_call_with_omit_entity_id(
hass: HomeAssistant, mock_handle_entity_call, mock_entities
hass: HomeAssistant,
mock_entities: dict[str, MockEntity],
mock_entities_method: AsyncMock,
) -> None:
"""Check service call if we do not pass an entity ID."""
await service.entity_service_call(
hass,
mock_entities,
Mock(),
"test_method",
ServiceCall(hass, "test_domain", "test_service"),
)
assert len(mock_handle_entity_call.mock_calls) == 0
mock_entities_method.assert_not_called()
async def test_register_admin_service(