mirror of
https://github.com/home-assistant/core.git
synced 2025-11-05 00:49:37 +00:00
Compare commits
12 Commits
power
...
dev_target
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7a7cb829e | ||
|
|
37eef965ad | ||
|
|
b706430e66 | ||
|
|
5012aa5cb0 | ||
|
|
6f6b2f1ad3 | ||
|
|
1cc4890f75 | ||
|
|
d3dd9b26c9 | ||
|
|
a64d61df05 | ||
|
|
e7c6c5311d | ||
|
|
72a524c868 | ||
|
|
b437113f31 | ||
|
|
e0e263d3b5 |
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from collections import Counter
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Literal, NotRequired, TypedDict
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -29,7 +29,7 @@ async def async_get_manager(hass: HomeAssistant) -> EnergyManager:
|
||||
class FlowFromGridSourceType(TypedDict):
|
||||
"""Dictionary describing the 'from' stat for the grid source."""
|
||||
|
||||
# statistic_id of an energy meter (kWh)
|
||||
# statistic_id of a an energy meter (kWh)
|
||||
stat_energy_from: str
|
||||
|
||||
# statistic_id of costs ($) incurred from the energy meter
|
||||
@@ -58,14 +58,6 @@ class FlowToGridSourceType(TypedDict):
|
||||
number_energy_price: float | None # Price for energy ($/kWh)
|
||||
|
||||
|
||||
class GridPowerSourceType(TypedDict):
|
||||
"""Dictionary holding the source of grid power consumption."""
|
||||
|
||||
# statistic_id of a power meter (kW)
|
||||
# negative values indicate grid return
|
||||
stat_rate: str
|
||||
|
||||
|
||||
class GridSourceType(TypedDict):
|
||||
"""Dictionary holding the source of grid energy consumption."""
|
||||
|
||||
@@ -73,7 +65,6 @@ class GridSourceType(TypedDict):
|
||||
|
||||
flow_from: list[FlowFromGridSourceType]
|
||||
flow_to: list[FlowToGridSourceType]
|
||||
power: NotRequired[list[GridPowerSourceType]]
|
||||
|
||||
cost_adjustment_day: float
|
||||
|
||||
@@ -84,7 +75,6 @@ class SolarSourceType(TypedDict):
|
||||
type: Literal["solar"]
|
||||
|
||||
stat_energy_from: str
|
||||
stat_rate: NotRequired[str]
|
||||
config_entry_solar_forecast: list[str] | None
|
||||
|
||||
|
||||
@@ -95,8 +85,6 @@ class BatterySourceType(TypedDict):
|
||||
|
||||
stat_energy_from: str
|
||||
stat_energy_to: str
|
||||
# positive when discharging, negative when charging
|
||||
stat_rate: NotRequired[str]
|
||||
|
||||
|
||||
class GasSourceType(TypedDict):
|
||||
@@ -148,15 +136,12 @@ class DeviceConsumption(TypedDict):
|
||||
# This is an ever increasing value
|
||||
stat_consumption: str
|
||||
|
||||
# Instantaneous rate of flow: W, L/min or m³/h
|
||||
stat_rate: NotRequired[str]
|
||||
|
||||
# An optional custom name for display in energy graphs
|
||||
name: str | None
|
||||
|
||||
# An optional statistic_id identifying a device
|
||||
# that includes this device's consumption in its total
|
||||
included_in_stat: NotRequired[str]
|
||||
included_in_stat: str | None
|
||||
|
||||
|
||||
class EnergyPreferences(TypedDict):
|
||||
@@ -209,12 +194,6 @@ FLOW_TO_GRID_SOURCE_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
GRID_POWER_SOURCE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("stat_rate"): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _generate_unique_value_validator(key: str) -> Callable[[list[dict]], list[dict]]:
|
||||
"""Generate a validator that ensures a value is only used once."""
|
||||
@@ -245,10 +224,6 @@ GRID_SOURCE_SCHEMA = vol.Schema(
|
||||
[FLOW_TO_GRID_SOURCE_SCHEMA],
|
||||
_generate_unique_value_validator("stat_energy_to"),
|
||||
),
|
||||
vol.Optional("power"): vol.All(
|
||||
[GRID_POWER_SOURCE_SCHEMA],
|
||||
_generate_unique_value_validator("stat_rate"),
|
||||
),
|
||||
vol.Required("cost_adjustment_day"): vol.Coerce(float),
|
||||
}
|
||||
)
|
||||
@@ -256,7 +231,6 @@ SOLAR_SOURCE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("type"): "solar",
|
||||
vol.Required("stat_energy_from"): str,
|
||||
vol.Optional("stat_rate"): str,
|
||||
vol.Optional("config_entry_solar_forecast"): vol.Any([str], None),
|
||||
}
|
||||
)
|
||||
@@ -265,7 +239,6 @@ BATTERY_SOURCE_SCHEMA = vol.Schema(
|
||||
vol.Required("type"): "battery",
|
||||
vol.Required("stat_energy_from"): str,
|
||||
vol.Required("stat_energy_to"): str,
|
||||
vol.Optional("stat_rate"): str,
|
||||
}
|
||||
)
|
||||
GAS_SOURCE_SCHEMA = vol.Schema(
|
||||
@@ -321,7 +294,6 @@ ENERGY_SOURCE_SCHEMA = vol.All(
|
||||
DEVICE_CONSUMPTION_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("stat_consumption"): str,
|
||||
vol.Optional("stat_rate"): str,
|
||||
vol.Optional("name"): str,
|
||||
vol.Optional("included_in_stat"): str,
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ from homeassistant.const import (
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
UnitOfEnergy,
|
||||
UnitOfPower,
|
||||
UnitOfVolume,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback, valid_entity_id
|
||||
@@ -24,17 +23,12 @@ ENERGY_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.ENERGY,)
|
||||
ENERGY_USAGE_UNITS: dict[str, tuple[UnitOfEnergy, ...]] = {
|
||||
sensor.SensorDeviceClass.ENERGY: tuple(UnitOfEnergy)
|
||||
}
|
||||
POWER_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.POWER,)
|
||||
POWER_USAGE_UNITS: dict[str, tuple[UnitOfPower, ...]] = {
|
||||
sensor.SensorDeviceClass.POWER: tuple(UnitOfPower)
|
||||
}
|
||||
|
||||
ENERGY_PRICE_UNITS = tuple(
|
||||
f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units
|
||||
)
|
||||
ENERGY_UNIT_ERROR = "entity_unexpected_unit_energy"
|
||||
ENERGY_PRICE_UNIT_ERROR = "entity_unexpected_unit_energy_price"
|
||||
POWER_UNIT_ERROR = "entity_unexpected_unit_power"
|
||||
GAS_USAGE_DEVICE_CLASSES = (
|
||||
sensor.SensorDeviceClass.ENERGY,
|
||||
sensor.SensorDeviceClass.GAS,
|
||||
@@ -88,10 +82,6 @@ def _get_placeholders(hass: HomeAssistant, issue_type: str) -> dict[str, str] |
|
||||
f"{currency}{unit}" for unit in ENERGY_PRICE_UNITS
|
||||
),
|
||||
}
|
||||
if issue_type == POWER_UNIT_ERROR:
|
||||
return {
|
||||
"power_units": ", ".join(POWER_USAGE_UNITS[sensor.SensorDeviceClass.POWER]),
|
||||
}
|
||||
if issue_type == GAS_UNIT_ERROR:
|
||||
return {
|
||||
"energy_units": ", ".join(GAS_USAGE_UNITS[sensor.SensorDeviceClass.ENERGY]),
|
||||
@@ -169,7 +159,7 @@ class EnergyPreferencesValidation:
|
||||
|
||||
|
||||
@callback
|
||||
def _async_validate_stat_common(
|
||||
def _async_validate_usage_stat(
|
||||
hass: HomeAssistant,
|
||||
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
|
||||
stat_id: str,
|
||||
@@ -177,41 +167,37 @@ def _async_validate_stat_common(
|
||||
allowed_units: Mapping[str, Sequence[str]],
|
||||
unit_error: str,
|
||||
issues: ValidationIssues,
|
||||
check_negative: bool = False,
|
||||
) -> str | None:
|
||||
"""Validate common aspects of a statistic.
|
||||
|
||||
Returns the entity_id if validation succeeds, None otherwise.
|
||||
"""
|
||||
) -> None:
|
||||
"""Validate a statistic."""
|
||||
if stat_id not in metadata:
|
||||
issues.add_issue(hass, "statistics_not_defined", stat_id)
|
||||
|
||||
has_entity_source = valid_entity_id(stat_id)
|
||||
|
||||
if not has_entity_source:
|
||||
return None
|
||||
return
|
||||
|
||||
entity_id = stat_id
|
||||
|
||||
if not recorder.is_entity_recorded(hass, entity_id):
|
||||
issues.add_issue(hass, "recorder_untracked", entity_id)
|
||||
return None
|
||||
return
|
||||
|
||||
if (state := hass.states.get(entity_id)) is None:
|
||||
issues.add_issue(hass, "entity_not_defined", entity_id)
|
||||
return None
|
||||
return
|
||||
|
||||
if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||
issues.add_issue(hass, "entity_unavailable", entity_id, state.state)
|
||||
return None
|
||||
return
|
||||
|
||||
try:
|
||||
current_value: float | None = float(state.state)
|
||||
except ValueError:
|
||||
issues.add_issue(hass, "entity_state_non_numeric", entity_id, state.state)
|
||||
return None
|
||||
return
|
||||
|
||||
if check_negative and current_value is not None and current_value < 0:
|
||||
if current_value is not None and current_value < 0:
|
||||
issues.add_issue(hass, "entity_negative_state", entity_id, current_value)
|
||||
|
||||
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
|
||||
@@ -225,36 +211,6 @@ def _async_validate_stat_common(
|
||||
if device_class and unit not in allowed_units.get(device_class, []):
|
||||
issues.add_issue(hass, unit_error, entity_id, unit)
|
||||
|
||||
return entity_id
|
||||
|
||||
|
||||
@callback
|
||||
def _async_validate_usage_stat(
|
||||
hass: HomeAssistant,
|
||||
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
|
||||
stat_id: str,
|
||||
allowed_device_classes: Sequence[str],
|
||||
allowed_units: Mapping[str, Sequence[str]],
|
||||
unit_error: str,
|
||||
issues: ValidationIssues,
|
||||
) -> None:
|
||||
"""Validate a statistic."""
|
||||
entity_id = _async_validate_stat_common(
|
||||
hass,
|
||||
metadata,
|
||||
stat_id,
|
||||
allowed_device_classes,
|
||||
allowed_units,
|
||||
unit_error,
|
||||
issues,
|
||||
check_negative=True,
|
||||
)
|
||||
|
||||
if entity_id is None:
|
||||
return
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
state_class = state.attributes.get(sensor.ATTR_STATE_CLASS)
|
||||
|
||||
allowed_state_classes = [
|
||||
@@ -299,39 +255,6 @@ def _async_validate_price_entity(
|
||||
issues.add_issue(hass, unit_error, entity_id, unit)
|
||||
|
||||
|
||||
@callback
|
||||
def _async_validate_power_stat(
|
||||
hass: HomeAssistant,
|
||||
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
|
||||
stat_id: str,
|
||||
allowed_device_classes: Sequence[str],
|
||||
allowed_units: Mapping[str, Sequence[str]],
|
||||
unit_error: str,
|
||||
issues: ValidationIssues,
|
||||
) -> None:
|
||||
"""Validate a power statistic."""
|
||||
entity_id = _async_validate_stat_common(
|
||||
hass,
|
||||
metadata,
|
||||
stat_id,
|
||||
allowed_device_classes,
|
||||
allowed_units,
|
||||
unit_error,
|
||||
issues,
|
||||
check_negative=False,
|
||||
)
|
||||
|
||||
if entity_id is None:
|
||||
return
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
state_class = state.attributes.get(sensor.ATTR_STATE_CLASS)
|
||||
|
||||
if state_class != sensor.SensorStateClass.MEASUREMENT:
|
||||
issues.add_issue(hass, "entity_unexpected_state_class", entity_id, state_class)
|
||||
|
||||
|
||||
@callback
|
||||
def _async_validate_cost_stat(
|
||||
hass: HomeAssistant,
|
||||
@@ -511,21 +434,6 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
|
||||
)
|
||||
)
|
||||
|
||||
for power_stat in source.get("power", []):
|
||||
wanted_statistics_metadata.add(power_stat["stat_rate"])
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_power_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
power_stat["stat_rate"],
|
||||
POWER_USAGE_DEVICE_CLASSES,
|
||||
POWER_USAGE_UNITS,
|
||||
POWER_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
elif source["type"] == "gas":
|
||||
wanted_statistics_metadata.add(source["stat_energy_from"])
|
||||
validate_calls.append(
|
||||
|
||||
@@ -744,7 +744,9 @@ class ManifestJSONView(HomeAssistantView):
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
"type": "frontend/get_icons",
|
||||
vol.Required("category"): vol.In({"entity", "entity_component", "services"}),
|
||||
vol.Required("category"): vol.In(
|
||||
{"entity", "entity_component", "services", "triggers"}
|
||||
),
|
||||
vol.Optional("integration"): vol.All(cv.ensure_list, [str]),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import itertools
|
||||
|
||||
from aiohasupervisor.models.mounts import MountState
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
@@ -13,8 +16,14 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import ADDONS_COORDINATOR, ATTR_STARTED, ATTR_STATE, DATA_KEY_ADDONS
|
||||
from .entity import HassioAddonEntity
|
||||
from .const import (
|
||||
ADDONS_COORDINATOR,
|
||||
ATTR_STARTED,
|
||||
ATTR_STATE,
|
||||
DATA_KEY_ADDONS,
|
||||
DATA_KEY_MOUNTS,
|
||||
)
|
||||
from .entity import HassioAddonEntity, HassioMountEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -34,6 +43,16 @@ ADDON_ENTITY_DESCRIPTIONS = (
|
||||
),
|
||||
)
|
||||
|
||||
MOUNT_ENTITY_DESCRIPTIONS = (
|
||||
HassioBinarySensorEntityDescription(
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
entity_registry_enabled_default=False,
|
||||
key=ATTR_STATE,
|
||||
translation_key="mount",
|
||||
target=MountState.ACTIVE.value,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -44,13 +63,26 @@ async def async_setup_entry(
|
||||
coordinator = hass.data[ADDONS_COORDINATOR]
|
||||
|
||||
async_add_entities(
|
||||
HassioAddonBinarySensor(
|
||||
addon=addon,
|
||||
coordinator=coordinator,
|
||||
entity_description=entity_description,
|
||||
itertools.chain(
|
||||
[
|
||||
HassioAddonBinarySensor(
|
||||
addon=addon,
|
||||
coordinator=coordinator,
|
||||
entity_description=entity_description,
|
||||
)
|
||||
for addon in coordinator.data[DATA_KEY_ADDONS].values()
|
||||
for entity_description in ADDON_ENTITY_DESCRIPTIONS
|
||||
],
|
||||
[
|
||||
HassioMountBinarySensor(
|
||||
mount=mount,
|
||||
coordinator=coordinator,
|
||||
entity_description=entity_description,
|
||||
)
|
||||
for mount in coordinator.data[DATA_KEY_MOUNTS].values()
|
||||
for entity_description in MOUNT_ENTITY_DESCRIPTIONS
|
||||
],
|
||||
)
|
||||
for addon in coordinator.data[DATA_KEY_ADDONS].values()
|
||||
for entity_description in ADDON_ENTITY_DESCRIPTIONS
|
||||
)
|
||||
|
||||
|
||||
@@ -68,3 +100,20 @@ class HassioAddonBinarySensor(HassioAddonEntity, BinarySensorEntity):
|
||||
if self.entity_description.target is None:
|
||||
return value
|
||||
return value == self.entity_description.target
|
||||
|
||||
|
||||
class HassioMountBinarySensor(HassioMountEntity, BinarySensorEntity):
|
||||
"""Binary sensor for Hass.io mount."""
|
||||
|
||||
entity_description: HassioBinarySensorEntityDescription
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if the binary sensor is on."""
|
||||
value = getattr(
|
||||
self.coordinator.data[DATA_KEY_MOUNTS][self._mount.name],
|
||||
self.entity_description.key,
|
||||
)
|
||||
if self.entity_description.target is None:
|
||||
return value
|
||||
return value == self.entity_description.target
|
||||
|
||||
@@ -90,6 +90,7 @@ DATA_SUPERVISOR_INFO = "hassio_supervisor_info"
|
||||
DATA_SUPERVISOR_STATS = "hassio_supervisor_stats"
|
||||
DATA_ADDONS_INFO = "hassio_addons_info"
|
||||
DATA_ADDONS_STATS = "hassio_addons_stats"
|
||||
DATA_MOUNTS_INFO = "hassio_mounts_info"
|
||||
HASSIO_UPDATE_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
ATTR_AUTO_UPDATE = "auto_update"
|
||||
@@ -110,6 +111,7 @@ DATA_KEY_SUPERVISOR = "supervisor"
|
||||
DATA_KEY_CORE = "core"
|
||||
DATA_KEY_HOST = "host"
|
||||
DATA_KEY_SUPERVISOR_ISSUES = "supervisor_issues"
|
||||
DATA_KEY_MOUNTS = "mounts"
|
||||
|
||||
PLACEHOLDER_KEY_ADDON = "addon"
|
||||
PLACEHOLDER_KEY_ADDON_INFO = "addon_info"
|
||||
@@ -174,3 +176,4 @@ class SupervisorEntityModel(StrEnum):
|
||||
CORE = "Home Assistant Core"
|
||||
SUPERVISOR = "Home Assistant Supervisor"
|
||||
HOST = "Home Assistant Host"
|
||||
MOUNT = "Home Assistant Mount"
|
||||
|
||||
@@ -10,6 +10,11 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from aiohasupervisor import SupervisorError, SupervisorNotFoundError
|
||||
from aiohasupervisor.models import StoreInfo
|
||||
from aiohasupervisor.models.mounts import (
|
||||
CIFSMountResponse,
|
||||
MountsInfo,
|
||||
NFSMountResponse,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_MANUFACTURER, ATTR_NAME
|
||||
@@ -41,9 +46,11 @@ from .const import (
|
||||
DATA_KEY_ADDONS,
|
||||
DATA_KEY_CORE,
|
||||
DATA_KEY_HOST,
|
||||
DATA_KEY_MOUNTS,
|
||||
DATA_KEY_OS,
|
||||
DATA_KEY_SUPERVISOR,
|
||||
DATA_KEY_SUPERVISOR_ISSUES,
|
||||
DATA_MOUNTS_INFO,
|
||||
DATA_NETWORK_INFO,
|
||||
DATA_OS_INFO,
|
||||
DATA_STORE,
|
||||
@@ -174,6 +181,16 @@ def get_core_info(hass: HomeAssistant) -> dict[str, Any] | None:
|
||||
return hass.data.get(DATA_CORE_INFO)
|
||||
|
||||
|
||||
@callback
|
||||
@bind_hass
|
||||
def get_mounts_info(hass: HomeAssistant) -> MountsInfo | None:
|
||||
"""Return Home Assistant mounts information from Supervisor.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
return hass.data.get(DATA_MOUNTS_INFO)
|
||||
|
||||
|
||||
@callback
|
||||
@bind_hass
|
||||
def get_issues_info(hass: HomeAssistant) -> SupervisorIssues | None:
|
||||
@@ -203,6 +220,25 @@ def async_register_addons_in_dev_reg(
|
||||
dev_reg.async_get_or_create(config_entry_id=entry_id, **params)
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_mounts_in_dev_reg(
|
||||
entry_id: str,
|
||||
dev_reg: dr.DeviceRegistry,
|
||||
mounts: list[CIFSMountResponse | NFSMountResponse],
|
||||
) -> None:
|
||||
"""Register mounts in the device registry."""
|
||||
for mount in mounts:
|
||||
params = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"mount_{mount.name}")},
|
||||
manufacturer="Home Assistant",
|
||||
model=SupervisorEntityModel.MOUNT,
|
||||
model_id=f"{mount.usage}/{mount.type}",
|
||||
name=mount.name,
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
dev_reg.async_get_or_create(config_entry_id=entry_id, **params)
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_os_in_dev_reg(
|
||||
entry_id: str, dev_reg: dr.DeviceRegistry, os_dict: dict[str, Any]
|
||||
@@ -272,12 +308,12 @@ def async_register_supervisor_in_dev_reg(
|
||||
|
||||
|
||||
@callback
|
||||
def async_remove_addons_from_dev_reg(
|
||||
dev_reg: dr.DeviceRegistry, addons: set[str]
|
||||
def async_remove_devices_from_dev_reg(
|
||||
dev_reg: dr.DeviceRegistry, devices: set[str]
|
||||
) -> None:
|
||||
"""Remove addons from the device registry."""
|
||||
for addon_slug in addons:
|
||||
if dev := dev_reg.async_get_device(identifiers={(DOMAIN, addon_slug)}):
|
||||
"""Remove devices from the device registry."""
|
||||
for device in devices:
|
||||
if dev := dev_reg.async_get_device(identifiers={(DOMAIN, device)}):
|
||||
dev_reg.async_remove_device(dev.id)
|
||||
|
||||
|
||||
@@ -362,12 +398,19 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
**get_supervisor_stats(self.hass),
|
||||
}
|
||||
new_data[DATA_KEY_HOST] = get_host_info(self.hass) or {}
|
||||
new_data[DATA_KEY_MOUNTS] = {
|
||||
mount.name: mount
|
||||
for mount in getattr(get_mounts_info(self.hass), "mounts", [])
|
||||
}
|
||||
|
||||
# If this is the initial refresh, register all addons and return the dict
|
||||
if is_first_update:
|
||||
async_register_addons_in_dev_reg(
|
||||
self.entry_id, self.dev_reg, new_data[DATA_KEY_ADDONS].values()
|
||||
)
|
||||
async_register_mounts_in_dev_reg(
|
||||
self.entry_id, self.dev_reg, new_data[DATA_KEY_MOUNTS].values()
|
||||
)
|
||||
async_register_core_in_dev_reg(
|
||||
self.entry_id, self.dev_reg, new_data[DATA_KEY_CORE]
|
||||
)
|
||||
@@ -389,7 +432,20 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
if device.model == SupervisorEntityModel.ADDON
|
||||
}
|
||||
if stale_addons := supervisor_addon_devices - set(new_data[DATA_KEY_ADDONS]):
|
||||
async_remove_addons_from_dev_reg(self.dev_reg, stale_addons)
|
||||
async_remove_devices_from_dev_reg(self.dev_reg, stale_addons)
|
||||
|
||||
# Remove mounts that no longer exists from device registry
|
||||
supervisor_mount_devices = {
|
||||
device.name
|
||||
for device in self.dev_reg.devices.get_devices_for_config_entry_id(
|
||||
self.entry_id
|
||||
)
|
||||
if device.model == SupervisorEntityModel.MOUNT
|
||||
}
|
||||
if stale_mounts := supervisor_mount_devices - set(new_data[DATA_KEY_MOUNTS]):
|
||||
async_remove_devices_from_dev_reg(
|
||||
self.dev_reg, {f"mount_{stale_mount}" for stale_mount in stale_mounts}
|
||||
)
|
||||
|
||||
if not self.is_hass_os and (
|
||||
dev := self.dev_reg.async_get_device(identifiers={(DOMAIN, "OS")})
|
||||
@@ -397,11 +453,12 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
# Remove the OS device if it exists and the installation is not hassos
|
||||
self.dev_reg.async_remove_device(dev.id)
|
||||
|
||||
# If there are new add-ons, we should reload the config entry so we can
|
||||
# If there are new add-ons or mounts, we should reload the config entry so we can
|
||||
# create new devices and entities. We can return an empty dict because
|
||||
# coordinator will be recreated.
|
||||
if self.data and set(new_data[DATA_KEY_ADDONS]) - set(
|
||||
self.data[DATA_KEY_ADDONS]
|
||||
if self.data and (
|
||||
set(new_data[DATA_KEY_ADDONS]) - set(self.data[DATA_KEY_ADDONS])
|
||||
or set(new_data[DATA_KEY_MOUNTS]) - set(self.data[DATA_KEY_MOUNTS])
|
||||
):
|
||||
self.hass.async_create_task(
|
||||
self.hass.config_entries.async_reload(self.entry_id)
|
||||
@@ -428,6 +485,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
DATA_CORE_INFO: hassio.get_core_info(),
|
||||
DATA_SUPERVISOR_INFO: hassio.get_supervisor_info(),
|
||||
DATA_OS_INFO: hassio.get_os_info(),
|
||||
DATA_MOUNTS_INFO: self.supervisor_client.mounts.info(),
|
||||
}
|
||||
if CONTAINER_STATS in container_updates[CORE_CONTAINER]:
|
||||
updates[DATA_CORE_STATS] = hassio.get_core_stats()
|
||||
|
||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiohasupervisor.models.mounts import CIFSMountResponse, NFSMountResponse
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
@@ -15,6 +17,7 @@ from .const import (
|
||||
DATA_KEY_ADDONS,
|
||||
DATA_KEY_CORE,
|
||||
DATA_KEY_HOST,
|
||||
DATA_KEY_MOUNTS,
|
||||
DATA_KEY_OS,
|
||||
DATA_KEY_SUPERVISOR,
|
||||
DOMAIN,
|
||||
@@ -192,3 +195,34 @@ class HassioCoreEntity(CoordinatorEntity[HassioDataUpdateCoordinator]):
|
||||
)
|
||||
if CONTAINER_STATS in update_types:
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
|
||||
class HassioMountEntity(CoordinatorEntity[HassioDataUpdateCoordinator]):
|
||||
"""Base Entity for Mount."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HassioDataUpdateCoordinator,
|
||||
entity_description: EntityDescription,
|
||||
mount: CIFSMountResponse | NFSMountResponse,
|
||||
) -> None:
|
||||
"""Initialize base entity."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = (
|
||||
f"home_assistant_mount_{mount.name}_{entity_description.key}"
|
||||
)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"mount_{mount.name}")}
|
||||
)
|
||||
self._mount = mount
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return (
|
||||
super().available
|
||||
and self._mount.name in self.coordinator.data[DATA_KEY_MOUNTS]
|
||||
)
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"binary_sensor": {
|
||||
"state": {
|
||||
"name": "Running"
|
||||
},
|
||||
"mount": {
|
||||
"name": "Connected"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
|
||||
@@ -25,5 +25,10 @@
|
||||
"turn_on": {
|
||||
"service": "mdi:lightbulb-on"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"state": {
|
||||
"trigger": "mdi:state-machine"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,13 @@
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
},
|
||||
"color_name": {
|
||||
"options": {
|
||||
"aliceblue": "Alice blue",
|
||||
@@ -289,6 +296,12 @@
|
||||
"long": "Long",
|
||||
"short": "Short"
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"options": {
|
||||
"off": "[%key:common::state::off%]",
|
||||
"on": "[%key:common::state::on%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
@@ -462,5 +475,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Light"
|
||||
"title": "Light",
|
||||
"triggers": {
|
||||
"state": {
|
||||
"description": "When the state of a light changes, such as turning on or off.",
|
||||
"description_configured": "When the state of a light changes",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "The behavior of the targeted entities to trigger on.",
|
||||
"name": "Behavior"
|
||||
},
|
||||
"state": {
|
||||
"description": "The state to trigger on.",
|
||||
"name": "State"
|
||||
}
|
||||
},
|
||||
"name": "State"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
152
homeassistant/components/light/trigger.py
Normal file
152
homeassistant/components/light/trigger.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""Provides triggers for lights."""
|
||||
|
||||
from typing import TYPE_CHECKING, Final, cast, override
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
CONF_STATE,
|
||||
CONF_TARGET,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.event import process_state_match
|
||||
from homeassistant.helpers.target import (
|
||||
TargetStateChangedData,
|
||||
async_track_target_selector_state_change_event,
|
||||
)
|
||||
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
# remove when #151314 is merged
|
||||
CONF_OPTIONS: Final = "options"
|
||||
|
||||
ATTR_BEHAVIOR: Final = "behavior"
|
||||
BEHAVIOR_FIRST: Final = "first"
|
||||
BEHAVIOR_LAST: Final = "last"
|
||||
BEHAVIOR_ANY: Final = "any"
|
||||
|
||||
STATE_PLATFORM_TYPE: Final = "state"
|
||||
STATE_TRIGGER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_OPTIONS): {
|
||||
vol.Required(CONF_STATE): vol.In([STATE_ON, STATE_OFF]),
|
||||
vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In(
|
||||
[BEHAVIOR_FIRST, BEHAVIOR_LAST, BEHAVIOR_ANY]
|
||||
),
|
||||
},
|
||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class StateTrigger(Trigger):
|
||||
"""Trigger for state changes."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, STATE_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the state trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.options is not None
|
||||
assert config.target is not None
|
||||
self._options = config.options
|
||||
self._target = config.target
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
match_config_state = process_state_match(self._options.get(CONF_STATE))
|
||||
|
||||
def check_all_match(entity_ids: set[str]) -> bool:
|
||||
"""Check if all entity states match."""
|
||||
return all(
|
||||
match_config_state(state.state)
|
||||
for entity_id in entity_ids
|
||||
if (state := self._hass.states.get(entity_id)) is not None
|
||||
)
|
||||
|
||||
def check_one_match(entity_ids: set[str]) -> bool:
|
||||
"""Check that only one entity state matches."""
|
||||
return (
|
||||
sum(
|
||||
match_config_state(state.state)
|
||||
for entity_id in entity_ids
|
||||
if (state := self._hass.states.get(entity_id)) is not None
|
||||
)
|
||||
== 1
|
||||
)
|
||||
|
||||
behavior = self._options.get(ATTR_BEHAVIOR)
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
if to_state is None:
|
||||
return
|
||||
|
||||
# This check is required for "first" behavior, to check that it went from zero
|
||||
# entities matching the state to one. Otherwise, if previously there were two
|
||||
# entities on CONF_STATE and one changed, this would trigger.
|
||||
# For "last" behavior it is not required, but serves as a quicker fail check.
|
||||
if not match_config_state(to_state.state):
|
||||
return
|
||||
if behavior == BEHAVIOR_LAST:
|
||||
if not check_all_match(target_state_change_data.targeted_entity_ids):
|
||||
return
|
||||
elif behavior == BEHAVIOR_FIRST:
|
||||
if not check_one_match(target_state_change_data.targeted_entity_ids):
|
||||
return
|
||||
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"state of {entity_id}",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
STATE_PLATFORM_TYPE: StateTrigger,
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for lights."""
|
||||
return TRIGGERS
|
||||
24
homeassistant/components/light/triggers.yaml
Normal file
24
homeassistant/components/light/triggers.yaml
Normal file
@@ -0,0 +1,24 @@
|
||||
state:
|
||||
target:
|
||||
entity:
|
||||
domain: light
|
||||
fields:
|
||||
state:
|
||||
required: true
|
||||
default: "on"
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- "off"
|
||||
- "on"
|
||||
translation_key: state
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
translation_key: behavior
|
||||
@@ -4,11 +4,13 @@ import logging
|
||||
|
||||
from libsoundtouch import soundtouch_device
|
||||
from libsoundtouch.device import SoundTouchDevice
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
@@ -130,7 +132,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Bose SoundTouch from a config entry."""
|
||||
device = await hass.async_add_executor_job(soundtouch_device, entry.data[CONF_HOST])
|
||||
try:
|
||||
device = await hass.async_add_executor_job(
|
||||
soundtouch_device, entry.data[CONF_HOST]
|
||||
)
|
||||
except requests.exceptions.ConnectionError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Unable to connect to SoundTouch device at {entry.data[CONF_HOST]}"
|
||||
) from err
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = SoundTouchData(device)
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
"following": {
|
||||
"default": "mdi:account-arrow-right"
|
||||
},
|
||||
"friends": {
|
||||
"default": "mdi:account-heart"
|
||||
},
|
||||
"gamer_score": {
|
||||
"default": "mdi:alpha-g-circle"
|
||||
},
|
||||
|
||||
@@ -36,6 +36,7 @@ class XboxSensor(StrEnum):
|
||||
FOLLOWING = "following"
|
||||
FOLLOWER = "follower"
|
||||
NOW_PLAYING = "now_playing"
|
||||
FRIENDS = "friends"
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
@@ -153,6 +154,11 @@ SENSOR_DESCRIPTIONS: tuple[XboxSensorEntityDescription, ...] = (
|
||||
attributes_fn=now_playing_attributes,
|
||||
entity_picture_fn=title_logo,
|
||||
),
|
||||
XboxSensorEntityDescription(
|
||||
key=XboxSensor.FRIENDS,
|
||||
translation_key=XboxSensor.FRIENDS,
|
||||
value_fn=lambda x, _: x.detail.friend_count if x.detail else None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -65,6 +65,10 @@
|
||||
"name": "Following",
|
||||
"unit_of_measurement": "people"
|
||||
},
|
||||
"friends": {
|
||||
"name": "Friends",
|
||||
"unit_of_measurement": "[%key:component::xbox::entity::sensor::following::unit_of_measurement%]"
|
||||
},
|
||||
"gamer_score": {
|
||||
"name": "Gamerscore",
|
||||
"unit_of_measurement": "points"
|
||||
|
||||
@@ -806,6 +806,9 @@ async def async_get_all_descriptions(
|
||||
|
||||
description = {"fields": yaml_description.get("fields", {})}
|
||||
|
||||
if (target := yaml_description.get("target")) is not None:
|
||||
description["target"] = target
|
||||
|
||||
new_descriptions_cache[missing_trigger] = description
|
||||
|
||||
hass.data[TRIGGER_DESCRIPTION_CACHE] = new_descriptions_cache
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Fixtures for energy component tests."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.energy import async_get_manager
|
||||
from homeassistant.components.energy.data import EnergyManager
|
||||
from homeassistant.components.recorder import Recorder
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_is_entity_recorded():
|
||||
"""Mock recorder.is_entity_recorded."""
|
||||
mocks = {}
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.recorder.is_entity_recorded",
|
||||
side_effect=lambda hass, entity_id: mocks.get(entity_id, True),
|
||||
):
|
||||
yield mocks
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_get_metadata():
|
||||
"""Mock recorder.statistics.get_metadata."""
|
||||
mocks = {}
|
||||
|
||||
def _get_metadata(_hass, *, statistic_ids):
|
||||
result = {}
|
||||
for statistic_id in statistic_ids:
|
||||
if statistic_id in mocks:
|
||||
if mocks[statistic_id] is not None:
|
||||
result[statistic_id] = mocks[statistic_id]
|
||||
else:
|
||||
result[statistic_id] = (1, {})
|
||||
return result
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.recorder.statistics.get_metadata",
|
||||
wraps=_get_metadata,
|
||||
):
|
||||
yield mocks
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_energy_manager(
|
||||
recorder_mock: Recorder, hass: HomeAssistant
|
||||
) -> EnergyManager:
|
||||
"""Set up energy."""
|
||||
assert await async_setup_component(hass, "energy", {"energy": {}})
|
||||
manager = await async_get_manager(hass)
|
||||
manager.data = manager.default_preferences()
|
||||
return manager
|
||||
@@ -1,24 +1,65 @@
|
||||
"""Test that validation works."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.energy import validate
|
||||
from homeassistant.components.energy import async_get_manager, validate
|
||||
from homeassistant.components.energy.data import EnergyManager
|
||||
from homeassistant.components.recorder import Recorder
|
||||
from homeassistant.const import UnitOfEnergy
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.json import JSON_DUMP
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
ENERGY_UNITS_STRING = ", ".join(tuple(UnitOfEnergy))
|
||||
|
||||
ENERGY_PRICE_UNITS_STRING = ", ".join(f"EUR/{unit}" for unit in tuple(UnitOfEnergy))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_is_entity_recorded():
|
||||
"""Mock recorder.is_entity_recorded."""
|
||||
mocks = {}
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.recorder.is_entity_recorded",
|
||||
side_effect=lambda hass, entity_id: mocks.get(entity_id, True),
|
||||
):
|
||||
yield mocks
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_get_metadata():
|
||||
"""Mock recorder.statistics.get_metadata."""
|
||||
mocks = {}
|
||||
|
||||
def _get_metadata(_hass, *, statistic_ids):
|
||||
result = {}
|
||||
for statistic_id in statistic_ids:
|
||||
if statistic_id in mocks:
|
||||
if mocks[statistic_id] is not None:
|
||||
result[statistic_id] = mocks[statistic_id]
|
||||
else:
|
||||
result[statistic_id] = (1, {})
|
||||
return result
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.recorder.statistics.get_metadata",
|
||||
wraps=_get_metadata,
|
||||
):
|
||||
yield mocks
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup_energy_for_validation(
|
||||
mock_energy_manager: EnergyManager,
|
||||
async def mock_energy_manager(
|
||||
recorder_mock: Recorder, hass: HomeAssistant
|
||||
) -> EnergyManager:
|
||||
"""Ensure energy manager is set up for validation tests."""
|
||||
return mock_energy_manager
|
||||
"""Set up energy."""
|
||||
assert await async_setup_component(hass, "energy", {"energy": {}})
|
||||
manager = await async_get_manager(hass)
|
||||
manager.data = manager.default_preferences()
|
||||
return manager
|
||||
|
||||
|
||||
async def test_validation_empty_config(hass: HomeAssistant) -> None:
|
||||
@@ -372,7 +413,6 @@ async def test_validation_grid(
|
||||
"stat_compensation": "sensor.grid_compensation_1",
|
||||
}
|
||||
],
|
||||
"power": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -464,7 +504,6 @@ async def test_validation_grid_external_cost_compensation(
|
||||
"stat_compensation": "external:grid_compensation_1",
|
||||
}
|
||||
],
|
||||
"power": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -703,7 +742,6 @@ async def test_validation_grid_price_errors(
|
||||
}
|
||||
],
|
||||
"flow_to": [],
|
||||
"power": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -909,7 +947,6 @@ async def test_validation_grid_no_costs_tracking(
|
||||
"number_energy_price": None,
|
||||
},
|
||||
],
|
||||
"power": [],
|
||||
"cost_adjustment_day": 0.0,
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,450 +0,0 @@
|
||||
"""Test power stat validation."""
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.energy import validate
|
||||
from homeassistant.components.energy.data import EnergyManager
|
||||
from homeassistant.const import UnitOfPower
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
POWER_UNITS_STRING = ", ".join(tuple(UnitOfPower))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup_energy_for_validation(
|
||||
mock_energy_manager: EnergyManager,
|
||||
) -> EnergyManager:
|
||||
"""Ensure energy manager is set up for validation tests."""
|
||||
return mock_energy_manager
|
||||
|
||||
|
||||
async def test_validation_grid_power_valid(
|
||||
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
|
||||
) -> None:
|
||||
"""Test validating grid with valid power sensor."""
|
||||
await mock_energy_manager.async_update(
|
||||
{
|
||||
"energy_sources": [
|
||||
{
|
||||
"type": "grid",
|
||||
"flow_from": [],
|
||||
"flow_to": [],
|
||||
"power": [
|
||||
{
|
||||
"stat_rate": "sensor.grid_power",
|
||||
}
|
||||
],
|
||||
"cost_adjustment_day": 0.0,
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
hass.states.async_set(
|
||||
"sensor.grid_power",
|
||||
"1.5",
|
||||
{
|
||||
"device_class": "power",
|
||||
"unit_of_measurement": UnitOfPower.KILO_WATT,
|
||||
"state_class": "measurement",
|
||||
},
|
||||
)
|
||||
|
||||
result = await validate.async_validate(hass)
|
||||
assert result.as_dict() == {
|
||||
"energy_sources": [[]],
|
||||
"device_consumption": [],
|
||||
}
|
||||
|
||||
|
||||
async def test_validation_grid_power_wrong_unit(
|
||||
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
|
||||
) -> None:
|
||||
"""Test validating grid with power sensor having wrong unit."""
|
||||
await mock_energy_manager.async_update(
|
||||
{
|
||||
"energy_sources": [
|
||||
{
|
||||
"type": "grid",
|
||||
"flow_from": [],
|
||||
"flow_to": [],
|
||||
"power": [
|
||||
{
|
||||
"stat_rate": "sensor.grid_power",
|
||||
}
|
||||
],
|
||||
"cost_adjustment_day": 0.0,
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
hass.states.async_set(
|
||||
"sensor.grid_power",
|
||||
"1.5",
|
||||
{
|
||||
"device_class": "power",
|
||||
"unit_of_measurement": "beers",
|
||||
"state_class": "measurement",
|
||||
},
|
||||
)
|
||||
|
||||
result = await validate.async_validate(hass)
|
||||
assert result.as_dict() == {
|
||||
"energy_sources": [
|
||||
[
|
||||
{
|
||||
"type": "entity_unexpected_unit_power",
|
||||
"affected_entities": {("sensor.grid_power", "beers")},
|
||||
"translation_placeholders": {"power_units": POWER_UNITS_STRING},
|
||||
}
|
||||
]
|
||||
],
|
||||
"device_consumption": [],
|
||||
}
|
||||
|
||||
|
||||
async def test_validation_grid_power_wrong_state_class(
|
||||
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
|
||||
) -> None:
|
||||
"""Test validating grid with power sensor having wrong state class."""
|
||||
await mock_energy_manager.async_update(
|
||||
{
|
||||
"energy_sources": [
|
||||
{
|
||||
"type": "grid",
|
||||
"flow_from": [],
|
||||
"flow_to": [],
|
||||
"power": [
|
||||
{
|
||||
"stat_rate": "sensor.grid_power",
|
||||
}
|
||||
],
|
||||
"cost_adjustment_day": 0.0,
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
hass.states.async_set(
|
||||
"sensor.grid_power",
|
||||
"1.5",
|
||||
{
|
||||
"device_class": "power",
|
||||
"unit_of_measurement": UnitOfPower.KILO_WATT,
|
||||
"state_class": "total_increasing",
|
||||
},
|
||||
)
|
||||
|
||||
result = await validate.async_validate(hass)
|
||||
assert result.as_dict() == {
|
||||
"energy_sources": [
|
||||
[
|
||||
{
|
||||
"type": "entity_unexpected_state_class",
|
||||
"affected_entities": {("sensor.grid_power", "total_increasing")},
|
||||
"translation_placeholders": None,
|
||||
}
|
||||
]
|
||||
],
|
||||
"device_consumption": [],
|
||||
}
|
||||
|
||||
|
||||
async def test_validation_grid_power_entity_missing(
|
||||
hass: HomeAssistant, mock_energy_manager
|
||||
) -> None:
|
||||
"""Test validating grid with missing power sensor."""
|
||||
await mock_energy_manager.async_update(
|
||||
{
|
||||
"energy_sources": [
|
||||
{
|
||||
"type": "grid",
|
||||
"flow_from": [],
|
||||
"flow_to": [],
|
||||
"power": [
|
||||
{
|
||||
"stat_rate": "sensor.missing_power",
|
||||
}
|
||||
],
|
||||
"cost_adjustment_day": 0.0,
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
result = await validate.async_validate(hass)
|
||||
assert result.as_dict() == {
|
||||
"energy_sources": [
|
||||
[
|
||||
{
|
||||
"type": "statistics_not_defined",
|
||||
"affected_entities": {("sensor.missing_power", None)},
|
||||
"translation_placeholders": None,
|
||||
},
|
||||
{
|
||||
"type": "entity_not_defined",
|
||||
"affected_entities": {("sensor.missing_power", None)},
|
||||
"translation_placeholders": None,
|
||||
},
|
||||
]
|
||||
],
|
||||
"device_consumption": [],
|
||||
}
|
||||
|
||||
|
||||
async def test_validation_grid_power_entity_unavailable(
|
||||
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
|
||||
) -> None:
|
||||
"""Test validating grid with unavailable power sensor."""
|
||||
await mock_energy_manager.async_update(
|
||||
{
|
||||
"energy_sources": [
|
||||
{
|
||||
"type": "grid",
|
||||
"flow_from": [],
|
||||
"flow_to": [],
|
||||
"power": [
|
||||
{
|
||||
"stat_rate": "sensor.unavailable_power",
|
||||
}
|
||||
],
|
||||
"cost_adjustment_day": 0.0,
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
hass.states.async_set("sensor.unavailable_power", "unavailable", {})
|
||||
|
||||
result = await validate.async_validate(hass)
|
||||
assert result.as_dict() == {
|
||||
"energy_sources": [
|
||||
[
|
||||
{
|
||||
"type": "entity_unavailable",
|
||||
"affected_entities": {("sensor.unavailable_power", "unavailable")},
|
||||
"translation_placeholders": None,
|
||||
}
|
||||
]
|
||||
],
|
||||
"device_consumption": [],
|
||||
}
|
||||
|
||||
|
||||
async def test_validation_grid_power_entity_non_numeric(
|
||||
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
|
||||
) -> None:
|
||||
"""Test validating grid with non-numeric power sensor."""
|
||||
await mock_energy_manager.async_update(
|
||||
{
|
||||
"energy_sources": [
|
||||
{
|
||||
"type": "grid",
|
||||
"flow_from": [],
|
||||
"flow_to": [],
|
||||
"power": [
|
||||
{
|
||||
"stat_rate": "sensor.non_numeric_power",
|
||||
}
|
||||
],
|
||||
"cost_adjustment_day": 0.0,
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
hass.states.async_set(
|
||||
"sensor.non_numeric_power",
|
||||
"not_a_number",
|
||||
{
|
||||
"device_class": "power",
|
||||
"unit_of_measurement": UnitOfPower.KILO_WATT,
|
||||
"state_class": "measurement",
|
||||
},
|
||||
)
|
||||
|
||||
result = await validate.async_validate(hass)
|
||||
assert result.as_dict() == {
|
||||
"energy_sources": [
|
||||
[
|
||||
{
|
||||
"type": "entity_state_non_numeric",
|
||||
"affected_entities": {("sensor.non_numeric_power", "not_a_number")},
|
||||
"translation_placeholders": None,
|
||||
}
|
||||
]
|
||||
],
|
||||
"device_consumption": [],
|
||||
}
|
||||
|
||||
|
||||
async def test_validation_grid_power_wrong_device_class(
|
||||
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
|
||||
) -> None:
|
||||
"""Test validating grid with power sensor having wrong device class."""
|
||||
await mock_energy_manager.async_update(
|
||||
{
|
||||
"energy_sources": [
|
||||
{
|
||||
"type": "grid",
|
||||
"flow_from": [],
|
||||
"flow_to": [],
|
||||
"power": [
|
||||
{
|
||||
"stat_rate": "sensor.wrong_device_class_power",
|
||||
}
|
||||
],
|
||||
"cost_adjustment_day": 0.0,
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
hass.states.async_set(
|
||||
"sensor.wrong_device_class_power",
|
||||
"1.5",
|
||||
{
|
||||
"device_class": "energy",
|
||||
"unit_of_measurement": "kWh",
|
||||
"state_class": "measurement",
|
||||
},
|
||||
)
|
||||
|
||||
result = await validate.async_validate(hass)
|
||||
assert result.as_dict() == {
|
||||
"energy_sources": [
|
||||
[
|
||||
{
|
||||
"type": "entity_unexpected_device_class",
|
||||
"affected_entities": {
|
||||
("sensor.wrong_device_class_power", "energy")
|
||||
},
|
||||
"translation_placeholders": None,
|
||||
}
|
||||
]
|
||||
],
|
||||
"device_consumption": [],
|
||||
}
|
||||
|
||||
|
||||
async def test_validation_grid_power_different_units(
|
||||
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
|
||||
) -> None:
|
||||
"""Test validating grid with power sensors using different valid units."""
|
||||
await mock_energy_manager.async_update(
|
||||
{
|
||||
"energy_sources": [
|
||||
{
|
||||
"type": "grid",
|
||||
"flow_from": [],
|
||||
"flow_to": [],
|
||||
"power": [
|
||||
{
|
||||
"stat_rate": "sensor.power_watt",
|
||||
},
|
||||
{
|
||||
"stat_rate": "sensor.power_milliwatt",
|
||||
},
|
||||
],
|
||||
"cost_adjustment_day": 0.0,
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
hass.states.async_set(
|
||||
"sensor.power_watt",
|
||||
"1500",
|
||||
{
|
||||
"device_class": "power",
|
||||
"unit_of_measurement": UnitOfPower.WATT,
|
||||
"state_class": "measurement",
|
||||
},
|
||||
)
|
||||
hass.states.async_set(
|
||||
"sensor.power_milliwatt",
|
||||
"1500000",
|
||||
{
|
||||
"device_class": "power",
|
||||
"unit_of_measurement": UnitOfPower.MILLIWATT,
|
||||
"state_class": "measurement",
|
||||
},
|
||||
)
|
||||
|
||||
result = await validate.async_validate(hass)
|
||||
assert result.as_dict() == {
|
||||
"energy_sources": [[]],
|
||||
"device_consumption": [],
|
||||
}
|
||||
|
||||
|
||||
async def test_validation_grid_power_external_statistics(
|
||||
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
|
||||
) -> None:
|
||||
"""Test validating grid with external power statistics (non-entity)."""
|
||||
mock_get_metadata["external:power_stat"] = None
|
||||
|
||||
await mock_energy_manager.async_update(
|
||||
{
|
||||
"energy_sources": [
|
||||
{
|
||||
"type": "grid",
|
||||
"flow_from": [],
|
||||
"flow_to": [],
|
||||
"power": [
|
||||
{
|
||||
"stat_rate": "external:power_stat",
|
||||
}
|
||||
],
|
||||
"cost_adjustment_day": 0.0,
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
result = await validate.async_validate(hass)
|
||||
assert result.as_dict() == {
|
||||
"energy_sources": [
|
||||
[
|
||||
{
|
||||
"type": "statistics_not_defined",
|
||||
"affected_entities": {("external:power_stat", None)},
|
||||
"translation_placeholders": None,
|
||||
}
|
||||
]
|
||||
],
|
||||
"device_consumption": [],
|
||||
}
|
||||
|
||||
|
||||
async def test_validation_grid_power_recorder_untracked(
|
||||
hass: HomeAssistant, mock_energy_manager, mock_is_entity_recorded, mock_get_metadata
|
||||
) -> None:
|
||||
"""Test validating grid with power sensor not tracked by recorder."""
|
||||
mock_is_entity_recorded["sensor.untracked_power"] = False
|
||||
|
||||
await mock_energy_manager.async_update(
|
||||
{
|
||||
"energy_sources": [
|
||||
{
|
||||
"type": "grid",
|
||||
"flow_from": [],
|
||||
"flow_to": [],
|
||||
"power": [
|
||||
{
|
||||
"stat_rate": "sensor.untracked_power",
|
||||
}
|
||||
],
|
||||
"cost_adjustment_day": 0.0,
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
result = await validate.async_validate(hass)
|
||||
assert result.as_dict() == {
|
||||
"energy_sources": [
|
||||
[
|
||||
{
|
||||
"type": "recorder_untracked",
|
||||
"affected_entities": {("sensor.untracked_power", None)},
|
||||
"translation_placeholders": None,
|
||||
}
|
||||
]
|
||||
],
|
||||
"device_consumption": [],
|
||||
}
|
||||
@@ -137,24 +137,17 @@ async def test_save_preferences(
|
||||
"number_energy_price": 0.20,
|
||||
},
|
||||
],
|
||||
"power": [
|
||||
{
|
||||
"stat_rate": "sensor.grid_power",
|
||||
}
|
||||
],
|
||||
"cost_adjustment_day": 1.2,
|
||||
},
|
||||
{
|
||||
"type": "solar",
|
||||
"stat_energy_from": "my_solar_production",
|
||||
"stat_rate": "my_solar_power",
|
||||
"config_entry_solar_forecast": ["predicted_config_entry"],
|
||||
},
|
||||
{
|
||||
"type": "battery",
|
||||
"stat_energy_from": "my_battery_draining",
|
||||
"stat_energy_to": "my_battery_charging",
|
||||
"stat_rate": "my_battery_power",
|
||||
},
|
||||
],
|
||||
"device_consumption": [
|
||||
@@ -162,7 +155,6 @@ async def test_save_preferences(
|
||||
"stat_consumption": "some_device_usage",
|
||||
"name": "My Device",
|
||||
"included_in_stat": "sensor.some_other_device",
|
||||
"stat_rate": "sensor.some_device_power",
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -261,7 +253,6 @@ async def test_handle_duplicate_from_stat(
|
||||
},
|
||||
],
|
||||
"flow_to": [],
|
||||
"power": [],
|
||||
"cost_adjustment_day": 0,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
"""The tests for the hassio binary sensors."""
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import timedelta
|
||||
import os
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from aiohasupervisor.models.mounts import CIFSMountResponse, MountsInfo, MountState
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.hassio import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .common import MOCK_REPOSITORIES, MOCK_STORE_ADDONS
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
|
||||
MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"}
|
||||
@@ -91,6 +95,7 @@ def mock_all(
|
||||
"version_latest": "2.0.1",
|
||||
"repository": "core",
|
||||
"url": "https://github.com/home-assistant/addons/test",
|
||||
"icon": False,
|
||||
},
|
||||
{
|
||||
"name": "test2",
|
||||
@@ -102,6 +107,7 @@ def mock_all(
|
||||
"version_latest": "3.1.0",
|
||||
"repository": "core",
|
||||
"url": "https://github.com",
|
||||
"icon": False,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -198,3 +204,81 @@ async def test_binary_sensor(
|
||||
# Verify that the entity have the expected state.
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.state == expected
|
||||
|
||||
|
||||
async def test_mount_binary_sensor(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
supervisor_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test hassio mounts binary sensor."""
|
||||
config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
result = await async_setup_component(
|
||||
hass,
|
||||
"hassio",
|
||||
{"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}},
|
||||
)
|
||||
assert result
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_id = "binary_sensor.nas_connected"
|
||||
|
||||
# Verify that the entity doesn't exist.
|
||||
assert hass.states.get(entity_id) is None
|
||||
|
||||
# Add a mount.
|
||||
mock_mounts = [
|
||||
CIFSMountResponse(
|
||||
share="files",
|
||||
server="1.2.3.4",
|
||||
name="NAS",
|
||||
type="cifs",
|
||||
usage="share",
|
||||
read_only=False,
|
||||
state=MountState.ACTIVE,
|
||||
user_path="/share/nas",
|
||||
)
|
||||
]
|
||||
supervisor_client.mounts.info = AsyncMock(
|
||||
return_value=MountsInfo(default_backup_mount=None, mounts=mock_mounts)
|
||||
)
|
||||
|
||||
# Let it reload.
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1000))
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
# Verify that the entity is disabled by default.
|
||||
assert hass.states.get(entity_id) is None
|
||||
|
||||
# Enable the entity.
|
||||
entity_registry.async_update_entity(entity_id, disabled_by=None)
|
||||
await hass.config_entries.async_reload(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Test new entity.
|
||||
entity = hass.states.get(entity_id)
|
||||
assert entity is not None
|
||||
assert entity.state == "on"
|
||||
|
||||
# Change state and test again.
|
||||
mock_mounts[0] = replace(mock_mounts[0], state=MountState.FAILED)
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1000))
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
entity = hass.states.get(entity_id)
|
||||
assert entity is not None
|
||||
assert entity.state == "off"
|
||||
|
||||
# Remove mount and test again.
|
||||
mount = mock_mounts.pop()
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1000))
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
assert hass.states.get(entity_id) is None
|
||||
|
||||
# Recreate mount with the same name.
|
||||
mock_mounts.append(mount)
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1000))
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
assert hass.states.get(entity_id) is not None
|
||||
|
||||
283
tests/components/light/test_trigger.py
Normal file
283
tests/components/light/test_trigger.py
Normal file
@@ -0,0 +1,283 @@
|
||||
"""Test light trigger."""
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import automation
|
||||
from homeassistant.const import (
|
||||
ATTR_AREA_ID,
|
||||
ATTR_DEVICE_ID,
|
||||
ATTR_FLOOR_ID,
|
||||
ATTR_LABEL_ID,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_PLATFORM,
|
||||
CONF_STATE,
|
||||
CONF_TARGET,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
floor_registry as fr,
|
||||
label_registry as lr,
|
||||
)
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry, mock_device_registry
|
||||
|
||||
# remove when #151314 is merged
|
||||
CONF_OPTIONS = "options"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
|
||||
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
|
||||
"""Stub copying the blueprints to the config folder."""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def target_lights(hass: HomeAssistant) -> None:
|
||||
"""Create multiple light entities associated with different targets."""
|
||||
await async_setup_component(hass, "light", {})
|
||||
|
||||
config_entry = MockConfigEntry(domain="test")
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
floor_reg = fr.async_get(hass)
|
||||
floor = floor_reg.async_create("Test Floor")
|
||||
|
||||
area_reg = ar.async_get(hass)
|
||||
area = area_reg.async_create("Test Area", floor_id=floor.floor_id)
|
||||
|
||||
label_reg = lr.async_get(hass)
|
||||
label = label_reg.async_create("Test Label")
|
||||
|
||||
device = dr.DeviceEntry(id="test_device", area_id=area.id, labels={label.label_id})
|
||||
mock_device_registry(hass, {device.id: device})
|
||||
|
||||
entity_reg = er.async_get(hass)
|
||||
# Light associated with area
|
||||
light_area = entity_reg.async_get_or_create(
|
||||
domain="light",
|
||||
platform="test",
|
||||
unique_id="light_area",
|
||||
suggested_object_id="area_light",
|
||||
)
|
||||
entity_reg.async_update_entity(light_area.entity_id, area_id=area.id)
|
||||
|
||||
# Light associated with device
|
||||
entity_reg.async_get_or_create(
|
||||
domain="light",
|
||||
platform="test",
|
||||
unique_id="light_device",
|
||||
suggested_object_id="device_light",
|
||||
device_id=device.id,
|
||||
)
|
||||
|
||||
# Light associated with label
|
||||
light_label = entity_reg.async_get_or_create(
|
||||
domain="light",
|
||||
platform="test",
|
||||
unique_id="light_label",
|
||||
suggested_object_id="label_light",
|
||||
)
|
||||
entity_reg.async_update_entity(light_label.entity_id, labels={label.label_id})
|
||||
|
||||
# Return all available light entities
|
||||
return [
|
||||
"light.standalone_light",
|
||||
"light.label_light",
|
||||
"light.area_light",
|
||||
"light.device_light",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("target_lights")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id"),
|
||||
[
|
||||
({CONF_ENTITY_ID: "light.standalone_light"}, "light.standalone_light"),
|
||||
({ATTR_LABEL_ID: "test_label"}, "light.label_light"),
|
||||
({ATTR_AREA_ID: "test_area"}, "light.area_light"),
|
||||
({ATTR_FLOOR_ID: "test_floor"}, "light.area_light"),
|
||||
({ATTR_LABEL_ID: "test_label"}, "light.device_light"),
|
||||
({ATTR_AREA_ID: "test_area"}, "light.device_light"),
|
||||
({ATTR_FLOOR_ID: "test_floor"}, "light.device_light"),
|
||||
({ATTR_DEVICE_ID: "test_device"}, "light.device_light"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("state", "reverse_state"), [(STATE_ON, STATE_OFF), (STATE_OFF, STATE_ON)]
|
||||
)
|
||||
async def test_light_state_trigger_behavior_any(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
state: str,
|
||||
reverse_state: str,
|
||||
) -> None:
|
||||
"""Test that the light state trigger fires when any light state changes to a specific state."""
|
||||
await async_setup_component(hass, "light", {})
|
||||
|
||||
hass.states.async_set(entity_id, reverse_state)
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "light.state",
|
||||
CONF_TARGET: {**trigger_target_config},
|
||||
CONF_OPTIONS: {CONF_STATE: state},
|
||||
},
|
||||
"action": {
|
||||
"service": "test.automation",
|
||||
"data_template": {CONF_ENTITY_ID: f"{entity_id}"},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set(entity_id, state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
hass.states.async_set(entity_id, reverse_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id"),
|
||||
[
|
||||
({CONF_ENTITY_ID: "light.standalone_light"}, "light.standalone_light"),
|
||||
({ATTR_LABEL_ID: "test_label"}, "light.label_light"),
|
||||
({ATTR_AREA_ID: "test_area"}, "light.area_light"),
|
||||
({ATTR_FLOOR_ID: "test_floor"}, "light.area_light"),
|
||||
({ATTR_LABEL_ID: "test_label"}, "light.device_light"),
|
||||
({ATTR_AREA_ID: "test_area"}, "light.device_light"),
|
||||
({ATTR_FLOOR_ID: "test_floor"}, "light.device_light"),
|
||||
({ATTR_DEVICE_ID: "test_device"}, "light.device_light"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("state", "reverse_state"), [(STATE_ON, STATE_OFF), (STATE_OFF, STATE_ON)]
|
||||
)
|
||||
async def test_light_state_trigger_behavior_first(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_lights: list[str],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
state: str,
|
||||
reverse_state: str,
|
||||
) -> None:
|
||||
"""Test that the light state trigger fires when the first light changes to a specific state."""
|
||||
await async_setup_component(hass, "light", {})
|
||||
|
||||
for other_entity_id in target_lights:
|
||||
hass.states.async_set(other_entity_id, reverse_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "light.state",
|
||||
CONF_TARGET: {**trigger_target_config},
|
||||
CONF_OPTIONS: {CONF_STATE: state, "behavior": "first"},
|
||||
},
|
||||
"action": {
|
||||
"service": "test.automation",
|
||||
"data_template": {CONF_ENTITY_ID: f"{entity_id}"},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
hass.states.async_set(entity_id, state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Triggering other lights should not cause any service calls after the first one
|
||||
for other_entity_id in target_lights:
|
||||
hass.states.async_set(other_entity_id, state)
|
||||
await hass.async_block_till_done()
|
||||
for other_entity_id in target_lights:
|
||||
hass.states.async_set(other_entity_id, reverse_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
hass.states.async_set(entity_id, state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id"),
|
||||
[
|
||||
({CONF_ENTITY_ID: "light.standalone_light"}, "light.standalone_light"),
|
||||
({ATTR_LABEL_ID: "test_label"}, "light.label_light"),
|
||||
({ATTR_AREA_ID: "test_area"}, "light.area_light"),
|
||||
({ATTR_FLOOR_ID: "test_floor"}, "light.area_light"),
|
||||
({ATTR_LABEL_ID: "test_label"}, "light.device_light"),
|
||||
({ATTR_AREA_ID: "test_area"}, "light.device_light"),
|
||||
({ATTR_FLOOR_ID: "test_floor"}, "light.device_light"),
|
||||
({ATTR_DEVICE_ID: "test_device"}, "light.device_light"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("state", "reverse_state"), [(STATE_ON, STATE_OFF), (STATE_OFF, STATE_ON)]
|
||||
)
|
||||
async def test_light_state_trigger_behavior_last(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_lights: list[str],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
state: str,
|
||||
reverse_state: str,
|
||||
) -> None:
|
||||
"""Test that the light state trigger fires when the last light changes to a specific state."""
|
||||
await async_setup_component(hass, "light", {})
|
||||
|
||||
for other_entity_id in target_lights:
|
||||
hass.states.async_set(other_entity_id, reverse_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "light.state",
|
||||
CONF_TARGET: {**trigger_target_config},
|
||||
CONF_OPTIONS: {CONF_STATE: state, "behavior": "last"},
|
||||
},
|
||||
"action": {
|
||||
"service": "test.automation",
|
||||
"data_template": {CONF_ENTITY_ID: f"{entity_id}"},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
target_lights.remove(entity_id)
|
||||
for other_entity_id in target_lights:
|
||||
hass.states.async_set(other_entity_id, state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
hass.states.async_set(entity_id, state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
@@ -97,6 +97,55 @@
|
||||
'state': '12',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.erics273_friends-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.erics273_friends',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Friends',
|
||||
'platform': 'xbox',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': <XboxSensor.FRIENDS: 'friends'>,
|
||||
'unique_id': '2533274913657542_friends',
|
||||
'unit_of_measurement': 'people',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.erics273_friends-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'erics273 Friends',
|
||||
'unit_of_measurement': 'people',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.erics273_friends',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '150',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.erics273_gamerscore-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
@@ -398,6 +447,55 @@
|
||||
'state': '121',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.gsr_ae_friends-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.gsr_ae_friends',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Friends',
|
||||
'platform': 'xbox',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': <XboxSensor.FRIENDS: 'friends'>,
|
||||
'unique_id': '271958441785640_friends',
|
||||
'unit_of_measurement': 'people',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.gsr_ae_friends-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'GSR Ae Friends',
|
||||
'unit_of_measurement': 'people',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.gsr_ae_friends',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '150',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.gsr_ae_gamerscore-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
@@ -700,6 +798,55 @@
|
||||
'state': '73',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.ikken_hissatsuu_friends-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.ikken_hissatsuu_friends',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Friends',
|
||||
'platform': 'xbox',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': <XboxSensor.FRIENDS: 'friends'>,
|
||||
'unique_id': '2533274838782903_friends',
|
||||
'unit_of_measurement': 'people',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.ikken_hissatsuu_friends-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Ikken Hissatsuu Friends',
|
||||
'unit_of_measurement': 'people',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.ikken_hissatsuu_friends',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '150',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.ikken_hissatsuu_gamerscore-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
||||
@@ -450,10 +450,10 @@ async def test_caching(hass: HomeAssistant) -> None:
|
||||
side_effect=translation.build_resources,
|
||||
) as mock_build_resources:
|
||||
load1 = await translation.async_get_translations(hass, "en", "entity_component")
|
||||
assert len(mock_build_resources.mock_calls) == 7
|
||||
assert len(mock_build_resources.mock_calls) == 8
|
||||
|
||||
load2 = await translation.async_get_translations(hass, "en", "entity_component")
|
||||
assert len(mock_build_resources.mock_calls) == 7
|
||||
assert len(mock_build_resources.mock_calls) == 8
|
||||
|
||||
assert load1 == load2
|
||||
|
||||
|
||||
Reference in New Issue
Block a user