Compare commits

..

38 Commits

Author SHA1 Message Date
Daniel Hjelseth Høyer c2ce313ec8 Bump pyTibber to 0.37.5 (#169981)
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-05-07 09:41:08 +02:00
Zoltán Farkasdi b8ba1c123d netatmo: add doortag direct category fetch (#169711)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2026-05-07 09:18:39 +02:00
Daniel Hjelseth Høyer 10f1cbb51e Migrate mill to use entry.runtime_data (#169948)
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-05-07 09:15:21 +02:00
Christian Lackas e3bcce06bf Bump PyViCare to 2.60.2 (#169918)
Co-authored-by: home-assistant[bot] <78085893+home-assistant[bot]@users.noreply.github.com>
2026-05-07 08:30:41 +02:00
Kamil Breguła 4e0472feb5 Add fixture for Tuya camera (knkaf1d0dytgyhix) (#169967)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
2026-05-07 07:33:28 +02:00
Jan Bouwhuis 046298f2ca No need for a local import of the paho mqtt client (#169925) 2026-05-06 22:45:36 +02:00
Jan Bouwhuis c92128b282 Remove advanced setting dependency for IMAP integration (#169827) 2026-05-06 22:37:27 +02:00
Christian Lackas 886e66e7e3 Bump homematicip to 2.10.0 (#169950) 2026-05-06 22:20:16 +02:00
Erik Montnemery 7da49570b5 Add support for options to todo triggers (#169947) 2026-05-06 22:16:55 +02:00
G Johansson b8baa3271b Bump holidays to 0.96 (#169939) 2026-05-06 22:08:38 +02:00
Erik Montnemery 65bc4bf1d0 Add missing trigger and condition tests (#169945) 2026-05-06 21:53:40 +02:00
Erik Montnemery 27a8d185c9 Add StatelessEntityTriggerBase base class (#169937) 2026-05-06 21:43:29 +02:00
Andriy Kushnir 1e5992f2b5 Remove myself as codeowner for roomba (#169922) 2026-05-06 20:33:15 +02:00
puddly ac84a14846 Bump serialx to 1.7.1 (#169928) 2026-05-06 21:04:13 +03:00
Robert Resch fa265b18ce Shorten docker publish job name (#169926) 2026-05-06 18:12:13 +02:00
Stefan Agner 38634ddd55 Fix hassio auth IndexError on Supervisor Unix socket requests (#169911) 2026-05-06 17:48:35 +02:00
Joakim Plate 13dd831874 Update gardena ble to 2.8.1 (#169914) 2026-05-06 16:25:37 +02:00
Tom Wilkie 3be5906398 Register Hive Hub MAC address as device connection (#169040)
Signed-off-by: Tom Wilkie <tom.wilkie@gmail.com>
2026-05-06 16:12:59 +02:00
Erik Montnemery cef918d6f8 Remove _get_tracked_value method from EntityConditionBase (#169906) 2026-05-06 14:59:57 +02:00
Jan Bouwhuis 19aa1b6578 Remove advanced options dependency from MQTT integration (#169833) 2026-05-06 14:52:07 +02:00
Daniel Hjelseth Høyer b0eb69936e Bump pyTibber to 0.37.4 (#169907) 2026-05-06 14:47:10 +02:00
Erik Montnemery b6096a71d1 Exclude incompatible humidifier entities from humidifier automations (#169905) 2026-05-06 14:44:30 +02:00
Erik Montnemery 059d7011ba Exclude incompatible water_heater entities from water_heater automations (#169904) 2026-05-06 14:44:19 +02:00
epenet bbe00ef79e De-duplicate code to build Tuya device info (#169899) 2026-05-06 14:29:47 +02:00
Erik Montnemery 7f447abc3a Exclude incompatible climate entities from climate automations (#169903) 2026-05-06 14:18:14 +02:00
Erik Montnemery 923e099467 Unload scripts and conditions created by template entities (#169366) 2026-05-06 14:11:37 +02:00
Erik Montnemery 26714c6d9f Add media_player volume condition (#169897) 2026-05-06 13:15:01 +02:00
Erik Montnemery 5f1201dbbe Exclude incompatible entities from temperature automations (#169901) 2026-05-06 13:10:53 +02:00
Erik Montnemery 52e1d9443c Exclude incompatible entities from humidity automations (#169898) 2026-05-06 13:10:24 +02:00
Manu 824f5205e9 Record notification from legacy notify action in Mobile App (#169749) 2026-05-06 12:57:57 +02:00
Erik Montnemery cf8bc55add Add media_player muted conditions (#169892) 2026-05-06 12:38:05 +02:00
Bram Kragten 1e9244f4fc Update frontend to 20260429.3 (#169893) 2026-05-06 12:19:24 +02:00
Tom Matheussen be4f4928d5 Bump satel_integra to 1.3.1 (#169889) 2026-05-06 11:27:14 +02:00
Erik Montnemery 80f6f8ee31 Improve entity trigger tests (#169881) 2026-05-06 10:48:36 +02:00
Erik Montnemery 267d52491a Add media_player volume triggers (#169885) 2026-05-06 10:48:10 +02:00
Ludovic BOUÉ ee84d625cd Expose SET_SPEED for all fans via PercentSetting in Matter (#169696)
Co-authored-by: Ludovic BOUÉ <132135057+lboue@users.noreply.github.com>
2026-05-06 10:16:31 +02:00
dependabot[bot] 5d091d25d5 Bump j178/prek-action from 2.0.2 to 2.0.3 (#169882) 2026-05-06 09:50:18 +02:00
Erik Montnemery 97b5f1cf64 Add method _should_include to EntityConditionBase (#169884)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-06 09:49:22 +02:00
132 changed files with 3248 additions and 1143 deletions
+1 -1
View File
@@ -323,7 +323,7 @@ jobs:
exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
publish_container:
name: Publish meta container for ${{ matrix.registry }}
name: Publish to ${{ matrix.registry }}
environment: ${{ needs.init.outputs.channel }}
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_base"]
+2 -2
View File
@@ -281,7 +281,7 @@ jobs:
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run prek
uses: j178/prek-action@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3 # v2.0.2
uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3
env:
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor
RUFF_OUTPUT_FORMAT: github
@@ -302,7 +302,7 @@ jobs:
with:
persist-credentials: false
- name: Run zizmor
uses: j178/prek-action@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3 # v2.0.2
uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3
with:
extra-args: --all-files zizmor
Generated
+2 -2
View File
@@ -1495,8 +1495,8 @@ CLAUDE.md @home-assistant/core
/tests/components/roku/ @ctalkington
/homeassistant/components/romy/ @xeniter
/tests/components/romy/ @xeniter
/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous
/tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous
/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn
/tests/components/roomba/ @pschmitt @cyr-ius @shenxn
/homeassistant/components/roon/ @pavoni
/tests/components/roon/ @pavoni
/homeassistant/components/route_b_smart_meter/ @SeraphicRav
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["serialx==1.7.0"]
"requirements": ["serialx==1.7.1"]
}
+3 -23
View File
@@ -1,36 +1,16 @@
"""Provides triggers for buttons."""
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
Trigger,
)
from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger
from . import DOMAIN
class ButtonPressedTrigger(EntityTriggerBase):
class ButtonPressedTrigger(StatelessEntityTriggerBase):
"""Trigger for button entity presses."""
_domain_specs = {DOMAIN: DomainSpec()}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and different from the current state."""
# UNKNOWN is a valid from_state, otherwise the first time the button is pressed
# would not trigger
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
def is_valid_state(self, state: State) -> bool:
"""Check if the new state is not invalid."""
return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
TRIGGERS: dict[str, type[Trigger]] = {
+23 -5
View File
@@ -13,8 +13,8 @@ from homeassistant.helpers.condition import (
Condition,
ConditionConfig,
EntityConditionBase,
EntityNumericalConditionBase,
EntityNumericalConditionWithUnitBase,
make_entity_numerical_condition,
make_entity_state_condition,
)
from homeassistant.util.unit_conversion import TemperatureConverter
@@ -59,12 +59,33 @@ class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase):
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target temperature."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_TEMPERATURE) is not None
)
def _get_entity_unit(self, entity_state: State) -> str | None:
"""Get the temperature unit of a climate entity from its state."""
# Climate entities convert temperatures to the system unit via show_temp
return self._hass.config.units.temperature_unit
class ClimateTargetHumidityCondition(EntityNumericalConditionBase):
"""Condition for climate target humidity."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}
_valid_unit = "%"
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target humidity."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_HUMIDITY) is not None
)
CONDITIONS: dict[str, type[Condition]] = {
"is_hvac_mode": ClimateHVACModeCondition,
"is_off": make_entity_state_condition(DOMAIN, HVACMode.OFF),
@@ -88,10 +109,7 @@ CONDITIONS: dict[str, type[Condition]] = {
"is_heating": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
),
"target_humidity": make_entity_numerical_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_humidity": ClimateTargetHumidityCondition,
"target_temperature": ClimateTargetTemperatureCondition,
}
@@ -56,6 +56,13 @@ class _ClimateTargetTemperatureTriggerMixin(EntityNumericalStateTriggerWithUnitB
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target temperature."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_TEMPERATURE) is not None
)
def _get_entity_unit(self, state: State) -> str | None:
"""Get the temperature unit of a climate entity from its state."""
# Climate entities convert temperatures to the system unit via show_temp
+4 -21
View File
@@ -6,39 +6,22 @@ from homeassistant.components.event import (
DoorbellEventType,
EventDeviceClass,
)
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
Trigger,
)
from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger
class DoorbellRangTrigger(EntityTriggerBase):
class DoorbellRangTrigger(StatelessEntityTriggerBase):
"""Trigger for doorbell event entity when a ring event is received."""
_domain_specs = {EVENT_DOMAIN: DomainSpec(device_class=EventDeviceClass.DOORBELL)}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_state(self, state: State) -> bool:
"""Check if the entity is available and the event type is ring."""
return (
state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
and state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING
return super().is_valid_state(state) and (
state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING
)
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and different from the current state."""
# UNKNOWN is a valid from_state, otherwise the first time the event is received
# would not trigger
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
TRIGGERS: dict[str, type[Trigger]] = {
"rang": DoorbellRangTrigger,
+6 -17
View File
@@ -2,13 +2,13 @@
import voluptuous as vol
from homeassistant.const import CONF_OPTIONS, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.const import CONF_OPTIONS
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
StatelessEntityTriggerBase,
Trigger,
TriggerConfig,
)
@@ -28,7 +28,7 @@ EVENT_RECEIVED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend(
)
class EventReceivedTrigger(EntityTriggerBase):
class EventReceivedTrigger(StatelessEntityTriggerBase):
"""Trigger for event entity when it receives a matching event."""
_domain_specs = {DOMAIN: DomainSpec()}
@@ -39,21 +39,10 @@ class EventReceivedTrigger(EntityTriggerBase):
super().__init__(hass, config)
self._event_types = set(self._options[CONF_EVENT_TYPE])
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and different from the current state."""
# UNKNOWN is a valid from_state, otherwise the first time the event is received
# would not trigger
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
def is_valid_state(self, state: State) -> bool:
"""Check if the event type is valid and matches one of the configured types."""
return (
state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
and state.attributes.get(ATTR_EVENT_TYPE) in self._event_types
"""Check if the event type matches one of the configured types."""
return super().is_valid_state(state) and (
state.attributes.get(ATTR_EVENT_TYPE) in self._event_types
)
+2 -70
View File
@@ -1,87 +1,19 @@
"""Support for Freebox devices (Freebox v6 and Freebox mini 4K)."""
from datetime import timedelta
import logging
from freebox_api.exceptions import HttpRequestError
from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.event import async_track_time_interval
from .const import DOMAIN, PLATFORMS
from .const import PLATFORMS
from .router import FreeboxConfigEntry, FreeboxRouter, get_api
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=30)
# Old entity name suffixes that need rewriting to the entity description key.
# Format: (platform, old name suffix, new key)
_STATIC_UNIQUE_ID_MIGRATIONS: tuple[tuple[Platform, str, str], ...] = (
(Platform.SENSOR, "Freebox download speed", "rate_down"),
(Platform.SENSOR, "Freebox upload speed", "rate_up"),
(Platform.SENSOR, "Freebox missed calls", "missed"),
(Platform.BUTTON, "Reboot Freebox", "reboot"),
(Platform.BUTTON, "Mark calls as read", "mark_calls_as_read"),
(Platform.SWITCH, "Freebox WiFi", "wifi"),
)
async def async_migrate_entry(hass: HomeAssistant, entry: FreeboxConfigEntry) -> bool:
"""Migrate old config entries."""
if entry.version < 2:
api = await get_api(hass, entry.data[CONF_HOST])
try:
await api.open(entry.data[CONF_HOST], entry.data[CONF_PORT])
freebox_config = await api.system.get_config()
except HttpRequestError:
_LOGGER.warning(
"Unable to migrate Freebox entry to version 2: cannot reach the router"
)
return False
finally:
await api.close()
mac: str = freebox_config["mac"]
entity_registry = er.async_get(hass)
migrations: list[tuple[Platform, str, str]] = [
(platform, f"{mac} {old_suffix}", f"{mac} {new_key}")
for platform, old_suffix, new_key in _STATIC_UNIQUE_ID_MIGRATIONS
]
migrations.extend(
(
Platform.SENSOR,
f"{mac} Freebox {sensor['name']}",
f"{mac} {sensor['id']}",
)
for sensor in freebox_config.get("sensors", [])
)
for platform, old_uid, new_uid in migrations:
entity_id = entity_registry.async_get_entity_id(platform, DOMAIN, old_uid)
if entity_id is None:
continue
try:
entity_registry.async_update_entity(entity_id, new_unique_id=new_uid)
except ValueError:
_LOGGER.warning(
"Unable to migrate unique_id from %s to %s: target already exists",
old_uid,
new_uid,
)
continue
_LOGGER.debug(
"Migrated %s unique_id from %s to %s", entity_id, old_uid, new_uid
)
hass.config_entries.async_update_entry(entry, version=2)
return True
async def async_setup_entry(hass: HomeAssistant, entry: FreeboxConfigEntry) -> bool:
"""Set up Freebox entry."""
@@ -48,7 +48,6 @@ class FreeboxAlarm(FreeboxHomeEntity, AlarmControlPanelEntity):
"""Representation of a Freebox alarm."""
_attr_code_arm_required = False
_attr_name = None
def __init__(self, router: FreeboxRouter, node: dict[str, Any]) -> None:
"""Initialize an alarm."""
@@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__)
RAID_SENSORS: tuple[BinarySensorEntityDescription, ...] = (
BinarySensorEntityDescription(
key="raid_degraded",
translation_key="raid_degraded",
name="degraded",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
@@ -68,7 +68,7 @@ async def async_setup_entry(
class FreeboxHomeBinarySensor(FreeboxHomeEntity, BinarySensorEntity):
"""Representation of a Freebox binary sensor."""
_endpoint_name = "trigger"
_sensor_name = "trigger"
def __init__(
self,
@@ -79,11 +79,9 @@ class FreeboxHomeBinarySensor(FreeboxHomeEntity, BinarySensorEntity):
"""Initialize a Freebox binary sensor."""
super().__init__(router, node, sub_node)
self._command_id = self.get_command_id(
node["type"]["endpoints"], "signal", self._endpoint_name
)
self._attr_is_on = self._edit_state(
self.get_value("signal", self._endpoint_name)
node["type"]["endpoints"], "signal", self._sensor_name
)
self._attr_is_on = self._edit_state(self.get_value("signal", self._sensor_name))
async def async_update_signal(self) -> None:
"""Update name & state."""
@@ -93,10 +91,10 @@ class FreeboxHomeBinarySensor(FreeboxHomeEntity, BinarySensorEntity):
await FreeboxHomeEntity.async_update_signal(self)
def _edit_state(self, state: bool | None) -> bool | None:
"""Edit state depending on endpoint name."""
"""Edit state depending on sensor name."""
if state is None:
return None
if self._endpoint_name == "trigger":
if self._sensor_name == "trigger":
return not state
return state
@@ -105,14 +103,12 @@ class FreeboxPirSensor(FreeboxHomeBinarySensor):
"""Representation of a Freebox motion binary sensor."""
_attr_device_class = BinarySensorDeviceClass.MOTION
_attr_name = None
class FreeboxDwsSensor(FreeboxHomeBinarySensor):
"""Representation of a Freebox door opener binary sensor."""
_attr_device_class = BinarySensorDeviceClass.DOOR
_attr_name = None
class FreeboxCoverSensor(FreeboxHomeBinarySensor):
@@ -121,15 +117,14 @@ class FreeboxCoverSensor(FreeboxHomeBinarySensor):
_attr_device_class = BinarySensorDeviceClass.SAFETY
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_entity_registry_enabled_default = False
_attr_translation_key = "cover"
_endpoint_name = "cover"
_sensor_name = "cover"
def __init__(self, router: FreeboxRouter, node: dict[str, Any]) -> None:
"""Initialize a cover for another device."""
cover_node = next(
filter(
lambda x: x["name"] == self._endpoint_name and x["ep_type"] == "signal",
lambda x: x["name"] == self._sensor_name and x["ep_type"] == "signal",
node["type"]["endpoints"],
),
None,
@@ -154,7 +149,7 @@ class FreeboxRaidDegradedSensor(BinarySensorEntity):
self._router = router
self._attr_device_info = router.device_info
self._raid = raid
self._attr_translation_placeholders = {"id": str(raid["id"])}
self._attr_name = f"Raid array {raid['id']} {description.name}"
self._attr_unique_id = (
f"{router.mac} {description.key} {raid['name']} {raid['id']}"
)
+3 -3
View File
@@ -25,13 +25,14 @@ class FreeboxButtonEntityDescription(ButtonEntityDescription):
BUTTON_DESCRIPTIONS: tuple[FreeboxButtonEntityDescription, ...] = (
FreeboxButtonEntityDescription(
key="reboot",
name="Reboot Freebox",
device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG,
async_press=lambda router: router.reboot(),
),
FreeboxButtonEntityDescription(
key="mark_calls_as_read",
translation_key="mark_calls_as_read",
name="Mark calls as read",
entity_category=EntityCategory.DIAGNOSTIC,
async_press=lambda router: router.call.mark_calls_log_as_read(),
),
@@ -54,7 +55,6 @@ async def async_setup_entry(
class FreeboxButton(ButtonEntity):
"""Representation of a Freebox button."""
_attr_has_entity_name = True
entity_description: FreeboxButtonEntityDescription
def __init__(
@@ -64,7 +64,7 @@ class FreeboxButton(ButtonEntity):
self.entity_description = description
self._router = router
self._attr_device_info = router.device_info
self._attr_unique_id = f"{router.mac} {description.key}"
self._attr_unique_id = f"{router.mac} {description.name}"
async def async_press(self) -> None:
"""Press the button."""
+2 -7
View File
@@ -66,8 +66,6 @@ def add_entities(
class FreeboxCamera(FreeboxHomeEntity, FFmpegCamera):
"""Representation of a Freebox camera."""
_attr_name = None
def __init__(
self, hass: HomeAssistant, router: FreeboxRouter, node: dict[str, Any]
) -> None:
@@ -91,11 +89,6 @@ class FreeboxCamera(FreeboxHomeEntity, FFmpegCamera):
self._attr_extra_state_attributes = {}
self.update_node(node)
@property
def name(self) -> str | None: # type: ignore[override]
"""Return None so the device name is used as entity name."""
return self._attr_name
async def async_enable_motion_detection(self) -> None:
"""Enable motion detection in the camera."""
if await self.set_home_endpoint_value(self._command_motion_detection, True):
@@ -113,6 +106,8 @@ class FreeboxCamera(FreeboxHomeEntity, FFmpegCamera):
def update_node(self, node: dict[str, Any]) -> None:
"""Update params."""
self._name = node["label"].strip()
# Get status
if self._node["status"] == "active":
self._attr_is_streaming = True
@@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__)
class FreeboxFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow."""
VERSION = 2
VERSION = 1
def __init__(self) -> None:
"""Initialize config flow."""
@@ -55,7 +55,6 @@ def add_entities(
class FreeboxDevice(ScannerEntity):
"""Representation of a Freebox device."""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(self, router: FreeboxRouter, device: dict[str, Any]) -> None:
+11 -11
View File
@@ -3,7 +3,6 @@
import logging
from typing import Any
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
@@ -17,8 +16,6 @@ _LOGGER = logging.getLogger(__name__)
class FreeboxHomeEntity(Entity):
"""Representation of a Freebox base entity."""
_attr_has_entity_name = True
def __init__(
self,
router: FreeboxRouter,
@@ -30,9 +27,12 @@ class FreeboxHomeEntity(Entity):
self._node = node
self._sub_node = sub_node
self._id = node["id"]
self._attr_name = node["label"].strip()
self._device_name = self._attr_name
self._attr_unique_id = f"{self._router.mac}-node_{self._id}"
if sub_node is not None:
self._attr_name += " " + sub_node["label"].strip()
self._attr_unique_id += "-" + sub_node["name"].strip()
self._available = True
@@ -52,7 +52,7 @@ class FreeboxHomeEntity(Entity):
identifiers={(DOMAIN, self._id)},
manufacturer=self._manufacturer,
model=self._model,
name=node["label"].strip(),
name=self._device_name,
sw_version=self._firmware,
via_device=(DOMAIN, router.mac),
)
@@ -60,13 +60,13 @@ class FreeboxHomeEntity(Entity):
async def async_update_signal(self) -> None:
"""Update signal."""
self._node = self._router.home_devices[self._id]
# Propagate Freebox device label changes to the device registry so
# the entity stays in sync when users rename it on the Freebox app.
device_registry = dr.async_get(self.hass)
if device := device_registry.async_get_device(identifiers={(DOMAIN, self._id)}):
new_name = self._node["label"].strip()
if device.name != new_name:
device_registry.async_update_device(device.id, name=new_name)
# Update name
if self._sub_node is None:
self._attr_name = self._node["label"].strip()
else:
self._attr_name = (
self._node["label"].strip() + " " + self._sub_node["label"].strip()
)
self.async_write_ha_state()
async def set_home_endpoint_value(
+3 -22
View File
@@ -1,26 +1,7 @@
{
"entity": {
"sensor": {
"missed": {
"default": "mdi:phone-missed"
},
"partition_free_space": {
"default": "mdi:harddisk"
},
"rate_down": {
"default": "mdi:download-network"
},
"rate_up": {
"default": "mdi:upload-network"
}
},
"switch": {
"wifi": {
"default": "mdi:wifi",
"state": {
"off": "mdi:wifi-off"
}
}
"services": {
"reboot": {
"service": "mdi:restart"
}
}
}
+1 -6
View File
@@ -125,7 +125,6 @@ class FreeboxRouter:
self.supports_raid = True
self.raids: dict[int, dict[str, Any]] = {}
self.sensors_temperature: dict[str, int] = {}
self.sensors_temperature_names: dict[str, str] = {}
self.sensors_connection: dict[str, float] = {}
self.call_list: list[dict[str, Any]] = []
self.home_granted = True
@@ -184,11 +183,7 @@ class FreeboxRouter:
# According to the doc `syst_datas["sensors"]` is temperature sensors in celsius degree.
# Name and id of sensors may vary under Freebox devices.
for sensor in syst_datas["sensors"]:
sensor_id = sensor["id"]
self.sensors_temperature[sensor_id] = sensor.get("value")
# Names are static per-device; only populate once.
if sensor_id not in self.sensors_temperature_names:
self.sensors_temperature_names[sensor_id] = sensor["name"]
self.sensors_temperature[sensor["name"]] = sensor.get("value")
# Connection sensors
connection_datas: dict[str, Any] = await self._api.connection.get_status()
+13 -12
View File
@@ -25,34 +25,36 @@ _LOGGER = logging.getLogger(__name__)
CONNECTION_SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="rate_down",
translation_key="rate_down",
name="Freebox download speed",
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND,
icon="mdi:download-network",
),
SensorEntityDescription(
key="rate_up",
translation_key="rate_up",
name="Freebox upload speed",
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND,
icon="mdi:upload-network",
),
)
CALL_SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="missed",
translation_key="missed",
native_unit_of_measurement="calls",
state_class=SensorStateClass.MEASUREMENT,
name="Freebox missed calls",
icon="mdi:phone-missed",
),
)
DISK_PARTITION_SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="partition_free_space",
translation_key="partition_free_space",
name="free space",
native_unit_of_measurement=PERCENTAGE,
icon="mdi:harddisk",
),
)
@@ -75,14 +77,14 @@ async def async_setup_entry(
FreeboxSensor(
router,
SensorEntityDescription(
key=sensor_id,
name=sensor_name,
key=sensor_name,
name=f"Freebox {sensor_name}",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
)
for sensor_id, sensor_name in router.sensors_temperature_names.items()
for sensor_name in router.sensors_temperature
]
entities.extend(
@@ -119,7 +121,6 @@ class FreeboxSensor(SensorEntity):
"""Representation of a Freebox sensor."""
_attr_should_poll = False
_attr_has_entity_name = True
def __init__(
self, router: FreeboxRouter, description: SensorEntityDescription
@@ -127,7 +128,7 @@ class FreeboxSensor(SensorEntity):
"""Initialize a Freebox sensor."""
self.entity_description = description
self._router = router
self._attr_unique_id = f"{router.mac} {description.key}"
self._attr_unique_id = f"{router.mac} {description.name}"
self._attr_device_info = router.device_info
@callback
@@ -203,7 +204,7 @@ class FreeboxDiskSensor(FreeboxSensor):
super().__init__(router, description)
self._disk_id = disk["id"]
self._partition_id = partition["id"]
self._attr_translation_placeholders = {"partition": partition["label"]}
self._attr_name = f"{partition['label']} {description.name}"
self._attr_unique_id = (
f"{router.mac} {description.key} {disk['id']} {partition['id']}"
)
@@ -0,0 +1,3 @@
# Freebox service entries description.
reboot:
+4 -32
View File
@@ -25,38 +25,10 @@
}
}
},
"entity": {
"binary_sensor": {
"cover": {
"name": "Cover"
},
"raid_degraded": {
"name": "RAID array {id} degraded"
}
},
"button": {
"mark_calls_as_read": {
"name": "Mark calls as read"
}
},
"sensor": {
"missed": {
"name": "Missed calls"
},
"partition_free_space": {
"name": "{partition} free space"
},
"rate_down": {
"name": "Download speed"
},
"rate_up": {
"name": "Upload speed"
}
},
"switch": {
"wifi": {
"name": "Wi-Fi"
}
"services": {
"reboot": {
"description": "Reboots the Freebox.",
"name": "Reboot"
}
}
}
+2 -4
View File
@@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__)
SWITCH_DESCRIPTIONS = [
SwitchEntityDescription(
key="wifi",
translation_key="wifi",
name="Freebox WiFi",
entity_category=EntityCategory.CONFIG,
)
]
@@ -41,8 +41,6 @@ async def async_setup_entry(
class FreeboxSwitch(SwitchEntity):
"""Representation of a freebox switch."""
_attr_has_entity_name = True
def __init__(
self, router: FreeboxRouter, entity_description: SwitchEntityDescription
) -> None:
@@ -50,7 +48,7 @@ class FreeboxSwitch(SwitchEntity):
self.entity_description = entity_description
self._router = router
self._attr_device_info = router.device_info
self._attr_unique_id = f"{router.mac} {entity_description.key}"
self._attr_unique_id = f"{router.mac} {entity_description.name}"
async def _async_set_state(self, enabled: bool) -> None:
"""Turn the switch on or off."""
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260429.2"]
"requirements": ["home-assistant-frontend==20260429.3"]
}
@@ -15,5 +15,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"],
"requirements": ["gardena-bluetooth==2.4.0"]
"requirements": ["gardena-bluetooth==2.8.1"]
}
+13 -8
View File
@@ -12,6 +12,7 @@ import voluptuous as vol
from homeassistant.auth.models import User
from homeassistant.auth.providers import homeassistant as auth_ha
from homeassistant.components.http import KEY_HASS, KEY_HASS_USER, HomeAssistantView
from homeassistant.components.http.const import is_supervisor_unix_socket_request
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
@@ -41,14 +42,18 @@ class HassIOBaseAuth(HomeAssistantView):
def _check_access(self, request: web.Request) -> None:
"""Check if this call is from Supervisor."""
# Check caller IP
hassio_ip = os.environ["SUPERVISOR"].split(":")[0]
assert request.transport
if ip_address(request.transport.get_extra_info("peername")[0]) != ip_address(
hassio_ip
):
_LOGGER.error("Invalid auth request from %s", request.remote)
raise HTTPUnauthorized
# Requests over the Supervisor Unix socket are authenticated by the
# http auth middleware as the Supervisor user, so the caller-IP check
# below does not apply (and would crash, since `peername` is empty for
# Unix sockets). The user-ID check still runs to ensure only the
# Supervisor user can reach this endpoint.
if not is_supervisor_unix_socket_request(request):
hassio_ip = os.environ["SUPERVISOR"].split(":")[0]
assert request.transport
peername = request.transport.get_extra_info("peername")
if not peername or ip_address(peername[0]) != ip_address(hassio_ip):
_LOGGER.error("Invalid auth request from %s", request.remote)
raise HTTPUnauthorized
# Check caller token
if request[KEY_HASS_USER].id != self.user.id:
+11 -5
View File
@@ -44,14 +44,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: HiveConfigEntry) -> bool
except HiveReauthRequired as err:
raise ConfigEntryAuthFailed from err
hub_data = devices["parent"][0]
connections: set[tuple[str, str]] = set()
if mac := hub_data.get("macAddress"):
connections.add((dr.CONNECTION_NETWORK_MAC, dr.format_mac(mac)))
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, devices["parent"][0]["device_id"])},
name=devices["parent"][0]["hiveName"],
model=devices["parent"][0]["deviceData"]["model"],
sw_version=devices["parent"][0]["deviceData"]["version"],
manufacturer=devices["parent"][0]["deviceData"]["manufacturer"],
identifiers={(DOMAIN, hub_data["device_id"])},
connections=connections,
name=hub_data["hiveName"],
model=hub_data["deviceData"]["model"],
sw_version=hub_data["deviceData"]["version"],
manufacturer=hub_data["deviceData"]["manufacturer"],
)
await hass.config_entries.async_forward_entry_setups(
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.95", "babel==2.15.0"]
"requirements": ["holidays==0.96", "babel==2.15.0"]
}
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["homematicip"],
"requirements": ["homematicip==2.9.0"]
"requirements": ["homematicip==2.10.0"]
}
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant.const import ATTR_MODE, CONF_OPTIONS, PERCENTAGE, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, State
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
@@ -13,8 +13,8 @@ from homeassistant.helpers.condition import (
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL,
Condition,
ConditionConfig,
EntityNumericalConditionBase,
EntityStateConditionBase,
make_entity_numerical_condition,
make_entity_state_condition,
)
from homeassistant.helpers.entity import get_supported_features
@@ -46,6 +46,20 @@ def _supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> boo
return False
class IsTargetHumidityCondition(EntityNumericalConditionBase):
"""Condition for humidifier target humidity."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}
_valid_unit = PERCENTAGE
def _should_include(self, state: State) -> bool:
"""Skip humidifier entities that do not expose a target humidity."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_HUMIDITY) is not None
)
class IsModeCondition(EntityStateConditionBase):
"""Condition for humidifier mode."""
@@ -79,10 +93,7 @@ CONDITIONS: dict[str, type[Condition]] = {
{DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING
),
"is_mode": IsModeCondition,
"is_target_humidity": make_entity_numerical_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit=PERCENTAGE,
),
"is_target_humidity": IsTargetHumidityCondition,
}
+26 -3
View File
@@ -14,9 +14,9 @@ from homeassistant.components.weather import (
DOMAIN as WEATHER_DOMAIN,
)
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import Condition, make_entity_numerical_condition
from homeassistant.helpers.condition import Condition, EntityNumericalConditionBase
HUMIDITY_DOMAIN_SPECS = {
CLIMATE_DOMAIN: DomainSpec(
@@ -31,8 +31,31 @@ HUMIDITY_DOMAIN_SPECS = {
),
}
class HumidityCondition(EntityNumericalConditionBase):
"""Condition for humidity value across multiple domains."""
_domain_specs = HUMIDITY_DOMAIN_SPECS
_valid_unit = PERCENTAGE
def _should_include(self, state: State) -> bool:
"""Skip attribute-source entities that lack the humidity attribute.
Mirrors the humidity trigger: for climate / humidifier / weather
(attribute-based), the entity is filtered when the source attribute
is absent; sensor entities (state-value-based) fall through to the
base impl.
"""
if not super()._should_include(state):
return False
domain_spec = self._domain_specs[state.domain]
if domain_spec.value_source is None:
return True
return state.attributes.get(domain_spec.value_source) is not None
CONDITIONS: dict[str, type[Condition]] = {
"is_value": make_entity_numerical_condition(HUMIDITY_DOMAIN_SPECS, PERCENTAGE),
"is_value": HumidityCondition,
}
+43 -9
View File
@@ -13,12 +13,13 @@ from homeassistant.components.weather import (
ATTR_WEATHER_HUMIDITY,
DOMAIN as WEATHER_DOMAIN,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
EntityNumericalStateChangedTriggerBase,
EntityNumericalStateCrossedThresholdTriggerBase,
EntityNumericalStateTriggerBase,
Trigger,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
)
HUMIDITY_DOMAIN_SPECS: dict[str, DomainSpec] = {
@@ -36,13 +37,46 @@ HUMIDITY_DOMAIN_SPECS: dict[str, DomainSpec] = {
),
}
class _HumidityTriggerMixin(EntityNumericalStateTriggerBase):
"""Mixin for humidity triggers providing entity filtering."""
_domain_specs = HUMIDITY_DOMAIN_SPECS
_valid_unit = "%"
def _should_include(self, state: State) -> bool:
"""Skip attribute-source entities that lack the humidity attribute.
For domains whose tracked value comes from an attribute
(climate / humidifier / weather), require the attribute to be
present; otherwise the all/count check would treat an entity that
cannot report a humidity as a non-match and block behavior=last.
Sensor entities source their value from `state.state`, so they
fall through to the base impl.
"""
if not super()._should_include(state):
return False
domain_spec = self._domain_specs[state.domain]
if domain_spec.value_source is None:
return True
return state.attributes.get(domain_spec.value_source) is not None
class HumidityChangedTrigger(
_HumidityTriggerMixin, EntityNumericalStateChangedTriggerBase
):
"""Trigger for humidity value changes across multiple domains."""
class HumidityCrossedThresholdTrigger(
_HumidityTriggerMixin, EntityNumericalStateCrossedThresholdTriggerBase
):
"""Trigger for humidity value crossing a threshold across multiple domains."""
TRIGGERS: dict[str, type[Trigger]] = {
"changed": make_entity_numerical_state_changed_trigger(
HUMIDITY_DOMAIN_SPECS, valid_unit="%"
),
"crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
HUMIDITY_DOMAIN_SPECS, valid_unit="%"
),
"changed": HumidityChangedTrigger,
"crossed_threshold": HumidityCrossedThresholdTrigger,
}
@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.4.0"]
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.8.1"]
}
+10 -19
View File
@@ -76,14 +76,12 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional(CONF_SEARCH, default="UnSeen UnDeleted"): str,
# The default for new entries is to not include text and headers
vol.Optional(CONF_EVENT_MESSAGE_DATA, default=[]): EVENT_MESSAGE_DATA_SELECTOR,
vol.Optional(
CONF_SSL_CIPHER_LIST, default=SSLCipherList.PYTHON_DEFAULT
): CIPHER_SELECTOR,
vol.Optional(CONF_VERIFY_SSL, default=True): BOOLEAN_SELECTOR,
}
)
CONFIG_SCHEMA_ADVANCED = {
vol.Optional(
CONF_SSL_CIPHER_LIST, default=SSLCipherList.PYTHON_DEFAULT
): CIPHER_SELECTOR,
vol.Optional(CONF_VERIFY_SSL, default=True): BOOLEAN_SELECTOR,
}
OPTIONS_SCHEMA = vol.Schema(
{
@@ -93,18 +91,15 @@ OPTIONS_SCHEMA = vol.Schema(
vol.Optional(
CONF_EVENT_MESSAGE_DATA, default=MESSAGE_DATA_OPTIONS
): EVENT_MESSAGE_DATA_SELECTOR,
vol.Optional(CONF_CUSTOM_EVENT_DATA_TEMPLATE): TEMPLATE_SELECTOR,
vol.Optional(CONF_MAX_MESSAGE_SIZE, default=DEFAULT_MAX_MESSAGE_SIZE): vol.All(
cv.positive_int,
vol.Range(min=DEFAULT_MAX_MESSAGE_SIZE, max=MAX_MESSAGE_SIZE_LIMIT),
),
vol.Optional(CONF_ENABLE_PUSH, default=True): BOOLEAN_SELECTOR,
}
)
OPTIONS_SCHEMA_ADVANCED = {
vol.Optional(CONF_CUSTOM_EVENT_DATA_TEMPLATE): TEMPLATE_SELECTOR,
vol.Optional(CONF_MAX_MESSAGE_SIZE, default=DEFAULT_MAX_MESSAGE_SIZE): vol.All(
cv.positive_int,
vol.Range(min=DEFAULT_MAX_MESSAGE_SIZE, max=MAX_MESSAGE_SIZE_LIMIT),
),
vol.Optional(CONF_ENABLE_PUSH, default=True): BOOLEAN_SELECTOR,
}
async def validate_input(
hass: HomeAssistant, user_input: dict[str, Any]
@@ -151,8 +146,6 @@ class IMAPConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the initial step."""
schema = CONFIG_SCHEMA
if self.show_advanced_options:
schema = schema.extend(CONFIG_SCHEMA_ADVANCED)
if user_input is None:
return self.async_show_form(step_id="user", data_schema=schema)
@@ -250,8 +243,6 @@ class ImapOptionsFlow(OptionsFlow):
return self.async_create_entry(data={})
schema = OPTIONS_SCHEMA
if self.show_advanced_options:
schema = schema.extend(OPTIONS_SCHEMA_ADVANCED)
schema = self.add_suggested_values_to_schema(schema, entry_data)
return self.async_show_form(step_id="init", data_schema=schema, errors=errors)
+8 -2
View File
@@ -251,8 +251,10 @@ class MatterFan(MatterEntity, FanEntity):
return
self._feature_map = feature_map
self._attr_supported_features = FanEntityFeature(0)
# Reset to default so a featuremap change from MultiSpeed -> non-MultiSpeed
# does not leave a stale speed_count / percentage_step.
self._attr_speed_count = 100
if feature_map & FanControlFeature.kMultiSpeed:
self._attr_supported_features |= FanEntityFeature.SET_SPEED
self._attr_speed_count = int(
self.get_matter_attribute_value(clusters.FanControl.Attributes.SpeedMax)
)
@@ -302,8 +304,12 @@ class MatterFan(MatterEntity, FanEntity):
if feature_map & FanControlFeature.kAirflowDirection:
self._attr_supported_features |= FanEntityFeature.DIRECTION
# PercentSetting is always a mandatory attribute of the FanControl cluster,
# so percentage-based speed control is always available.
self._attr_supported_features |= (
FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
@@ -1,11 +1,108 @@
"""Provides conditions for media players."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from datetime import datetime
from typing import Any
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
Condition,
EntityConditionBase,
EntityNumericalConditionBase,
make_entity_state_condition,
)
from . import ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED
from .const import DOMAIN, MediaPlayerState
class _MediaPlayerMutedConditionBase(EntityConditionBase):
"""Base class for media player is_muted/is_unmuted conditions."""
_domain_specs = {DOMAIN: DomainSpec()}
_target_muted: bool
def _state_valid_since(self, state: State) -> datetime:
"""Anchor `for:` durations to `last_updated` for the muted attribute.
Needed because the domain spec does not reflect that the condition
reads from the muted and volume attributes.
"""
return state.last_updated
def _has_volume_attributes(self, state: State) -> bool:
"""Check if the state has volume muted or volume level attributes."""
return (
state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is not None
or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None
)
def _should_include(self, state: State) -> bool:
"""Skip entities without volume attributes from the all/count check."""
return super()._should_include(state) and self._has_volume_attributes(state)
def _is_muted(self, state: State) -> bool:
"""Check if the media player is muted."""
return (
state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is True
or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0
)
def is_valid_state(self, entity_state: State) -> bool:
"""Check if the entity state matches the targeted muted state."""
if not self._has_volume_attributes(entity_state):
return False
return self._is_muted(entity_state) is self._target_muted
class MediaPlayerIsMutedCondition(_MediaPlayerMutedConditionBase):
"""Condition that passes when the media player is muted."""
_target_muted = True
class MediaPlayerIsUnmutedCondition(_MediaPlayerMutedConditionBase):
"""Condition that passes when the media player is not muted."""
_target_muted = False
class MediaPlayerIsVolumeCondition(EntityNumericalConditionBase):
"""Condition for media player volume level with 0.0-1.0 to percentage conversion."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_MEDIA_VOLUME_LEVEL)}
_valid_unit = "%"
def _get_tracked_value(self, entity_state: State) -> Any:
"""Get the volume value converted from 0.0-1.0 to percentage (0-100)."""
raw = super()._get_tracked_value(entity_state)
if raw is None:
return None
try:
return float(raw) * 100.0
except TypeError, ValueError:
return None
def _should_include(self, state: State) -> bool:
"""Skip media players that do not expose a volume_level attribute."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None
)
CONDITIONS: dict[str, type[Condition]] = {
"is_muted": MediaPlayerIsMutedCondition,
"is_not_playing": make_entity_state_condition(
DOMAIN,
{
MediaPlayerState.BUFFERING,
MediaPlayerState.IDLE,
MediaPlayerState.OFF,
MediaPlayerState.ON,
MediaPlayerState.PAUSED,
},
),
"is_off": make_entity_state_condition(DOMAIN, MediaPlayerState.OFF),
"is_on": make_entity_state_condition(
DOMAIN,
@@ -17,18 +114,10 @@ CONDITIONS: dict[str, type[Condition]] = {
MediaPlayerState.PLAYING,
},
),
"is_not_playing": make_entity_state_condition(
DOMAIN,
{
MediaPlayerState.BUFFERING,
MediaPlayerState.IDLE,
MediaPlayerState.OFF,
MediaPlayerState.ON,
MediaPlayerState.PAUSED,
},
),
"is_paused": make_entity_state_condition(DOMAIN, MediaPlayerState.PAUSED),
"is_playing": make_entity_state_condition(DOMAIN, MediaPlayerState.PLAYING),
"is_unmuted": MediaPlayerIsUnmutedCondition,
"is_volume": MediaPlayerIsVolumeCondition,
}
@@ -1,22 +1,51 @@
.condition_common: &condition_common
target:
target: &condition_media_player_target
entity:
domain: media_player
fields:
behavior:
behavior: &condition_behavior
required: true
default: any
selector:
automation_behavior:
mode: condition
for:
for: &condition_for
required: true
default: 00:00:00
selector:
duration:
.volume_threshold_entity: &volume_threshold_entity
- domain: input_number
unit_of_measurement: "%"
- domain: number
unit_of_measurement: "%"
- domain: sensor
unit_of_measurement: "%"
.volume_threshold_number: &volume_threshold_number
min: 0
max: 100
mode: box
unit_of_measurement: "%"
is_muted: *condition_common
is_off: *condition_common
is_on: *condition_common
is_not_playing: *condition_common
is_paused: *condition_common
is_playing: *condition_common
is_unmuted: *condition_common
is_volume:
target: *condition_media_player_target
fields:
behavior: *condition_behavior
for: *condition_for
threshold:
required: true
selector:
numeric_threshold:
entity: *volume_threshold_entity
mode: is
number: *volume_threshold_number
@@ -1,5 +1,8 @@
{
"conditions": {
"is_muted": {
"condition": "mdi:volume-mute"
},
"is_not_playing": {
"condition": "mdi:stop"
},
@@ -14,6 +17,12 @@
},
"is_playing": {
"condition": "mdi:play"
},
"is_unmuted": {
"condition": "mdi:volume-high"
},
"is_volume": {
"condition": "mdi:volume-medium"
}
},
"entity_component": {
@@ -143,6 +152,12 @@
},
"unmuted": {
"trigger": "mdi:volume-high"
},
"volume_changed": {
"trigger": "mdi:volume-medium"
},
"volume_crossed_threshold": {
"trigger": "mdi:volume-medium"
}
}
}
@@ -2,10 +2,24 @@
"common": {
"condition_behavior_name": "Condition passes if",
"condition_for_name": "For at least",
"condition_threshold_name": "Threshold",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
"trigger_for_name": "For at least",
"trigger_threshold_name": "Threshold"
},
"conditions": {
"is_muted": {
"description": "Tests if one or more media players are muted.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::condition_for_name%]"
}
},
"name": "Media player is muted"
},
"is_not_playing": {
"description": "Tests if one or more media players are not playing.",
"fields": {
@@ -65,6 +79,33 @@
}
},
"name": "Media player is playing"
},
"is_unmuted": {
"description": "Tests if one or more media players are not muted.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::condition_for_name%]"
}
},
"name": "Media player is not muted"
},
"is_volume": {
"description": "Tests the volume of one or more media players.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::condition_for_name%]"
},
"threshold": {
"name": "[%key:component::media_player::common::condition_threshold_name%]"
}
},
"name": "Volume"
}
},
"device_automation": {
@@ -520,6 +561,30 @@
}
},
"name": "Media player unmuted"
},
"volume_changed": {
"description": "Triggers after the volume of one or more media players changes.",
"fields": {
"threshold": {
"name": "[%key:component::media_player::common::trigger_threshold_name%]"
}
},
"name": "Media player volume changed"
},
"volume_crossed_threshold": {
"description": "Triggers after the volume of one or more media players crosses a threshold.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::media_player::common::trigger_threshold_name%]"
}
},
"name": "Media player volume crossed threshold"
}
}
}
@@ -4,6 +4,9 @@ from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
EntityNumericalStateChangedTriggerBase,
EntityNumericalStateCrossedThresholdTriggerBase,
EntityNumericalStateTriggerBase,
EntityTriggerBase,
Trigger,
make_entity_transition_trigger,
@@ -12,6 +15,10 @@ from homeassistant.helpers.trigger import (
from . import ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, MediaPlayerState
from .const import DOMAIN
VOLUME_DOMAIN_SPECS = {
DOMAIN: DomainSpec(value_source=ATTR_MEDIA_VOLUME_LEVEL),
}
class _MediaPlayerMutedStateTriggerBase(EntityTriggerBase):
"""Base class for media player muted/unmuted triggers."""
@@ -71,9 +78,48 @@ class MediaPlayerUnmutedTrigger(_MediaPlayerMutedStateTriggerBase):
_target_muted = False
class VolumeTriggerMixin(EntityNumericalStateTriggerBase):
"""Mixin for volume triggers."""
_domain_specs = VOLUME_DOMAIN_SPECS
_valid_unit = "%"
def _get_tracked_value(self, state: State) -> float | None:
"""Get tracked volume as a percentage."""
value = super()._get_tracked_value(state)
if value is None:
return None
# Convert 0.0-1.0 range to percentage (0-100)
return value * 100.0
def _should_include(self, state: State) -> bool:
"""Check if an entity should participate in all/count checks.
Entities without a volume level cannot have their volume tracked,
so they are excluded - otherwise an "all" check would never pass
when there are media players without volume support.
"""
return (
super()._should_include(state)
and state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None
)
class VolumeChangedTrigger(EntityNumericalStateChangedTriggerBase, VolumeTriggerMixin):
"""Trigger for media player volume changes."""
class VolumeCrossedThresholdTrigger(
EntityNumericalStateCrossedThresholdTriggerBase, VolumeTriggerMixin
):
"""Trigger for media player volume crossing a threshold."""
TRIGGERS: dict[str, type[Trigger]] = {
"muted": MediaPlayerMutedTrigger,
"unmuted": MediaPlayerUnmutedTrigger,
"volume_changed": VolumeChangedTrigger,
"volume_crossed_threshold": VolumeCrossedThresholdTrigger,
"paused_playing": make_entity_transition_trigger(
DOMAIN,
from_states={
@@ -1,20 +1,34 @@
.trigger_common: &trigger_common
target:
target: &trigger_media_player_target
entity:
domain: media_player
fields:
behavior:
behavior: &trigger_behavior
required: true
default: any
selector:
automation_behavior:
mode: trigger
for:
for: &trigger_for
required: true
default: 00:00:00
selector:
duration:
.volume_threshold_entity: &volume_threshold_entity
- domain: input_number
unit_of_measurement: "%"
- domain: number
unit_of_measurement: "%"
- domain: sensor
unit_of_measurement: "%"
.volume_threshold_number: &volume_threshold_number
min: 0
max: 100
mode: box
unit_of_measurement: "%"
muted: *trigger_common
unmuted: *trigger_common
paused_playing: *trigger_common
@@ -22,3 +36,27 @@ started_playing: *trigger_common
stopped_playing: *trigger_common
turned_off: *trigger_common
turned_on: *trigger_common
volume_changed:
target: *trigger_media_player_target
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *volume_threshold_entity
mode: changed
number: *volume_threshold_number
volume_crossed_threshold:
target: *trigger_media_player_target
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
numeric_threshold:
entity: *volume_threshold_entity
mode: crossed
number: *volume_threshold_number
+10 -13
View File
@@ -5,30 +5,31 @@ from datetime import timedelta
from mill import Mill
from mill_local import Mill as MillLocal
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CLOUD, CONNECTION_TYPE, DOMAIN, LOCAL
from .coordinator import MillDataUpdateCoordinator, MillHistoricDataUpdateCoordinator
from .coordinator import (
MillConfigEntry,
MillDataUpdateCoordinator,
MillHistoricDataUpdateCoordinator,
)
PLATFORMS = [Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR]
__all__ = ["CLOUD", "CONNECTION_TYPE", "DOMAIN", "LOCAL"]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: MillConfigEntry) -> bool:
"""Set up the Mill heater."""
hass.data.setdefault(DOMAIN, {LOCAL: {}, CLOUD: {}})
if entry.data.get(CONNECTION_TYPE) == LOCAL:
mill_data_connection = MillLocal(
entry.data[CONF_IP_ADDRESS],
websession=async_get_clientsession(hass),
)
update_interval = timedelta(seconds=15)
key = entry.data[CONF_IP_ADDRESS]
conn_type = LOCAL
else:
mill_data_connection = Mill(
entry.data[CONF_USERNAME],
@@ -36,8 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
websession=async_get_clientsession(hass),
)
update_interval = timedelta(seconds=30)
key = entry.data[CONF_USERNAME]
conn_type = CLOUD
historic_data_coordinator = MillHistoricDataUpdateCoordinator(
hass,
@@ -56,14 +55,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
await data_coordinator.async_config_entry_first_refresh()
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
hass.data[DOMAIN][conn_type][key] = data_coordinator
entry.runtime_data = data_coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: MillConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+5 -15
View File
@@ -1,5 +1,4 @@
"""Support for mill wifi-enabled home heaters."""
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
from typing import Any
@@ -14,14 +13,7 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_TEMPERATURE,
CONF_IP_ADDRESS,
CONF_USERNAME,
PRECISION_TENTHS,
UnitOfTemperature,
)
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
@@ -33,7 +25,6 @@ from .const import (
ATTR_COMFORT_TEMP,
ATTR_ROOM_NAME,
ATTR_SLEEP_TEMP,
CLOUD,
CONNECTION_TYPE,
DOMAIN,
LOCAL,
@@ -42,7 +33,7 @@ from .const import (
MIN_TEMP,
SERVICE_SET_ROOM_TEMP,
)
from .coordinator import MillDataUpdateCoordinator
from .coordinator import MillConfigEntry, MillDataUpdateCoordinator
from .entity import MillBaseEntity
SET_ROOM_TEMP_SCHEMA = vol.Schema(
@@ -57,17 +48,16 @@ SET_ROOM_TEMP_SCHEMA = vol.Schema(
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MillConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Mill climate."""
mill_data_coordinator = entry.runtime_data
if entry.data.get(CONNECTION_TYPE) == LOCAL:
mill_data_coordinator = hass.data[DOMAIN][LOCAL][entry.data[CONF_IP_ADDRESS]]
async_add_entities([LocalMillHeater(mill_data_coordinator)])
return
mill_data_coordinator = hass.data[DOMAIN][CLOUD][entry.data[CONF_USERNAME]]
entities = [
MillHeater(mill_data_coordinator, mill_device)
for mill_device in mill_data_coordinator.data.values()
@@ -57,6 +57,9 @@ class MillDataUpdateCoordinator(DataUpdateCoordinator):
)
type MillConfigEntry = ConfigEntry[MillDataUpdateCoordinator]
class MillHistoricDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching Mill historic data."""
+5 -10
View File
@@ -3,28 +3,23 @@
from mill import Heater, MillDevice
from homeassistant.components.number import NumberDeviceClass, NumberEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_USERNAME, UnitOfPower
from homeassistant.const import UnitOfPower
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CLOUD, CONNECTION_TYPE, DOMAIN
from .coordinator import MillDataUpdateCoordinator
from .const import CLOUD, CONNECTION_TYPE
from .coordinator import MillConfigEntry, MillDataUpdateCoordinator
from .entity import MillBaseEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MillConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Mill Number."""
if entry.data.get(CONNECTION_TYPE) == CLOUD:
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
mill_data_coordinator: MillDataUpdateCoordinator = hass.data[DOMAIN][CLOUD][
entry.data[CONF_USERNAME]
]
mill_data_coordinator = entry.runtime_data
async_add_entities(
MillNumber(mill_data_coordinator, mill_device)
+4 -12
View File
@@ -1,5 +1,4 @@
"""Support for mill wifi-enabled home heaters."""
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
import mill
@@ -9,12 +8,9 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
CONF_IP_ADDRESS,
CONF_USERNAME,
PERCENTAGE,
EntityCategory,
UnitOfEnergy,
@@ -29,11 +25,9 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
BATTERY,
CLOUD,
CONNECTION_TYPE,
CONSUMPTION_TODAY,
CONSUMPTION_YEAR,
DOMAIN,
ECO2,
HUMIDITY,
LOCAL,
@@ -41,7 +35,7 @@ from .const import (
TEMPERATURE,
TVOC,
)
from .coordinator import MillDataUpdateCoordinator
from .coordinator import MillConfigEntry, MillDataUpdateCoordinator
from .entity import MillBaseEntity
HEATER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
@@ -146,13 +140,13 @@ SOCKET_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MillConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Mill sensor."""
if entry.data.get(CONNECTION_TYPE) == LOCAL:
mill_data_coordinator = hass.data[DOMAIN][LOCAL][entry.data[CONF_IP_ADDRESS]]
mill_data_coordinator = entry.runtime_data
if entry.data.get(CONNECTION_TYPE) == LOCAL:
async_add_entities(
LocalMillSensor(
mill_data_coordinator,
@@ -162,8 +156,6 @@ async def async_setup_entry(
)
return
mill_data_coordinator = hass.data[DOMAIN][CLOUD][entry.data[CONF_USERNAME]]
entities = [
MillSensor(
mill_data_coordinator,
@@ -82,6 +82,7 @@ ATTR_SENSOR_UOM = "unit_of_measurement"
SIGNAL_SENSOR_UPDATE = f"{DOMAIN}_sensor_update"
SIGNAL_LOCATION_UPDATE = DOMAIN + "_location_update_{}"
SIGNAL_RECORD_NOTIFICATION = f"{DOMAIN}_record_notification"
ATTR_CAMERA_ENTITY_ID = "camera_entity_id"
+23 -1
View File
@@ -21,9 +21,13 @@ from homeassistant.components.notify import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
@@ -46,6 +50,7 @@ from .const import (
DATA_NOTIFY,
DATA_PUSH_CHANNEL,
DOMAIN,
SIGNAL_RECORD_NOTIFICATION,
)
from .helpers import device_info
from .push_notification import PushChannel
@@ -111,6 +116,21 @@ class MobileAppNotifyEntity(NotifyEntity):
translation_placeholders={"device_name": self._config_entry.title},
)
@callback
def _async_handle_notification(self, webhook_id: str) -> None:
"""Handle notifications triggered externally."""
if webhook_id == self._config_entry.data[ATTR_WEBHOOK_ID]:
self._async_record_notification()
async def async_added_to_hass(self) -> None:
"""Register callback."""
self.async_on_remove(
async_dispatcher_connect(
self.hass, SIGNAL_RECORD_NOTIFICATION, self._async_handle_notification
)
)
def push_registrations(hass: HomeAssistant) -> dict[str, str]:
"""Return a dictionary of push enabled registrations."""
@@ -195,6 +215,7 @@ class MobileAppNotificationService(BaseNotificationService):
data,
partial(self._async_send_remote_message_target, entry),
)
async_dispatcher_send(self.hass, SIGNAL_RECORD_NOTIFICATION, target)
continue
# Test if local push only.
@@ -203,6 +224,7 @@ class MobileAppNotificationService(BaseNotificationService):
continue
await self._async_send_remote_message_target(entry, data)
async_dispatcher_send(self.hass, SIGNAL_RECORD_NOTIFICATION, target)
if failed_targets:
raise HomeAssistantError(
+3 -23
View File
@@ -16,6 +16,8 @@ from typing import TYPE_CHECKING, Any
from uuid import uuid4
import certifi
import paho.mqtt.client as mqtt
from paho.mqtt.matcher import MQTTMatcher
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -47,6 +49,7 @@ from homeassistant.setup import SetupPhases, async_pause_setup
from homeassistant.util.collection import chunked_or_all
from homeassistant.util.logging import catch_log_exception, log_exception
from .async_client import AsyncMQTTClient
from .const import (
CONF_BIRTH_MESSAGE,
CONF_BROKER,
@@ -86,13 +89,6 @@ from .models import (
)
from .util import EnsureJobAfterCooldown, get_file_path, mqtt_config_entry_enabled
if TYPE_CHECKING:
# Only import for paho-mqtt type checking here, imports are done locally
# because integrations should be able to optionally rely on MQTT.
import paho.mqtt.client as mqtt
from .async_client import AsyncMQTTClient
_LOGGER = logging.getLogger(__name__)
MIN_BUFFER_SIZE = 131072 # Minimum buffer size to use if preferred size fails
@@ -323,12 +319,6 @@ class MqttClientSetup:
The setup of the MQTT client should be run in an executor job,
because it accesses files, so it does IO.
"""
# We don't import on the top because some integrations
# should be able to optionally rely on MQTT.
from paho.mqtt import client as mqtt # noqa: PLC0415
from .async_client import AsyncMQTTClient # noqa: PLC0415
config = self._config
clean_session: bool | None = None
# If no protocol setting is set in the config entry data
@@ -561,7 +551,6 @@ class MQTT:
"""Start the misc periodic."""
assert self._misc_timer is None, "Misc periodic already started"
_LOGGER.debug("%s: Starting client misc loop", self.config_entry.title)
import paho.mqtt.client as mqtt # noqa: PLC0415
# Inner function to avoid having to check late import
# each time the function is called.
@@ -705,7 +694,6 @@ class MQTT:
async def async_connect(self, client_available: asyncio.Future[bool]) -> None:
"""Connect to the host. Does not process messages yet."""
import paho.mqtt.client as mqtt # noqa: PLC0415
result: int | None = None
self._available_future = client_available
@@ -763,7 +751,6 @@ class MQTT:
async def _reconnect_loop(self) -> None:
"""Reconnect to the MQTT server."""
import paho.mqtt.client as mqtt # noqa: PLC0415
while True:
if not self.connected:
@@ -1265,9 +1252,6 @@ class MQTT:
@callback
def _async_handle_callback_exception(self, status: mqtt.MQTTErrorCode) -> None:
"""Handle a callback exception."""
# We don't import on the top because some integrations
# should be able to optionally rely on MQTT.
import paho.mqtt.client as mqtt # noqa: PLC0415
_LOGGER.warning(
"Error returned from MQTT server: %s",
@@ -1312,8 +1296,6 @@ class MQTT:
) -> None:
"""Wait for ACK from broker or raise on error."""
if result_code != 0:
import paho.mqtt.client as mqtt # noqa: PLC0415
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="mqtt_broker_error",
@@ -1360,8 +1342,6 @@ class MQTT:
def _matcher_for_topic(subscription: str) -> Callable[[str], bool]:
from paho.mqtt.matcher import MQTTMatcher # noqa: PLC0415
matcher = MQTTMatcher() # type: ignore[no-untyped-call]
matcher[subscription] = True
+3 -9
View File
@@ -22,6 +22,7 @@ from cryptography.hazmat.primitives.serialization import (
load_pem_private_key,
)
from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate
import paho.mqtt.client as mqtt
import voluptuous as vol
import yaml
@@ -5371,12 +5372,9 @@ async def async_get_broker_settings( # noqa: C901
description={"suggested_value": current_pass},
)
] = PASSWORD_SELECTOR
# show advanced options checkbox if requested and
# advanced options are enabled
# or when the defaults of advanced options are overridden
# show advanced options checkbox if no defaults
# of the advanced options are overridden
if not advanced_broker_options:
if not flow.show_advanced_options:
return False
fields[
vol.Optional(
ADVANCED_OPTIONS,
@@ -5482,10 +5480,6 @@ def try_connection(
user_input: dict[str, Any],
) -> bool:
"""Test if we can connect to an MQTT broker."""
# We don't import on the top because some integrations
# should be able to optionally rely on MQTT.
import paho.mqtt.client as mqtt # noqa: PLC0415
mqtt_client_setup = MqttClientSetup(user_input)
mqtt_client_setup.setup()
client = mqtt_client_setup.client
+2 -2
View File
@@ -9,6 +9,8 @@ from enum import StrEnum
import logging
from typing import TYPE_CHECKING, Any, TypedDict
from paho.mqtt.client import MQTTMessage
from homeassistant.const import ATTR_ENTITY_ID, ATTR_NAME, Platform
from homeassistant.core import CALLBACK_TYPE, callback
from homeassistant.exceptions import ServiceValidationError, TemplateError
@@ -24,8 +26,6 @@ from homeassistant.helpers.typing import (
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from paho.mqtt.client import MQTTMessage
from .client import MQTT, Subscription
from .debug_info import TimestampedPublishMessage
from .device_trigger import Trigger
@@ -67,25 +67,11 @@ OPENING_CATEGORY_TO_DEVICE_CLASS: Final[dict[str | None, BinarySensorDeviceClass
def get_opening_category(netatmo_device: NetatmoDevice) -> str:
"""Helper function to get opening category from Netatmo API raw data."""
"""Helper function to get opening category for doortag."""
# Iterate through each home in the raw data.
for home in netatmo_device.data_handler.account.raw_data["homes"]:
# Check if the modules list exists for the current home.
if "modules" in home:
# Iterate through each module to find a matching ID.
for module in home["modules"]:
if module["id"] == netatmo_device.device.entity_id:
# We found the matching device. Get its category.
if module.get("category") is not None:
return cast(str, module["category"])
raise ValueError(
f"Device {netatmo_device.device.entity_id} found, "
"but 'category' is missing in raw data."
)
raise ValueError(
f"Device {netatmo_device.device.entity_id} not found in Netatmo raw data."
return (
getattr(netatmo_device.device, "doortag_category", None)
or DOORTAG_CATEGORY_OTHER
)
@@ -1,7 +1,7 @@
{
"domain": "roomba",
"name": "iRobot Roomba and Braava",
"codeowners": ["@pschmitt", "@cyr-ius", "@shenxn", "@Orhideous"],
"codeowners": ["@pschmitt", "@cyr-ius", "@shenxn"],
"config_flow": true,
"dhcp": [
{
@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["satel_integra"],
"quality_scale": "bronze",
"requirements": ["satel-integra==1.3.0"]
"requirements": ["satel-integra==1.3.1"]
}
+3 -23
View File
@@ -1,36 +1,16 @@
"""Provides triggers for scenes."""
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
Trigger,
)
from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger
from . import DOMAIN
class SceneActivatedTrigger(EntityTriggerBase):
class SceneActivatedTrigger(StatelessEntityTriggerBase):
"""Trigger for scene entity activations."""
_domain_specs = {DOMAIN: DomainSpec()}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and different from the current state."""
# UNKNOWN is a valid from_state, otherwise the first time the scene is activated
# it would not trigger
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
def is_valid_state(self, state: State) -> bool:
"""Check if the new state is not invalid."""
return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
TRIGGERS: dict[str, type[Trigger]] = {
@@ -4,5 +4,5 @@
"codeowners": ["@fabaff"],
"documentation": "https://www.home-assistant.io/integrations/serial",
"iot_class": "local_polling",
"requirements": ["serialx==1.7.0"]
"requirements": ["serialx==1.7.1"]
}
@@ -46,6 +46,21 @@ class TemperatureCondition(EntityNumericalConditionWithUnitBase):
_domain_specs = TEMPERATURE_DOMAIN_SPECS
_unit_converter = TemperatureConverter
def _should_include(self, state: State) -> bool:
"""Skip attribute-source entities that lack the temperature attribute.
Mirrors the temperature trigger: for climate / water_heater /
weather (attribute-based), the entity is filtered when the source
attribute is absent; sensor entities (state-value-based) fall
through to the base impl.
"""
if not super()._should_include(state):
return False
domain_spec = self._domain_specs[state.domain]
if domain_spec.value_source is None:
return True
return state.attributes.get(domain_spec.value_source) is not None
def _get_entity_unit(self, entity_state: State) -> str | None:
"""Get the temperature unit of an entity from its state."""
if entity_state.domain == SENSOR_DOMAIN:
@@ -46,6 +46,23 @@ class _TemperatureTriggerMixin(EntityNumericalStateTriggerWithUnitBase):
_domain_specs = TEMPERATURE_DOMAIN_SPECS
_unit_converter = TemperatureConverter
def _should_include(self, state: State) -> bool:
"""Skip attribute-source entities that lack the temperature attribute.
For domains whose tracked value comes from an attribute
(climate / water_heater / weather), require the attribute to be
present; otherwise the all/count check would treat an entity that
cannot report a temperature as a non-match and block behavior=last.
Sensor entities source their value from `state.state`, so they
fall through to the base impl.
"""
if not super()._should_include(state):
return False
domain_spec = self._domain_specs[state.domain]
if domain_spec.value_source is None:
return True
return state.attributes.get(domain_spec.value_source) is not None
def _get_entity_unit(self, state: State) -> str | None:
"""Get the temperature unit of an entity from its state."""
if state.domain == SENSOR_DOMAIN:
@@ -1,8 +1,8 @@
"""Data update coordinator for trigger based template entities."""
from collections.abc import Callable, Mapping
from collections.abc import Callable
import logging
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, cast
from homeassistant.components.blueprint import CONF_USE_BLUEPRINT
from homeassistant.const import (
@@ -37,7 +37,7 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator):
hass, _LOGGER, config_entry=None, name="Trigger Update Coordinator"
)
self.config = config
self._cond_func: Callable[[Mapping[str, Any] | None], bool] | None = None
self._cond_func: condition.ConditionsChecker | None = None
self._unsub_start: Callable[[], None] | None = None
self._unsub_trigger: Callable[[], None] | None = None
self._script: Script | None = None
@@ -69,7 +69,9 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator):
self._unsub_trigger()
self._unsub_trigger = None
if self._script is not None:
await self._script.async_stop()
await self._script.async_unload()
if self._cond_func is not None:
self._cond_func.async_unload()
async def async_setup(self, hass_config: ConfigType) -> None:
"""Set up the trigger and create entities."""
@@ -158,7 +160,7 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator):
def _check_condition(self, run_variables: TemplateVarsType) -> bool:
if not self._cond_func:
return True
condition_result = self._cond_func(run_variables)
condition_result = self._cond_func.async_check(variables=run_variables)
if condition_result is False:
_LOGGER.debug(
"Conditions not met, aborting template trigger update. Condition summary: %s",
+9 -3
View File
@@ -169,9 +169,15 @@ class AbstractTemplateEntity(Entity):
)
async def async_will_remove_from_hass(self) -> None:
"""Stop scripts when removing from Home Assistant."""
for action_script in self._action_scripts.values():
await action_script.async_stop()
"""Clean up scripts when removing from Home Assistant."""
if not self.registry_entry or self.registry_entry.entity_id == self.entity_id:
# Entity ID not changed, unload scripts as they will not be reused.
for action_script in self._action_scripts.values():
await action_script.async_unload()
else:
# Entity ID changed, just stop scripts
for action_script in self._action_scripts.values():
await action_script.async_stop()
async def async_run_script(
self,
@@ -8,5 +8,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["tibber"],
"requirements": ["pyTibber==0.37.3"]
"requirements": ["pyTibber==0.37.5"]
}
+2 -1
View File
@@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, cast, override
import voluptuous as vol
from homeassistant.const import ATTR_ENTITY_ID, CONF_TARGET
from homeassistant.const import ATTR_ENTITY_ID, CONF_OPTIONS, CONF_TARGET
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
@@ -25,6 +25,7 @@ from .const import DATA_COMPONENT, DOMAIN, TodoItemStatus
ITEM_TRIGGER_SCHEMA = vol.Schema(
{
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
vol.Required(CONF_OPTIONS, default={}): {},
}
)
+2 -8
View File
@@ -28,6 +28,7 @@ from .const import (
TUYA_DISCOVERY_NEW,
TUYA_HA_SIGNAL_UPDATE_ENTITY,
)
from .util import get_device_info
type TuyaConfigEntry = ConfigEntry[DeviceListener]
@@ -145,14 +146,7 @@ class DeviceListener(SharingDeviceListener):
device_registry.async_get_or_create(
config_entry_id=self._entry.entry_id,
identifiers={(DOMAIN, device.id)},
manufacturer="Tuya",
name=device.name,
# Note: the model is overridden via entity.device_info property
# when the entity is created. If no entities are generated, it will
# stay as unsupported
model=f"{device.product_name} (unsupported)",
model_id=device.product_id,
**get_device_info(device, initial=True),
)
def remove_device(self, device_id: str) -> None:
+3 -13
View File
@@ -5,11 +5,11 @@ from typing import Any
from tuya_device_handlers.device_wrapper import DeviceWrapper
from tuya_sharing import CustomerDevice, Manager
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity, EntityDescription
from .const import DOMAIN, LOGGER, TUYA_HA_SIGNAL_UPDATE_ENTITY
from .const import LOGGER, TUYA_HA_SIGNAL_UPDATE_ENTITY
from .util import get_device_info
class TuyaEntity(Entity):
@@ -25,6 +25,7 @@ class TuyaEntity(Entity):
description: EntityDescription,
) -> None:
"""Init TuyaEntity."""
self._attr_device_info = get_device_info(device)
self._attr_unique_id = f"tuya.{device.id}{description.key}"
self.entity_description = description
# TuyaEntity initialize mq can subscribe
@@ -32,17 +33,6 @@ class TuyaEntity(Entity):
self.device = device
self.device_manager = device_manager
@property
def device_info(self) -> DeviceInfo:
"""Return a device description for device registry."""
return DeviceInfo(
identifiers={(DOMAIN, self.device.id)},
manufacturer="Tuya",
name=self.device.name,
model=self.device.product_name,
model_id=self.device.product_id,
)
@property
def available(self) -> bool:
"""Return if the device is available."""
+20
View File
@@ -3,6 +3,7 @@
from tuya_sharing import CustomerDevice
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.device_registry import DeviceInfo
from .const import DOMAIN, DPCode
@@ -31,3 +32,22 @@ class ActionDPCodeNotFoundError(ServiceValidationError):
"available": str(sorted(device.function.keys())),
},
)
def get_device_info(device: CustomerDevice, *, initial: bool = False) -> DeviceInfo:
"""Get device info."""
model = device.product_name
if initial:
# Note: the model is overridden via entity.device_info property
# when the entity is created. If no entities are generated, it will
# stay as unsupported
model = f"{device.product_name} (unsupported)"
return DeviceInfo(
identifiers={(DOMAIN, device.id)},
manufacturer="Tuya",
name=device.name,
model=model,
model_id=device.product_id,
)
+1 -1
View File
@@ -7,5 +7,5 @@
"integration_type": "system",
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": ["aiousbwatcher==1.1.2", "serialx==1.7.0"]
"requirements": ["aiousbwatcher==1.1.2", "serialx==1.7.1"]
}
@@ -12,5 +12,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["PyViCare"],
"requirements": ["PyViCare==2.60.1"]
"requirements": ["PyViCare==2.60.2"]
}
@@ -74,6 +74,13 @@ class WaterHeaterTargetTemperatureCondition(EntityNumericalConditionWithUnitBase
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _should_include(self, state: State) -> bool:
"""Skip water heater entities that do not expose a target temperature."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_TEMPERATURE) is not None
)
def _get_entity_unit(self, entity_state: State) -> str | None:
"""Get the temperature unit of a water heater entity from its state."""
# Water heater entities convert temperatures to the system unit via show_temp
@@ -60,6 +60,13 @@ class _WaterHeaterTargetTemperatureTriggerMixin(
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _should_include(self, state: State) -> bool:
"""Skip water heater entities that do not expose a target temperature."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_TEMPERATURE) is not None
)
def _get_entity_unit(self, state: State) -> str | None:
"""Get the temperature unit of a water heater entity from its state."""
# Water heater entities convert temperatures to the system unit via show_temp
@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["holidays"],
"quality_scale": "internal",
"requirements": ["holidays==0.95"]
"requirements": ["holidays==0.96"]
}
+35 -24
View File
@@ -437,6 +437,9 @@ class EntityConditionBase(Condition):
"""Base class for entity conditions."""
_domain_specs: Mapping[str, DomainSpec]
_excluded_states: Final[frozenset[str]] = frozenset(
{STATE_UNAVAILABLE, STATE_UNKNOWN}
)
_schema: vol.Schema = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL
# When True, indirect target expansion (via device/area/floor) skips
# entities with an entity_category.
@@ -484,34 +487,32 @@ class EntityConditionBase(Condition):
"""
return True
def _state_valid_since(self, _state: State) -> datetime:
"""Return the datetime that anchors `for:` durations for `state`.
Override in subclasses whose `is_valid_state` reads
attributes directly without going through `value_source`.
"""
if self._domain_specs[_state.domain].value_source is None:
return _state.last_changed
return _state.last_updated
def _update_valid_since(self, entity_id: str, _state: State | None) -> None:
"""Update _valid_since tracking for an entity based on its current state.
If the entity is in a valid state and not already tracked, records when
the condition became true. If the entity is not in a valid state, removes
it from tracking.
For state-based conditions (value_source is None), last_changed
accurately reflects when the state changed to the current value.
For attribute-based conditions, last_changed only tracks main state
changes, so we use last_updated which is bumped on any update
(state or attributes). This is conservative the tracked attribute
may have held its value longer but it's the best we can do
to avoid false positives.
If the entity is in a valid state and not already tracked, records
when the condition became true (via `_state_valid_since`). If the
entity is not in a valid state, removes it from tracking.
"""
if (
_state is not None
and _state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
and self._should_include(_state)
and self.is_valid_state(_state)
):
# Only record the time if not already tracked, to avoid
# resetting the duration on unrelated state/attribute updates.
if entity_id not in self._valid_since:
domain_spec = self._domain_specs[_state.domain]
if domain_spec.value_source is None:
self._valid_since[entity_id] = _state.last_changed
else:
self._valid_since[entity_id] = _state.last_updated
self._valid_since[entity_id] = self._state_valid_since(_state)
else:
self._valid_since.pop(entity_id, None)
@@ -559,12 +560,15 @@ class EntityConditionBase(Condition):
cb()
self._on_unload.clear()
def _get_tracked_value(self, entity_state: State) -> Any:
"""Get the tracked value from a state based on the DomainSpec."""
domain_spec = self._domain_specs[entity_state.domain]
if domain_spec.value_source is None:
return entity_state.state
return entity_state.attributes.get(domain_spec.value_source)
def _should_include(self, _state: State) -> bool:
"""Check if an entity should participate in any/all checks.
The default implementation excludes only entities whose state.state
is in `_excluded_states` (unavailable / unknown). Subclasses can
override to also exclude entities that lack the optional capability
the condition relies on.
"""
return _state.state not in self._excluded_states
@abc.abstractmethod
def is_valid_state(self, entity_state: State) -> bool:
@@ -622,7 +626,7 @@ class EntityConditionBase(Condition):
_state
for entity_id in filtered_entity_ids
if (_state := self._hass.states.get(entity_id))
and _state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
and self._should_include(_state)
]
return self._matcher(entity_states)
@@ -641,6 +645,13 @@ class EntityStateConditionBase(EntityConditionBase):
spec.value_source is not None for spec in self._domain_specs.values()
)
def _get_tracked_value(self, entity_state: State) -> Any:
"""Get the tracked value from a state based on the DomainSpec."""
domain_spec = self._domain_specs[entity_state.domain]
if domain_spec.value_source is None:
return entity_state.state
return entity_state.attributes.get(domain_spec.value_source)
def is_valid_state(self, entity_state: State) -> bool:
"""Check if the state matches the expected state(s)."""
return self._get_tracked_value(entity_state) in self._states
+24
View File
@@ -626,6 +626,30 @@ class EntityOriginStateTriggerBase(EntityTriggerBase):
)
class StatelessEntityTriggerBase(EntityTriggerBase):
"""Trigger for entities that don't carry meaningful state.
Used for stateless entities (buttons, scenes, doorbells, events)
whose `state.state` is just a timestamp of the last activation.
"""
_schema: vol.Schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is available and the state has changed.
STATE_UNKNOWN is allowed as the origin state so the first
activation fires.
"""
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
def is_valid_state(self, state: State) -> bool:
"""Check that the entity has been activated at least once."""
return state.state not in self._excluded_states
NUMERICAL_ATTRIBUTE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend(
{
vol.Required(CONF_OPTIONS, default={}): vol.All(
+2 -2
View File
@@ -39,7 +39,7 @@ habluetooth==6.1.0
hass-nabucasa==2.2.0
hassil==3.5.0
home-assistant-bluetooth==2.0.0
home-assistant-frontend==20260429.2
home-assistant-frontend==20260429.3
home-assistant-intents==2026.5.5
httpx==0.28.1
ifaddr==0.2.0
@@ -63,7 +63,7 @@ PyTurboJPEG==1.8.3
PyYAML==6.0.3
requests==2.33.1
securetar==2026.4.1
serialx==1.7.0
serialx==1.7.1
SQLAlchemy==2.0.49
standard-aifc==3.13.0
standard-telnetlib==3.13.0
+8 -8
View File
@@ -99,7 +99,7 @@ PyTransportNSW==0.1.1
PyTurboJPEG==1.8.3
# homeassistant.components.vicare
PyViCare==2.60.1
PyViCare==2.60.2
# homeassistant.components.xiaomi_aqara
PyXiaomiGateway==0.14.3
@@ -1051,7 +1051,7 @@ gTTS==2.5.3
# homeassistant.components.gardena_bluetooth
# homeassistant.components.husqvarna_automower_ble
gardena-bluetooth==2.4.0
gardena-bluetooth==2.8.1
# homeassistant.components.google_assistant_sdk
gassist-text==0.0.14
@@ -1245,10 +1245,10 @@ hole==0.9.0
# homeassistant.components.holiday
# homeassistant.components.workday
holidays==0.95
holidays==0.96
# homeassistant.components.frontend
home-assistant-frontend==20260429.2
home-assistant-frontend==20260429.3
# homeassistant.components.conversation
home-assistant-intents==2026.5.5
@@ -1260,7 +1260,7 @@ homekit-audio-proxy==1.2.1
homelink-integration-api==0.0.1
# homeassistant.components.homematicip_cloud
homematicip==2.9.0
homematicip==2.10.0
# homeassistant.components.homevolt
homevolt==0.5.0
@@ -1947,7 +1947,7 @@ pyRFXtrx==0.31.1
pySDCP==1
# homeassistant.components.tibber
pyTibber==0.37.3
pyTibber==0.37.5
# homeassistant.components.dlink
pyW215==0.8.0
@@ -2906,7 +2906,7 @@ samsungtvws[async,encrypted]==2.7.2
sanix==1.0.6
# homeassistant.components.satel_integra
satel-integra==1.3.0
satel-integra==1.3.1
# homeassistant.components.screenlogic
screenlogicpy==0.10.2
@@ -2951,7 +2951,7 @@ sentry-sdk==2.48.0
# homeassistant.components.acer_projector
# homeassistant.components.serial
# homeassistant.components.usb
serialx==1.7.0
serialx==1.7.1
# homeassistant.components.sfr_box
sfrbox-api==0.1.1
+8 -8
View File
@@ -96,7 +96,7 @@ PyTransportNSW==0.1.1
PyTurboJPEG==1.8.3
# homeassistant.components.vicare
PyViCare==2.60.1
PyViCare==2.60.2
# homeassistant.components.xiaomi_aqara
PyXiaomiGateway==0.14.3
@@ -933,7 +933,7 @@ gTTS==2.5.3
# homeassistant.components.gardena_bluetooth
# homeassistant.components.husqvarna_automower_ble
gardena-bluetooth==2.4.0
gardena-bluetooth==2.8.1
# homeassistant.components.google_assistant_sdk
gassist-text==0.0.14
@@ -1109,10 +1109,10 @@ hole==0.9.0
# homeassistant.components.holiday
# homeassistant.components.workday
holidays==0.95
holidays==0.96
# homeassistant.components.frontend
home-assistant-frontend==20260429.2
home-assistant-frontend==20260429.3
# homeassistant.components.conversation
home-assistant-intents==2026.5.5
@@ -1124,7 +1124,7 @@ homekit-audio-proxy==1.2.1
homelink-integration-api==0.0.1
# homeassistant.components.homematicip_cloud
homematicip==2.9.0
homematicip==2.10.0
# homeassistant.components.homevolt
homevolt==0.5.0
@@ -1690,7 +1690,7 @@ pyHomee==1.3.8
pyRFXtrx==0.31.1
# homeassistant.components.tibber
pyTibber==0.37.3
pyTibber==0.37.5
# homeassistant.components.dlink
pyW215==0.8.0
@@ -2478,7 +2478,7 @@ samsungtvws[async,encrypted]==2.7.2
sanix==1.0.6
# homeassistant.components.satel_integra
satel-integra==1.3.0
satel-integra==1.3.1
# homeassistant.components.screenlogic
screenlogicpy==0.10.2
@@ -2517,7 +2517,7 @@ sentry-sdk==2.48.0
# homeassistant.components.acer_projector
# homeassistant.components.serial
# homeassistant.components.usb
serialx==1.7.0
serialx==1.7.1
# homeassistant.components.sfr_box
sfrbox-api==0.1.1
+1 -5
View File
@@ -29,6 +29,7 @@ from unittest.mock import AsyncMock, Mock, patch
from aiohttp.test_utils import unused_port as get_test_instance_port
from annotatedyaml import load_yaml_dict, loader as yaml_loader
import attr
from paho.mqtt.client import MQTTMessage
import pytest
from syrupy.assertion import SnapshotAssertion
import voluptuous as vol
@@ -453,11 +454,6 @@ def async_fire_mqtt_message(
retain: bool = False,
) -> None:
"""Fire the MQTT message."""
# Local import to avoid processing MQTT modules when running a testcase
# which does not use MQTT.
from paho.mqtt.client import MQTTMessage # noqa: PLC0415
from homeassistant.components.mqtt import MqttData # noqa: PLC0415
if isinstance(payload, str):
@@ -63,6 +63,9 @@ async def test_battery_conditions_gated_by_labs_flag(
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
_LEVEL_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}}
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_key", "base_options", "supports_behavior", "supports_duration"),
@@ -71,6 +74,7 @@ async def test_battery_conditions_gated_by_labs_flag(
("battery.is_not_low", {}, True, True),
("battery.is_charging", {}, True, True),
("battery.is_not_charging", {}, True, True),
("battery.is_level", _LEVEL_THRESHOLD, True, True),
],
)
async def test_battery_condition_options_validation(
+6
View File
@@ -63,6 +63,10 @@ async def test_battery_triggers_gated_by_labs_flag(
await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key)
_LEVEL_CHANGED_THRESHOLD = {"threshold": {"type": "any"}}
_LEVEL_CROSSED_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}}
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_key", "base_options", "supports_behavior", "supports_duration"),
@@ -71,6 +75,8 @@ async def test_battery_triggers_gated_by_labs_flag(
("battery.not_low", {}, True, True),
("battery.started_charging", {}, True, True),
("battery.stopped_charging", {}, True, True),
("battery.level_changed", _LEVEL_CHANGED_THRESHOLD, False, False),
("battery.level_crossed_threshold", _LEVEL_CROSSED_THRESHOLD, True, True),
],
)
async def test_battery_trigger_options_validation(
+25
View File
@@ -56,10 +56,35 @@ from tests.common import (
async_mock_service,
mock_device_registry,
)
from tests.components.common import assert_trigger_options_supported
_LOGGER = logging.getLogger(__name__)
@pytest.mark.parametrize(
("trigger_key", "base_options", "supports_behavior", "supports_duration"),
[
("calendar.event_started", {}, False, False),
("calendar.event_ended", {}, False, False),
],
)
async def test_calendar_trigger_options_validation(
hass: HomeAssistant,
trigger_key: str,
base_options: dict[str, Any] | None,
supports_behavior: bool,
supports_duration: bool,
) -> None:
"""Test that calendar triggers support the expected options."""
await assert_trigger_options_supported(
hass,
trigger_key,
base_options,
supports_behavior=supports_behavior,
supports_duration=supports_duration,
)
@dataclass
class TriggerFormat:
"""Abstraction for different trigger configuration formats."""
@@ -60,6 +60,15 @@ async def test_climate_conditions_gated_by_labs_flag(
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
_HUMIDITY_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}}
_TEMPERATURE_THRESHOLD = {
"threshold": {
"type": "above",
"value": {"number": 20, "unit_of_measurement": UnitOfTemperature.CELSIUS},
}
}
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_key", "base_options", "supports_behavior", "supports_duration"),
@@ -69,6 +78,9 @@ async def test_climate_conditions_gated_by_labs_flag(
("climate.is_cooling", {}, True, True),
("climate.is_drying", {}, True, True),
("climate.is_heating", {}, True, True),
("climate.is_hvac_mode", {"hvac_mode": [HVACMode.HEAT]}, True, True),
("climate.target_humidity", _HUMIDITY_THRESHOLD, True, True),
("climate.target_temperature", _TEMPERATURE_THRESHOLD, True, True),
],
)
async def test_climate_condition_options_validation(
@@ -332,12 +344,14 @@ async def test_climate_attribute_condition_behavior_all(
"climate.target_humidity",
HVACMode.AUTO,
ATTR_HUMIDITY,
attribute_required=True,
),
*parametrize_numerical_attribute_condition_above_below_any(
"climate.target_temperature",
HVACMode.AUTO,
ATTR_TEMPERATURE,
threshold_unit=UnitOfTemperature.CELSIUS,
attribute_required=True,
),
],
)
@@ -376,12 +390,14 @@ async def test_climate_numerical_condition_behavior_any(
"climate.target_humidity",
HVACMode.AUTO,
ATTR_HUMIDITY,
attribute_required=True,
),
*parametrize_numerical_attribute_condition_above_below_all(
"climate.target_temperature",
HVACMode.AUTO,
ATTR_TEMPERATURE,
threshold_unit=UnitOfTemperature.CELSIUS,
attribute_required=True,
),
],
)
+29
View File
@@ -67,6 +67,16 @@ async def test_climate_triggers_gated_by_labs_flag(
await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key)
_CHANGED_THRESHOLD = {"threshold": {"type": "any"}}
_HUMIDITY_CROSSED_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}}
_TEMPERATURE_CROSSED_THRESHOLD = {
"threshold": {
"type": "above",
"value": {"number": 20, "unit_of_measurement": UnitOfTemperature.CELSIUS},
}
}
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_key", "base_options", "supports_behavior", "supports_duration"),
@@ -76,6 +86,21 @@ async def test_climate_triggers_gated_by_labs_flag(
("climate.started_heating", {}, True, True),
("climate.turned_off", {}, True, True),
("climate.turned_on", {}, True, True),
("climate.hvac_mode_changed", {"hvac_mode": [HVACMode.HEAT]}, True, True),
("climate.target_humidity_changed", _CHANGED_THRESHOLD, False, False),
(
"climate.target_humidity_crossed_threshold",
_HUMIDITY_CROSSED_THRESHOLD,
True,
True,
),
("climate.target_temperature_changed", _CHANGED_THRESHOLD, False, False),
(
"climate.target_temperature_crossed_threshold",
_TEMPERATURE_CROSSED_THRESHOLD,
True,
True,
),
],
)
async def test_climate_trigger_options_validation(
@@ -228,6 +253,7 @@ async def test_climate_state_trigger_behavior_any(
HVACMode.AUTO,
ATTR_TEMPERATURE,
threshold_unit=UnitOfTemperature.CELSIUS,
attribute_required=True,
),
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.target_humidity_crossed_threshold",
@@ -240,6 +266,7 @@ async def test_climate_state_trigger_behavior_any(
HVACMode.AUTO,
ATTR_TEMPERATURE,
threshold_unit=UnitOfTemperature.CELSIUS,
attribute_required=True,
),
*parametrize_trigger_states(
trigger="climate.started_cooling",
@@ -358,6 +385,7 @@ async def test_climate_state_trigger_behavior_first(
HVACMode.AUTO,
ATTR_TEMPERATURE,
threshold_unit=UnitOfTemperature.CELSIUS,
attribute_required=True,
),
*parametrize_trigger_states(
trigger="climate.started_cooling",
@@ -476,6 +504,7 @@ async def test_climate_state_trigger_behavior_last(
HVACMode.AUTO,
ATTR_TEMPERATURE,
threshold_unit=UnitOfTemperature.CELSIUS,
attribute_required=True,
),
*parametrize_trigger_states(
trigger="climate.started_cooling",
+168 -67
View File
@@ -246,6 +246,7 @@ def _parametrize_condition_states(
condition_options: dict[str, Any] | None = None,
target_states: list[str | None | tuple[str | None, dict]],
other_states: list[str | None | tuple[str | None, dict]],
extra_excluded_states: list[str | None | tuple[str | None, dict]] | None = None,
required_filter_attributes: dict | None,
condition_true_if_invalid: bool,
excluded_entities_from_other_domain: bool,
@@ -261,6 +262,7 @@ def _parametrize_condition_states(
required_filter_attributes = required_filter_attributes or {}
condition_options = condition_options or {}
extra_excluded_states = extra_excluded_states or []
add_excluded_state = excluded_entities_from_other_domain or bool(
required_filter_attributes
)
@@ -314,6 +316,18 @@ def _parametrize_condition_states(
STATE_UNKNOWN, condition_true_if_invalid, True
),
),
# `extra_excluded_states` are filtered by the condition's
# `_should_include` override exactly like
# missing/unavailable/unknown, so they share the
# `condition_true_if_invalid` expectation: vacuous True
# under behavior=all (every entity filtered → all-check
# vacuous), vacuous False under behavior=any.
(
state_with_attributes(
extra_excluded_state, condition_true_if_invalid, True
)
for extra_excluded_state in extra_excluded_states
),
(
state_with_attributes(other_state, False, False)
for other_state in other_states
@@ -342,6 +356,7 @@ def parametrize_condition_states_any(
condition_options: dict[str, Any] | None = None,
target_states: list[str | None | tuple[str | None, dict]],
other_states: list[str | None | tuple[str | None, dict]],
extra_excluded_states: list[str | None | tuple[str | None, dict]] | None = None,
required_filter_attributes: dict | None = None,
excluded_entities_from_other_domain: bool = False,
) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]:
@@ -364,6 +379,13 @@ def parametrize_condition_states_any(
other_states: States the condition is expected to evaluate False for.
Same accepted shapes as `target_states`. With behavior=any, an
entity in such a state does not satisfy the condition.
extra_excluded_states: *Additional* states (on top of the always-
excluded missing/unavailable/unknown states) that the
condition's `_should_include` override is expected to filter out.
Under behavior=any, every targeted entity sitting in a filtered
state yields `any([]) False`, so these share the built-in
invalid states' expectation. Set this for conditions whose
`_should_include` skips entities lacking the tracked attribute.
required_filter_attributes: Attributes that must be present on the
entity for the condition's domain filter to accept it. The
helper merges these into every generated state so the entity
@@ -380,6 +402,7 @@ def parametrize_condition_states_any(
condition_options=condition_options,
target_states=target_states,
other_states=other_states,
extra_excluded_states=extra_excluded_states,
required_filter_attributes=required_filter_attributes,
condition_true_if_invalid=False,
excluded_entities_from_other_domain=excluded_entities_from_other_domain,
@@ -392,6 +415,7 @@ def parametrize_condition_states_all(
condition_options: dict[str, Any] | None = None,
target_states: list[str | None | tuple[str | None, dict]],
other_states: list[str | None | tuple[str | None, dict]],
extra_excluded_states: list[str | None | tuple[str | None, dict]] | None = None,
required_filter_attributes: dict | None = None,
excluded_entities_from_other_domain: bool = False,
) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]:
@@ -416,6 +440,14 @@ def parametrize_condition_states_all(
for. Same accepted shapes as `target_states`. Under behavior=all,
an entity in such a state blocks the all-check (counts toward
the check but is not a match).
extra_excluded_states: *Additional* states (on top of the always-
excluded/filtered-out missing/unavailable/unknown states) that
the condition's `_should_include` override is expected to filter
out. Under behavior=all, every targeted entity sitting in a
filtered state yields `all([]) True` (vacuous), so these share
the built-in invalid states' expectation. Set this for
conditions whose `_should_include` skips entities lacking the
tracked attribute.
required_filter_attributes: Attributes that must be present on the
entity for the condition's domain filter to accept it. The
helper merges these into every generated state so the entity
@@ -432,6 +464,7 @@ def parametrize_condition_states_all(
condition_options=condition_options,
target_states=target_states,
other_states=other_states,
extra_excluded_states=extra_excluded_states,
required_filter_attributes=required_filter_attributes,
condition_true_if_invalid=True,
excluded_entities_from_other_domain=excluded_entities_from_other_domain,
@@ -741,12 +774,15 @@ def parametrize_trigger_states(
# trigger should still fire when the entity under test alone transitions
# other -> target.
# Sequence per (target, other, excluded):
# entity_id: other -> target (1)
# others: other -> excluded
# i.e. step 0 sets all entities to `other`; step 1 transitions the
# entity under test to `target` while the others go to `excluded` (via
# `others_state`). The all/count check filters the others out, so a
# single matching entity is enough to fire `behavior=last`.
# step 0: all entities at `other`.
# step 1: entity_id stays at `other`, peers transition to `excluded`.
# This positions peers in their filtered state *before* the
# entity under test transitions, so all three behaviors
# (any/first/last) evaluate the firing transition with peers
# already filtered. count = 0.
# step 2: entity_id transitions to `target`, peers stay at `excluded`.
# The all/count check filters the peers out, so a single
# matching entity is enough to fire. count = 1.
tests.append(
(
trigger,
@@ -755,6 +791,9 @@ def parametrize_trigger_states(
itertools.chain.from_iterable(
(
state_with_attributes(other_state, 0),
state_with_attributes(
other_state, 0, others_state=excluded_state
),
state_with_attributes(
target_state, 1, others_state=excluded_state
),
@@ -794,6 +833,7 @@ def parametrize_numerical_attribute_changed_trigger_states(
trigger_options: dict[str, Any] | None = None,
required_filter_attributes: dict | None = None,
unit_attributes: dict | None = None,
attribute_value_scale: float = 1.0,
attribute_required: bool = False,
) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]:
"""Parametrize states and expected service call counts for numerical-changed triggers.
@@ -827,6 +867,12 @@ def parametrize_numerical_attribute_changed_trigger_states(
unit_attributes: Attributes (typically `{ATTR_UNIT_OF_MEASUREMENT: ...}`)
merged into every generated state, so the entity carries a unit
alongside its tracked attribute.
attribute_value_scale: Multiplier applied to the helper's fixed
attribute values before they are written to the state. Use
this when the trigger stores its tracked value on a different
scale than the threshold e.g. `media_player` volume is
stored as 0.01.0 but the threshold is in percent, so pass
`attribute_value_scale=0.01`.
attribute_required: When True, `(state, {attribute: None})` is
classified as an *excluded* state (filtered out of the all/count
check by the trigger's `_should_include` override) instead of an
@@ -843,6 +889,7 @@ def parametrize_numerical_attribute_changed_trigger_states(
# the all/count check), giving us a proper "other" state. Mirrors how
# `parametrize_numerical_state_value_changed_trigger_states` uses the
# literal string "none" as a non-numeric state value.
s = attribute_value_scale
if attribute_required:
extra_excluded_states = [(state, {attribute: None} | unit_attributes)]
other_invalid_attr = (state, {attribute: "none"} | unit_attributes)
@@ -863,9 +910,9 @@ def parametrize_numerical_attribute_changed_trigger_states(
threshold_unit,
),
target_states=[
(state, {attribute: 0} | unit_attributes),
(state, {attribute: 50} | unit_attributes),
(state, {attribute: 100} | unit_attributes),
(state, {attribute: 0 * s} | unit_attributes),
(state, {attribute: 50 * s} | unit_attributes),
(state, {attribute: 100 * s} | unit_attributes),
],
other_states=[other_invalid_attr],
extra_excluded_states=extra_excluded_states,
@@ -885,12 +932,12 @@ def parametrize_numerical_attribute_changed_trigger_states(
threshold_unit,
),
target_states=[
(state, {attribute: 50} | unit_attributes),
(state, {attribute: 100} | unit_attributes),
(state, {attribute: 50 * s} | unit_attributes),
(state, {attribute: 100 * s} | unit_attributes),
],
other_states=[
other_invalid_attr,
(state, {attribute: 0} | unit_attributes),
(state, {attribute: 0 * s} | unit_attributes),
],
extra_excluded_states=extra_excluded_states,
required_filter_attributes=required_filter_attributes,
@@ -909,12 +956,12 @@ def parametrize_numerical_attribute_changed_trigger_states(
threshold_unit,
),
target_states=[
(state, {attribute: 0} | unit_attributes),
(state, {attribute: 50} | unit_attributes),
(state, {attribute: 0 * s} | unit_attributes),
(state, {attribute: 50 * s} | unit_attributes),
],
other_states=[
other_invalid_attr,
(state, {attribute: 100} | unit_attributes),
(state, {attribute: 100 * s} | unit_attributes),
],
extra_excluded_states=extra_excluded_states,
required_filter_attributes=required_filter_attributes,
@@ -932,6 +979,7 @@ def parametrize_numerical_attribute_crossed_threshold_trigger_states(
trigger_options: dict[str, Any] | None = None,
required_filter_attributes: dict | None = None,
unit_attributes: dict | None = None,
attribute_value_scale: float = 1.0,
attribute_required: bool = False,
) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]:
"""Parametrize states and expected service call counts for numerical crossed-threshold triggers.
@@ -967,6 +1015,12 @@ def parametrize_numerical_attribute_crossed_threshold_trigger_states(
unit_attributes: Attributes (typically `{ATTR_UNIT_OF_MEASUREMENT: ...}`)
merged into every generated state, so the entity carries a unit
alongside its tracked attribute.
attribute_value_scale: Multiplier applied to the helper's fixed
attribute values before they are written to the state. Use
this when the trigger stores its tracked value on a different
scale than the threshold e.g. `media_player` volume is
stored as 0.01.0 but the threshold is in percent, so pass
`attribute_value_scale=0.01`.
attribute_required: When True, `(state, {attribute: None})` is
classified as an *excluded* state (filtered out of the all/count
check by the trigger's `_should_include` override) instead of an
@@ -980,6 +1034,7 @@ def parametrize_numerical_attribute_crossed_threshold_trigger_states(
# when `attribute_required=True`: the override would filter `None`
# out of the all/count check, so we use a value that fails
# `is_valid_state` but is still included.
s = attribute_value_scale
if attribute_required:
extra_excluded_states = [(state, {attribute: None} | unit_attributes)]
other_invalid_attr = (state, {attribute: "none"} | unit_attributes)
@@ -1002,13 +1057,13 @@ def parametrize_numerical_attribute_crossed_threshold_trigger_states(
threshold_unit,
),
target_states=[
(state, {attribute: 50} | unit_attributes),
(state, {attribute: 60} | unit_attributes),
(state, {attribute: 50 * s} | unit_attributes),
(state, {attribute: 60 * s} | unit_attributes),
],
other_states=[
other_invalid_attr,
(state, {attribute: 0} | unit_attributes),
(state, {attribute: 100} | unit_attributes),
(state, {attribute: 0 * s} | unit_attributes),
(state, {attribute: 100 * s} | unit_attributes),
],
extra_excluded_states=extra_excluded_states,
required_filter_attributes=required_filter_attributes,
@@ -1027,13 +1082,13 @@ def parametrize_numerical_attribute_crossed_threshold_trigger_states(
threshold_unit,
),
target_states=[
(state, {attribute: 0} | unit_attributes),
(state, {attribute: 100} | unit_attributes),
(state, {attribute: 0 * s} | unit_attributes),
(state, {attribute: 100 * s} | unit_attributes),
],
other_states=[
other_invalid_attr,
(state, {attribute: 50} | unit_attributes),
(state, {attribute: 60} | unit_attributes),
(state, {attribute: 50 * s} | unit_attributes),
(state, {attribute: 60 * s} | unit_attributes),
],
extra_excluded_states=extra_excluded_states,
required_filter_attributes=required_filter_attributes,
@@ -1051,12 +1106,12 @@ def parametrize_numerical_attribute_crossed_threshold_trigger_states(
threshold_unit,
),
target_states=[
(state, {attribute: 50} | unit_attributes),
(state, {attribute: 100} | unit_attributes),
(state, {attribute: 50 * s} | unit_attributes),
(state, {attribute: 100 * s} | unit_attributes),
],
other_states=[
other_invalid_attr,
(state, {attribute: 0} | unit_attributes),
(state, {attribute: 0 * s} | unit_attributes),
],
extra_excluded_states=extra_excluded_states,
required_filter_attributes=required_filter_attributes,
@@ -1074,12 +1129,12 @@ def parametrize_numerical_attribute_crossed_threshold_trigger_states(
threshold_unit,
),
target_states=[
(state, {attribute: 0} | unit_attributes),
(state, {attribute: 50} | unit_attributes),
(state, {attribute: 0 * s} | unit_attributes),
(state, {attribute: 50 * s} | unit_attributes),
],
other_states=[
other_invalid_attr,
(state, {attribute: 100} | unit_attributes),
(state, {attribute: 100 * s} | unit_attributes),
],
extra_excluded_states=extra_excluded_states,
required_filter_attributes=required_filter_attributes,
@@ -2095,6 +2150,8 @@ def parametrize_numerical_attribute_condition_above_below_any(
required_filter_attributes: dict | None = None,
threshold_unit: str | None | UndefinedType = UNDEFINED,
unit_attributes: dict | None = None,
attribute_required: bool = False,
attribute_value_scale: float = 1.0,
) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]:
"""Parametrize above/below/between threshold cases for attribute-based numerical conditions under behavior=any.
@@ -2134,9 +2191,27 @@ def parametrize_numerical_attribute_condition_above_below_any(
`{ATTR_UNIT_OF_MEASUREMENT: ...}`) merged into every generated
state, so the entity carries a unit alongside its tracked
attribute.
attribute_required: When True, `(state, {attribute: None})` is
classified as an *excluded* state (filtered out of the all/any
check by the condition's `_should_include` override) rather
than treated as just-missing. Set this for conditions whose
`_should_include` skips entities lacking the tracked
attribute.
attribute_value_scale: Multiplier applied to the helper's fixed
attribute values before they are written to the state. Use
this when the condition stores its tracked value on a
different scale than the threshold e.g. `media_player`
volume is stored as 0.01.0 but the threshold is in percent,
so pass `attribute_value_scale=0.01`; light brightness is
stored as 0255 but the threshold is in percent, so pass
`attribute_value_scale=255/100`.
"""
condition_options = condition_options or {}
unit_attributes = unit_attributes or {}
s = attribute_value_scale
extra_excluded_states = (
[(state, {attribute: None} | unit_attributes)] if attribute_required else None
)
return [
*parametrize_condition_states_any(
@@ -2149,15 +2224,16 @@ def parametrize_numerical_attribute_condition_above_below_any(
threshold_unit,
),
target_states=[
(state, {attribute: 21} | unit_attributes),
(state, {attribute: 50} | unit_attributes),
(state, {attribute: 100} | unit_attributes),
(state, {attribute: 21 * s} | unit_attributes),
(state, {attribute: 50 * s} | unit_attributes),
(state, {attribute: 100 * s} | unit_attributes),
],
other_states=[
(state, {attribute: 0} | unit_attributes),
(state, {attribute: 10} | unit_attributes),
(state, {attribute: 20} | unit_attributes),
(state, {attribute: 0 * s} | unit_attributes),
(state, {attribute: 10 * s} | unit_attributes),
(state, {attribute: 20 * s} | unit_attributes),
],
extra_excluded_states=extra_excluded_states,
required_filter_attributes=required_filter_attributes,
),
*parametrize_condition_states_any(
@@ -2170,15 +2246,16 @@ def parametrize_numerical_attribute_condition_above_below_any(
threshold_unit,
),
target_states=[
(state, {attribute: 0} | unit_attributes),
(state, {attribute: 50} | unit_attributes),
(state, {attribute: 79} | unit_attributes),
(state, {attribute: 0 * s} | unit_attributes),
(state, {attribute: 50 * s} | unit_attributes),
(state, {attribute: 79 * s} | unit_attributes),
],
other_states=[
(state, {attribute: 80} | unit_attributes),
(state, {attribute: 90} | unit_attributes),
(state, {attribute: 100} | unit_attributes),
(state, {attribute: 80 * s} | unit_attributes),
(state, {attribute: 90 * s} | unit_attributes),
(state, {attribute: 100 * s} | unit_attributes),
],
extra_excluded_states=extra_excluded_states,
required_filter_attributes=required_filter_attributes,
),
*parametrize_condition_states_any(
@@ -2195,16 +2272,17 @@ def parametrize_numerical_attribute_condition_above_below_any(
threshold_unit,
),
target_states=[
(state, {attribute: 21} | unit_attributes),
(state, {attribute: 50} | unit_attributes),
(state, {attribute: 79} | unit_attributes),
(state, {attribute: 21 * s} | unit_attributes),
(state, {attribute: 50 * s} | unit_attributes),
(state, {attribute: 79 * s} | unit_attributes),
],
other_states=[
(state, {attribute: 0} | unit_attributes),
(state, {attribute: 20} | unit_attributes),
(state, {attribute: 80} | unit_attributes),
(state, {attribute: 100} | unit_attributes),
(state, {attribute: 0 * s} | unit_attributes),
(state, {attribute: 20 * s} | unit_attributes),
(state, {attribute: 80 * s} | unit_attributes),
(state, {attribute: 100 * s} | unit_attributes),
],
extra_excluded_states=extra_excluded_states,
required_filter_attributes=required_filter_attributes,
),
]
@@ -2219,6 +2297,8 @@ def parametrize_numerical_attribute_condition_above_below_all(
required_filter_attributes: dict | None = None,
threshold_unit: str | None | UndefinedType = UNDEFINED,
unit_attributes: dict | None = None,
attribute_required: bool = False,
attribute_value_scale: float = 1.0,
) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]:
"""Parametrize above/below/between threshold cases for attribute-based numerical conditions under behavior=all.
@@ -2256,9 +2336,27 @@ def parametrize_numerical_attribute_condition_above_below_all(
`{ATTR_UNIT_OF_MEASUREMENT: ...}`) merged into every generated
state, so the entity carries a unit alongside its tracked
attribute.
attribute_required: When True, `(state, {attribute: None})` is
classified as an *excluded* state (filtered out of the all/any
check by the condition's `_should_include` override) rather
than treated as just-missing. Set this for conditions whose
`_should_include` skips entities lacking the tracked
attribute.
attribute_value_scale: Multiplier applied to the helper's fixed
attribute values before they are written to the state. Use
this when the condition stores its tracked value on a
different scale than the threshold e.g. `media_player`
volume is stored as 0.01.0 but the threshold is in percent,
so pass `attribute_value_scale=0.01`; light brightness is
stored as 0255 but the threshold is in percent, so pass
`attribute_value_scale=255/100`.
"""
condition_options = condition_options or {}
unit_attributes = unit_attributes or {}
s = attribute_value_scale
extra_excluded_states = (
[(state, {attribute: None} | unit_attributes)] if attribute_required else None
)
return [
*parametrize_condition_states_all(
@@ -2271,15 +2369,16 @@ def parametrize_numerical_attribute_condition_above_below_all(
threshold_unit,
),
target_states=[
(state, {attribute: 21} | unit_attributes),
(state, {attribute: 50} | unit_attributes),
(state, {attribute: 100} | unit_attributes),
(state, {attribute: 21 * s} | unit_attributes),
(state, {attribute: 50 * s} | unit_attributes),
(state, {attribute: 100 * s} | unit_attributes),
],
other_states=[
(state, {attribute: 0} | unit_attributes),
(state, {attribute: 10} | unit_attributes),
(state, {attribute: 20} | unit_attributes),
(state, {attribute: 0 * s} | unit_attributes),
(state, {attribute: 10 * s} | unit_attributes),
(state, {attribute: 20 * s} | unit_attributes),
],
extra_excluded_states=extra_excluded_states,
required_filter_attributes=required_filter_attributes,
),
*parametrize_condition_states_all(
@@ -2292,15 +2391,16 @@ def parametrize_numerical_attribute_condition_above_below_all(
threshold_unit,
),
target_states=[
(state, {attribute: 0} | unit_attributes),
(state, {attribute: 50} | unit_attributes),
(state, {attribute: 79} | unit_attributes),
(state, {attribute: 0 * s} | unit_attributes),
(state, {attribute: 50 * s} | unit_attributes),
(state, {attribute: 79 * s} | unit_attributes),
],
other_states=[
(state, {attribute: 80} | unit_attributes),
(state, {attribute: 90} | unit_attributes),
(state, {attribute: 100} | unit_attributes),
(state, {attribute: 80 * s} | unit_attributes),
(state, {attribute: 90 * s} | unit_attributes),
(state, {attribute: 100 * s} | unit_attributes),
],
extra_excluded_states=extra_excluded_states,
required_filter_attributes=required_filter_attributes,
),
*parametrize_condition_states_all(
@@ -2317,16 +2417,17 @@ def parametrize_numerical_attribute_condition_above_below_all(
threshold_unit,
),
target_states=[
(state, {attribute: 21} | unit_attributes),
(state, {attribute: 50} | unit_attributes),
(state, {attribute: 79} | unit_attributes),
(state, {attribute: 21 * s} | unit_attributes),
(state, {attribute: 50 * s} | unit_attributes),
(state, {attribute: 79 * s} | unit_attributes),
],
other_states=[
(state, {attribute: 0} | unit_attributes),
(state, {attribute: 20} | unit_attributes),
(state, {attribute: 80} | unit_attributes),
(state, {attribute: 100} | unit_attributes),
(state, {attribute: 0 * s} | unit_attributes),
(state, {attribute: 20 * s} | unit_attributes),
(state, {attribute: 80 * s} | unit_attributes),
(state, {attribute: 100 * s} | unit_attributes),
],
extra_excluded_states=extra_excluded_states,
required_filter_attributes=required_filter_attributes,
),
]
+10 -4
View File
@@ -25,11 +25,17 @@ async def target_counters(hass: HomeAssistant) -> dict[str, list[str]]:
return await target_entities(hass, "counter")
async def test_counter_condition_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
@pytest.mark.parametrize(
"condition",
[
"counter.is_value",
],
)
async def test_counter_conditions_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str
) -> None:
"""Test the counter condition is gated by the labs flag."""
await assert_condition_gated_by_labs_flag(hass, caplog, "counter.is_value")
"""Test the counter conditions are gated by the labs flag."""
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
_PLAIN_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}}
+10 -3
View File
@@ -39,9 +39,16 @@ async def target_covers(hass: HomeAssistant) -> dict[str, list[str]]:
@pytest.mark.parametrize(
"condition",
[
condition
for _, is_open, is_closed in DEVICE_CLASS_CONDITIONS
for condition in (is_open, is_closed)
"cover.awning_is_closed",
"cover.awning_is_open",
"cover.blind_is_closed",
"cover.blind_is_open",
"cover.curtain_is_closed",
"cover.curtain_is_open",
"cover.shade_is_closed",
"cover.shade_is_open",
"cover.shutter_is_closed",
"cover.shutter_is_open",
],
)
async def test_cover_conditions_gated_by_labs_flag(
+10 -3
View File
@@ -38,9 +38,16 @@ async def target_covers(hass: HomeAssistant) -> dict[str, list[str]]:
@pytest.mark.parametrize(
"trigger_key",
[
trigger
for _, opened, closed in DEVICE_CLASS_TRIGGERS
for trigger in (opened, closed)
"cover.awning_closed",
"cover.awning_opened",
"cover.blind_closed",
"cover.blind_opened",
"cover.curtain_closed",
"cover.curtain_opened",
"cover.shade_closed",
"cover.shade_opened",
"cover.shutter_closed",
"cover.shutter_opened",
],
)
async def test_cover_triggers_gated_by_labs_flag(
-1
View File
@@ -18,7 +18,6 @@ async def setup_platform(hass: HomeAssistant, platform: str) -> MockConfigEntry:
domain=DOMAIN,
data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT},
unique_id=MOCK_HOST,
version=2,
)
mock_entry.add_to_hass(hass)
@@ -63,7 +63,7 @@ async def test_home(
== BinarySensorDeviceClass.DOOR
)
assert (
hass.states.get("binary_sensor.ouverture_porte_cover").attributes[
hass.states.get("binary_sensor.ouverture_porte_couvercle").attributes[
ATTR_DEVICE_CLASS
]
== BinarySensorDeviceClass.SAFETY
@@ -71,9 +71,9 @@ async def test_home(
# Initial state
assert hass.states.get("binary_sensor.detecteur").state == "on"
assert hass.states.get("binary_sensor.detecteur_cover").state == "off"
assert hass.states.get("binary_sensor.detecteur_couvercle").state == "off"
assert hass.states.get("binary_sensor.ouverture_porte").state == "unknown"
assert hass.states.get("binary_sensor.ouverture_porte_cover").state == "off"
assert hass.states.get("binary_sensor.ouverture_porte_couvercle").state == "off"
# Now simulate a changed status
data_home_get_values_changed = deepcopy(DATA_HOME_PIR_GET_VALUE)
@@ -86,6 +86,6 @@ async def test_home(
await hass.async_block_till_done()
assert hass.states.get("binary_sensor.detecteur").state == "off"
assert hass.states.get("binary_sensor.detecteur_cover").state == "on"
assert hass.states.get("binary_sensor.detecteur_couvercle").state == "on"
assert hass.states.get("binary_sensor.ouverture_porte").state == "off"
assert hass.states.get("binary_sensor.ouverture_porte_cover").state == "on"
assert hass.states.get("binary_sensor.ouverture_porte_couvercle").state == "on"
+1 -1
View File
@@ -28,7 +28,7 @@ async def test_reboot(hass: HomeAssistant, router: Mock) -> None:
BUTTON_DOMAIN,
SERVICE_PRESS,
service_data={
ATTR_ENTITY_ID: "button.freebox_server_r2_restart",
ATTR_ENTITY_ID: "button.freebox_server_r2_reboot_freebox",
},
blocking=True,
)
+4 -136
View File
@@ -1,31 +1,21 @@
"""Tests for the Freebox init."""
from copy import deepcopy
from unittest.mock import ANY, Mock
from freezegun.api import FrozenDateTimeFactory
import pytest
from pytest_unordered import unordered
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN
from homeassistant.components.device_tracker import DOMAIN as DT_DOMAIN
from homeassistant.components.freebox import SCAN_INTERVAL
from homeassistant.components.freebox.const import DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_HOST, CONF_PORT, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
from .common import setup_platform
from .const import DATA_HOME_GET_NODES, MOCK_HOST, MOCK_PORT
from .const import MOCK_HOST, MOCK_PORT
from tests.common import MockConfigEntry, async_fire_time_changed
MOCK_MAC = "68:A3:78:00:00:00"
from tests.common import MockConfigEntry
async def test_setup(hass: HomeAssistant, router: Mock) -> None:
@@ -34,7 +24,6 @@ async def test_setup(hass: HomeAssistant, router: Mock) -> None:
domain=DOMAIN,
data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT},
unique_id=MOCK_HOST,
version=2,
)
entry.add_to_hass(hass)
assert await async_setup_component(hass, DOMAIN, {})
@@ -52,7 +41,6 @@ async def test_setup_import(hass: HomeAssistant, router: Mock) -> None:
domain=DOMAIN,
data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT},
unique_id=MOCK_HOST,
version=2,
)
entry.add_to_hass(hass)
assert await async_setup_component(
@@ -68,13 +56,12 @@ async def test_setup_import(hass: HomeAssistant, router: Mock) -> None:
async def test_unload_remove(hass: HomeAssistant, router: Mock) -> None:
"""Test unload and remove of integration."""
entity_id_dt = f"{DT_DOMAIN}.freebox_server_r2"
entity_id_sensor = f"{SENSOR_DOMAIN}.freebox_server_r2_download_speed"
entity_id_switch = f"{SWITCH_DOMAIN}.freebox_server_r2_wi_fi"
entity_id_sensor = f"{SENSOR_DOMAIN}.freebox_server_r2_freebox_download_speed"
entity_id_switch = f"{SWITCH_DOMAIN}.freebox_server_r2_freebox_wifi"
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT},
version=2,
)
entry.add_to_hass(hass)
@@ -116,122 +103,3 @@ async def test_unload_remove(hass: HomeAssistant, router: Mock) -> None:
assert state_sensor is None
state_switch = hass.states.get(entity_id_switch)
assert state_switch is None
@pytest.mark.parametrize(
("platform", "old_suffix", "new_key"),
[
(SENSOR_DOMAIN, "Freebox download speed", "rate_down"),
(SENSOR_DOMAIN, "Freebox upload speed", "rate_up"),
(SENSOR_DOMAIN, "Freebox missed calls", "missed"),
(SENSOR_DOMAIN, "Freebox Disque dur", "temp_hdd"),
(SENSOR_DOMAIN, "Freebox Disque dur 2", "temp_hdd2"),
(SENSOR_DOMAIN, "Freebox Température Switch", "temp_sw"),
(SENSOR_DOMAIN, "Freebox Température CPU M", "temp_cpum"),
(SENSOR_DOMAIN, "Freebox Température CPU B", "temp_cpub"),
(BUTTON_DOMAIN, "Reboot Freebox", "reboot"),
(BUTTON_DOMAIN, "Mark calls as read", "mark_calls_as_read"),
(SWITCH_DOMAIN, "Freebox WiFi", "wifi"),
],
)
async def test_unique_id_migration(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
router: Mock,
platform: str,
old_suffix: str,
new_key: str,
) -> None:
"""Test migration of name-based unique ids to key-based ones."""
old_unique_id = f"{MOCK_MAC} {old_suffix}"
new_unique_id = f"{MOCK_MAC} {new_key}"
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT},
unique_id=MOCK_HOST,
)
entry.add_to_hass(hass)
entity_registry.async_get_or_create(
platform,
DOMAIN,
old_unique_id,
config_entry=entry,
)
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
assert entity_registry.async_get_entity_id(platform, DOMAIN, old_unique_id) is None
assert (
entity_registry.async_get_entity_id(platform, DOMAIN, new_unique_id) is not None
)
async def test_unique_id_migration_collision(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
router: Mock,
) -> None:
"""Test migration logs a warning when target unique_id already exists."""
old_unique_id = f"{MOCK_MAC} Freebox download speed"
new_unique_id = f"{MOCK_MAC} rate_down"
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT},
unique_id=MOCK_HOST,
)
entry.add_to_hass(hass)
entity_registry.async_get_or_create(
SENSOR_DOMAIN, DOMAIN, old_unique_id, config_entry=entry
)
entity_registry.async_get_or_create(
SENSOR_DOMAIN, DOMAIN, new_unique_id, config_entry=entry
)
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
# Both entities still exist; migration did not crash.
assert (
entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, old_unique_id)
is not None
)
assert (
entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, new_unique_id)
is not None
)
async def test_home_device_label_sync(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
freezer: FrozenDateTimeFactory,
router: Mock,
) -> None:
"""Test home device label changes propagate to the device registry."""
await setup_platform(hass, BINARY_SENSOR_DOMAIN)
pir_node_id = 26 # Détecteur from fixture
device = device_registry.async_get_device(identifiers={(DOMAIN, pir_node_id)})
assert device is not None
assert device.name == "Détecteur"
# API now returns a different label for the PIR.
updated_nodes = deepcopy(DATA_HOME_GET_NODES)
for node in updated_nodes:
if node["id"] == pir_node_id:
node["label"] = "Détecteur cuisine"
break
router().home.get_home_nodes.return_value = updated_nodes
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
device = device_registry.async_get_device(identifiers={(DOMAIN, pir_node_id)})
assert device is not None
assert device.name == "Détecteur cuisine"
+24 -22
View File
@@ -25,8 +25,14 @@ async def test_network_speed(
"""Test missed call sensor."""
await setup_platform(hass, SENSOR_DOMAIN)
assert hass.states.get("sensor.freebox_server_r2_download_speed").state == "198.9"
assert hass.states.get("sensor.freebox_server_r2_upload_speed").state == "1440.0"
assert (
hass.states.get("sensor.freebox_server_r2_freebox_download_speed").state
== "198.9"
)
assert (
hass.states.get("sensor.freebox_server_r2_freebox_upload_speed").state
== "1440.0"
)
# Simulate a changed speed
data_connection_get_status_changed = deepcopy(DATA_CONNECTION_GET_STATUS)
@@ -38,8 +44,14 @@ async def test_network_speed(
async_fire_time_changed(hass)
# To execute the save
await hass.async_block_till_done()
assert hass.states.get("sensor.freebox_server_r2_download_speed").state == "123.4"
assert hass.states.get("sensor.freebox_server_r2_upload_speed").state == "432.1"
assert (
hass.states.get("sensor.freebox_server_r2_freebox_download_speed").state
== "123.4"
)
assert (
hass.states.get("sensor.freebox_server_r2_freebox_upload_speed").state
== "432.1"
)
async def test_call(
@@ -48,7 +60,7 @@ async def test_call(
"""Test missed call sensor."""
await setup_platform(hass, SENSOR_DOMAIN)
assert hass.states.get("sensor.freebox_server_r2_missed_calls").state == "3"
assert hass.states.get("sensor.freebox_server_r2_freebox_missed_calls").state == "3"
# Simulate we marked calls as read
data_call_get_calls_marked_as_read = []
@@ -58,7 +70,7 @@ async def test_call(
async_fire_time_changed(hass)
# To execute the save
await hass.async_block_till_done()
assert hass.states.get("sensor.freebox_server_r2_missed_calls").state == "0"
assert hass.states.get("sensor.freebox_server_r2_freebox_missed_calls").state == "0"
async def test_disk(
@@ -92,25 +104,15 @@ async def test_disk(
assert hass.states.get("sensor.disk_3000_freebox_free_space").state == "44.9"
async def test_temperature(hass: HomeAssistant, router: Mock) -> None:
"""Test temperature sensors expose API names and values."""
await setup_platform(hass, SENSOR_DOMAIN)
assert hass.states.get("sensor.freebox_server_r2_disque_dur").state == "40"
assert hass.states.get("sensor.freebox_server_r2_temperature_switch").state == "50"
assert hass.states.get("sensor.freebox_server_r2_temperature_cpu_m").state == "60"
assert hass.states.get("sensor.freebox_server_r2_temperature_cpu_b").state == "56"
async def test_battery(
hass: HomeAssistant, freezer: FrozenDateTimeFactory, router: Mock
) -> None:
"""Test battery sensor."""
await setup_platform(hass, SENSOR_DOMAIN)
assert hass.states.get("sensor.telecommande_battery").state == "100"
assert hass.states.get("sensor.ouverture_porte_battery").state == "100"
assert hass.states.get("sensor.detecteur_battery").state == "100"
assert hass.states.get("sensor.telecommande_niveau_de_batterie").state == "100"
assert hass.states.get("sensor.ouverture_porte_niveau_de_batterie").state == "100"
assert hass.states.get("sensor.detecteur_niveau_de_batterie").state == "100"
# Simulate a changed battery
data_home_get_nodes_changed = deepcopy(DATA_HOME_GET_NODES)
@@ -123,6 +125,6 @@ async def test_battery(
async_fire_time_changed(hass)
# To execute the save
await hass.async_block_till_done()
assert hass.states.get("sensor.telecommande_battery").state == "25"
assert hass.states.get("sensor.ouverture_porte_battery").state == "50"
assert hass.states.get("sensor.detecteur_battery").state == "75"
assert hass.states.get("sensor.telecommande_niveau_de_batterie").state == "25"
assert hass.states.get("sensor.ouverture_porte_niveau_de_batterie").state == "50"
assert hass.states.get("sensor.detecteur_niveau_de_batterie").state == "75"
+46 -1
View File
@@ -1,11 +1,17 @@
"""The tests for the hassio component."""
from contextlib import AbstractContextManager, ExitStack as DefaultContext
from http import HTTPStatus
from unittest.mock import Mock, patch
from unittest.mock import MagicMock, Mock, patch
from aiohttp.test_utils import TestClient
from aiohttp.web_exceptions import HTTPUnauthorized
import pytest
from homeassistant.auth.providers.homeassistant import InvalidAuth
from homeassistant.components.hassio.auth import HassIOBaseAuth
from homeassistant.components.hassio.const import DATA_CONFIG_STORE
from homeassistant.core import HomeAssistant
async def test_auth_success(hassio_client_supervisor: TestClient) -> None:
@@ -162,6 +168,45 @@ async def test_password_fails_no_auth(hassio_noauth_client: TestClient) -> None:
assert resp.status == HTTPStatus.UNAUTHORIZED
@pytest.mark.parametrize(
("peername", "unix_socket", "expectation"),
[
# Unix socket transports report an empty string for peername. Before
# the fix this raised IndexError on `peername[0]`.
("", True, DefaultContext()),
# Defensive: a TCP transport with no peername at all should be
# rejected, not crash.
(None, False, pytest.raises(HTTPUnauthorized)),
],
)
@pytest.mark.usefixtures("hassio_stubs")
async def test_check_access_unix_socket_or_missing_peername(
hass: HomeAssistant,
peername: str | None,
unix_socket: bool,
expectation: AbstractContextManager,
) -> None:
"""Test _check_access handles Unix socket requests and missing peername."""
hassio_user_id = hass.data[DATA_CONFIG_STORE].data.hassio_user
assert hassio_user_id is not None
user = await hass.auth.async_get_user(hassio_user_id)
assert user is not None
auth_view = HassIOBaseAuth(hass, user)
request = MagicMock()
request.transport.get_extra_info.return_value = peername
request.__getitem__.return_value = user
with (
patch(
"homeassistant.components.hassio.auth.is_supervisor_unix_socket_request",
return_value=unix_socket,
),
expectation,
):
auth_view._check_access(request)
async def test_password_no_user(hassio_client_supervisor: TestClient) -> None:
"""Test changing password for invalid user."""
resp = await hassio_client_supervisor.post(
+93
View File
@@ -0,0 +1,93 @@
"""Tests for the Hive integration __init__."""
from unittest.mock import AsyncMock, MagicMock, patch
from homeassistant.components.hive.const import DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from tests.common import MockConfigEntry
_ENTRY_DATA = {
CONF_USERNAME: "user@example.com",
CONF_PASSWORD: "password",
"tokens": {
"AuthenticationResult": {
"AccessToken": "mock-access-token",
"RefreshToken": "mock-refresh-token",
},
"ChallengeName": "SUCCESS",
},
}
_HUB_BASE = {
"device_id": "hive-hub-id",
"hiveName": "Hive Hub",
"deviceData": {
"model": "Hub",
"version": "1.2.3",
"manufacturer": "Hive",
"online": True,
},
}
def _make_mock_hive(hub_extra: dict) -> MagicMock:
"""Return a mocked Hive instance whose startSession returns a minimal devices dict."""
hub_data = {**_HUB_BASE, **hub_extra}
mock_hive = MagicMock()
mock_hive.session.startSession = AsyncMock(return_value={"parent": [hub_data]})
return mock_hive
async def test_hub_device_registers_mac_connection(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
) -> None:
"""Hub device entry includes a MAC connection when macAddress is present."""
entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA)
entry.add_to_hass(hass)
mock_hive = _make_mock_hive({"macAddress": "00:1C:2B:1C:2E:68"})
with (
patch(
"homeassistant.components.hive.Hive",
return_value=mock_hive,
),
patch("homeassistant.components.hive.aiohttp_client.async_get_clientsession"),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
device = device_registry.async_get_device(identifiers={(DOMAIN, "hive-hub-id")})
assert device is not None
assert (dr.CONNECTION_NETWORK_MAC, "00:1c:2b:1c:2e:68") in device.connections
async def test_hub_device_no_mac_connection_when_absent(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
) -> None:
"""Hub device entry has no MAC connection when macAddress is absent."""
entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA)
entry.add_to_hass(hass)
mock_hive = _make_mock_hive({}) # no macAddress key
with (
patch(
"homeassistant.components.hive.Hive",
return_value=mock_hive,
),
patch("homeassistant.components.hive.aiohttp_client.async_get_clientsession"),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
device = device_registry.async_get_device(identifiers={(DOMAIN, "hive-hub-id")})
assert device is not None
assert not any(
conn_type == dr.CONNECTION_NETWORK_MAC for conn_type, _ in device.connections
)
@@ -64,6 +64,9 @@ async def test_humidifier_conditions_gated_by_labs_flag(
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
_HUMIDITY_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}}
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_key", "base_options", "supports_behavior", "supports_duration"),
@@ -72,6 +75,8 @@ async def test_humidifier_conditions_gated_by_labs_flag(
("humidifier.is_on", {}, True, True),
("humidifier.is_drying", {}, True, True),
("humidifier.is_humidifying", {}, True, True),
("humidifier.is_mode", {"mode": ["normal"]}, True, True),
("humidifier.is_target_humidity", _HUMIDITY_THRESHOLD, True, True),
],
)
async def test_humidifier_condition_options_validation(
@@ -302,6 +307,7 @@ async def test_humidifier_attribute_condition_behavior_all(
"humidifier.is_target_humidity",
STATE_ON,
ATTR_HUMIDITY,
attribute_required=True,
),
)
async def test_humidifier_numerical_condition_behavior_any(
@@ -338,6 +344,7 @@ async def test_humidifier_numerical_condition_behavior_any(
"humidifier.is_target_humidity",
STATE_ON,
ATTR_HUMIDITY,
attribute_required=True,
),
)
async def test_humidifier_numerical_condition_behavior_all(
@@ -68,6 +68,7 @@ async def test_humidifier_triggers_gated_by_labs_flag(
("humidifier.started_humidifying", {}, True, True),
("humidifier.turned_on", {}, True, True),
("humidifier.turned_off", {}, True, True),
("humidifier.mode_changed", {"mode": ["normal"]}, True, True),
],
)
async def test_humidifier_trigger_options_validation(
@@ -179,6 +179,7 @@ async def test_humidity_sensor_condition_behavior_all(
"humidity.is_value",
HVACMode.AUTO,
CLIMATE_ATTR_CURRENT_HUMIDITY,
attribute_required=True,
),
)
async def test_humidity_climate_condition_behavior_any(
@@ -215,6 +216,7 @@ async def test_humidity_climate_condition_behavior_any(
"humidity.is_value",
HVACMode.AUTO,
CLIMATE_ATTR_CURRENT_HUMIDITY,
attribute_required=True,
),
)
async def test_humidity_climate_condition_behavior_all(
@@ -251,6 +253,7 @@ async def test_humidity_climate_condition_behavior_all(
"humidity.is_value",
STATE_ON,
HUMIDIFIER_ATTR_CURRENT_HUMIDITY,
attribute_required=True,
),
)
async def test_humidity_humidifier_condition_behavior_any(
@@ -287,6 +290,7 @@ async def test_humidity_humidifier_condition_behavior_any(
"humidity.is_value",
STATE_ON,
HUMIDIFIER_ATTR_CURRENT_HUMIDITY,
attribute_required=True,
),
)
async def test_humidity_humidifier_condition_behavior_all(
@@ -323,6 +327,7 @@ async def test_humidity_humidifier_condition_behavior_all(
"humidity.is_value",
"sunny",
ATTR_WEATHER_HUMIDITY,
attribute_required=True,
),
)
async def test_humidity_weather_condition_behavior_any(
@@ -359,6 +364,7 @@ async def test_humidity_weather_condition_behavior_any(
"humidity.is_value",
"sunny",
ATTR_WEATHER_HUMIDITY,
attribute_required=True,
),
)
async def test_humidity_weather_condition_behavior_all(
+21 -3
View File
@@ -240,12 +240,16 @@ async def test_humidity_trigger_sensor_crossed_threshold_behavior_last(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_changed_trigger_states(
"humidity.changed", HVACMode.AUTO, CLIMATE_ATTR_CURRENT_HUMIDITY
"humidity.changed",
HVACMode.AUTO,
CLIMATE_ATTR_CURRENT_HUMIDITY,
attribute_required=True,
),
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"humidity.crossed_threshold",
HVACMode.AUTO,
CLIMATE_ATTR_CURRENT_HUMIDITY,
attribute_required=True,
),
],
)
@@ -284,6 +288,7 @@ async def test_humidity_trigger_climate_behavior_any(
"humidity.crossed_threshold",
HVACMode.AUTO,
CLIMATE_ATTR_CURRENT_HUMIDITY,
attribute_required=True,
),
],
)
@@ -322,6 +327,7 @@ async def test_humidity_trigger_climate_crossed_threshold_behavior_first(
"humidity.crossed_threshold",
HVACMode.AUTO,
CLIMATE_ATTR_CURRENT_HUMIDITY,
attribute_required=True,
),
],
)
@@ -360,12 +366,16 @@ async def test_humidity_trigger_climate_crossed_threshold_behavior_last(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_changed_trigger_states(
"humidity.changed", STATE_ON, HUMIDIFIER_ATTR_CURRENT_HUMIDITY
"humidity.changed",
STATE_ON,
HUMIDIFIER_ATTR_CURRENT_HUMIDITY,
attribute_required=True,
),
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"humidity.crossed_threshold",
STATE_ON,
HUMIDIFIER_ATTR_CURRENT_HUMIDITY,
attribute_required=True,
),
],
)
@@ -404,6 +414,7 @@ async def test_humidity_trigger_humidifier_behavior_any(
"humidity.crossed_threshold",
STATE_ON,
HUMIDIFIER_ATTR_CURRENT_HUMIDITY,
attribute_required=True,
),
],
)
@@ -442,6 +453,7 @@ async def test_humidity_trigger_humidifier_crossed_threshold_behavior_first(
"humidity.crossed_threshold",
STATE_ON,
HUMIDIFIER_ATTR_CURRENT_HUMIDITY,
attribute_required=True,
),
],
)
@@ -480,12 +492,16 @@ async def test_humidity_trigger_humidifier_crossed_threshold_behavior_last(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_changed_trigger_states(
"humidity.changed", "sunny", ATTR_WEATHER_HUMIDITY
"humidity.changed",
"sunny",
ATTR_WEATHER_HUMIDITY,
attribute_required=True,
),
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"humidity.crossed_threshold",
"sunny",
ATTR_WEATHER_HUMIDITY,
attribute_required=True,
),
],
)
@@ -524,6 +540,7 @@ async def test_humidity_trigger_weather_behavior_any(
"humidity.crossed_threshold",
"sunny",
ATTR_WEATHER_HUMIDITY,
attribute_required=True,
),
],
)
@@ -562,6 +579,7 @@ async def test_humidity_trigger_weather_crossed_threshold_behavior_first(
"humidity.crossed_threshold",
"sunny",
ATTR_WEATHER_HUMIDITY,
attribute_required=True,
),
],
)
@@ -56,12 +56,16 @@ async def test_illuminance_conditions_gated_by_labs_flag(
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
_ILLUMINANCE_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}}
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_key", "base_options", "supports_behavior", "supports_duration"),
[
("illuminance.is_detected", {}, True, True),
("illuminance.is_not_detected", {}, True, True),
("illuminance.is_value", _ILLUMINANCE_THRESHOLD, True, True),
],
)
async def test_illuminance_condition_options_validation(
@@ -58,12 +58,18 @@ async def test_illuminance_triggers_gated_by_labs_flag(
await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key)
_CHANGED_THRESHOLD = {"threshold": {"type": "any"}}
_CROSSED_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}}
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_key", "base_options", "supports_behavior", "supports_duration"),
[
("illuminance.detected", {}, True, True),
("illuminance.cleared", {}, True, True),
("illuminance.changed", _CHANGED_THRESHOLD, False, False),
("illuminance.crossed_threshold", _CROSSED_THRESHOLD, True, True),
],
)
async def test_illuminance_trigger_options_validation(
+14 -14
View File
@@ -30,6 +30,8 @@ MOCK_CONFIG = {
"folder": "INBOX",
"search": "UnSeen UnDeleted",
"event_message_data": ["text", "headers"],
"ssl_cipher_list": "python_default",
"verify_ssl": True,
}
MOCK_OPTIONS = {
@@ -301,7 +303,7 @@ async def test_reauth_failed_conn_error(hass: HomeAssistant) -> None:
async def test_options_form(hass: HomeAssistant) -> None:
"""Test we show the options form."""
"""Test the options form."""
entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG)
entry.add_to_hass(hass)
@@ -381,7 +383,7 @@ async def test_key_options_in_options_form(hass: HomeAssistant) -> None:
@pytest.mark.parametrize(
("advanced_options", "assert_result"),
("test_options", "assert_result"),
[
({"max_message_size": 8192}, FlowResultType.CREATE_ENTRY),
({"max_message_size": 1024}, FlowResultType.FORM),
@@ -407,12 +409,12 @@ async def test_key_options_in_options_form(hass: HomeAssistant) -> None:
"enable_push_false",
],
)
async def test_advanced_options_form(
async def test_options_flow_when_connection_fails(
hass: HomeAssistant,
advanced_options: dict[str, str],
test_options: dict[str, str],
assert_result: FlowResultType,
) -> None:
"""Test we show the advanced options."""
"""Test the options flow when the connection fails."""
entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG)
entry.add_to_hass(hass)
@@ -420,14 +422,14 @@ async def test_advanced_options_form(
result = await hass.config_entries.options.async_init(
entry.entry_id,
context={"source": config_entries.SOURCE_USER, "show_advanced_options": True},
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
new_config = MOCK_OPTIONS.copy()
new_config.update(advanced_options)
new_config.update(test_options)
try:
with patch(
@@ -462,7 +464,7 @@ async def test_config_flow_with_cipherlist_and_ssl_verify(
config["verify_ssl"] = verify_ssl
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER, "show_advanced_options": True},
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] is None
@@ -494,7 +496,7 @@ async def test_config_flow_with_event_message_data(
config["event_message_data"] = event_message_data
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER, "show_advanced_options": False},
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] is None
@@ -517,16 +519,14 @@ async def test_config_flow_with_event_message_data(
assert len(mock_setup_entry.mock_calls) == 1
async def test_config_flow_from_with_advanced_settings(
async def test_cipher_settings_in_config_flow(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test if advanced settings show correctly."""
"""Test cipher settings in config flow."""
config = MOCK_CONFIG.copy()
config["ssl_cipher_list"] = "python_default"
config["verify_ssl"] = True
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER, "show_advanced_options": True},
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] is None
@@ -72,6 +72,8 @@ async def test_entry_diagnostics(
],
"search": "UnSeen UnDeleted",
"custom_event_data_template": "{{ 4 * 4 }}",
"ssl_cipher_list": "python_default",
"verify_ssl": True,
}
expected_event_data = {
"date": "2023-03-24T13:52:00+01:00",

Some files were not shown because too many files have changed in this diff Show More