mirror of
https://github.com/home-assistant/core.git
synced 2026-05-07 10:26:51 +02:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2ce313ec8 | |||
| b8ba1c123d | |||
| 10f1cbb51e | |||
| e3bcce06bf | |||
| 4e0472feb5 | |||
| 046298f2ca | |||
| c92128b282 | |||
| 886e66e7e3 | |||
| 7da49570b5 | |||
| b8baa3271b | |||
| 65bc4bf1d0 | |||
| 27a8d185c9 | |||
| 1e5992f2b5 | |||
| ac84a14846 | |||
| fa265b18ce | |||
| 38634ddd55 | |||
| 13dd831874 | |||
| 3be5906398 | |||
| cef918d6f8 | |||
| 19aa1b6578 | |||
| b0eb69936e | |||
| b6096a71d1 | |||
| 059d7011ba | |||
| bbe00ef79e | |||
| 7f447abc3a | |||
| 923e099467 | |||
| 26714c6d9f | |||
| 5f1201dbbe | |||
| 52e1d9443c | |||
| 824f5205e9 | |||
| cf8bc55add | |||
| 1e9244f4fc | |||
| be4f4928d5 | |||
| 80f6f8ee31 | |||
| 267d52491a | |||
| ee84d625cd | |||
| 5d091d25d5 | |||
| 97b5f1cf64 |
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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]] = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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']}"
|
||||
)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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={}): {},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+8
-8
@@ -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
|
||||
|
||||
Generated
+8
-8
@@ -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
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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
@@ -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.0–1.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.0–1.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.0–1.0 but the threshold is in percent,
|
||||
so pass `attribute_value_scale=0.01`; light brightness is
|
||||
stored as 0–255 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.0–1.0 but the threshold is in percent,
|
||||
so pass `attribute_value_scale=0.01`; light brightness is
|
||||
stored as 0–255 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,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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}}}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user