mirror of
https://github.com/home-assistant/core.git
synced 2026-05-18 07:51:46 +00:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0190dc16a6 | |||
| f4637db26d | |||
| b4bfe6b80b | |||
| 278f25ec6e | |||
| 39d3bc3e53 | |||
| bb41a2df9f | |||
| 284242b90e | |||
| a95c216983 | |||
| d41a3ae0cd | |||
| 0dfbe3ef84 | |||
| 71fc725d75 | |||
| d41c9aee52 | |||
| 8091f511b8 | |||
| a7baedc22b | |||
| 05bfb3a52e | |||
| 2a5b95ba4d | |||
| 3dd972cc7a | |||
| acd9dd218a | |||
| 6552cf8f7a | |||
| e4e4785225 | |||
| d531ce8d1d | |||
| 0224928655 | |||
| 9b29b07329 | |||
| 59711ba797 | |||
| 999d987108 | |||
| f660ddddea | |||
| 47579a9ac7 | |||
| c65c502e2f | |||
| 13e28210aa |
@@ -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: {}
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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."]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
Generated
+4
-4
@@ -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
|
||||
|
||||
Generated
+4
-4
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
Generated
+1
-1
@@ -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 \
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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') }}",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
{},
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
+16
-1306
File diff suppressed because it is too large
Load Diff
@@ -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"}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user