Compare commits

..

12 Commits

Author SHA1 Message Date
abmantis
e7a7cb829e Merge branch 'dev' of github.com:home-assistant/core into dev_target_triggers_conditions 2025-11-04 12:28:39 +00:00
Manu
37eef965ad Add friend count sensor to Xbox integration (#155761) 2025-11-04 11:27:48 +01:00
Amit Finkelstein
b706430e66 Add binary sensor for HassOS share mount status (#149197) 2025-11-04 11:14:10 +01:00
Fredrik Mårtensson
5012aa5cb0 Catch exception from libsoundtouch if device not available (#155749)
Co-authored-by: Robert Resch <robert@resch.dev>
2025-11-04 10:24:38 +01:00
abmantis
6f6b2f1ad3 Merge branch 'dev_target_triggers_conditions' of github.com:home-assistant/core into dev_target_triggers_conditions 2025-10-15 17:03:28 +01:00
abmantis
1cc4890f75 Merge branch 'dev' of github.com:home-assistant/core into dev_target_triggers_conditions 2025-10-15 17:03:18 +01:00
Bram Kragten
d3dd9b26c9 Fixes for triggers.yaml descriptions (#153841)
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
2025-10-09 18:00:56 +01:00
Abílio Costa
a64d61df05 Fix light trigger with new Trigger class changes (#154087) 2025-10-09 18:14:55 +02:00
abmantis
e7c6c5311d Merge branch 'dev' of github.com:home-assistant/core into dev_target_triggers_conditions 2025-10-09 15:55:39 +01:00
abmantis
72a524c868 Merge branch 'dev' of github.com:home-assistant/core into dev_target_triggers_conditions 2025-09-29 16:56:23 +01:00
abmantis
b437113f31 Merge branch 'dev' of github.com:home-assistant/core into dev_target_triggers_conditions 2025-09-29 11:18:39 +01:00
Abílio Costa
e0e263d3b5 Add state trigger to light component (#148416)
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
2025-09-18 19:53:26 +01:00
25 changed files with 980 additions and 679 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,9 @@
"binary_sensor": {
"state": {
"name": "Running"
},
"mount": {
"name": "Connected"
}
},
"sensor": {

View File

@@ -25,5 +25,10 @@
"turn_on": {
"service": "mdi:lightbulb-on"
}
},
"triggers": {
"state": {
"trigger": "mdi:state-machine"
}
}
}

View File

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

View 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

View 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

View File

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

View File

@@ -23,6 +23,9 @@
"following": {
"default": "mdi:account-arrow-right"
},
"friends": {
"default": "mdi:account-heart"
},
"gamer_score": {
"default": "mdi:alpha-g-circle"
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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