Compare commits

...

15 Commits

Author SHA1 Message Date
Michael
e70a3e05a0 Apply suggestions from code review
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-17 22:32:05 +01:00
mib1185
3463139b18 add domain driven triggers 2025-12-17 21:15:25 +00:00
hanwg
c418d9750b Remove ALLOW_EXTRA from Telegram bot action schema (#158886) 2025-12-17 19:49:34 +01:00
Joost Lekkerkerker
e96d614076 Add integration_type service to meteo_france (#159315) 2025-12-17 19:19:14 +01:00
Abílio Costa
f0a5e0a023 Enable duplicated log file on supervised when env var is set (#158679) 2025-12-17 17:44:54 +00:00
Klaas Schoute
6ac6b86060 Set quality scale in Autarco manifest (#159263) 2025-12-17 16:17:19 +01:00
PaulCavill
3909171b1a Login exception reason (#159259) 2025-12-17 16:13:54 +01:00
Luke Lashley
769029505f Bump python-roborock to 3.18.0 (#159271) 2025-12-17 06:39:06 -08:00
Paul Tarjan
080ec3524b Fix flaky camera stream teardown (#158507)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2025-12-17 13:47:22 +01:00
Matthew Vance
48d671ad5f Update py-improv-ble-client to 2.0.1 (#159233) 2025-12-17 08:27:06 +01:00
alorente
7115db5d22 Change device class from PRESSURE to ATMOSPHERIC_PRESSURE (#159149) 2025-12-17 07:16:46 +01:00
Jordan Harvey
d0c8792e4b Improve Nintendo Switch parental controls exception handling (#159199) 2025-12-17 07:15:26 +01:00
Richard
84d7c37502 Bump mill-local to 0.5.0 (#159220) 2025-12-16 20:41:28 +01:00
Jordan Harvey
8a10638470 Add select platform to Nintendo Switch parental controls (#159217) 2025-12-16 19:06:43 +01:00
Abílio Costa
10dd53ffc2 Rename base trigger class and methods (#159213) 2025-12-16 18:01:37 +00:00
48 changed files with 971 additions and 221 deletions

View File

@@ -624,13 +624,16 @@ async def async_enable_logging(
if log_file is None:
default_log_path = hass.config.path(ERROR_LOG_FILENAME)
if "SUPERVISOR" in os.environ:
_LOGGER.info("Running in Supervisor, not logging to file")
if "SUPERVISOR" in os.environ and "HA_DUPLICATE_LOG_FILE" not in os.environ:
# Rename the default log file if it exists, since previous versions created
# it even on Supervisor
if os.path.isfile(default_log_path):
with contextlib.suppress(OSError):
os.rename(default_log_path, f"{default_log_path}.old")
def rename_old_file() -> None:
"""Rename old log file in executor."""
if os.path.isfile(default_log_path):
with contextlib.suppress(OSError):
os.rename(default_log_path, f"{default_log_path}.old")
await hass.async_add_executor_job(rename_old_file)
err_log_path = None
else:
err_log_path = default_log_path

View File

@@ -4,10 +4,10 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import get_supported_features
from homeassistant.helpers.trigger import (
EntityStateTriggerBase,
EntityTargetStateTriggerBase,
Trigger,
make_conditional_entity_state_trigger,
make_entity_state_trigger,
make_entity_target_state_trigger,
make_entity_transition_trigger,
)
from .const import DOMAIN, AlarmControlPanelEntityFeature, AlarmControlPanelState
@@ -21,7 +21,7 @@ def supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool
return False
class EntityStateTriggerRequiredFeatures(EntityStateTriggerBase):
class EntityStateTriggerRequiredFeatures(EntityTargetStateTriggerBase):
"""Trigger for entity state changes."""
_required_features: int
@@ -38,7 +38,7 @@ class EntityStateTriggerRequiredFeatures(EntityStateTriggerBase):
def make_entity_state_trigger_required_features(
domain: str, to_state: str, required_features: int
) -> type[EntityStateTriggerBase]:
) -> type[EntityTargetStateTriggerBase]:
"""Create an entity state trigger class."""
class CustomTrigger(EntityStateTriggerRequiredFeatures):
@@ -52,7 +52,7 @@ def make_entity_state_trigger_required_features(
TRIGGERS: dict[str, type[Trigger]] = {
"armed": make_conditional_entity_state_trigger(
"armed": make_entity_transition_trigger(
DOMAIN,
from_states={
AlarmControlPanelState.ARMING,
@@ -89,8 +89,12 @@ TRIGGERS: dict[str, type[Trigger]] = {
AlarmControlPanelState.ARMED_VACATION,
AlarmControlPanelEntityFeature.ARM_VACATION,
),
"disarmed": make_entity_state_trigger(DOMAIN, AlarmControlPanelState.DISARMED),
"triggered": make_entity_state_trigger(DOMAIN, AlarmControlPanelState.TRIGGERED),
"disarmed": make_entity_target_state_trigger(
DOMAIN, AlarmControlPanelState.DISARMED
),
"triggered": make_entity_target_state_trigger(
DOMAIN, AlarmControlPanelState.TRIGGERED
),
}

View File

@@ -1,16 +1,22 @@
"""Provides triggers for assist satellites."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from .const import DOMAIN
from .entity import AssistSatelliteState
TRIGGERS: dict[str, type[Trigger]] = {
"idle": make_entity_state_trigger(DOMAIN, AssistSatelliteState.IDLE),
"listening": make_entity_state_trigger(DOMAIN, AssistSatelliteState.LISTENING),
"processing": make_entity_state_trigger(DOMAIN, AssistSatelliteState.PROCESSING),
"responding": make_entity_state_trigger(DOMAIN, AssistSatelliteState.RESPONDING),
"idle": make_entity_target_state_trigger(DOMAIN, AssistSatelliteState.IDLE),
"listening": make_entity_target_state_trigger(
DOMAIN, AssistSatelliteState.LISTENING
),
"processing": make_entity_target_state_trigger(
DOMAIN, AssistSatelliteState.PROCESSING
),
"responding": make_entity_target_state_trigger(
DOMAIN, AssistSatelliteState.RESPONDING
),
}

View File

@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/autarco",
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["autarco==3.2.0"]
}

View File

@@ -132,6 +132,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"fan",
"lawn_mower",
"light",
"lock",
"media_player",
"switch",
"text",

View File

@@ -4,7 +4,7 @@ from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import get_device_class
from homeassistant.helpers.trigger import EntityStateTriggerBase, Trigger
from homeassistant.helpers.trigger import EntityTargetStateTriggerBase, Trigger
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from . import DOMAIN, BinarySensorDeviceClass
@@ -20,7 +20,7 @@ def get_device_class_or_undefined(
return UNDEFINED
class BinarySensorOnOffTrigger(EntityStateTriggerBase):
class BinarySensorOnOffTrigger(EntityTargetStateTriggerBase):
"""Class for binary sensor on/off triggers."""
_device_class: BinarySensorDeviceClass | None

View File

@@ -3,22 +3,22 @@
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import (
Trigger,
make_conditional_entity_state_trigger,
make_entity_state_attribute_trigger,
make_entity_state_trigger,
make_entity_target_state_attribute_trigger,
make_entity_target_state_trigger,
make_entity_transition_trigger,
)
from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
TRIGGERS: dict[str, type[Trigger]] = {
"started_cooling": make_entity_state_attribute_trigger(
"started_cooling": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
),
"started_drying": make_entity_state_attribute_trigger(
"started_drying": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
),
"turned_off": make_entity_state_trigger(DOMAIN, HVACMode.OFF),
"turned_on": make_conditional_entity_state_trigger(
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
"turned_on": make_entity_transition_trigger(
DOMAIN,
from_states={
HVACMode.OFF,
@@ -32,7 +32,7 @@ TRIGGERS: dict[str, type[Trigger]] = {
HVACMode.HEAT_COOL,
},
),
"started_heating": make_entity_state_attribute_trigger(
"started_heating": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.HEATING
),
}

View File

@@ -4,15 +4,15 @@ from homeassistant.const import STATE_HOME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import (
Trigger,
make_entity_from_state_trigger,
make_entity_state_trigger,
make_entity_origin_state_trigger,
make_entity_target_state_trigger,
)
from .const import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"entered_home": make_entity_state_trigger(DOMAIN, STATE_HOME),
"left_home": make_entity_from_state_trigger(DOMAIN, from_state=STATE_HOME),
"entered_home": make_entity_target_state_trigger(DOMAIN, STATE_HOME),
"left_home": make_entity_origin_state_trigger(DOMAIN, from_state=STATE_HOME),
}

View File

@@ -2,13 +2,13 @@
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from . import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"turned_off": make_entity_state_trigger(DOMAIN, STATE_OFF),
"turned_on": make_entity_state_trigger(DOMAIN, STATE_ON),
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
}

View File

@@ -108,7 +108,7 @@ class IcloudAccount:
if self.api.requires_2fa:
# Trigger a new log in to ensure the user enters the 2FA code again.
raise PyiCloudFailedLoginException # noqa: TRY301
raise PyiCloudFailedLoginException("2FA Required") # noqa: TRY301
except PyiCloudFailedLoginException:
self.api = None

View File

@@ -261,7 +261,8 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN):
if self._can_identify is None:
try:
self._can_identify = await self._try_call(device.can_identify())
await self._try_call(device.ensure_connected())
self._can_identify = device.can_identify
except AbortFlow as err:
return self.async_abort(reason=err.reason)
if self._can_identify:

View File

@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/improv_ble",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["py-improv-ble-client==1.0.3"]
"requirements": ["py-improv-ble-client==2.0.1"]
}

View File

@@ -1,15 +1,17 @@
"""Provides triggers for lawn mowers."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from .const import DOMAIN, LawnMowerActivity
TRIGGERS: dict[str, type[Trigger]] = {
"docked": make_entity_state_trigger(DOMAIN, LawnMowerActivity.DOCKED),
"errored": make_entity_state_trigger(DOMAIN, LawnMowerActivity.ERROR),
"paused_mowing": make_entity_state_trigger(DOMAIN, LawnMowerActivity.PAUSED),
"started_mowing": make_entity_state_trigger(DOMAIN, LawnMowerActivity.MOWING),
"docked": make_entity_target_state_trigger(DOMAIN, LawnMowerActivity.DOCKED),
"errored": make_entity_target_state_trigger(DOMAIN, LawnMowerActivity.ERROR),
"paused_mowing": make_entity_target_state_trigger(DOMAIN, LawnMowerActivity.PAUSED),
"started_mowing": make_entity_target_state_trigger(
DOMAIN, LawnMowerActivity.MOWING
),
}

View File

@@ -2,13 +2,13 @@
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from .const import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"turned_off": make_entity_state_trigger(DOMAIN, STATE_OFF),
"turned_on": make_entity_state_trigger(DOMAIN, STATE_ON),
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
}

View File

@@ -22,5 +22,19 @@
"unlock": {
"service": "mdi:lock-open-variant"
}
},
"triggers": {
"jammed": {
"trigger": "mdi:lock-alert"
},
"locked": {
"trigger": "mdi:lock"
},
"opened": {
"trigger": "mdi:lock-open-variant"
},
"unlocked": {
"trigger": "mdi:lock-open-variant"
}
}
}

View File

@@ -1,4 +1,8 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted locks to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"action_type": {
"lock": "Lock {entity_name}",
@@ -50,6 +54,15 @@
"message": "The code for {entity_id} doesn't match pattern {code_format}."
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"lock": {
"description": "Locks a lock.",
@@ -82,5 +95,47 @@
"name": "Unlock"
}
},
"title": "Lock"
"title": "Lock",
"triggers": {
"jammed": {
"description": "Triggers after one or more locks jam.",
"fields": {
"behavior": {
"description": "[%key:component::lock::common::trigger_behavior_description%]",
"name": "[%key:component::lock::common::trigger_behavior_name%]"
}
},
"name": "Lock jammed"
},
"locked": {
"description": "Triggers after one or more locks lock.",
"fields": {
"behavior": {
"description": "[%key:component::lock::common::trigger_behavior_description%]",
"name": "[%key:component::lock::common::trigger_behavior_name%]"
}
},
"name": "Lock locked"
},
"opened": {
"description": "Triggers after one or more locks open.",
"fields": {
"behavior": {
"description": "[%key:component::lock::common::trigger_behavior_description%]",
"name": "[%key:component::lock::common::trigger_behavior_name%]"
}
},
"name": "Lock opened"
},
"unlocked": {
"description": "Triggers after one or more locks unlock.",
"fields": {
"behavior": {
"description": "[%key:component::lock::common::trigger_behavior_description%]",
"name": "[%key:component::lock::common::trigger_behavior_name%]"
}
},
"name": "Lock unlocked"
}
}
}

View File

@@ -0,0 +1,18 @@
"""Provides triggers for locks."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from .const import DOMAIN, LockState
TRIGGERS: dict[str, type[Trigger]] = {
"jammed": make_entity_target_state_trigger(DOMAIN, LockState.JAMMED),
"locked": make_entity_target_state_trigger(DOMAIN, LockState.LOCKED),
"opened": make_entity_target_state_trigger(DOMAIN, LockState.OPEN),
"unlocked": make_entity_target_state_trigger(DOMAIN, LockState.UNLOCKED),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for locks."""
return TRIGGERS

View File

@@ -0,0 +1,20 @@
.trigger_common: &trigger_common
target:
entity:
domain: lock
fields:
behavior:
required: true
default: any
selector:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
jammed: *trigger_common
locked: *trigger_common
opened: *trigger_common
unlocked: *trigger_common

View File

@@ -1,13 +1,13 @@
"""Provides triggers for media players."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_conditional_entity_state_trigger
from homeassistant.helpers.trigger import Trigger, make_entity_transition_trigger
from . import MediaPlayerState
from .const import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"stopped_playing": make_conditional_entity_state_trigger(
"stopped_playing": make_entity_transition_trigger(
DOMAIN,
from_states={
MediaPlayerState.BUFFERING,

View File

@@ -1,9 +1,10 @@
{
"domain": "meteo_france",
"name": "M\u00e9t\u00e9o-France",
"name": "Météo-France",
"codeowners": ["@hacf-fr", "@oncleben31", "@Quentame"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/meteo_france",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["meteofrance_api"],
"requirements": ["meteofrance-api==1.4.0"]

View File

@@ -62,7 +62,7 @@ SENSOR_TYPES: tuple[MeteoFranceSensorEntityDescription, ...] = (
key="pressure",
name="Pressure",
native_unit_of_measurement=UnitOfPressure.HPA,
device_class=SensorDeviceClass.PRESSURE,
device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
data_path="current_forecast:sea_level",

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/mill",
"iot_class": "local_polling",
"loggers": ["mill", "mill_local"],
"requirements": ["millheater==0.14.1", "mill-local==0.3.0"]
"requirements": ["millheater==0.14.1", "mill-local==0.5.0"]
}

View File

@@ -24,6 +24,7 @@ _PLATFORMS: list[Platform] = [
Platform.TIME,
Platform.SWITCH,
Platform.NUMBER,
Platform.SELECT,
]
PLATFORM_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)

View File

@@ -5,14 +5,18 @@ from __future__ import annotations
from datetime import timedelta
import logging
from pynintendoauth.exceptions import InvalidOAuthConfigurationException
from pynintendoauth.exceptions import (
HttpException,
InvalidOAuthConfigurationException,
InvalidSessionTokenException,
)
from pynintendoparental import Authenticator, NintendoParental
from pynintendoparental.exceptions import NoDevicesFoundException
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -58,3 +62,13 @@ class NintendoUpdateCoordinator(DataUpdateCoordinator[None]):
translation_domain=DOMAIN,
translation_key="no_devices_found",
) from err
except InvalidSessionTokenException as err:
_LOGGER.debug("Session token invalid, will renew on next update")
raise UpdateFailed from err
except HttpException as err:
if err.error_code == "update_required":
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="update_required",
) from err
raise UpdateFailed(retry_after=900) from err

View File

@@ -0,0 +1,95 @@
"""Nintendo Switch Parental Controls select entity definitions."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from enum import StrEnum
from typing import Any
from pynintendoparental.enum import DeviceTimerMode
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import NintendoParentalControlsConfigEntry, NintendoUpdateCoordinator
from .entity import Device, NintendoDevice
PARALLEL_UPDATES = 1
class NintendoParentalSelect(StrEnum):
"""Store keys for Nintendo Parental Controls select entities."""
TIMER_MODE = "timer_mode"
@dataclass(kw_only=True, frozen=True)
class NintendoParentalControlsSelectEntityDescription(SelectEntityDescription):
"""Description for Nintendo Parental Controls select entities."""
get_option: Callable[[Device], DeviceTimerMode | None]
set_option_fn: Callable[[Device, DeviceTimerMode], Coroutine[Any, Any, None]]
options_enum: type[DeviceTimerMode]
SELECT_DESCRIPTIONS: tuple[NintendoParentalControlsSelectEntityDescription, ...] = (
NintendoParentalControlsSelectEntityDescription(
key=NintendoParentalSelect.TIMER_MODE,
translation_key=NintendoParentalSelect.TIMER_MODE,
get_option=lambda device: device.timer_mode,
set_option_fn=lambda device, option: device.set_timer_mode(option),
options_enum=DeviceTimerMode,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: NintendoParentalControlsConfigEntry,
async_add_devices: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the select platform."""
async_add_devices(
NintendoParentalSelectEntity(
coordinator=entry.runtime_data,
device=device,
description=description,
)
for device in entry.runtime_data.api.devices.values()
for description in SELECT_DESCRIPTIONS
)
class NintendoParentalSelectEntity(NintendoDevice, SelectEntity):
"""Nintendo Parental Controls select entity."""
entity_description: NintendoParentalControlsSelectEntityDescription
def __init__(
self,
coordinator: NintendoUpdateCoordinator,
device: Device,
description: NintendoParentalControlsSelectEntityDescription,
) -> None:
"""Initialize the select entity."""
super().__init__(coordinator=coordinator, device=device, key=description.key)
self.entity_description = description
@property
def current_option(self) -> str | None:
"""Return the current selected option."""
option = self.entity_description.get_option(self._device)
return option.name.lower() if option else None
@property
def options(self) -> list[str]:
"""Return a list of available options."""
return [option.name.lower() for option in self.entity_description.options_enum]
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
enum_option = self.entity_description.options_enum[option.upper()]
await self.entity_description.set_option_fn(self._device, enum_option)
await self.coordinator.async_request_refresh()

View File

@@ -37,6 +37,15 @@
"name": "Max screentime today"
}
},
"select": {
"timer_mode": {
"name": "Restriction mode",
"state": {
"daily": "Same for all days",
"each_day_of_the_week": "Different for each day"
}
}
},
"sensor": {
"playing_time": {
"name": "Used screen time"
@@ -74,6 +83,9 @@
},
"no_devices_found": {
"message": "No Nintendo devices found for this account."
},
"update_required": {
"message": "The Nintendo Switch parental controls integration requires an update due to changes in Nintendo's API."
}
},
"services": {

View File

@@ -20,7 +20,7 @@
"loggers": ["roborock"],
"quality_scale": "silver",
"requirements": [
"python-roborock==3.12.2",
"python-roborock==3.18.0",
"vacuum-map-parser-roborock==0.1.4"
]
}

View File

@@ -2,13 +2,13 @@
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from .const import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"turned_on": make_entity_state_trigger(DOMAIN, STATE_ON),
"turned_off": make_entity_state_trigger(DOMAIN, STATE_OFF),
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
}

View File

@@ -63,6 +63,7 @@ from .const import (
ATTR_PASSWORD,
ATTR_QUESTION,
ATTR_REACTION,
ATTR_REPLY_TO_MSGID,
ATTR_RESIZE_KEYBOARD,
ATTR_SHOW_ALERT,
ATTR_STICKER_ID,
@@ -126,21 +127,26 @@ BASE_SERVICE_SCHEMA = vol.Schema(
vol.Optional(ATTR_TIMEOUT): cv.positive_int,
vol.Optional(ATTR_MESSAGE_TAG): cv.string,
vol.Optional(ATTR_MESSAGE_THREAD_ID): vol.Coerce(int),
},
extra=vol.ALLOW_EXTRA,
}
)
SERVICE_SCHEMA_SEND_MESSAGE = vol.All(
cv.deprecated(ATTR_TIMEOUT),
BASE_SERVICE_SCHEMA.extend(
{vol.Required(ATTR_MESSAGE): cv.string, vol.Optional(ATTR_TITLE): cv.string}
{
vol.Required(ATTR_MESSAGE): cv.string,
vol.Optional(ATTR_TITLE): cv.string,
vol.Optional(ATTR_REPLY_TO_MSGID): vol.Coerce(int),
}
),
)
SERVICE_SCHEMA_SEND_CHAT_ACTION = vol.All(
cv.deprecated(ATTR_TIMEOUT),
BASE_SERVICE_SCHEMA.extend(
vol.Schema(
{
vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string,
vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [vol.Coerce(int)]),
vol.Required(ATTR_CHAT_ACTION): vol.In(
(
CHAT_ACTION_TYPING,
@@ -156,6 +162,7 @@ SERVICE_SCHEMA_SEND_CHAT_ACTION = vol.All(
CHAT_ACTION_UPLOAD_VIDEO_NOTE,
)
),
vol.Optional(ATTR_MESSAGE_THREAD_ID): vol.Coerce(int),
}
),
)
@@ -169,6 +176,7 @@ SERVICE_SCHEMA_BASE_SEND_FILE = BASE_SERVICE_SCHEMA.extend(
vol.Optional(ATTR_PASSWORD): cv.string,
vol.Optional(ATTR_AUTHENTICATION): cv.string,
vol.Optional(ATTR_VERIFY_SSL): cv.boolean,
vol.Optional(ATTR_REPLY_TO_MSGID): vol.Coerce(int),
}
)
@@ -188,6 +196,7 @@ SERVICE_SCHEMA_SEND_LOCATION = vol.All(
{
vol.Required(ATTR_LONGITUDE): cv.string,
vol.Required(ATTR_LATITUDE): cv.string,
vol.Optional(ATTR_REPLY_TO_MSGID): vol.Coerce(int),
}
),
)
@@ -205,18 +214,25 @@ SERVICE_SCHEMA_SEND_POLL = vol.All(
vol.Optional(ATTR_ALLOWS_MULTIPLE_ANSWERS, default=False): cv.boolean,
vol.Optional(ATTR_DISABLE_NOTIF): cv.boolean,
vol.Optional(ATTR_MESSAGE_THREAD_ID): vol.Coerce(int),
vol.Optional(ATTR_REPLY_TO_MSGID): vol.Coerce(int),
}
),
)
SERVICE_SCHEMA_EDIT_MESSAGE = vol.All(
cv.deprecated(ATTR_TIMEOUT),
SERVICE_SCHEMA_BASE_SEND_FILE.extend(
vol.Schema(
{
vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string,
vol.Optional(ATTR_TITLE): cv.string,
vol.Required(ATTR_MESSAGE): cv.string,
vol.Required(ATTR_MESSAGEID): vol.Any(
cv.positive_int, vol.All(cv.string, "last")
),
vol.Required(ATTR_CHAT_ID): vol.Coerce(int),
vol.Optional(ATTR_PARSER): cv.string,
vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list,
vol.Optional(ATTR_DISABLE_WEB_PREV): cv.boolean,
}
),
)

View File

@@ -783,6 +783,7 @@ class TelegramNotificationService:
None,
chat_id=chat_id,
action=chat_action,
message_thread_id=kwargs.get(ATTR_MESSAGE_THREAD_ID),
context=context,
)
result[chat_id] = is_successful

View File

@@ -2,12 +2,12 @@
from homeassistant.const import STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from .const import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"update_became_available": make_entity_state_trigger(DOMAIN, STATE_ON),
"update_became_available": make_entity_target_state_trigger(DOMAIN, STATE_ON),
}

View File

@@ -1,15 +1,17 @@
"""Provides triggers for vacuum cleaners."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from .const import DOMAIN, VacuumActivity
TRIGGERS: dict[str, type[Trigger]] = {
"docked": make_entity_state_trigger(DOMAIN, VacuumActivity.DOCKED),
"errored": make_entity_state_trigger(DOMAIN, VacuumActivity.ERROR),
"paused_cleaning": make_entity_state_trigger(DOMAIN, VacuumActivity.PAUSED),
"started_cleaning": make_entity_state_trigger(DOMAIN, VacuumActivity.CLEANING),
"docked": make_entity_target_state_trigger(DOMAIN, VacuumActivity.DOCKED),
"errored": make_entity_target_state_trigger(DOMAIN, VacuumActivity.ERROR),
"paused_cleaning": make_entity_target_state_trigger(DOMAIN, VacuumActivity.PAUSED),
"started_cleaning": make_entity_target_state_trigger(
DOMAIN, VacuumActivity.CLEANING
),
}

View File

@@ -3938,7 +3938,7 @@
},
"meteo_france": {
"name": "M\u00e9t\u00e9o-France",
"integration_type": "hub",
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_polling"
},

View File

@@ -430,8 +430,8 @@ class EntityTriggerBase(Trigger):
)
class EntityStateTriggerBase(EntityTriggerBase):
"""Trigger for entity state changes."""
class EntityTargetStateTriggerBase(EntityTriggerBase):
"""Trigger for entity state changes to a specific state."""
_to_state: str
@@ -440,8 +440,8 @@ class EntityStateTriggerBase(EntityTriggerBase):
return state.state == self._to_state
class ConditionalEntityStateTriggerBase(EntityTriggerBase):
"""Class for entity state changes where the from state is restricted."""
class EntityTransitionTriggerBase(EntityTriggerBase):
"""Trigger for entity state changes between specific states."""
_from_states: set[str]
_to_states: set[str]
@@ -458,8 +458,8 @@ class ConditionalEntityStateTriggerBase(EntityTriggerBase):
return state.state in self._to_states
class EntityFromStateTriggerBase(EntityTriggerBase):
"""Class for entity state changes from a specific state."""
class EntityOriginStateTriggerBase(EntityTriggerBase):
"""Trigger for entity state changes from a specific state."""
_from_state: str
@@ -474,8 +474,8 @@ class EntityFromStateTriggerBase(EntityTriggerBase):
return state.state != self._from_state
class EntityStateAttributeTriggerBase(EntityTriggerBase):
"""Trigger for entity state attribute changes."""
class EntityTargetStateAttributeTriggerBase(EntityTriggerBase):
"""Trigger for entity state attribute changes to a specific state."""
_attribute: str
_attribute_to_state: str
@@ -494,12 +494,12 @@ class EntityStateAttributeTriggerBase(EntityTriggerBase):
return state.attributes.get(self._attribute) == self._attribute_to_state
def make_entity_state_trigger(
def make_entity_target_state_trigger(
domain: str, to_state: str
) -> type[EntityStateTriggerBase]:
"""Create an entity state trigger class."""
) -> type[EntityTargetStateTriggerBase]:
"""Create a trigger for entity state changes to a specific state."""
class CustomTrigger(EntityStateTriggerBase):
class CustomTrigger(EntityTargetStateTriggerBase):
"""Trigger for entity state changes."""
_domain = domain
@@ -508,12 +508,12 @@ def make_entity_state_trigger(
return CustomTrigger
def make_conditional_entity_state_trigger(
def make_entity_transition_trigger(
domain: str, *, from_states: set[str], to_states: set[str]
) -> type[ConditionalEntityStateTriggerBase]:
"""Create a conditional entity state trigger class."""
) -> type[EntityTransitionTriggerBase]:
"""Create a trigger for entity state changes between specific states."""
class CustomTrigger(ConditionalEntityStateTriggerBase):
class CustomTrigger(EntityTransitionTriggerBase):
"""Trigger for conditional entity state changes."""
_domain = domain
@@ -523,12 +523,12 @@ def make_conditional_entity_state_trigger(
return CustomTrigger
def make_entity_from_state_trigger(
def make_entity_origin_state_trigger(
domain: str, *, from_state: str
) -> type[EntityFromStateTriggerBase]:
"""Create an entity "from state" trigger class."""
) -> type[EntityOriginStateTriggerBase]:
"""Create a trigger for entity state changes from a specific state."""
class CustomTrigger(EntityFromStateTriggerBase):
class CustomTrigger(EntityOriginStateTriggerBase):
"""Trigger for entity "from state" changes."""
_domain = domain
@@ -537,12 +537,12 @@ def make_entity_from_state_trigger(
return CustomTrigger
def make_entity_state_attribute_trigger(
def make_entity_target_state_attribute_trigger(
domain: str, attribute: str, to_state: str
) -> type[EntityStateAttributeTriggerBase]:
"""Create an entity state attribute trigger class."""
) -> type[EntityTargetStateAttributeTriggerBase]:
"""Create a trigger for entity state attribute changes to a specific state."""
class CustomTrigger(EntityStateAttributeTriggerBase):
class CustomTrigger(EntityTargetStateAttributeTriggerBase):
"""Trigger for entity state changes."""
_domain = domain
@@ -1135,6 +1135,5 @@ async def async_get_all_descriptions(
description["target"] = target
new_descriptions_cache[missing_trigger] = description
hass.data[TRIGGER_DESCRIPTION_CACHE] = new_descriptions_cache
return new_descriptions_cache

6
requirements_all.txt generated
View File

@@ -1482,7 +1482,7 @@ micloud==0.5
microBeesPy==0.3.5
# homeassistant.components.mill
mill-local==0.3.0
mill-local==0.5.0
# homeassistant.components.mill
millheater==0.14.1
@@ -1810,7 +1810,7 @@ py-dactyl==2.0.4
py-dormakaba-dkey==1.0.6
# homeassistant.components.improv_ble
py-improv-ble-client==1.0.3
py-improv-ble-client==2.0.1
# homeassistant.components.madvr
py-madvr2==1.6.40
@@ -2575,7 +2575,7 @@ python-rabbitair==0.0.8
python-ripple-api==0.0.3
# homeassistant.components.roborock
python-roborock==3.12.2
python-roborock==3.18.0
# homeassistant.components.smarttub
python-smarttub==0.0.46

View File

@@ -1289,7 +1289,7 @@ micloud==0.5
microBeesPy==0.3.5
# homeassistant.components.mill
mill-local==0.3.0
mill-local==0.5.0
# homeassistant.components.mill
millheater==0.14.1
@@ -1550,7 +1550,7 @@ py-dactyl==2.0.4
py-dormakaba-dkey==1.0.6
# homeassistant.components.improv_ble
py-improv-ble-client==1.0.3
py-improv-ble-client==2.0.1
# homeassistant.components.madvr
py-madvr2==1.6.40
@@ -2159,7 +2159,7 @@ python-pooldose==0.8.1
python-rabbitair==0.0.8
# homeassistant.components.roborock
python-roborock==3.12.2
python-roborock==3.18.0
# homeassistant.components.smarttub
python-smarttub==0.0.46

View File

@@ -1176,7 +1176,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"aten_pe",
"atome",
"august",
"autarco",
"aurora",
"aurora_abb_powerone",
"aussie_broadband",

View File

@@ -148,6 +148,22 @@ def mock_stream_source_fixture() -> Generator[AsyncMock]:
yield mock_stream_source
@pytest.fixture(name="mock_create_stream")
def mock_create_stream_fixture() -> Generator[Mock]:
"""Fixture to mock create_stream and prevent real stream threads."""
mock_stream = Mock()
mock_stream.add_provider = Mock()
mock_stream.start = AsyncMock()
mock_stream.endpoint_url = Mock(return_value="http://home.assistant/playlist.m3u8")
mock_stream.set_update_callback = Mock()
mock_stream.available = True
with patch(
"homeassistant.components.camera.create_stream",
return_value=mock_stream,
):
yield mock_stream
@pytest.fixture
async def mock_test_webrtc_cameras(hass: HomeAssistant) -> None:
"""Initialize test WebRTC cameras with native RTC support."""

View File

@@ -346,20 +346,14 @@ async def test_websocket_stream_no_source(
@pytest.mark.usefixtures("mock_camera", "mock_stream")
async def test_websocket_camera_stream(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_create_stream: Mock
) -> None:
"""Test camera/stream websocket command."""
await async_setup_component(hass, "camera", {})
with (
patch(
"homeassistant.components.camera.Stream.endpoint_url",
return_value="http://home.assistant/playlist.m3u8",
) as mock_stream_view_url,
patch(
"homeassistant.components.demo.camera.DemoCamera.stream_source",
return_value="http://example.com",
),
with patch(
"homeassistant.components.demo.camera.DemoCamera.stream_source",
return_value="http://example.com",
):
# Request playlist through WebSocket
client = await hass_ws_client(hass)
@@ -369,7 +363,7 @@ async def test_websocket_camera_stream(
msg = await client.receive_json()
# Assert WebSocket response
assert mock_stream_view_url.called
assert mock_create_stream.endpoint_url.called
assert msg["id"] == 6
assert msg["type"] == TYPE_RESULT
assert msg["success"]
@@ -505,21 +499,18 @@ async def test_play_stream_service_no_source(hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("mock_camera", "mock_stream")
async def test_handle_play_stream_service(hass: HomeAssistant) -> None:
async def test_handle_play_stream_service(
hass: HomeAssistant, mock_create_stream: Mock
) -> None:
"""Test camera play_stream service."""
await async_process_ha_core_config(
hass,
{"external_url": "https://example.com"},
)
await async_setup_component(hass, "media_player", {})
with (
patch(
"homeassistant.components.camera.Stream.endpoint_url",
) as mock_request_stream,
patch(
"homeassistant.components.demo.camera.DemoCamera.stream_source",
return_value="http://example.com",
),
with patch(
"homeassistant.components.demo.camera.DemoCamera.stream_source",
return_value="http://example.com",
):
# Call service
await hass.services.async_call(
@@ -533,17 +524,14 @@ async def test_handle_play_stream_service(hass: HomeAssistant) -> None:
)
# So long as we request the stream, the rest should be covered
# by the play_media service tests.
assert mock_request_stream.called
assert mock_create_stream.endpoint_url.called
@pytest.mark.usefixtures("mock_stream")
async def test_no_preload_stream(hass: HomeAssistant) -> None:
async def test_no_preload_stream(hass: HomeAssistant, mock_create_stream: Mock) -> None:
"""Test camera preload preference."""
demo_settings = camera.DynamicStreamSettings()
with (
patch(
"homeassistant.components.camera.Stream.endpoint_url",
) as mock_request_stream,
patch(
"homeassistant.components.camera.prefs.CameraPreferences.get_dynamic_stream_settings",
return_value=demo_settings,
@@ -557,15 +545,14 @@ async def test_no_preload_stream(hass: HomeAssistant) -> None:
await async_setup_component(hass, "camera", {DOMAIN: {"platform": "demo"}})
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
assert not mock_request_stream.called
assert not mock_create_stream.endpoint_url.called
@pytest.mark.usefixtures("mock_stream")
async def test_preload_stream(hass: HomeAssistant) -> None:
async def test_preload_stream(hass: HomeAssistant, mock_create_stream: Mock) -> None:
"""Test camera preload preference."""
demo_settings = camera.DynamicStreamSettings(preload_stream=True)
with (
patch("homeassistant.components.camera.create_stream") as mock_create_stream,
patch(
"homeassistant.components.camera.prefs.CameraPreferences.get_dynamic_stream_settings",
return_value=demo_settings,
@@ -575,14 +562,13 @@ async def test_preload_stream(hass: HomeAssistant) -> None:
return_value="http://example.com",
),
):
mock_create_stream.return_value.start = AsyncMock()
assert await async_setup_component(
hass, "camera", {DOMAIN: {"platform": "demo"}}
)
await hass.async_block_till_done()
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
assert mock_create_stream.called
assert mock_create_stream.start.called
@pytest.mark.usefixtures("mock_camera")
@@ -694,25 +680,16 @@ async def test_state_streaming(hass: HomeAssistant) -> None:
assert demo_camera.state == camera.CameraState.STREAMING
@pytest.mark.usefixtures("mock_camera", "mock_stream")
@pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_create_stream")
async def test_stream_unavailable(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_create_stream: Mock
) -> None:
"""Camera state."""
await async_setup_component(hass, "camera", {})
with (
patch(
"homeassistant.components.camera.Stream.endpoint_url",
return_value="http://home.assistant/playlist.m3u8",
),
patch(
"homeassistant.components.demo.camera.DemoCamera.stream_source",
return_value="http://example.com",
),
patch(
"homeassistant.components.camera.Stream.set_update_callback",
) as mock_update_callback,
with patch(
"homeassistant.components.demo.camera.DemoCamera.stream_source",
return_value="http://example.com",
):
# Request playlist through WebSocket. We just want to create the stream
# but don't care about the result.
@@ -721,26 +698,22 @@ async def test_stream_unavailable(
{"id": 10, "type": "camera/stream", "entity_id": "camera.demo_camera"}
)
await client.receive_json()
assert mock_update_callback.called
assert mock_create_stream.set_update_callback.called
# Simulate the stream going unavailable
callback = mock_update_callback.call_args.args[0]
with patch(
"homeassistant.components.camera.Stream.available", new_callable=lambda: False
):
callback()
await hass.async_block_till_done()
callback = mock_create_stream.set_update_callback.call_args.args[0]
mock_create_stream.available = False
callback()
await hass.async_block_till_done()
demo_camera = hass.states.get("camera.demo_camera")
assert demo_camera is not None
assert demo_camera.state == STATE_UNAVAILABLE
# Simulate stream becomes available
with patch(
"homeassistant.components.camera.Stream.available", new_callable=lambda: True
):
callback()
await hass.async_block_till_done()
mock_create_stream.available = True
callback()
await hass.async_block_till_done()
demo_camera = hass.states.get("camera.demo_camera")
assert demo_camera is not None

View File

@@ -2,7 +2,7 @@
import asyncio
from collections.abc import Callable
from unittest.mock import patch
from unittest.mock import PropertyMock, patch
from bleak.exc import BleakError
from improv_ble_client import (
@@ -294,8 +294,13 @@ async def test_bluetooth_rediscovery_after_successful_provision(
assert result["step_id"] == "bluetooth_confirm"
# Start provisioning
with patch(
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False
with (
patch(
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify",
return_value=False,
new_callable=PropertyMock,
),
patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.ensure_connected"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -375,8 +380,13 @@ async def _test_common_success_with_identify(
hass: HomeAssistant, result: FlowResult, address: str
) -> None:
"""Test bluetooth and user flow success paths."""
with patch(
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=True
with (
patch(
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify",
return_value=True,
new_callable=PropertyMock,
),
patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.ensure_connected"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -420,8 +430,13 @@ async def _test_common_success_wo_identify(
placeholders: dict[str, str] | None = None,
) -> None:
"""Test bluetooth and user flow success paths."""
with patch(
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False
with (
patch(
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify",
return_value=False,
new_callable=PropertyMock,
),
patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.ensure_connected"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -475,8 +490,13 @@ async def _test_common_success_wo_identify_w_authorize(
hass: HomeAssistant, result: FlowResult, address: str
) -> None:
"""Test bluetooth and user flow success paths."""
with patch(
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False
with (
patch(
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify",
return_value=False,
new_callable=PropertyMock,
),
patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.ensure_connected"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -571,7 +591,7 @@ async def test_bluetooth_step_already_in_progress(hass: HomeAssistant) -> None:
(improv_ble_errors.CharacteristicMissingError, "characteristic_missing"),
],
)
async def test_can_identify_fails(hass: HomeAssistant, exc, error) -> None:
async def test_ensure_connected_fails(hass: HomeAssistant, exc, error) -> None:
"""Test bluetooth flow with error."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -588,7 +608,8 @@ async def test_can_identify_fails(hass: HomeAssistant, exc, error) -> None:
assert result["errors"] is None
with patch(
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", side_effect=exc
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.ensure_connected",
side_effect=exc,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -622,8 +643,13 @@ async def test_identify_fails(hass: HomeAssistant, exc, error) -> None:
assert result["step_id"] == "bluetooth_confirm"
assert result["errors"] is None
with patch(
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=True
with (
patch(
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify",
return_value=True,
new_callable=PropertyMock,
),
patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.ensure_connected"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -665,8 +691,13 @@ async def test_need_authorization_fails(hass: HomeAssistant, exc, error) -> None
assert result["step_id"] == "bluetooth_confirm"
assert result["errors"] is None
with patch(
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False
with (
patch(
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify",
return_value=False,
new_callable=PropertyMock,
),
patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.ensure_connected"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -709,8 +740,13 @@ async def test_authorize_fails(hass: HomeAssistant, exc, error) -> None:
assert result["step_id"] == "bluetooth_confirm"
assert result["errors"] is None
with patch(
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False
with (
patch(
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify",
return_value=False,
new_callable=PropertyMock,
),
patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.ensure_connected"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -752,8 +788,13 @@ async def _test_provision_error(hass: HomeAssistant, exc) -> str:
assert result["step_id"] == "bluetooth_confirm"
assert result["errors"] is None
with patch(
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False
with (
patch(
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify",
return_value=False,
new_callable=PropertyMock,
),
patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.ensure_connected"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -878,8 +919,13 @@ async def test_flow_chaining_with_next_flow(hass: HomeAssistant) -> None:
assert result["step_id"] == "bluetooth_confirm"
# Start provisioning
with patch(
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False
with (
patch(
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify",
return_value=False,
new_callable=PropertyMock,
),
patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.ensure_connected"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -946,8 +992,13 @@ async def test_flow_chaining_timeout(hass: HomeAssistant) -> None:
assert result["step_id"] == "bluetooth_confirm"
# Start provisioning
with patch(
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False
with (
patch(
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify",
return_value=False,
new_callable=PropertyMock,
),
patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.ensure_connected"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -998,8 +1049,13 @@ async def test_flow_chaining_with_redirect_url(hass: HomeAssistant) -> None:
assert result["step_id"] == "bluetooth_confirm"
# Start provisioning
with patch(
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False
with (
patch(
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify",
return_value=False,
new_callable=PropertyMock,
),
patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.ensure_connected"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -1069,8 +1125,13 @@ async def test_flow_chaining_future_already_done(
assert result["step_id"] == "bluetooth_confirm"
# Start provisioning
with patch(
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False
with (
patch(
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify",
return_value=False,
new_callable=PropertyMock,
),
patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.ensure_connected"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],

View File

@@ -0,0 +1,261 @@
"""Test lock triggers."""
from collections.abc import Generator
from unittest.mock import patch
import pytest
from homeassistant.components.lock import DOMAIN, LockState
from homeassistant.const import ATTR_LABEL_ID, CONF_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall
from tests.components import (
StateDescription,
arm_trigger,
other_states,
parametrize_target_entities,
parametrize_trigger_states,
set_or_remove_state,
target_entities,
)
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
"""Stub copying the blueprints to the config folder."""
@pytest.fixture(name="enable_experimental_triggers_conditions")
def enable_experimental_triggers_conditions() -> Generator[None]:
"""Enable experimental triggers and conditions."""
with patch(
"homeassistant.components.labs.async_is_preview_feature_enabled",
return_value=True,
):
yield
@pytest.fixture
async def target_locks(hass: HomeAssistant) -> list[str]:
"""Create multiple lock entities associated with different targets."""
return (await target_entities(hass, DOMAIN))["included"]
@pytest.mark.parametrize(
"trigger_key",
[
"lock.jammed",
"lock.locked",
"lock.opened",
"lock.unlocked",
],
)
async def test_lock_triggers_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
) -> None:
"""Test the lock triggers are gated by the labs flag."""
await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"})
assert (
"Unnamed automation failed to setup triggers and has been disabled: Trigger "
f"'{trigger_key}' requires the experimental 'New triggers and conditions' "
"feature to be enabled in Home Assistant Labs settings (feature flag: "
"'new_triggers_conditions')"
) in caplog.text
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="lock.jammed",
target_states=[LockState.JAMMED],
other_states=other_states(LockState.JAMMED),
),
*parametrize_trigger_states(
trigger="lock.locked",
target_states=[LockState.LOCKED],
other_states=other_states(LockState.LOCKED),
),
*parametrize_trigger_states(
trigger="lock.opened",
target_states=[LockState.OPEN],
other_states=other_states(LockState.OPEN),
),
*parametrize_trigger_states(
trigger="lock.unlocked",
target_states=[LockState.UNLOCKED],
other_states=other_states(LockState.UNLOCKED),
),
],
)
async def test_lock_state_trigger_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_locks: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the lock state trigger fires when any lock state changes to a specific state."""
other_entity_ids = set(target_locks) - {entity_id}
# Set all locks, including the tested one, to the initial state
for eid in target_locks:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {}, trigger_target_config)
for state in states[1:]:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Check if changing other locks also triggers
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == (entities_in_target - 1) * state["count"]
service_calls.clear()
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="lock.jammed",
target_states=[LockState.JAMMED],
other_states=other_states(LockState.JAMMED),
),
*parametrize_trigger_states(
trigger="lock.locked",
target_states=[LockState.LOCKED],
other_states=other_states(LockState.LOCKED),
),
*parametrize_trigger_states(
trigger="lock.opened",
target_states=[LockState.OPEN],
other_states=other_states(LockState.OPEN),
),
*parametrize_trigger_states(
trigger="lock.unlocked",
target_states=[LockState.UNLOCKED],
other_states=other_states(LockState.UNLOCKED),
),
],
)
async def test_lock_state_trigger_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_locks: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the lock state trigger fires when the first lock changes to a specific state."""
other_entity_ids = set(target_locks) - {entity_id}
# Set all locks, including the tested one, to the initial state
for eid in target_locks:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config)
for state in states[1:]:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Triggering other locks should not cause the trigger to fire again
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="lock.jammed",
target_states=[LockState.JAMMED],
other_states=other_states(LockState.JAMMED),
),
*parametrize_trigger_states(
trigger="lock.locked",
target_states=[LockState.LOCKED],
other_states=other_states(LockState.LOCKED),
),
*parametrize_trigger_states(
trigger="lock.opened",
target_states=[LockState.OPEN],
other_states=other_states(LockState.OPEN),
),
*parametrize_trigger_states(
trigger="lock.unlocked",
target_states=[LockState.UNLOCKED],
other_states=other_states(LockState.UNLOCKED),
),
],
)
async def test_lock_state_trigger_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_locks: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the lock state trigger fires when the last lock changes to a specific state."""
other_entity_ids = set(target_locks) - {entity_id}
# Set all locks, including the tested one, to the initial state
for eid in target_locks:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config)
for state in states[1:]:
included_state = state["included"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()

View File

@@ -391,7 +391,7 @@
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.PRESSURE: 'pressure'>,
'original_device_class': <SensorDeviceClass.ATMOSPHERIC_PRESSURE: 'atmospheric_pressure'>,
'original_icon': None,
'original_name': 'La Clusaz Pressure',
'platform': 'meteo_france',
@@ -407,7 +407,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by Météo-France',
'device_class': 'pressure',
'device_class': 'atmospheric_pressure',
'friendly_name': 'La Clusaz Pressure',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfPressure.HPA: 'hPa'>,

View File

@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from pynintendoparental import NintendoParental
from pynintendoparental.device import Device
from pynintendoparental.enum import DeviceTimerMode
import pytest
from homeassistant.components.nintendo_parental_controls.const import DOMAIN
@@ -39,9 +40,11 @@ def mock_nintendo_device() -> Device:
mock.today_playing_time = 110
mock.today_time_remaining = 10
mock.bedtime_alarm = time(hour=19)
mock.timer_mode = DeviceTimerMode.DAILY
mock.add_extra_time.return_value = None
mock.set_bedtime_alarm.return_value = None
mock.update_max_daily_playtime.return_value = None
mock.set_timer_mode.return_value = None
mock.forced_termination_mode = True
mock.model = "Test Model"
mock.generation = "P00"

View File

@@ -0,0 +1,58 @@
# serializer version: 1
# name: test_select[select.home_assistant_test_restriction_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'daily',
'each_day_of_the_week',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': None,
'entity_id': 'select.home_assistant_test_restriction_mode',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Restriction mode',
'platform': 'nintendo_parental_controls',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': <NintendoParentalSelect.TIMER_MODE: 'timer_mode'>,
'unique_id': 'testdevid_timer_mode',
'unit_of_measurement': None,
})
# ---
# name: test_select[select.home_assistant_test_restriction_mode-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Home Assistant Test Restriction mode',
'options': list([
'daily',
'each_day_of_the_week',
]),
}),
'context': <ANY>,
'entity_id': 'select.home_assistant_test_restriction_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'daily',
})
# ---

View File

@@ -2,8 +2,13 @@
from unittest.mock import AsyncMock
from pynintendoauth.exceptions import InvalidOAuthConfigurationException
from pynintendoauth.exceptions import (
HttpException,
InvalidOAuthConfigurationException,
InvalidSessionTokenException,
)
from pynintendoparental.exceptions import NoDevicesFoundException
import pytest
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
@@ -14,16 +19,62 @@ from . import setup_integration
from tests.common import MockConfigEntry
async def test_invalid_authentication(
@pytest.mark.parametrize(
("exception", "translation_key", "expected_state", "expected_log_message"),
[
(
InvalidOAuthConfigurationException(
status_code=401, message="Authentication failed"
),
"invalid_auth",
ConfigEntryState.SETUP_ERROR,
None,
),
(
NoDevicesFoundException(),
"no_devices_found",
ConfigEntryState.SETUP_ERROR,
None,
),
(
HttpException(
status_code=400, error_code="update_required", message="Update required"
),
"update_required",
ConfigEntryState.SETUP_ERROR,
None,
),
(
HttpException(
status_code=500, error_code="unknown", message="Unknown error"
),
None,
ConfigEntryState.SETUP_RETRY,
None,
),
(
InvalidSessionTokenException(
status_code=403, error_code="invalid_token", message="Invalid token"
),
None,
ConfigEntryState.SETUP_RETRY,
"Session token invalid, will renew on next update",
),
],
)
async def test_update_errors(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_nintendo_client: AsyncMock,
entity_registry: er.EntityRegistry,
caplog: pytest.LogCaptureFixture,
exception: Exception,
translation_key: str,
expected_state: ConfigEntryState,
expected_log_message: str | None,
) -> None:
"""Test handling of invalid authentication."""
mock_nintendo_client.update.side_effect = InvalidOAuthConfigurationException(
status_code=401, message="Authentication failed"
)
"""Test handling of update errors."""
mock_nintendo_client.update.side_effect = exception
await setup_integration(hass, mock_config_entry)
@@ -32,25 +83,13 @@ async def test_invalid_authentication(
entity_registry, mock_config_entry.entry_id
)
assert len(entries) == 0
# Ensure the config entry is marked as error
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
# Ensure the config entry is marked as expected state
assert mock_config_entry.state is expected_state
async def test_no_devices(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_nintendo_client: AsyncMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test handling of invalid authentication."""
mock_nintendo_client.update.side_effect = NoDevicesFoundException()
# Ensure the correct translation key is used in the error
assert mock_config_entry.error_reason_translation_key == translation_key
await setup_integration(hass, mock_config_entry)
# Ensure no entities are created
entries = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
assert len(entries) == 0
# Ensure the config entry is marked as error
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
# If there's an expected log message, check that it was logged
if expected_log_message:
assert expected_log_message in caplog.text

View File

@@ -0,0 +1,64 @@
"""Tests for Nintendo Switch Parental Controls select platform."""
from unittest.mock import AsyncMock, patch
from pynintendoparental.enum import DeviceTimerMode
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.select import (
ATTR_OPTION,
DOMAIN as SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
async def test_select(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_nintendo_client: AsyncMock,
mock_nintendo_device: AsyncMock,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test select platform."""
with patch(
"homeassistant.components.nintendo_parental_controls._PLATFORMS",
[Platform.SELECT],
):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_select_option(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_nintendo_client: AsyncMock,
mock_nintendo_device: AsyncMock,
) -> None:
"""Test select option service."""
with patch(
"homeassistant.components.nintendo_parental_controls._PLATFORMS",
[Platform.SELECT],
):
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{
ATTR_ENTITY_ID: "select.home_assistant_test_restriction_mode",
ATTR_OPTION: DeviceTimerMode.EACH_DAY_OF_THE_WEEK.name.lower(),
},
blocking=True,
)
mock_nintendo_device.set_timer_mode.assert_awaited_once_with(
DeviceTimerMode.EACH_DAY_OF_THE_WEEK
)

View File

@@ -158,7 +158,6 @@ async def test_polling_platform_init(
(
SERVICE_SEND_LOCATION,
{
ATTR_MESSAGE: "test_message",
ATTR_MESSAGE_THREAD_ID: "123",
ATTR_LONGITUDE: "1.123",
ATTR_LATITUDE: "1.123",
@@ -414,6 +413,7 @@ async def test_send_chat_action(
CONF_CONFIG_ENTRY_ID: mock_broadcast_config_entry.entry_id,
ATTR_TARGET: [123456],
ATTR_CHAT_ACTION: CHAT_ACTION_TYPING,
ATTR_MESSAGE_THREAD_ID: 123,
},
blocking=True,
return_response=True,
@@ -421,7 +421,9 @@ async def test_send_chat_action(
await hass.async_block_till_done()
mock.assert_called_once()
mock.assert_called_with(chat_id=123456, action=CHAT_ACTION_TYPING)
mock.assert_called_with(
chat_id=123456, action=CHAT_ACTION_TYPING, message_thread_id=123
)
@pytest.mark.parametrize(
@@ -1505,7 +1507,6 @@ async def test_set_message_reaction(
SERVICE_SEND_LOCATION,
{
ATTR_TARGET: 654321,
ATTR_MESSAGE: "test_message",
ATTR_MESSAGE_THREAD_ID: "123",
ATTR_LONGITUDE: "1.123",
ATTR_LATITUDE: "1.123",

View File

@@ -130,8 +130,16 @@ async def test_async_enable_logging(
cleanup_log_files()
@pytest.mark.parametrize(
("extra_env", "log_file_count", "old_log_file_count"),
[({}, 0, 1), ({"HA_DUPLICATE_LOG_FILE": "1"}, 1, 0)],
)
async def test_async_enable_logging_supervisor(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
extra_env: dict[str, str],
log_file_count: int,
old_log_file_count: int,
) -> None:
"""Test to ensure the default log file is not created on Supervisor installations."""
@@ -141,14 +149,14 @@ async def test_async_enable_logging_supervisor(
assert len(glob.glob(ARG_LOG_FILE)) == 0
with (
patch.dict(os.environ, {"SUPERVISOR": "1"}),
patch.dict(os.environ, {"SUPERVISOR": "1", **extra_env}),
patch(
"homeassistant.bootstrap.async_activate_log_queue_handler"
) as mock_async_activate_log_queue_handler,
patch("logging.getLogger"),
):
await bootstrap.async_enable_logging(hass)
assert len(glob.glob(CONFIG_LOG_FILE)) == 0
assert len(glob.glob(CONFIG_LOG_FILE)) == log_file_count
mock_async_activate_log_queue_handler.assert_called_once()
mock_async_activate_log_queue_handler.reset_mock()
@@ -162,9 +170,10 @@ async def test_async_enable_logging_supervisor(
await hass.async_add_executor_job(write_log_file)
assert len(glob.glob(CONFIG_LOG_FILE)) == 1
assert len(glob.glob(f"{CONFIG_LOG_FILE}.old")) == 0
await bootstrap.async_enable_logging(hass)
assert len(glob.glob(CONFIG_LOG_FILE)) == 0
assert len(glob.glob(f"{CONFIG_LOG_FILE}.old")) == 1
assert len(glob.glob(CONFIG_LOG_FILE)) == log_file_count
assert len(glob.glob(f"{CONFIG_LOG_FILE}.old")) == old_log_file_count
mock_async_activate_log_queue_handler.assert_called_once()
mock_async_activate_log_queue_handler.reset_mock()