Compare commits

..

47 Commits

Author SHA1 Message Date
Erik 2083f6d5f0 Add WS command recorder/recorded_entities/get 2026-06-17 15:48:25 +02:00
Mark 3437bcfb42 Add Rabbit Air air quality sensor (#172993) 2026-06-17 10:49:53 +02:00
Åke Strandberg 1fd5d0a5fd Aqvify reaches Platinum tier (#174111) 2026-06-17 10:49:41 +02:00
Dellle 404c58435a Bump sentence-stream to 1.3.0 (#174113) 2026-06-17 10:48:42 +02:00
Erik Montnemery 7aba1daa16 Adjust language in condition history manager comments (#174106) 2026-06-17 09:23:32 +02:00
Jan Bouwhuis 12397cc4c1 Rename advanced settings/options in MQTT subentry translation strings (#174071) 2026-06-17 09:22:25 +02:00
Josef Zweck 73cdf7e067 Revert "Add pyserial-asyncio and pyserial-asyncio-fast to deprecated packages" (#174110) 2026-06-17 09:18:44 +02:00
Franck Nijhof 4e2cfecd96 Filter out closed sites in Amber Electric config flow (#174084) 2026-06-17 09:18:19 +02:00
Åke Strandberg 4625f7de27 Aqvify has reached gold tier (#174018) 2026-06-17 09:11:42 +02:00
Brett Adams 53a1db405c Improve test coverage of Teslemetry offline polling (#174108)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:47:08 +02:00
Paul Bottein ff7262d36f Fix Yoto quality scale comments (#174088) 2026-06-17 08:43:08 +02:00
tronikos 54feb95b76 Gemini: Update TTS model to gemini-3.1 and adjust configuration options (#174094) 2026-06-17 08:42:00 +02:00
epenet d9e2b49c0c Fix incorrect use of entity component constants in template (#172532) 2026-06-17 07:55:57 +02:00
renovate[bot] 4f9051464d Update cryptography to 48.0.1 (#174096) 2026-06-17 07:34:00 +02:00
Paulus Schoutsen 87894fd623 Activate venv before running python commands (#174093) 2026-06-17 07:32:22 +02:00
Franck Nijhof 34a70a9210 Clean up deprecated solar_rising entity from sun integration (#174079) 2026-06-17 06:44:16 +02:00
Paulus Schoutsen c9fb6a13fb Remove stale requirements_test_all.txt reference (#174095) 2026-06-17 05:08:20 +02:00
Franck Nijhof 1601b5151c Bump opower to 0.18.5 (#174080) 2026-06-16 14:20:30 -07:00
Franck Nijhof da0e23093d Cast system version to string for simplisafe device model (#174081) 2026-06-16 22:05:31 +02:00
Paul Bottein 7863468a34 Enable strict typing for Yoto (#174068) 2026-06-16 22:02:48 +02:00
Erik Montnemery 4ff5ee0520 Fix trigger first all race (#174078) 2026-06-16 22:00:03 +02:00
Franck Nijhof 6d8e3ab0c9 Retry webdav setup on connection errors (#174077) 2026-06-16 21:49:44 +02:00
epenet faa3a4ddef Add new enum for Density units (#172551) 2026-06-16 21:18:13 +02:00
Erik Montnemery 9cd7ea97e9 Improve condition history manager (#174069) 2026-06-16 21:17:34 +02:00
Paul Bottein 6012ec97b3 Add diagnostics to Yoto (#174070) 2026-06-16 21:09:36 +02:00
Jan Bouwhuis c58b281eda Remove term "Advanced" in IMAP translation strings (#174074) 2026-06-16 21:07:08 +02:00
jasonjhofmann 05001e581a Add network MAC connection to Renson devices (#173677)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:00:35 +02:00
jasonjhofmann 20dbfd19e2 Add network MAC connection to Electra Smart devices (#173678)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:59:54 +02:00
jasonjhofmann 179cb6e385 Add network MAC connection to Vilfo router (#173680)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:59:20 +02:00
Ariel Ebersberger 163fe9f20c Fix flaky cover device_condition test by ignoring asyncio slow-callback warnings (#173876)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:58:38 +02:00
some-random-climber f7d8bb112f Use dt_util.utcnow in reolink tests (#174022)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2026-06-16 20:57:29 +02:00
Josef Zweck c973bd90b2 Add pyserial-asyncio and pyserial-asyncio-fast to deprecated packages (#174013) 2026-06-16 20:57:20 +02:00
Michael Hansen 92e947ac28 Fix punctuation in voice aliases (#173945)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-16 20:56:44 +02:00
Joost Lekkerkerker a514683efa Remove unnecessary source state attribute in Sonos (#173995) 2026-06-16 20:56:20 +02:00
some-random-climber 41fe4f4f69 Use dt_util.utcnow in yolink (#174027) 2026-06-16 20:54:02 +02:00
some-random-climber e613f2b1e7 Use dt_util.utcnow in mcp tests (#174019) 2026-06-16 20:51:18 +02:00
some-random-climber 5c4f48a069 Use dt_util.utcnow in nest tests (#174021) 2026-06-16 20:50:56 +02:00
some-random-climber 219455ab4b Use dt_util.utcnow in sensoterra (#174023) 2026-06-16 20:50:01 +02:00
some-random-climber 75815fbc15 Use dt_util.utcnow in integration (#174024) 2026-06-16 20:49:36 +02:00
some-random-climber 33d9249d34 Use dt_util.utcnow in reolink (#174025) 2026-06-16 20:49:10 +02:00
some-random-climber 7cefe94467 Use dt_util.utcnow in starlink (#174026) 2026-06-16 20:48:38 +02:00
Markus Jacobsen c95ea00479 Replace "advanced" wording for Beolink actions in Bang & Olufsen (#174062) 2026-06-16 20:47:34 +02:00
Przemko92 730b6065ff Upgrade compit-inext-api to 0.9.1 (#173955) 2026-06-16 20:46:32 +02:00
Abílio Costa 1589ad2c6a Don't use infrared entity id as unique id for LG Infrared (#174072) 2026-06-16 20:44:12 +02:00
Tom d0df0de267 Rename airOS advanced settings (#174066) 2026-06-16 19:41:10 +02:00
Franck Nijhof aec09fadd4 Avoid allocating the exclude attributes set for every recorded state (#173690) 2026-06-16 18:34:46 +02:00
Franck Nijhof e2d68fcf58 Skip setting unused legacy columns when recording states and events (#173691) 2026-06-16 18:34:20 +02:00
107 changed files with 2171 additions and 1214 deletions
+1
View File
@@ -642,6 +642,7 @@ homeassistant.components.xbox.*
homeassistant.components.xiaomi_ble.*
homeassistant.components.yale_smart_alarm.*
homeassistant.components.yalexs_ble.*
homeassistant.components.yoto.*
homeassistant.components.youtube.*
homeassistant.components.zeroconf.*
homeassistant.components.zinvolt.*
+6 -6
View File
@@ -30,7 +30,7 @@ from homeassistant.exceptions import (
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS
from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADDITIONAL_SETTINGS
from .coordinator import (
AirOSConfigEntry,
AirOSDataUpdateCoordinator,
@@ -55,14 +55,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
# By default airOS 8 comes with self-signed SSL certificates,
# with no option in the web UI to change or upload a custom certificate.
session = async_get_clientsession(
hass, verify_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL]
hass, verify_ssl=entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_VERIFY_SSL]
)
conn_data = {
CONF_HOST: entry.data[CONF_HOST],
CONF_USERNAME: entry.data[CONF_USERNAME],
CONF_PASSWORD: entry.data[CONF_PASSWORD],
"use_ssl": entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
"use_ssl": entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL],
"session": session,
}
@@ -116,15 +116,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
async def async_migrate_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool:
"""Migrate old config entry."""
# 1.1 Migrate config_entry to add advanced ssl settings
# 1.1 Migrate config_entry to add additional ssl settings
if entry.version == 1 and entry.minor_version == 1:
new_minor_version = 2
new_data = {**entry.data}
advanced_data = {
additional_data = {
CONF_SSL: DEFAULT_SSL,
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
}
new_data[SECTION_ADVANCED_SETTINGS] = advanced_data
new_data[SECTION_ADDITIONAL_SETTINGS] = additional_data
hass.config_entries.async_update_entry(
entry,
@@ -52,7 +52,7 @@ from .const import (
HOSTNAME,
IP_ADDRESS,
MAC_ADDRESS,
SECTION_ADVANCED_SETTINGS,
SECTION_ADDITIONAL_SETTINGS,
)
_LOGGER = logging.getLogger(__name__)
@@ -66,7 +66,7 @@ STEP_DISCOVERY_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(SECTION_ADVANCED_SETTINGS): section(
vol.Required(SECTION_ADDITIONAL_SETTINGS): section(
vol.Schema(
{
vol.Required(CONF_SSL, default=DEFAULT_SSL): bool,
@@ -134,7 +134,7 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
# with no option in the web UI to change or upload a custom certificate.
session = async_get_clientsession(
self.hass,
verify_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL],
verify_ssl=config_data[SECTION_ADDITIONAL_SETTINGS][CONF_VERIFY_SSL],
)
try:
@@ -143,7 +143,7 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
username=config_data[CONF_USERNAME],
password=config_data[CONF_PASSWORD],
session=session,
use_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
use_ssl=config_data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL],
)
except (
@@ -234,18 +234,18 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
autocomplete="current-password",
)
),
vol.Required(SECTION_ADVANCED_SETTINGS): section(
vol.Required(SECTION_ADDITIONAL_SETTINGS): section(
vol.Schema(
{
vol.Required(
CONF_SSL,
default=current_data[SECTION_ADVANCED_SETTINGS][
default=current_data[SECTION_ADDITIONAL_SETTINGS][
CONF_SSL
],
): bool,
vol.Required(
CONF_VERIFY_SSL,
default=current_data[SECTION_ADVANCED_SETTINGS][
default=current_data[SECTION_ADDITIONAL_SETTINGS][
CONF_VERIFY_SSL
],
): bool,
+1 -1
View File
@@ -12,7 +12,7 @@ MANUFACTURER = "Ubiquiti"
DEFAULT_VERIFY_SSL = False
DEFAULT_SSL = True
SECTION_ADVANCED_SETTINGS = "advanced_settings"
SECTION_ADDITIONAL_SETTINGS = "additional_settings"
# Discovery related
DEFAULT_USERNAME = "ubnt"
+2 -2
View File
@@ -4,7 +4,7 @@ from homeassistant.const import CONF_HOST, CONF_SSL
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER, SECTION_ADVANCED_SETTINGS
from .const import DOMAIN, MANUFACTURER, SECTION_ADDITIONAL_SETTINGS
from .coordinator import AirOSDataUpdateCoordinator
@@ -20,7 +20,7 @@ class AirOSEntity(CoordinatorEntity[AirOSDataUpdateCoordinator]):
airos_data = self.coordinator.data
url_schema = (
"https"
if coordinator.config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL]
if coordinator.config_entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL]
else "http"
)
+12 -12
View File
@@ -33,16 +33,16 @@
},
"description": "Enter the username and password for {device_name}",
"sections": {
"advanced_settings": {
"additional_settings": {
"data": {
"ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data::ssl%]",
"ssl": "[%key:component::airos::config::step::manual::sections::additional_settings::data::ssl%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::ssl%]",
"verify_ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::verify_ssl%]"
"ssl": "[%key:component::airos::config::step::manual::sections::additional_settings::data_description::ssl%]",
"verify_ssl": "[%key:component::airos::config::step::manual::sections::additional_settings::data_description::verify_ssl%]"
},
"name": "[%key:component::airos::config::step::manual::sections::advanced_settings::name%]"
"name": "[%key:component::airos::config::step::manual::sections::additional_settings::name%]"
}
}
},
@@ -58,7 +58,7 @@
"username": "Administrator username for the airOS device, normally 'ubnt'"
},
"sections": {
"advanced_settings": {
"additional_settings": {
"data": {
"ssl": "Use HTTPS",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
@@ -67,7 +67,7 @@
"ssl": "Whether the connection should be encrypted (required for most devices)",
"verify_ssl": "Whether the certificate should be verified when using HTTPS. This should be off for self-signed certificates"
},
"name": "Advanced settings"
"name": "Additional settings"
}
}
},
@@ -87,16 +87,16 @@
"password": "[%key:component::airos::config::step::manual::data_description::password%]"
},
"sections": {
"advanced_settings": {
"additional_settings": {
"data": {
"ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data::ssl%]",
"ssl": "[%key:component::airos::config::step::manual::sections::additional_settings::data::ssl%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::ssl%]",
"verify_ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::verify_ssl%]"
"ssl": "[%key:component::airos::config::step::manual::sections::additional_settings::data_description::ssl%]",
"verify_ssl": "[%key:component::airos::config::step::manual::sections::additional_settings::data_description::verify_ssl%]"
},
"name": "[%key:component::airos::config::step::manual::sections::advanced_settings::name%]"
"name": "[%key:component::airos::config::step::manual::sections::additional_settings::name%]"
}
}
},
@@ -34,11 +34,13 @@ def generate_site_selector_name(site: Site) -> str:
def filter_sites(sites: list[Site]) -> list[Site]:
"""Deduplicates the list of sites."""
"""Filter out closed sites and deduplicate the list of sites."""
filtered: list[Site] = []
filtered_nmi: set[str] = set()
for site in sorted(sites, key=lambda site: site.status):
if site.status == SiteStatus.CLOSED:
continue
if site.status == SiteStatus.ACTIVE or site.nmi not in filtered_nmi:
filtered.append(site)
filtered_nmi.add(site.nmi)
@@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyaqvify"],
"quality_scale": "silver",
"quality_scale": "platinum",
"requirements": ["pyaqvify==0.0.11"]
}
@@ -53,29 +53,43 @@ rules:
test-coverage: done
# Gold
devices: todo
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
devices: done
diagnostics: done
discovery-update-info:
status: exempt
comment: |
Discovery not possible, as device is connected via 4G only. No LAN connection.
discovery:
status: exempt
comment: |
Discovery not possible, as device is connected via 4G only. No LAN connection.
docs-data-update: done
docs-examples: done
docs-known-limitations:
status: done
comment: |
No known limitations
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: done
entity-category:
status: done
comment: |
None of current sensors should be set as diagnostic
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
reconfiguration-flow: done
repair-issues:
status: exempt
comment: |
No repair issues are created.
stale-devices: done
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo
async-dependency: done
inject-websession: done
strict-typing: done
@@ -1,6 +1,6 @@
{
"common": {
"jid_options_description": "Advanced grouping options, where devices' unique Beolink IDs (Called JIDs) are used directly. JIDs can be found in the state attributes of the media player entity.",
"jid_options_description": "Additional grouping options, where devices' unique Beolink IDs (Called JIDs) are used directly. JIDs can be found in the state attributes of the media player entity.",
"jid_options_name": "JID options",
"key_press": "Press",
"key_release": "Release",
+11 -16
View File
@@ -3,7 +3,7 @@
import logging
from typing import Any
from compit_inext_api import Param, Parameter
from compit_inext_api import Parameter
from compit_inext_api.consts import (
CompitFanMode,
CompitHVACMode,
@@ -150,7 +150,7 @@ class CompitClimate(CoordinatorEntity[CompitDataUpdateCoordinator], ClimateEntit
value = self.get_parameter_value(CompitParameter.CURRENT_TEMPERATURE)
if value is None:
return None
return float(value.value)
return float(value)
@property
def target_temperature(self) -> float | None:
@@ -158,7 +158,7 @@ class CompitClimate(CoordinatorEntity[CompitDataUpdateCoordinator], ClimateEntit
value = self.get_parameter_value(CompitParameter.SET_TARGET_TEMPERATURE)
if value is None:
return None
return float(value.value)
return float(value)
@cached_property
def preset_modes(self) -> list[str] | None:
@@ -195,27 +195,24 @@ class CompitClimate(CoordinatorEntity[CompitDataUpdateCoordinator], ClimateEntit
"""Return the current preset mode."""
preset_mode = self.get_parameter_value(CompitParameter.PRESET_MODE)
if preset_mode:
compit_preset_mode = CompitPresetMode(preset_mode.value)
return COMPIT_PRESET_MAP.get(compit_preset_mode)
if preset_mode is not None:
return COMPIT_PRESET_MAP.get(CompitPresetMode(preset_mode))
return None
@property
def fan_mode(self) -> str | None:
"""Return the current fan mode."""
fan_mode = self.get_parameter_value(CompitParameter.FAN_MODE)
if fan_mode:
compit_fan_mode = CompitFanMode(fan_mode.value)
return COMPIT_FANSPEED_MAP.get(compit_fan_mode)
if fan_mode is not None:
return COMPIT_FANSPEED_MAP.get(CompitFanMode(fan_mode))
return None
@property
def hvac_mode(self) -> HVACMode | None:
"""Return the current HVAC mode."""
hvac_mode = self.get_parameter_value(CompitParameter.HVAC_MODE)
if hvac_mode:
compit_hvac_mode = CompitHVACMode(hvac_mode.value)
return COMPIT_MODE_MAP.get(compit_hvac_mode)
if hvac_mode is not None:
return COMPIT_MODE_MAP.get(CompitHVACMode(hvac_mode))
return None
async def async_set_temperature(self, **kwargs: Any) -> None:
@@ -258,8 +255,6 @@ class CompitClimate(CoordinatorEntity[CompitDataUpdateCoordinator], ClimateEntit
)
self.async_write_ha_state()
def get_parameter_value(self, parameter: CompitParameter) -> Param | None:
def get_parameter_value(self, parameter: CompitParameter) -> str | float | None:
"""Get the parameter value from the device state."""
return self.coordinator.connector.get_device_parameter(
self.device_id, parameter
)
return self.coordinator.connector.get_current_value(self.device_id, parameter)
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["compit"],
"quality_scale": "bronze",
"requirements": ["compit-inext-api==0.8.0"]
"requirements": ["compit-inext-api==0.9.1"]
}
@@ -852,7 +852,7 @@ class DefaultAgent(ConversationEntity):
)
# Build filtered slot list
text_lower = text.strip().lower()
text_lower = remove_punctuation(text).strip().lower()
return TextSlotList(
name="name",
values=[
@@ -889,7 +889,8 @@ class DefaultAgent(ConversationEntity):
for name in intent.async_get_entity_aliases(
self.hass, entity_entry, state=state
):
yield (name, name, context)
# Strip punctuation so aliases match the cleaned input text.
yield (remove_punctuation(name).strip(), name, context)
def _recognize_strict(
self,
@@ -1162,7 +1163,7 @@ class DefaultAgent(ConversationEntity):
areas = ar.async_get(self.hass)
area_names = []
for area in areas.async_list_areas():
area_names.append((area.name, area.name))
area_names.append((remove_punctuation(area.name).strip(), area.name))
if not area.aliases:
continue
@@ -1171,13 +1172,13 @@ class DefaultAgent(ConversationEntity):
if not alias:
continue
area_names.append((alias, alias))
area_names.append((remove_punctuation(alias).strip(), alias))
# Expose all floors.
floors = fr.async_get(self.hass)
floor_names = []
for floor in floors.async_list_floors():
floor_names.append((floor.name, floor.name))
floor_names.append((remove_punctuation(floor.name).strip(), floor.name))
if not floor.aliases:
continue
@@ -1186,7 +1187,7 @@ class DefaultAgent(ConversationEntity):
if not alias:
continue
floor_names.append((alias, floor.name))
floor_names.append((remove_punctuation(alias).strip(), floor.name))
# Build trie
self._exposed_names_trie = Trie()
@@ -4,7 +4,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER]
PLATFORMS = [Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@@ -1,180 +0,0 @@
"""Button platform for Edifier infrared integration."""
from dataclasses import dataclass
from infrared_protocols.codes.edifier.models import EdifierCommandSet, EdifierModel
from infrared_protocols.codes.edifier.r1280db import EdifierR1280DBCode
from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode
from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode
from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.components.infrared import InfraredEmitterConsumerEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_MODEL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_COMMAND_SET, CONF_INFRARED_ENTITY_ID, EdifierCode
from .entity import EdifierIrEntity
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class EdifierIrButtonEntityDescription(ButtonEntityDescription):
"""Describes Edifier IR button entity."""
command_code: EdifierCode
COMMAND_SET_BUTTONS: dict[
EdifierCommandSet,
tuple[EdifierIrButtonEntityDescription, ...],
] = {
EdifierCommandSet.R1700BT: (
EdifierIrButtonEntityDescription(
key="bluetooth",
translation_key="bluetooth",
command_code=EdifierR1700BTCode.BLUETOOTH,
),
EdifierIrButtonEntityDescription(
key="line_1",
translation_key="line_1",
command_code=EdifierR1700BTCode.LINE_1,
),
EdifierIrButtonEntityDescription(
key="line_2",
translation_key="line_2",
command_code=EdifierR1700BTCode.LINE_2,
),
EdifierIrButtonEntityDescription(
key="fx_on",
translation_key="fx_on",
command_code=EdifierR1700BTCode.FX_ON,
),
EdifierIrButtonEntityDescription(
key="fx_off",
translation_key="fx_off",
command_code=EdifierR1700BTCode.FX_OFF,
),
),
EdifierCommandSet.R1280DB: (
EdifierIrButtonEntityDescription(
key="bluetooth",
translation_key="bluetooth",
command_code=EdifierR1280DBCode.BLUETOOTH,
),
EdifierIrButtonEntityDescription(
key="line_1",
translation_key="line_1",
command_code=EdifierR1280DBCode.LINE_1,
),
EdifierIrButtonEntityDescription(
key="line_2",
translation_key="line_2",
command_code=EdifierR1280DBCode.LINE_2,
),
EdifierIrButtonEntityDescription(
key="optical",
translation_key="optical",
command_code=EdifierR1280DBCode.OPTICAL,
),
EdifierIrButtonEntityDescription(
key="coax",
translation_key="coax",
command_code=EdifierR1280DBCode.COAX,
),
),
EdifierCommandSet.S360DB: (
EdifierIrButtonEntityDescription(
key="bluetooth",
translation_key="bluetooth",
command_code=EdifierS360DBCode.BLUETOOTH,
),
EdifierIrButtonEntityDescription(
key="optical",
translation_key="optical",
command_code=EdifierS360DBCode.OPTICAL,
),
EdifierIrButtonEntityDescription(
key="coax",
translation_key="coax",
command_code=EdifierS360DBCode.COAX,
),
EdifierIrButtonEntityDescription(
key="pc",
translation_key="pc",
command_code=EdifierS360DBCode.PC,
),
EdifierIrButtonEntityDescription(
key="aux",
translation_key="aux",
command_code=EdifierS360DBCode.AUX,
),
),
EdifierCommandSet.RC20G: (
EdifierIrButtonEntityDescription(
key="bluetooth",
translation_key="bluetooth",
command_code=EdifierRC20GCode.BLUETOOTH,
),
EdifierIrButtonEntityDescription(
key="pc",
translation_key="pc",
command_code=EdifierRC20GCode.PC,
),
EdifierIrButtonEntityDescription(
key="aux",
translation_key="aux",
command_code=EdifierRC20GCode.AUX,
),
EdifierIrButtonEntityDescription(
key="optical",
translation_key="optical",
command_code=EdifierRC20GCode.OPTICAL,
),
EdifierIrButtonEntityDescription(
key="coax",
translation_key="coax",
command_code=EdifierRC20GCode.COAX,
),
),
}
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Edifier IR buttons from a config entry."""
infrared_entity_id = entry.data[CONF_INFRARED_ENTITY_ID]
command_set = EdifierCommandSet(entry.data[CONF_COMMAND_SET])
model = EdifierModel(entry.data[CONF_MODEL])
async_add_entities(
EdifierIrButton(entry, model, infrared_entity_id, description)
for description in COMMAND_SET_BUTTONS.get(command_set, ())
)
class EdifierIrButton(EdifierIrEntity, InfraredEmitterConsumerEntity, ButtonEntity):
"""Edifier IR button entity."""
entity_description: EdifierIrButtonEntityDescription
def __init__(
self,
entry: ConfigEntry,
model: EdifierModel,
infrared_entity_id: str,
description: EdifierIrButtonEntityDescription,
) -> None:
"""Initialize Edifier IR button."""
super().__init__(entry, model, unique_id_suffix=description.key)
self._infrared_emitter_entity_id = infrared_entity_id
self.entity_description = description
async def async_press(self) -> None:
"""Press the button."""
await self._send_command(self.entity_description.command_code.to_command())
@@ -18,36 +18,5 @@
"title": "Set up Edifier IR speaker"
}
}
},
"entity": {
"button": {
"aux": {
"name": "AUX"
},
"bluetooth": {
"name": "Bluetooth"
},
"coax": {
"name": "Coaxial"
},
"fx_off": {
"name": "FX off"
},
"fx_on": {
"name": "FX on"
},
"line_1": {
"name": "Line 1"
},
"line_2": {
"name": "Line 2"
},
"optical": {
"name": "Optical"
},
"pc": {
"name": "PC"
}
}
}
}
@@ -25,7 +25,7 @@ from homeassistant.components.climate import (
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ElectraSmartConfigEntry
@@ -145,6 +145,7 @@ class ElectraClimateEntity(ClimateEntity):
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._electra_ac_device.mac)},
connections={(CONNECTION_NETWORK_MAC, self._electra_ac_device.mac)},
name=device.name,
model=self._electra_ac_device.model,
manufacturer=self._electra_ac_device.manufactor,
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["elevenlabs"],
"requirements": ["elevenlabs==2.3.0", "sentence-stream==1.2.0"]
"requirements": ["elevenlabs==2.3.0", "sentence-stream==1.3.0"]
}
@@ -434,49 +434,56 @@ async def google_generative_ai_config_option_schema(
description={"suggested_value": options.get(CONF_TEMPERATURE)},
default=RECOMMENDED_TEMPERATURE,
): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)),
vol.Optional(
CONF_TOP_P,
description={"suggested_value": options.get(CONF_TOP_P)},
default=RECOMMENDED_TOP_P,
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
vol.Optional(
CONF_TOP_K,
description={"suggested_value": options.get(CONF_TOP_K)},
default=RECOMMENDED_TOP_K,
): int,
vol.Optional(
CONF_MAX_TOKENS,
description={"suggested_value": options.get(CONF_MAX_TOKENS)},
default=RECOMMENDED_MAX_TOKENS,
): int,
vol.Optional(
CONF_HARASSMENT_BLOCK_THRESHOLD,
description={
"suggested_value": options.get(CONF_HARASSMENT_BLOCK_THRESHOLD)
},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
vol.Optional(
CONF_HATE_BLOCK_THRESHOLD,
description={"suggested_value": options.get(CONF_HATE_BLOCK_THRESHOLD)},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
vol.Optional(
CONF_SEXUAL_BLOCK_THRESHOLD,
description={
"suggested_value": options.get(CONF_SEXUAL_BLOCK_THRESHOLD)
},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
vol.Optional(
CONF_DANGEROUS_BLOCK_THRESHOLD,
description={
"suggested_value": options.get(CONF_DANGEROUS_BLOCK_THRESHOLD)
},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
}
)
if subentry_type != "tts":
schema.update(
{
vol.Optional(
CONF_TOP_P,
description={"suggested_value": options.get(CONF_TOP_P)},
default=RECOMMENDED_TOP_P,
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
vol.Optional(
CONF_TOP_K,
description={"suggested_value": options.get(CONF_TOP_K)},
default=RECOMMENDED_TOP_K,
): int,
vol.Optional(
CONF_MAX_TOKENS,
description={"suggested_value": options.get(CONF_MAX_TOKENS)},
default=RECOMMENDED_MAX_TOKENS,
): int,
vol.Optional(
CONF_HARASSMENT_BLOCK_THRESHOLD,
description={
"suggested_value": options.get(CONF_HARASSMENT_BLOCK_THRESHOLD)
},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
vol.Optional(
CONF_HATE_BLOCK_THRESHOLD,
description={
"suggested_value": options.get(CONF_HATE_BLOCK_THRESHOLD)
},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
vol.Optional(
CONF_SEXUAL_BLOCK_THRESHOLD,
description={
"suggested_value": options.get(CONF_SEXUAL_BLOCK_THRESHOLD)
},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
vol.Optional(
CONF_DANGEROUS_BLOCK_THRESHOLD,
description={
"suggested_value": options.get(CONF_DANGEROUS_BLOCK_THRESHOLD)
},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
}
)
if subentry_type == "conversation":
schema.update(
{
@@ -21,7 +21,7 @@ CONF_RECOMMENDED = "recommended"
CONF_CHAT_MODEL = "chat_model"
RECOMMENDED_CHAT_MODEL = "models/gemini-3.1-flash-lite"
RECOMMENDED_STT_MODEL = RECOMMENDED_CHAT_MODEL
RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts"
RECOMMENDED_TTS_MODEL = "models/gemini-3.1-flash-tts-preview"
RECOMMENDED_IMAGE_MODEL = "models/gemini-2.5-flash-image"
CONF_TEMPERATURE = "temperature"
RECOMMENDED_TEMPERATURE = 1.0
@@ -18,7 +18,13 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_CHAT_MODEL, LOGGER, RECOMMENDED_TTS_MODEL
from .const import (
CONF_CHAT_MODEL,
CONF_TEMPERATURE,
LOGGER,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TTS_MODEL,
)
from .entity import GoogleGenerativeAILLMBaseEntity
from .helpers import convert_to_wav
@@ -191,7 +197,10 @@ class GoogleGenerativeAITextToSpeechEntity(
self, message: str, language: str, options: dict[str, Any]
) -> TtsAudioType:
"""Load tts audio file from the engine."""
config = self.create_generate_content_config()
config = types.GenerateContentConfig()
config.temperature = self.subentry.data.get(
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
)
config.response_modalities = ["AUDIO"]
config.speech_config = types.SpeechConfig(
voice_config=types.VoiceConfig(
+1 -1
View File
@@ -32,7 +32,7 @@
"port": "[%key:common::config_flow::data::port%]",
"search": "IMAP search",
"server": "Server",
"ssl_cipher_list": "SSL cipher list (Advanced)",
"ssl_cipher_list": "SSL cipher list",
"username": "[%key:common::config_flow::data::username%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
@@ -2,7 +2,7 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
from decimal import Decimal, InvalidOperation
from enum import Enum
import logging
@@ -49,6 +49,7 @@ from homeassistant.helpers.event import (
async_track_state_report_event,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
from .const import (
CONF_MAX_SUB_INTERVAL,
@@ -339,8 +340,7 @@ class IntegrationSensor(RestoreSensor):
else max_sub_interval
)
self._max_sub_interval_exceeded_callback: CALLBACK_TYPE = lambda *args: None
# pylint: disable-next=home-assistant-enforce-utcnow
self._last_integration_time: datetime = datetime.now(tz=UTC)
self._last_integration_time: datetime = dt_util.utcnow()
self._last_integration_trigger = _IntegrationTrigger.StateEvent
self._attr_suggested_display_precision = round_digits or 2
@@ -499,8 +499,7 @@ class IntegrationSensor(RestoreSensor):
old_timestamp, new_timestamp, old_state, new_state
)
self._last_integration_trigger = _IntegrationTrigger.StateEvent
# pylint: disable-next=home-assistant-enforce-utcnow
self._last_integration_time = datetime.now(tz=UTC)
self._last_integration_time = dt_util.utcnow()
finally:
# When max_sub_interval exceeds without state change the source is assumed
# constant with the last known state (new_state).
@@ -608,8 +607,7 @@ class IntegrationSensor(RestoreSensor):
self._update_integral(area)
self.async_write_ha_state()
# pylint: disable-next=home-assistant-enforce-utcnow
self._last_integration_time = datetime.now(tz=UTC)
self._last_integration_time = dt_util.utcnow()
self._last_integration_trigger = _IntegrationTrigger.TimeElapsed
self._schedule_max_sub_interval_exceeded_if_state_is_numeric(
@@ -1,11 +1,15 @@
"""LG IR Remote integration for Home Assistant."""
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
PLATFORMS = [Platform.BUTTON, Platform.EVENT, Platform.MEDIA_PLAYER]
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up LG IR from a config entry."""
@@ -16,3 +20,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a LG IR config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate old config entry."""
if entry.version == 1:
# v1 used the infrared entity_id in the entry's unique_id, which is
# not stable and was removed in v2.
_LOGGER.debug("Migrating config entry from version 1 to 2")
hass.config_entries.async_update_entry(entry, unique_id=None, version=2)
return True
@@ -1,6 +1,6 @@
"""Config flow for LG IR integration."""
from typing import Any
from typing import TYPE_CHECKING, Any
import voluptuous as vol
@@ -35,7 +35,7 @@ DEVICE_TYPE_NAMES: dict[LGDeviceType, str] = {
class LgIrConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle config flow for LG IR."""
VERSION = 1
VERSION = 2
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -49,24 +49,39 @@ class LgIrConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
if user_input is not None:
if entity_id := user_input.get(CONF_INFRARED_ENTITY_ID) or user_input.get(
CONF_INFRARED_RECEIVER_ENTITY_ID
):
emitter_id = user_input.get(CONF_INFRARED_ENTITY_ID)
receiver_id = user_input.get(CONF_INFRARED_RECEIVER_ENTITY_ID)
if emitter_id or receiver_id:
device_type = user_input[CONF_DEVICE_TYPE]
await self.async_set_unique_id(f"lg_ir_{device_type}_{entity_id}")
self._abort_if_unique_id_configured()
if emitter_id:
self._async_abort_entries_match(
{
CONF_DEVICE_TYPE: device_type,
CONF_INFRARED_ENTITY_ID: emitter_id,
}
)
if receiver_id:
self._async_abort_entries_match(
{
CONF_DEVICE_TYPE: device_type,
CONF_INFRARED_RECEIVER_ENTITY_ID: receiver_id,
}
)
# Get entity name for the title
title_entity_id = emitter_id or receiver_id
if TYPE_CHECKING:
assert title_entity_id is not None
ent_reg = er.async_get(self.hass)
entry = ent_reg.async_get(entity_id)
entity_name = (
entry.name or entry.original_name or entity_id
entry = ent_reg.async_get(title_entity_id)
title_entity_name = (
entry.name or entry.original_name or title_entity_id
if entry
else entity_id
else title_entity_id
)
device_type_name = DEVICE_TYPE_NAMES[LGDeviceType(device_type)]
title = f"LG {device_type_name} via {entity_name}"
title = f"LG {device_type_name} via {title_entity_name}"
return self.async_create_entry(title=title, data=user_input)
+21 -21
View File
@@ -1124,7 +1124,7 @@ def validate_light_platform_config(user_data: dict[str, Any]) -> dict[str, str]:
if user_data.get(CONF_MIN_KELVIN, DEFAULT_MIN_KELVIN) >= user_data.get(
CONF_MAX_KELVIN, DEFAULT_MAX_KELVIN
):
errors["advanced_settings"] = "max_below_min_kelvin"
errors["other_settings"] = "max_below_min_kelvin"
return errors
@@ -1217,7 +1217,7 @@ def validate_text_platform_config(
and CONF_MAX in config
and config[CONF_MIN] > config[CONF_MAX]
):
errors["text_advanced_settings"] = "max_below_min"
errors["text_other_settings"] = "max_below_min"
return errors
@@ -1506,7 +1506,7 @@ PLATFORM_ENTITY_FIELDS: dict[Platform, dict[str, PlatformField]] = {
selector=SUGGESTED_DISPLAY_PRECISION_SELECTOR,
required=False,
validator=cv.positive_int,
section="advanced_settings",
section="other_settings",
),
CONF_OPTIONS: PlatformField(
selector=OPTIONS_SELECTOR,
@@ -1678,13 +1678,13 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
selector=TIMEOUT_SELECTOR,
required=False,
validator=cv.positive_int,
section="advanced_settings",
section="other_settings",
),
CONF_OFF_DELAY: PlatformField(
selector=TIMEOUT_SELECTOR,
required=False,
validator=cv.positive_int,
section="advanced_settings",
section="other_settings",
),
},
Platform.BUTTON: {
@@ -3125,7 +3125,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
default=False,
validator=cv.boolean,
conditions=({CONF_SCHEMA: "json"},),
section="advanced_settings",
section="other_settings",
),
CONF_FLASH_TIME_SHORT: PlatformField(
selector=FLASH_TIME_SELECTOR,
@@ -3133,7 +3133,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
validator=cv.positive_int,
default=2,
conditions=({CONF_SCHEMA: "json"},),
section="advanced_settings",
section="other_settings",
),
CONF_FLASH_TIME_LONG: PlatformField(
selector=FLASH_TIME_SELECTOR,
@@ -3141,7 +3141,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
validator=cv.positive_int,
default=10,
conditions=({CONF_SCHEMA: "json"},),
section="advanced_settings",
section="other_settings",
),
CONF_TRANSITION: PlatformField(
selector=BOOLEAN_SELECTOR,
@@ -3149,21 +3149,21 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
default=False,
validator=cv.boolean,
conditions=({CONF_SCHEMA: "json"},),
section="advanced_settings",
section="other_settings",
),
CONF_MAX_KELVIN: PlatformField(
selector=KELVIN_SELECTOR,
required=False,
validator=cv.positive_int,
default=DEFAULT_MAX_KELVIN,
section="advanced_settings",
section="other_settings",
),
CONF_MIN_KELVIN: PlatformField(
selector=KELVIN_SELECTOR,
required=False,
validator=cv.positive_int,
default=DEFAULT_MIN_KELVIN,
section="advanced_settings",
section="other_settings",
),
},
Platform.LOCK: {
@@ -3372,7 +3372,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
selector=TIMEOUT_SELECTOR,
required=False,
validator=cv.positive_int,
section="advanced_settings",
section="other_settings",
),
},
Platform.SIREN: {
@@ -3437,7 +3437,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
required=False,
validator=validate(cv.template),
error="invalid_template",
section="siren_advanced_settings",
section="siren_other_settings",
),
},
Platform.SWITCH: {
@@ -3516,26 +3516,26 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
selector=TEXT_SIZE_SELECTOR,
required=True,
default=0,
section="text_advanced_settings",
section="text_other_settings",
),
CONF_MAX: PlatformField(
selector=TEXT_SIZE_SELECTOR,
required=True,
default=255,
section="text_advanced_settings",
section="text_other_settings",
),
CONF_MODE: PlatformField(
selector=TEXT_MODE_SELECTOR,
required=True,
default=TextSelectorType.TEXT.value,
section="text_advanced_settings",
section="text_other_settings",
),
CONF_PATTERN: PlatformField(
selector=TEXT_SELECTOR,
required=False,
validator=validate(cv.is_regex),
error="invalid_regular_expression",
section="text_advanced_settings",
section="text_other_settings",
),
},
Platform.TIME: {
@@ -3798,10 +3798,10 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
MQTT_DEVICE_PLATFORM_FIELDS = {
CONF_NAME: PlatformField(selector=TEXT_SELECTOR, required=True),
CONF_SW_VERSION: PlatformField(
selector=TEXT_SELECTOR, required=False, section="advanced_settings"
selector=TEXT_SELECTOR, required=False, section="other_settings"
),
CONF_HW_VERSION: PlatformField(
selector=TEXT_SELECTOR, required=False, section="advanced_settings"
selector=TEXT_SELECTOR, required=False, section="other_settings"
),
CONF_MODEL: PlatformField(selector=TEXT_SELECTOR, required=False),
CONF_MODEL_ID: PlatformField(selector=TEXT_SELECTOR, required=False),
@@ -4686,8 +4686,8 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
if user_input is not None:
new_device_data: dict[str, Any] = user_input.copy()
_, errors = validate_user_input(user_input, MQTT_DEVICE_PLATFORM_FIELDS)
if "advanced_settings" in new_device_data:
new_device_data |= new_device_data.pop("advanced_settings")
if "other_settings" in new_device_data:
new_device_data |= new_device_data.pop("other_settings")
if not errors:
self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, new_device_data)
if self.source == SOURCE_RECONFIGURE:
+40 -40
View File
@@ -184,17 +184,6 @@
},
"description": "Enter the MQTT device details:",
"sections": {
"advanced_settings": {
"data": {
"hw_version": "Hardware version",
"sw_version": "Software version"
},
"data_description": {
"hw_version": "The hardware version of the device. E.g. 'v1.0 rev a'.",
"sw_version": "The software version of the device. E.g. '2025.1.0'."
},
"name": "Advanced device settings"
},
"mqtt_settings": {
"data": {
"message_expiry_interval": "Message Expiry Interval",
@@ -205,6 +194,17 @@
"qos": "The Quality of Service value the device's entities should use."
},
"name": "MQTT settings"
},
"other_settings": {
"data": {
"hw_version": "Hardware version",
"sw_version": "Software version"
},
"data_description": {
"hw_version": "The hardware version of the device. E.g. 'v1.0 rev a'.",
"sw_version": "The software version of the device. E.g. '2025.1.0'."
},
"name": "Other device settings"
}
},
"title": "Configure MQTT device details"
@@ -286,14 +286,14 @@
},
"description": "Please configure specific details for {platform} entity \"{entity}\":",
"sections": {
"advanced_settings": {
"other_settings": {
"data": {
"suggested_display_precision": "Suggested display precision"
},
"data_description": {
"suggested_display_precision": "The number of decimals which should be used in the {platform} entity state after rounding. [Learn more.]({url}#suggested_display_precision)"
},
"name": "Advanced options"
"name": "Other settings"
}
},
"title": "Configure MQTT device \"{mqtt_device}\""
@@ -438,29 +438,6 @@
},
"description": "Please configure MQTT specific details for {platform} entity \"{entity}\":",
"sections": {
"advanced_settings": {
"data": {
"expire_after": "Expire after",
"flash": "Flash support",
"flash_time_long": "Flash time long",
"flash_time_short": "Flash time short",
"max_kelvin": "Max Kelvin",
"min_kelvin": "Min Kelvin",
"off_delay": "OFF delay",
"transition": "Transition support"
},
"data_description": {
"expire_after": "If set, it defines the number of seconds after the sensors state expires, if its not updated. After expiry, the sensors state becomes unavailable. If not set, the sensor's state never expires. [Learn more.]({url}#expire_after)",
"flash": "Enable the flash feature for this light",
"flash_time_long": "The duration, in seconds, of a \"long\" flash.",
"flash_time_short": "The duration, in seconds, of a \"short\" flash.",
"max_kelvin": "The maximum color temperature in Kelvin.",
"min_kelvin": "The minimum color temperature in Kelvin.",
"off_delay": "For sensors that only send \"on\" state updates (like PIRs), this variable sets a delay in seconds after which the sensors state will be updated back to \"off\".",
"transition": "Enable the transition feature for this light"
},
"name": "Advanced settings"
},
"alarm_control_panel_payload_settings": {
"data": {
"payload_arm_away": "Payload \"arm away\"",
@@ -916,14 +893,37 @@
},
"name": "Lock payload settings"
},
"siren_advanced_settings": {
"other_settings": {
"data": {
"expire_after": "Expire after",
"flash": "Flash support",
"flash_time_long": "Flash time long",
"flash_time_short": "Flash time short",
"max_kelvin": "Max Kelvin",
"min_kelvin": "Min Kelvin",
"off_delay": "OFF delay",
"transition": "Transition support"
},
"data_description": {
"expire_after": "If set, it defines the number of seconds after the sensors state expires, if its not updated. After expiry, the sensors state becomes unavailable. If not set, the sensor's state never expires. [Learn more.]({url}#expire_after)",
"flash": "Enable the flash feature for this light",
"flash_time_long": "The duration, in seconds, of a \"long\" flash.",
"flash_time_short": "The duration, in seconds, of a \"short\" flash.",
"max_kelvin": "The maximum color temperature in Kelvin.",
"min_kelvin": "The minimum color temperature in Kelvin.",
"off_delay": "For sensors that only send \"on\" state updates (like PIRs), this variable sets a delay in seconds after which the sensors state will be updated back to \"off\".",
"transition": "Enable the transition feature for this light"
},
"name": "Other settings"
},
"siren_other_settings": {
"data": {
"command_off_template": "Command \"off\" template"
},
"data_description": {
"command_off_template": "The [template]({command_templating_url}) for \"off\" state changes. By default the \"[Command template]({url}#command_template)\" will be used. [Learn more.]({url}#command_off_template)"
},
"name": "Advanced siren settings"
"name": "Other siren settings"
},
"target_humidity_settings": {
"data": {
@@ -985,7 +985,7 @@
},
"name": "Target temperature settings"
},
"text_advanced_settings": {
"text_other_settings": {
"data": {
"max": "Maximum length",
"min": "Minimum length",
@@ -998,7 +998,7 @@
"mode": "Mode of the text input",
"pattern": "A valid regex pattern"
},
"name": "Advanced text entity settings"
"name": "Other text entity settings"
},
"valve_payload_settings": {
"data": {
@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["opower"],
"quality_scale": "platinum",
"requirements": ["opower==0.18.4"]
"requirements": ["opower==0.18.5"]
}
@@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant
from .coordinator import RabbitAirConfigEntry, RabbitAirDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.FAN]
PLATFORMS: list[Platform] = [Platform.FAN, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: RabbitAirConfigEntry) -> bool:
+2 -1
View File
@@ -25,6 +25,8 @@ MODELS = {
class RabbitAirBaseEntity(CoordinatorEntity[RabbitAirDataUpdateCoordinator]):
"""Base class for Rabbit Air entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: RabbitAirDataUpdateCoordinator,
@@ -32,7 +34,6 @@ class RabbitAirBaseEntity(CoordinatorEntity[RabbitAirDataUpdateCoordinator]):
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_name = entry.title
self._attr_unique_id = entry.unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.data[CONF_MAC])},
@@ -46,6 +46,7 @@ async def async_setup_entry(
class RabbitAirFanEntity(RabbitAirBaseEntity, FanEntity):
"""Fan control functions of the Rabbit Air air purifier."""
_attr_name = None
_attr_translation_key = "rabbitair"
_attr_supported_features = (
FanEntityFeature.PRESET_MODE
@@ -12,6 +12,11 @@
}
}
}
},
"sensor": {
"air_quality": {
"default": "mdi:air-filter"
}
}
}
}
@@ -0,0 +1,60 @@
"""Support for Rabbit Air sensors."""
from rabbitair import Quality
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import RabbitAirConfigEntry, RabbitAirDataUpdateCoordinator
from .entity import RabbitAirBaseEntity
def _quality_value(quality: Quality | None) -> StateType:
"""Return the air quality state."""
return None if quality is None else quality.name.lower()
AIR_QUALITY_OPTIONS = [quality.name.lower() for quality in Quality]
AIR_QUALITY_DESCRIPTION = SensorEntityDescription(
key="air_quality",
translation_key="air_quality",
device_class=SensorDeviceClass.ENUM,
options=AIR_QUALITY_OPTIONS,
)
async def async_setup_entry(
hass: HomeAssistant,
entry: RabbitAirConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Rabbit Air sensors."""
if entry.runtime_data.data.quality is not None:
async_add_entities([RabbitAirAirQualitySensor(entry.runtime_data, entry)])
class RabbitAirAirQualitySensor(RabbitAirBaseEntity, SensorEntity):
"""Rabbit Air air quality sensor."""
entity_description = AIR_QUALITY_DESCRIPTION
def __init__(
self,
coordinator: RabbitAirDataUpdateCoordinator,
entry: RabbitAirConfigEntry,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator, entry)
self._attr_unique_id = f"{entry.unique_id}_{self.entity_description.key}"
@property
def native_value(self) -> StateType:
"""Return the air quality state."""
return _quality_value(self.coordinator.data.quality)
@@ -32,6 +32,18 @@
}
}
}
},
"sensor": {
"air_quality": {
"name": "Air quality",
"state": {
"high": "[%key:common::state::high%]",
"highest": "Highest",
"low": "[%key:common::state::low%]",
"lowest": "Lowest",
"medium": "[%key:common::state::medium%]"
}
}
}
}
}
@@ -31,6 +31,7 @@ from homeassistant.util.event_type import EventType
from . import (
backup, # noqa: F401
entity_registry,
recorded_entities,
websocket_api,
)
from .const import ( # noqa: F401
@@ -167,6 +168,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
get_instance.cache_clear()
entity_registry.async_setup(hass)
await recorded_entities.async_setup(hass)
instance.async_initialize()
instance.async_register()
instance.start()
+19 -15
View File
@@ -308,17 +308,17 @@ class Events(Base):
def from_event(event: Event) -> Events:
"""Create an event database object from a native event."""
context = event.context
# The unused legacy columns (event_type, event_data, time_fired,
# context_id, context_user_id, context_parent_id) are nullable with no
# default, so they are intentionally left unset here. Assigning them
# None would still insert NULL, but each assignment goes through
# SQLAlchemy's instrumented attribute machinery, which is a measurable
# cost when run for every recorded event.
return Events(
event_type=None,
event_data=None,
origin_idx=event.origin.idx,
time_fired=None,
time_fired_ts=event.time_fired_timestamp,
context_id=None,
context_id_bin=ulid_to_bytes_or_none(context.id),
context_user_id=None,
context_user_id_bin=uuid_hex_to_bytes_or_none(context.user_id),
context_parent_id=None,
context_parent_id_bin=ulid_to_bytes_or_none(context.parent_id),
)
@@ -491,19 +491,18 @@ class States(Base):
else:
last_reported_ts = state.last_reported_timestamp
context = event.context
# The unused legacy columns (entity_id, attributes, context_id,
# context_user_id, context_parent_id, last_updated, last_changed) are
# nullable with no default, so they are intentionally left unset here.
# Assigning them None would still insert NULL, but each assignment goes
# through SQLAlchemy's instrumented attribute machinery, which is a
# measurable cost when run for every recorded state change.
return States(
state=state_value,
entity_id=None,
attributes=None,
context_id=None,
context_id_bin=ulid_to_bytes_or_none(context.id),
context_user_id=None,
context_user_id_bin=uuid_hex_to_bytes_or_none(context.user_id),
context_parent_id=None,
context_parent_id_bin=ulid_to_bytes_or_none(context.parent_id),
origin_idx=event.origin.idx,
last_updated=None,
last_changed=None,
last_updated_ts=last_updated_ts,
last_changed_ts=last_changed_ts,
last_reported_ts=last_reported_ts,
@@ -560,8 +559,13 @@ class StateAttributes(Base):
# None state means the state was removed from the state machine
if (state := event.data["new_state"]) is None:
return b"{}"
if state_info := state.state_info:
unrecorded_attributes = state_info["unrecorded_attributes"]
if (state_info := state.state_info) and (
unrecorded_attributes := state_info["unrecorded_attributes"]
):
# The entity has unrecorded attributes, so a combined exclude set
# has to be built. The common case (no unrecorded attributes) falls
# through to the shared constant below without allocating a set per
# recorded state change.
exclude_attrs = {
*ALL_DOMAIN_EXCLUDE_ATTRS,
*unrecorded_attributes,
@@ -0,0 +1,57 @@
"""Control which entities are recorded."""
import dataclasses
from enum import StrEnum
from typing import Any
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
async def async_setup(hass: HomeAssistant) -> None:
"""Set up the recorded entities."""
websocket_api.async_register_command(hass, ws_get_recorded_entity)
class EntityRecordingDisabler(StrEnum):
"""What disabled recording of an entity."""
USER = "user"
@dataclasses.dataclass(frozen=True)
class RecordedEntity:
"""A recorded entity without a unique_id."""
recording_disabled_by: EntityRecordingDisabler | None = None
def to_json(self) -> dict[str, Any]:
"""Return a JSON serializable representation for storage."""
return {
"recording_disabled_by": self.recording_disabled_by,
}
@callback
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "recorder/recorded_entities/get",
vol.Required("entity_id"): str,
}
)
def ws_get_recorded_entity(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Get recorder settings for a single entity."""
from . import is_entity_recorded # noqa: PLC0415
entity_id: str = msg["entity_id"]
recording_disabled = (
None if is_entity_recorded(hass, entity_id) else EntityRecordingDisabler.USER
)
options = RecordedEntity(recording_disabled_by=recording_disabled)
connection.send_result(msg["id"], options.to_json())
+6 -7
View File
@@ -8,7 +8,7 @@ from renson_endura_delta.field_enum import (
)
from renson_endura_delta.renson import RensonVentilation
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -24,10 +24,11 @@ class RensonEntity(CoordinatorEntity[RensonCoordinator]):
"""Initialize the Renson entity."""
super().__init__(coordinator)
mac = api.get_field_value(coordinator.data, MAC_ADDRESS.name)
self._attr_device_info = DeviceInfo(
identifiers={
(DOMAIN, api.get_field_value(coordinator.data, MAC_ADDRESS.name))
},
identifiers={(DOMAIN, mac)},
connections={(CONNECTION_NETWORK_MAC, mac)},
manufacturer="Renson",
model=api.get_field_value(coordinator.data, DEVICE_NAME_FIELD.name),
name="Ventilation",
@@ -41,6 +42,4 @@ class RensonEntity(CoordinatorEntity[RensonCoordinator]):
self.api = api
self._attr_unique_id = (
api.get_field_value(coordinator.data, MAC_ADDRESS.name) + f"{name}"
)
self._attr_unique_id = f"{mac}{name}"
+3 -2
View File
@@ -1,7 +1,7 @@
"""Reolink integration for HomeAssistant."""
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from datetime import timedelta
import logging
from random import uniform
from time import time
@@ -26,6 +26,7 @@ from homeassistant.helpers import (
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from .const import (
BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL,
@@ -192,7 +193,7 @@ async def async_setup_entry(
hass.config_entries.async_update_entry(config_entry, data=data)
# If camera WAN blocked, firmware check fails and takes long, do not prevent setup
now = datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
now = dt_util.utcnow()
check_time = timedelta(seconds=check_time_sec)
delta_midnight = now - now.replace(hour=0, minute=0, second=0, microsecond=0)
firmware_check_delay = check_time - delta_midnight
@@ -1,6 +1,6 @@
"""Sensoterra devices."""
from datetime import UTC, datetime, timedelta
from datetime import timedelta
from enum import StrEnum, auto
from sensoterra.probe import Probe, Sensor
@@ -22,6 +22,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from .const import CONFIGURATION_URL, DOMAIN, SENSOR_EXPIRATION_DAYS
from .coordinator import SensoterraConfigEntry, SensoterraCoordinator
@@ -165,5 +166,5 @@ class SensoterraEntity(CoordinatorEntity[SensoterraCoordinator], SensorEntity):
return False
# Expire sensor if no update within the last few days.
expiration = datetime.now(UTC) - timedelta(days=SENSOR_EXPIRATION_DAYS) # pylint: disable=home-assistant-enforce-utcnow
expiration = dt_util.utcnow() - timedelta(days=SENSOR_EXPIRATION_DAYS)
return sensor.timestamp >= expiration
@@ -247,7 +247,7 @@ def _async_register_base_station(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, str(system.system_id))},
manufacturer="SimpliSafe",
model=system.version,
model=str(system.version),
name=system.address,
)
@@ -21,7 +21,6 @@ from sonos_websocket.exception import SonosWebsocketError
from homeassistant.components import media_source, spotify
from homeassistant.components.media_player import (
ATTR_INPUT_SOURCE,
ATTR_MEDIA_ALBUM_NAME,
ATTR_MEDIA_ANNOUNCE,
ATTR_MEDIA_ARTIST,
@@ -779,9 +778,6 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
if self.media.queue_size:
attributes["queue_size"] = self.media.queue_size
if self.source:
attributes[ATTR_INPUT_SOURCE] = self.source
return attributes
async def async_get_browse_image(
+2 -2
View File
@@ -9,6 +9,7 @@ from homeassistant.components.time import TimeEntity, TimeEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .coordinator import StarlinkConfigEntry, StarlinkData, StarlinkUpdateCoordinator
from .entity import StarlinkEntity
@@ -63,8 +64,7 @@ def _utc_minutes_to_time(utc_minutes: int, timezone: tzinfo) -> time:
hour -= 24
minute = utc_minutes % 60
try:
# pylint: disable-next=home-assistant-enforce-utcnow
utc = datetime.now(UTC).replace(
utc = dt_util.utcnow().replace(
hour=hour, minute=minute, second=0, microsecond=0
)
except ValueError as exc:
+8 -1
View File
@@ -5,7 +5,7 @@ import logging
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
@@ -50,6 +50,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: SunConfigEntry) -> bool:
"""Set up from a config entry."""
# Remove deprecated solar_rising sensor entity (removed in 2026.1)
ent_reg = er.async_get(hass)
if entity_id := ent_reg.async_get_entity_id(
Platform.SENSOR, DOMAIN, f"{entry.entry_id}-solar_rising"
):
ent_reg.async_remove(entity_id)
sun = Sun(hass)
component = EntityComponent[Sun](_LOGGER, DOMAIN, hass)
await component.async_add_entities([sun])
+16 -10
View File
@@ -1,15 +1,12 @@
"""Support for Template fans."""
from enum import StrEnum
import logging
from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.components.fan import (
ATTR_DIRECTION,
ATTR_OSCILLATING,
ATTR_PERCENTAGE,
ATTR_PRESET_MODE,
DIRECTION_FORWARD,
DIRECTION_REVERSE,
DOMAIN as FAN_DOMAIN,
@@ -100,6 +97,15 @@ FAN_CONFIG_ENTRY_SCHEMA = FAN_COMMON_SCHEMA.extend(
)
class FanScriptVariable(StrEnum):
"""Variables for scripts."""
DIRECTION = "direction"
OSCILLATING = "oscillating"
PERCENTAGE = "percentage"
PRESET_MODE = "preset_mode"
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
@@ -235,8 +241,8 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
await self.async_run_script(
self._action_scripts[CONF_ON_ACTION],
run_variables={
ATTR_PERCENTAGE: percentage,
ATTR_PRESET_MODE: preset_mode,
FanScriptVariable.PERCENTAGE: percentage,
FanScriptVariable.PRESET_MODE: preset_mode,
},
context=self._context,
)
@@ -267,7 +273,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
if script := self._action_scripts.get(CONF_SET_PERCENTAGE_ACTION):
await self.async_run_script(
script,
run_variables={ATTR_PERCENTAGE: self._attr_percentage},
run_variables={FanScriptVariable.PERCENTAGE: self._attr_percentage},
context=self._context,
)
@@ -284,7 +290,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
if script := self._action_scripts.get(CONF_SET_PRESET_MODE_ACTION):
await self.async_run_script(
script,
run_variables={ATTR_PRESET_MODE: self._attr_preset_mode},
run_variables={FanScriptVariable.PRESET_MODE: self._attr_preset_mode},
context=self._context,
)
@@ -302,7 +308,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
) is not None:
await self.async_run_script(
script,
run_variables={ATTR_OSCILLATING: self.oscillating},
run_variables={FanScriptVariable.OSCILLATING: self.oscillating},
context=self._context,
)
@@ -318,7 +324,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
) is not None:
await self.async_run_script(
script,
run_variables={ATTR_DIRECTION: direction},
run_variables={FanScriptVariable.DIRECTION: direction},
context=self._context,
)
if CONF_DIRECTION not in self._templates:
+1 -2
View File
@@ -5,7 +5,6 @@ from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.components.number import (
ATTR_VALUE,
DEFAULT_MAX_VALUE,
DEFAULT_MIN_VALUE,
DEFAULT_STEP,
@@ -161,7 +160,7 @@ class AbstractTemplateNumber(AbstractTemplateEntity, NumberEntity):
if set_value := self._action_scripts.get(CONF_SET_VALUE):
await self.async_run_script(
set_value,
run_variables={ATTR_VALUE: value},
run_variables={"value": value},
context=self._context,
)
+3 -5
View File
@@ -6,8 +6,6 @@ from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.components.select import (
ATTR_OPTION,
ATTR_OPTIONS,
DOMAIN as SELECT_DOMAIN,
ENTITY_ID_FORMAT,
SelectEntity,
@@ -48,7 +46,7 @@ SCRIPT_FIELDS = (CONF_SELECT_OPTION,)
SELECT_COMMON_SCHEMA = vol.Schema(
{
vol.Required(ATTR_OPTIONS): cv.template,
vol.Required(CONF_OPTIONS): cv.template,
vol.Optional(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_STATE): cv.template,
}
@@ -147,7 +145,7 @@ class AbstractTemplateSelect(AbstractTemplateEntity, SelectEntity):
if select_option := self._action_scripts.get(CONF_SELECT_OPTION):
await self.async_run_script(
select_option,
run_variables={ATTR_OPTION: option},
run_variables={"option": option},
context=self._context,
)
@@ -175,7 +173,7 @@ class TriggerSelectEntity(TriggerEntity, AbstractTemplateSelect):
"""Select entity based on trigger data."""
domain = SELECT_DOMAIN
extra_template_keys_complex = (ATTR_OPTIONS,)
extra_template_keys_complex = (CONF_OPTIONS,)
def __init__(
self,
+5 -5
View File
@@ -9,7 +9,6 @@ from typing import Any
import voluptuous as vol
from homeassistant.components.sensor import (
ATTR_LAST_RESET,
CONF_STATE_CLASS,
DEVICE_CLASSES_SCHEMA,
DOMAIN as SENSOR_DOMAIN,
@@ -50,13 +49,14 @@ from .schemas import (
from .template_entity import TemplateEntity
from .trigger_entity import TriggerEntity
CONF_LAST_RESET = "last_reset"
DEFAULT_NAME = "Template Sensor"
def validate_last_reset(val):
"""Run extra validation checks."""
if (
val.get(ATTR_LAST_RESET) is not None
val.get(CONF_LAST_RESET) is not None
and val.get(CONF_STATE_CLASS) != SensorStateClass.TOTAL
):
raise vol.Invalid(
@@ -78,7 +78,7 @@ SENSOR_COMMON_SCHEMA = vol.Schema(
SENSOR_YAML_SCHEMA = vol.All(
vol.Schema(
{
vol.Optional(ATTR_LAST_RESET): cv.template,
vol.Optional(CONF_LAST_RESET): cv.template,
}
)
.extend(SENSOR_COMMON_SCHEMA.schema)
@@ -204,10 +204,10 @@ class AbstractTemplateSensor(AbstractTemplateEntity, RestoreSensor):
self._validate_state,
)
self.setup_template(
ATTR_LAST_RESET,
CONF_LAST_RESET,
"_attr_last_reset",
validate_datetime(
self, ATTR_LAST_RESET, SensorDeviceClass.TIMESTAMP, require_tzinfo=False
self, CONF_LAST_RESET, SensorDeviceClass.TIMESTAMP, require_tzinfo=False
),
)
+1 -2
View File
@@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.components.vacuum import (
ATTR_FAN_SPEED,
DOMAIN as VACUUM_DOMAIN,
SERVICE_CLEAN_SPOT,
SERVICE_LOCATE,
@@ -389,7 +388,7 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity):
if script := self._action_scripts.get(SERVICE_SET_FAN_SPEED):
await self.async_run_script(
script, run_variables={ATTR_FAN_SPEED: fan_speed}, context=self._context
script, run_variables={"fan_speed": fan_speed}, context=self._context
)
+7 -2
View File
@@ -1,5 +1,6 @@
"""Provides triggers for timers."""
from collections.abc import Mapping
from datetime import datetime, timedelta
from typing import cast, override
@@ -128,13 +129,17 @@ class TimeRemainingTrigger(Trigger):
schedule_for_state(entity_id, to_state, event.context)
@callback
def on_entities_update(added: set[str], removed: set[str]) -> None:
def on_entities_update(
added: set[str],
removed: set[str],
entity_states: Mapping[str, State | None],
) -> None:
"""Handle changes to the tracked entity set."""
for entity_id in removed:
if entity_id in scheduled:
scheduled.pop(entity_id)()
for entity_id in added:
state = self._hass.states.get(entity_id)
state = entity_states[entity_id]
schedule_for_state(entity_id, state, state.context if state else None)
unsub = await async_track_target_selector_state_change_event(
+9 -1
View File
@@ -9,7 +9,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import VilfoConfigEntry
@@ -72,12 +72,20 @@ class VilfoRouterSensor(SensorEntity):
self.entity_description = description
self.api = api
self._attr_device_info = DeviceInfo(
# This identifier is a non-standard 3-tuple kept as-is to avoid
# migrating existing devices; only the connection is added here.
identifiers={(DOMAIN, api.host, api.mac_address)}, # type: ignore[arg-type]
name=ROUTER_DEFAULT_NAME,
manufacturer=ROUTER_MANUFACTURER,
model=ROUTER_DEFAULT_MODEL,
sw_version=api.firmware_version,
)
# The router does not always report a MAC address (e.g. when set up by
# host), so only attach the connection when one is available.
if api.mac_address:
self._attr_device_info["connections"] = {
(CONNECTION_NETWORK_MAC, api.mac_address)
}
self._attr_unique_id = f"{api.unique_id}_{description.key}"
@property
+10 -1
View File
@@ -3,7 +3,11 @@
import logging
from aiowebdav2.client import Client
from aiowebdav2.exceptions import UnauthorizedError
from aiowebdav2.exceptions import (
ConnectionExceptionError,
NoConnectionError,
UnauthorizedError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL
@@ -35,6 +39,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: WebDavConfigEntry) -> bo
translation_domain=DOMAIN,
translation_key="invalid_username_password",
) from err
except (ConnectionExceptionError, NoConnectionError, TimeoutError) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
# Check if we can connect to the WebDAV server
# and access the root directory
@@ -2,7 +2,7 @@
import asyncio
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
import logging
from typing import Any
@@ -16,6 +16,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import ATTR_DEVICE_STATE, ATTR_LORA_INFO, DOMAIN, YOLINK_OFFLINE_TIME
@@ -72,8 +73,7 @@ class YoLinkCoordinator(DataUpdateCoordinator[dict]):
device_reporttime = device_state_resp.data.get("reportAt")
if device_reporttime is not None:
rpt_time_delta = (
# pylint: disable-next=home-assistant-enforce-utcnow
datetime.now(tz=UTC).replace(tzinfo=None)
dt_util.utcnow().replace(tzinfo=None)
- datetime.strptime(device_reporttime, "%Y-%m-%dT%H:%M:%S.%fZ")
).total_seconds()
self.dev_online = rpt_time_delta < YOLINK_OFFLINE_TIME
@@ -97,10 +97,6 @@ class YotoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, YotoPlayer]]):
async def _async_update_data(self) -> dict[str, YotoPlayer]:
"""Fetch fresh data from the Yoto cloud."""
# _async_setup already populated the client; skip the duplicate first fetch.
if self.data is None:
return self.client.players
try:
await self._session.async_ensure_token_valid()
except OAuth2TokenRequestReauthError as err:
@@ -0,0 +1,33 @@
"""Diagnostics support for the Yoto integration."""
from dataclasses import asdict
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from .coordinator import YotoConfigEntry
TO_REDACT = {
"access_token",
"refresh_token",
"mac",
"network_ssid",
}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: YotoConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
return {
"entry": async_redact_data(entry.as_dict(), TO_REDACT),
"players": async_redact_data(
{
player_id: asdict(player)
for player_id, player in coordinator.data.items()
},
TO_REDACT,
),
}
@@ -5,7 +5,7 @@ rules:
comment: This integration does not register custom service actions.
appropriate-polling:
status: done
comment: 5 minute interval. MQTT carries live state; polling is what surfaces the online -> offline transition since the broker doesn't push disconnect events.
comment: Live state is pushed over MQTT; the coordinator polls REST every 5 min only for the device roster and player config (firmware, day/night times).
brands: done
common-modules: done
config-flow-test-coverage: done
@@ -45,10 +45,10 @@ rules:
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery-update-info:
status: exempt
comment: The integration supports local DHCP discovery (via hostname pattern), but does not implement a separate discovery update handling flow.
comment: Cloud connection; DHCP discovery only triggers setup, no network address is persisted to update.
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
@@ -70,10 +70,10 @@ rules:
comment: Authorization is the only configuration; reauth covers re-linking the account.
repair-issues:
status: exempt
comment: No repair issues are raised yet.
comment: Auth failures go through the reauth flow; other errors are transient and retried by the coordinator.
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo
strict-typing: done
+22 -4
View File
@@ -760,11 +760,29 @@ class UnitOfPrecipitationDepth(StrEnum):
"""Derived from cm³/cm²"""
class UnitOfDensity(StrEnum):
"""Density units.
Ratio of a substance's mass to its volume.
"""
GRAMS_PER_CUBIC_METER = "g/m³"
MILLIGRAMS_PER_CUBIC_METER = "mg/m³"
MICROGRAMS_PER_CUBIC_METER = "μg/m³"
MICROGRAMS_PER_CUBIC_FOOT = "μg/ft³"
# Concentration units
CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³"
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: Final = "mg/m³"
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = "μg/m³"
CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT: Final = "μg/ft³"
CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = UnitOfDensity.GRAMS_PER_CUBIC_METER.value
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: Final = (
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER.value
)
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = (
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER.value
)
CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT: Final = (
UnitOfDensity.MICROGRAMS_PER_CUBIC_FOOT.value
)
_DEPRECATED_CONCENTRATION_PARTS_PER_CUBIC_METER = DeprecatedConstant(
"p/m³", "p/m³", "2027.7"
)
+24 -13
View File
@@ -490,8 +490,11 @@ class _HistoryPrimingManager:
The flush a condition relies on must begin after that condition started
tracking its entities, or the read could miss a change still queued in the
recorder and compute too generous an anchor. A condition therefore never
rides a flush that was already running when it arrived (the lobby); it waits
that one out and joins the next.
relies on a flush that was already running when it arrived (the lobby); it
waits that one out and joins the next, re-attempting if the flush it waited
for was cancelled before completing. This mirrors `ReloadServiceHelper`
minus its target de-duplication, which does not apply because each condition
reads its own entities.
"""
def __init__(self, hass: HomeAssistant) -> None:
@@ -499,6 +502,7 @@ class _HistoryPrimingManager:
self._hass = hass
self._flush_condition = asyncio.Condition()
self._flushing = False
self._flush_ok = False
self._query_lock = asyncio.Lock()
async def async_prime[_T](
@@ -512,37 +516,41 @@ class _HistoryPrimingManager:
async def _async_flush(self) -> None:
"""Return once a recorder flush that began no earlier than this call ends.
The first condition of a generation performs the flush; the rest ride it.
The first condition of a generation performs the flush; the rest rely on
it.
"""
async with self._flush_condition:
# Lobby: a flush already running began before we arrived, so it may
# not capture our entity's queued changes. Wait it out, don't ride it.
# not capture our entity's queued changes. Wait it out, don't rely on
# it.
if self._flushing:
await self._flush_condition.wait()
do_flush = False
while True:
async with self._flush_condition:
if not self._flushing:
# First past the lobby this generation: we run the flush.
self._flushing = True
do_flush = True
break
# A peer began a fresh flush after we cleared the lobby; it
# covers us too, so wait for it and ride it.
# A peer began a fresh flush after we cleared the lobby; wait for
# it.
await self._flush_condition.wait()
break
if not do_flush:
return
if self._flush_ok:
return
# The flush we waited for was cancelled before completing (its owner
# timed out): loop and start or wait for a fresh one rather than read
# against a queue that was never flushed.
instance = get_instance(self._hass)
flushed = False
try:
if (commit_future := instance.async_get_commit_future()) is not None:
await commit_future
flushed = True
finally:
async with self._flush_condition:
self._flushing = False
self._flush_ok = flushed
self._flush_condition.notify_all()
@@ -670,7 +678,10 @@ class EntityConditionBase(Condition):
self._on_unload.append(unsub)
async def _async_on_entities_update(
self, added: set[str], removed: set[str]
self,
added: set[str],
removed: set[str],
_entity_states: Mapping[str, State | None],
) -> None:
"""Handle changes to the tracked entity set.
+57 -13
View File
@@ -2,7 +2,7 @@
import abc
import asyncio
from collections.abc import Callable, Coroutine
from collections.abc import Callable, Coroutine, Mapping
import dataclasses
import logging
from logging import Logger
@@ -21,6 +21,7 @@ from homeassistant.core import (
Event,
EventStateChangedData,
HomeAssistant,
State,
callback,
)
from homeassistant.exceptions import HomeAssistantError
@@ -43,10 +44,19 @@ _LOGGER = logging.getLogger(__name__)
@dataclasses.dataclass(slots=True, frozen=True)
class TargetStateChangedData:
"""Data for state change events related to targets."""
"""Data for state change events related to targets.
`targeted_entity_states` holds the states of all targeted entities as of
the state change event. State change events are dispatched one event loop
iteration after the state machine is updated, so the live state machine
may already contain later changes; this mapping does not. It is only
valid during the synchronous callback: it is updated in place as
subsequent events are dispatched.
"""
state_change_event: Event[EventStateChangedData]
targeted_entity_ids: set[str]
targeted_entity_states: Mapping[str, State | None]
def _has_match(ids: str | list[str] | None) -> TypeGuard[str | list[str]]:
@@ -360,7 +370,8 @@ class TargetStateChangeTracker(TargetEntityChangeTracker):
action: Callable[[TargetStateChangedData], Any],
entity_filter: Callable[[set[str]], set[str]],
on_entities_update: Callable[
[set[str], set[str]], Coroutine[Any, Any, None] | None
[set[str], set[str], Mapping[str, State | None]],
Coroutine[Any, Any, None] | None,
]
| None = None,
*,
@@ -371,7 +382,10 @@ class TargetStateChangeTracker(TargetEntityChangeTracker):
`on_entities_update` may be a plain callback or a coroutine function.
A coroutine is awaited for the initial entity set (so setup is
deterministic) and scheduled as a background task for later
registry-driven changes.
registry-driven changes. It is called with the added and removed
entity ids and the states of all currently targeted entities; the
states mapping is only valid during the synchronous call, so a
coroutine must copy what it needs before awaiting.
"""
super().__init__(
hass,
@@ -383,6 +397,7 @@ class TargetStateChangeTracker(TargetEntityChangeTracker):
self._on_entities_update = on_entities_update
self._state_change_unsub: CALLBACK_TYPE | None = None
self._tracked_entities: set[str] = set()
self._tracked_entity_states: dict[str, State | None] = {}
self._update_tasks: set[asyncio.Task[None]] = set()
async def async_setup(self) -> Callable[[], None]:
@@ -418,25 +433,49 @@ class TargetStateChangeTracker(TargetEntityChangeTracker):
previous_entities = self._tracked_entities
self._tracked_entities = tracked_entities
# Carry over the tracked states of still-tracked entities: they are
# consistent with the already-dispatched event stream, while the live
# state machine may be ahead of it. Only entities new to the view are
# read from the live state machine.
previous_states = self._tracked_entity_states
tracked_entity_states = {
entity_id: (
previous_states[entity_id]
if entity_id in previous_states
else self._hass.states.get(entity_id)
)
for entity_id in tracked_entities
}
self._tracked_entity_states = tracked_entity_states
result: Coroutine[Any, Any, None] | None = None
if self._on_entities_update is not None:
added = tracked_entities - previous_entities
removed = previous_entities - tracked_entities
if added or removed:
result = self._on_entities_update(added, removed)
result = self._on_entities_update(added, removed, tracked_entity_states)
@callback
def state_change_listener(event: Event[EventStateChangedData]) -> None:
"""Handle state change events."""
if event.data["entity_id"] in tracked_entities:
self._action(TargetStateChangedData(event, tracked_entities))
if (entity_id := event.data["entity_id"]) not in tracked_entities:
return
tracked_entity_states[entity_id] = event.data["new_state"]
self._action(
TargetStateChangedData(event, tracked_entities, tracked_entity_states)
)
_LOGGER.debug("Tracking state changes for entities: %s", tracked_entities)
if self._state_change_unsub:
self._state_change_unsub()
# Subscribe before unsubscribing the previous listener: if this
# tracker is the only subscriber, unsubscribing first tears down the
# shared state change tracker, dropping events which have been fired
# but not yet dispatched.
previous_unsub = self._state_change_unsub
self._state_change_unsub = async_track_state_change_event(
self._hass, tracked_entities, state_change_listener
)
if previous_unsub:
previous_unsub()
return result
def _unsubscribe(self) -> None:
@@ -455,7 +494,10 @@ async def async_track_target_selector_state_change_event(
target_selector_config: ConfigType,
action: Callable[[TargetStateChangedData], Any],
entity_filter: Callable[[set[str]], set[str]] = lambda x: x,
on_entities_update: Callable[[set[str], set[str]], Coroutine[Any, Any, None] | None]
on_entities_update: Callable[
[set[str], set[str], Mapping[str, State | None]],
Coroutine[Any, Any, None] | None,
]
| None = None,
*,
primary_entities_only: bool = True,
@@ -467,9 +509,11 @@ async def async_track_target_selector_state_change_event(
expansion (via device, area, and floor) skips entities
with an `entity_category` (config or diagnostic entities).
`on_entities_update` may be a coroutine function; it is awaited for the
initial entity set and scheduled as a task for later registry-driven
changes, so this function must itself be awaited.
`on_entities_update` is called with the added and removed entity ids and
the states of all currently targeted entities. It may be a coroutine
function; it is awaited for the initial entity set and scheduled as a
task for later registry-driven changes, so this function must itself be
awaited. The states mapping is only valid during the synchronous call.
"""
target_selection = TargetSelection(target_selector_config)
if not target_selection.has_any_target:
+126 -51
View File
@@ -5,7 +5,7 @@ import asyncio
from collections import defaultdict
from collections.abc import Callable, Coroutine, Iterable, Mapping
from dataclasses import dataclass, field
from datetime import timedelta
from datetime import datetime, timedelta
import functools
import inspect
import logging
@@ -75,7 +75,7 @@ from .automation import (
get_relative_description_key,
move_options_fields_to_top_level,
)
from .event import async_track_same_state
from .event import async_call_later
from .integration_platform import async_process_integration_platforms
from .selector import (
NumericThresholdMode,
@@ -438,7 +438,11 @@ class EntityTriggerBase(Trigger):
"""
return state.state not in self._excluded_states
def count_matches(self, entity_ids: set[str]) -> tuple[int, int]:
def count_matches(
self,
entity_ids: Iterable[str],
states: Mapping[str, State | None] | None = None,
) -> tuple[int, int]:
"""Return (matches, included) for the entity set.
`matches` is the number of entities that pass `_should_include` AND
@@ -447,11 +451,19 @@ class EntityTriggerBase(Trigger):
Callers can use the pair to distinguish vacuous truth
(`included == 0`) from a genuine all-match
(`matches == included > 0`).
Entity states are read from `states` when provided, otherwise from
the live state machine. Pass the targeted entity states received
with a state change event to evaluate the event against the states
as they were when the event fired.
"""
matches = 0
included = 0
for entity_id in entity_ids:
state = self._hass.states.get(entity_id)
if states is not None:
state = states[entity_id]
else:
state = self._hass.states.get(entity_id)
if state is None or not self._should_include(state):
continue
included += 1
@@ -459,6 +471,60 @@ class EntityTriggerBase(Trigger):
matches += 1
return matches, included
@callback
def _cancel_invalidated_timers(
self,
behavior: str,
pending_timers: dict[str, CALLBACK_TYPE],
target_state_change_data: TargetStateChangedData,
) -> None:
"""Cancel pending duration timers invalidated by a state change.
Runs on every delivered state change, before the trigger's own
validity checks: an event which cannot fire the trigger, e.g. an
entity becoming unavailable, may still invalidate a pending timer.
The targeted entity states have already been updated with this
event, so the first/all check can simply recount.
"""
event = target_state_change_data.state_change_event
if behavior == BEHAVIOR_EACH:
entity_id = event.data["entity_id"]
if entity_id not in pending_timers:
return
to_state = event.data["new_state"]
if (
to_state is None
or to_state.state in self._excluded_states
or not self.is_valid_state(to_state)
):
pending_timers.pop(entity_id)()
return
if behavior not in pending_timers:
return
if not self._combined_state_still_valid(
behavior,
target_state_change_data.targeted_entity_ids,
target_state_change_data.targeted_entity_states,
):
pending_timers.pop(behavior)()
def _combined_state_still_valid(
self,
behavior: str,
entity_ids: Iterable[str],
states: Mapping[str, State | None],
) -> bool:
"""Check the combined first/all state for a pending duration timer."""
matches, included = self.count_matches(entity_ids, states)
if behavior == BEHAVIOR_FIRST:
return matches >= 1
# Require at least one included entity to avoid keeping the timer
# alive when every targeted entity has been filtered out since it
# started — a vacuous all-match (`included == 0`) would otherwise
# let the action fire after `for:` even though no entity still
# matches.
return included > 0 and matches == included
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
@@ -466,7 +532,32 @@ class EntityTriggerBase(Trigger):
"""Attach the trigger to an action runner."""
behavior: str = self._options.get(ATTR_BEHAVIOR, BEHAVIOR_EACH)
unsub_track_same: dict[str, Callable[[], None]] = {}
# Pending `for:` duration timers, keyed by entity_id for behavior
# each and by the behavior for first/all.
pending_timers: dict[str, CALLBACK_TYPE] = {}
@callback
def handle_entities_update(
added: set[str],
removed: set[str],
entity_states: Mapping[str, State | None],
) -> None:
"""Re-validate pending duration timers on target changes.
Timers of entities no longer targeted are cancelled, and the
combined first/all condition is recounted over the updated
target: e.g. a non-matching entity added to the target breaks a
pending all-match.
"""
for entity_id in removed:
if (cancel := pending_timers.pop(entity_id, None)) is not None:
cancel()
if behavior not in pending_timers:
return
if not self._combined_state_still_valid(
behavior, entity_states.keys(), entity_states
):
pending_timers.pop(behavior)()
@callback
def state_change_listener(
@@ -478,35 +569,10 @@ class EntityTriggerBase(Trigger):
from_state = event.data["old_state"]
to_state = event.data["new_state"]
def state_still_valid(
_: str, from_state: State | None, to_state: State | None
) -> bool:
"""Check if the state is still valid during the duration wait.
Called by async_track_same_state on each state change to
determine whether to cancel the timer.
For behavior each, checks the individual entity's state.
For behavior first/all, checks the combined state.
"""
if behavior == BEHAVIOR_ALL:
matches, included = self.count_matches(
target_state_change_data.targeted_entity_ids
)
# Require at least one included entity to avoid keeping
# the timer alive when every targeted entity has been
# filtered out since it started — a vacuous all-match
# (`included == 0`) would otherwise let the action fire
# after `for:` even though no entity still matches.
return included > 0 and matches == included
if behavior == BEHAVIOR_FIRST:
matches, _included = self.count_matches(
target_state_change_data.targeted_entity_ids
)
return matches >= 1
# Behavior each: check the individual entity's state
if not to_state or to_state.state in self._excluded_states:
return False
return self.is_valid_state(to_state)
if pending_timers:
self._cancel_invalidated_timers(
behavior, pending_timers, target_state_change_data
)
if not from_state or not to_state:
return
@@ -526,9 +592,15 @@ class EntityTriggerBase(Trigger):
):
return
# Count against the targeted entity states as of this event, not
# the live state machine: state change events are dispatched one
# event loop iteration after the state machine is updated, so the
# state machine may already contain later changes to other
# targeted entities.
if behavior == BEHAVIOR_ALL:
matches, included = self.count_matches(
target_state_change_data.targeted_entity_ids
target_state_change_data.targeted_entity_ids,
target_state_change_data.targeted_entity_states,
)
if matches != included:
return
@@ -537,7 +609,8 @@ class EntityTriggerBase(Trigger):
# were previously 2 matches the transition would not be valid and we
# would have returned already.
matches, _ = self.count_matches(
target_state_change_data.targeted_entity_ids
target_state_change_data.targeted_entity_ids,
target_state_change_data.targeted_entity_states,
)
if matches != 1:
return
@@ -565,18 +638,19 @@ class EntityTriggerBase(Trigger):
return
subscription_key = entity_id if behavior == BEHAVIOR_EACH else behavior
if subscription_key in unsub_track_same:
unsub_track_same.pop(subscription_key)()
unsub_track_same[subscription_key] = async_track_same_state(
self._hass,
self._duration,
call_action,
state_still_valid,
entity_ids=(
entity_id
if behavior == BEHAVIOR_EACH
else target_state_change_data.targeted_entity_ids
),
if (
previous_timer := pending_timers.pop(subscription_key, None)
) is not None:
previous_timer()
@callback
def fire_after_duration(_now: datetime) -> None:
"""Fire the action once the state has held for the duration."""
del pending_timers[subscription_key]
call_action()
pending_timers[subscription_key] = async_call_later(
self._hass, self._duration, fire_after_duration
)
unsub = await async_track_target_selector_state_change_event(
@@ -584,6 +658,7 @@ class EntityTriggerBase(Trigger):
self._target,
state_change_listener,
self.entity_filter,
handle_entities_update if self._duration else None,
primary_entities_only=self._primary_entities_only,
)
@@ -591,9 +666,9 @@ class EntityTriggerBase(Trigger):
def async_remove() -> None:
"""Remove state listeners async."""
unsub()
for async_remove in unsub_track_same.values():
async_remove()
unsub_track_same.clear()
for cancel_timer in pending_timers.values():
cancel_timer()
pending_timers.clear()
return async_remove
+1 -1
View File
@@ -29,7 +29,7 @@ cached-ipaddress==1.1.2
certifi>=2021.5.30
ciso8601==2.3.3
cronsim==2.7
cryptography==48.0.0
cryptography==48.0.1
dbus-fast==5.0.16
file-read-backwards==2.0.0
fnv-hash-fast==2.0.3
+19 -21
View File
@@ -5,9 +5,6 @@ from functools import lru_cache
from math import floor, log10
from homeassistant.const import (
CONCENTRATION_GRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
@@ -17,6 +14,7 @@ from homeassistant.const import (
UnitOfBloodGlucoseConcentration,
UnitOfConductivity,
UnitOfDataRate,
UnitOfDensity,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
@@ -248,18 +246,18 @@ class CarbonMonoxideConcentrationConverter(BaseUnitConverter):
_UNIT_CONVERSION: dict[str | None, float] = {
CONCENTRATION_PARTS_PER_BILLION: 1e9,
CONCENTRATION_PARTS_PER_MILLION: 1e6,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: (
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER: (
_CARBON_MONOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e3
),
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER: (
_CARBON_MONOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
),
}
VALID_UNITS = {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
}
@@ -494,14 +492,14 @@ class MassVolumeConcentrationConverter(BaseUnitConverter):
UNIT_CLASS = "concentration"
_UNIT_CONVERSION: dict[str | None, float] = {
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: 1_000_000.0, # 1000 µg/m³ = 1 mg/m³
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: 1000.0, # 1000 mg/m³ = 1 g/m³
CONCENTRATION_GRAMS_PER_CUBIC_METER: 1.0,
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER: 1_000_000.0, # 1000 µg/m³ = 1 mg/m³
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER: 1000.0, # 1000 mg/m³ = 1 g/m³
UnitOfDensity.GRAMS_PER_CUBIC_METER: 1.0,
}
VALID_UNITS = {
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_GRAMS_PER_CUBIC_METER,
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
UnitOfDensity.GRAMS_PER_CUBIC_METER,
}
@@ -512,14 +510,14 @@ class NitrogenDioxideConcentrationConverter(BaseUnitConverter):
_UNIT_CONVERSION: dict[str | None, float] = {
CONCENTRATION_PARTS_PER_BILLION: 1e9,
CONCENTRATION_PARTS_PER_MILLION: 1e6,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER: (
_NITROGEN_DIOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
),
}
VALID_UNITS = {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
}
@@ -529,13 +527,13 @@ class NitrogenMonoxideConcentrationConverter(BaseUnitConverter):
UNIT_CLASS = "nitrogen_monoxide"
_UNIT_CONVERSION: dict[str | None, float] = {
CONCENTRATION_PARTS_PER_BILLION: 1e9,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER: (
_NITROGEN_MONOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
),
}
VALID_UNITS = {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
}
@@ -546,14 +544,14 @@ class OzoneConcentrationConverter(BaseUnitConverter):
_UNIT_CONVERSION: dict[str | None, float] = {
CONCENTRATION_PARTS_PER_BILLION: 1e9,
CONCENTRATION_PARTS_PER_MILLION: 1e6,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER: (
_OZONE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
),
}
VALID_UNITS = {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
}
@@ -751,13 +749,13 @@ class SulphurDioxideConcentrationConverter(BaseUnitConverter):
UNIT_CLASS = "sulphur_dioxide"
_UNIT_CONVERSION: dict[str | None, float] = {
CONCENTRATION_PARTS_PER_BILLION: 1e9,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER: (
_SULPHUR_DIOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
),
}
VALID_UNITS = {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
}
Generated
+10
View File
@@ -6180,6 +6180,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.yoto.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.youtube.*]
check_untyped_defs = true
disallow_incomplete_defs = true
+1 -1
View File
@@ -57,7 +57,7 @@ dependencies = [
"lru-dict==1.4.1",
"PyJWT==2.12.1",
# PyJWT has loose dependency. We want the latest one.
"cryptography==48.0.0",
"cryptography==48.0.1",
"Pillow==12.2.0",
"propcache==0.5.2",
"pyOpenSSL==26.2.0",
+1 -1
View File
@@ -21,7 +21,7 @@ bcrypt==5.0.0
certifi>=2021.5.30
ciso8601==2.3.3
cronsim==2.7
cryptography==48.0.0
cryptography==48.0.1
fnv-hash-fast==2.0.3
ha-ffmpeg==3.2.2
hass-nabucasa==2.2.0
+3 -3
View File
@@ -766,7 +766,7 @@ colorlog==6.10.1
colorthief==0.2.1
# homeassistant.components.compit
compit-inext-api==0.8.0
compit-inext-api==0.9.1
# homeassistant.components.concord232
concord232==0.15.1
@@ -1788,7 +1788,7 @@ openwrt-luci-rpc==1.1.17
openwrt-ubus-rpc==0.0.3
# homeassistant.components.opower
opower==0.18.4
opower==0.18.5
# homeassistant.components.oralb
oralb-ble==1.1.0
@@ -2996,7 +2996,7 @@ sensorpush-ha==1.3.2
sensoterra==2.0.1
# homeassistant.components.elevenlabs
sentence-stream==1.2.0
sentence-stream==1.3.0
# homeassistant.components.sentry
sentry-sdk==2.48.0
+4
View File
@@ -6,6 +6,10 @@ set -e
cd "$(realpath "$(dirname "$0")/..")"
if [ ! -n "$VIRTUAL_ENV" ]; then
source .venv/bin/activate
fi
echo "Installing development dependencies..."
uv pip install \
-e . \
+1 -1
View File
@@ -17,7 +17,7 @@ from script.hassfest.model import Config, Integration
# Requirements which can't be installed on all systems because they
# rely on additional system packages. Requirements listed in
# EXCLUDED_REQUIREMENTS_ALL will be commented-out in
# requirements_all.txt and requirements_test_all.txt.
# requirements_all.txt.
EXCLUDED_REQUIREMENTS_ALL = {
"atenpdu", # depends on pysnmp which is not maintained at this time
"avion",
+1
View File
@@ -69,6 +69,7 @@ OSI_APPROVED_LICENSES_SPDX = {
"BSD-1-Clause",
"BSD-2-Clause",
"BSD-3-Clause",
"CNRI-Python",
"EPL-1.0",
"EPL-2.0",
"GPL-2.0-only",
@@ -639,7 +639,7 @@
}),
}),
'entry_data': dict({
'advanced_settings': dict({
'additional_settings': dict({
'ssl': True,
'verify_ssl': False,
}),
+14 -14
View File
@@ -21,7 +21,7 @@ from homeassistant.components.airos.const import (
HOSTNAME,
IP_ADDRESS,
MAC_ADDRESS,
SECTION_ADVANCED_SETTINGS,
SECTION_ADDITIONAL_SETTINGS,
)
from homeassistant.config_entries import (
SOURCE_DHCP,
@@ -48,7 +48,7 @@ NEW_PASSWORD = "new_password"
REAUTH_STEP = "reauth_confirm"
RECONFIGURE_STEP = "reconfigure"
MOCK_ADVANCED_SETTINGS = {
MOCK_ADDITIONAL_SETTINGS = {
CONF_SSL: True,
CONF_VERIFY_SSL: False,
}
@@ -57,7 +57,7 @@ MOCK_CONFIG = {
CONF_HOST: "1.1.1.1",
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: "test-password",
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
SECTION_ADDITIONAL_SETTINGS: MOCK_ADDITIONAL_SETTINGS,
}
MOCK_CONFIG_REAUTH = {
CONF_HOST: "1.1.1.1",
@@ -410,7 +410,7 @@ async def test_successful_reconfigure(
user_input = {
CONF_PASSWORD: NEW_PASSWORD,
SECTION_ADVANCED_SETTINGS: {
SECTION_ADDITIONAL_SETTINGS: {
CONF_SSL: True,
CONF_VERIFY_SSL: True,
},
@@ -426,8 +426,8 @@ async def test_successful_reconfigure(
updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
assert updated_entry.data[CONF_PASSWORD] == NEW_PASSWORD
assert updated_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL] is True
assert updated_entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] is True
assert updated_entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL] is True
assert updated_entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_VERIFY_SSL] is True
assert updated_entry.data[CONF_HOST] == MOCK_CONFIG[CONF_HOST]
assert updated_entry.data[CONF_USERNAME] == MOCK_CONFIG[CONF_USERNAME]
@@ -468,7 +468,7 @@ async def test_reconfigure_flow_failure(
user_input = {
CONF_PASSWORD: NEW_PASSWORD,
SECTION_ADVANCED_SETTINGS: {
SECTION_ADDITIONAL_SETTINGS: {
CONF_SSL: True,
CONF_VERIFY_SSL: True,
},
@@ -525,7 +525,7 @@ async def test_reconfigure_unique_id_mismatch(
user_input = {
CONF_PASSWORD: NEW_PASSWORD,
SECTION_ADVANCED_SETTINGS: {
SECTION_ADDITIONAL_SETTINGS: {
CONF_SSL: True,
CONF_VERIFY_SSL: True,
},
@@ -546,8 +546,8 @@ async def test_reconfigure_unique_id_mismatch(
updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
assert updated_entry.data[CONF_PASSWORD] == MOCK_CONFIG[CONF_PASSWORD]
assert (
updated_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL]
== MOCK_CONFIG[SECTION_ADVANCED_SETTINGS][CONF_SSL]
updated_entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL]
== MOCK_CONFIG[SECTION_ADDITIONAL_SETTINGS][CONF_SSL]
)
@@ -611,7 +611,7 @@ async def test_discover_flow_one_device_found(
{
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: "test-password",
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
SECTION_ADDITIONAL_SETTINGS: MOCK_ADDITIONAL_SETTINGS,
},
)
@@ -687,7 +687,7 @@ async def test_discover_flow_multiple_devices_found(
{
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: "test-password",
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
SECTION_ADDITIONAL_SETTINGS: MOCK_ADDITIONAL_SETTINGS,
},
)
@@ -785,7 +785,7 @@ async def test_configure_device_flow_exceptions(
{
CONF_USERNAME: "wrong-user",
CONF_PASSWORD: "wrong-password",
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
SECTION_ADDITIONAL_SETTINGS: MOCK_ADDITIONAL_SETTINGS,
},
)
@@ -801,7 +801,7 @@ async def test_configure_device_flow_exceptions(
{
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: "some-password",
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
SECTION_ADDITIONAL_SETTINGS: MOCK_ADDITIONAL_SETTINGS,
},
)
+7 -7
View File
@@ -14,7 +14,7 @@ from homeassistant.components.airos.const import (
DEFAULT_SSL,
DEFAULT_VERIFY_SSL,
DOMAIN,
SECTION_ADVANCED_SETTINGS,
SECTION_ADDITIONAL_SETTINGS,
)
from homeassistant.components.airos.coordinator import async_fetch_airos_data
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
@@ -46,7 +46,7 @@ MOCK_CONFIG_PLAIN = {
CONF_HOST: "1.1.1.1",
CONF_USERNAME: "ubnt",
CONF_PASSWORD: "test-password",
SECTION_ADVANCED_SETTINGS: {
SECTION_ADDITIONAL_SETTINGS: {
CONF_SSL: False,
CONF_VERIFY_SSL: False,
},
@@ -56,7 +56,7 @@ MOCK_CONFIG_V1_2 = {
CONF_HOST: "1.1.1.1",
CONF_USERNAME: "ubnt",
CONF_PASSWORD: "test-password",
SECTION_ADVANCED_SETTINGS: {
SECTION_ADDITIONAL_SETTINGS: {
CONF_SSL: DEFAULT_SSL,
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
},
@@ -86,8 +86,8 @@ async def test_setup_entry_with_default_ssl(
use_ssl=DEFAULT_SSL,
)
assert mock_config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL] is True
assert mock_config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] is False
assert mock_config_entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL] is True
assert mock_config_entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_VERIFY_SSL] is False
async def test_setup_entry_without_ssl(
@@ -120,8 +120,8 @@ async def test_setup_entry_without_ssl(
use_ssl=False,
)
assert entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL] is False
assert entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] is False
assert entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL] is False
assert entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_VERIFY_SSL] is False
async def test_ssl_migrate_entry(
@@ -216,34 +216,15 @@ async def test_single_site(hass: HomeAssistant, single_site_api: Mock) -> None:
async def test_single_closed_site_no_closed_date(
hass: HomeAssistant, single_site_closed_no_close_date_api: Mock
) -> None:
"""Test single closed site with no closed date."""
initial_result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert initial_result.get("type") is FlowResultType.FORM
assert initial_result.get("step_id") == "user"
# Test filling in API key
"""Test single closed site with no closed date is filtered out."""
enter_api_key_result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data={CONF_API_TOKEN: API_KEY},
)
assert enter_api_key_result.get("type") is FlowResultType.FORM
assert enter_api_key_result.get("step_id") == "site"
select_site_result = await hass.config_entries.flow.async_configure(
enter_api_key_result["flow_id"],
{CONF_SITE_ID: "01FG0AGP818PXK0DWHXJRRT2DH", CONF_SITE_NAME: "Home"},
)
# Show available sites
assert select_site_result.get("type") is FlowResultType.CREATE_ENTRY
assert select_site_result.get("title") == "Home"
data = select_site_result.get("data")
assert data
assert data[CONF_API_TOKEN] == API_KEY
assert data[CONF_SITE_ID] == "01FG0AGP818PXK0DWHXJRRT2DH"
assert enter_api_key_result.get("step_id") == "user"
assert enter_api_key_result.get("errors") == {"api_token": "no_site"}
async def test_single_site_rejoin(
@@ -333,13 +314,9 @@ async def test_unknown_error(hass: HomeAssistant, api_error: Mock) -> None:
assert result.get("errors") == {"api_token": "unknown_error"}
async def test_site_deduplication(single_site_rejoin_api: Mock) -> None:
"""Test site deduplication."""
async def test_site_filtering(single_site_rejoin_api: Mock) -> None:
"""Test that closed sites are filtered out and remaining sites are deduplicated."""
filtered = filter_sites(single_site_rejoin_api.get_sites())
assert len(filtered) == 2
assert (
next(s for s in filtered if s.nmi == "11111111111").status == SiteStatus.ACTIVE
)
assert (
next(s for s in filtered if s.nmi == "11111111112").status == SiteStatus.CLOSED
)
assert len(filtered) == 1
assert filtered[0].nmi == "11111111111"
assert filtered[0].status == SiteStatus.ACTIVE
@@ -255,6 +255,88 @@ async def test_punctuation(hass: HomeAssistant) -> None:
assert result.response.intent.slots["name"]["text"] == "test light"
@pytest.mark.parametrize(
"sentence",
[
# STT may or may not insert the comma based on speech cadence
"Turn off upstairs, hallway",
"Turn off upstairs hallway",
],
)
@pytest.mark.usefixtures("init_components")
async def test_punctuation_in_alias(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
sentence: str,
) -> None:
"""Test that an alias containing punctuation can still be matched.
The input is matched with punctuation removed, so the alias must be too.
"""
entity_registry.async_get_or_create(
"light", "demo", "1234", suggested_object_id="test_light"
)
entity_registry.async_update_entity(
"light.test_light", aliases=["Upstairs, hallway"]
)
hass.states.async_set(
"light.test_light",
"on",
attributes={ATTR_FRIENDLY_NAME: "Test light"},
)
expose_entity(hass, "light.test_light", True)
calls = async_mock_service(hass, "light", "turn_off")
result = await conversation.async_converse(hass, sentence, None, Context(), None)
assert len(calls) == 1
assert calls[0].data["entity_id"][0] == "light.test_light"
assert result.response.response_type is intent.IntentResponseType.ACTION_DONE
@pytest.mark.parametrize(
"sentence",
[
# STT may or may not insert the comma based on speech cadence
"Turn on lights in second, floor",
"Turn on lights in second floor",
],
)
@pytest.mark.usefixtures("init_components")
async def test_punctuation_in_area_alias(
hass: HomeAssistant,
area_registry: ar.AreaRegistry,
entity_registry: er.EntityRegistry,
sentence: str,
) -> None:
"""Test that an area alias containing punctuation can still be matched.
The input is matched with punctuation removed, so the alias must be too.
"""
area = area_registry.async_get_or_create("area_id")
area = area_registry.async_update(area.id, aliases={"Second, floor"})
entity_registry.async_get_or_create(
"light", "demo", "1234", suggested_object_id="test_light"
)
entity_registry.async_update_entity("light.test_light", area_id=area.id)
hass.states.async_set(
"light.test_light",
"off",
attributes={ATTR_FRIENDLY_NAME: "Test light"},
)
expose_entity(hass, "light.test_light", True)
calls = async_mock_service(hass, "light", "turn_on")
result = await conversation.async_converse(hass, sentence, None, Context(), None)
assert len(calls) == 1
assert calls[0].data["entity_id"][0] == "light.test_light"
assert result.response.response_type is intent.IntentResponseType.ACTION_DONE
assert result.response.intent is not None
assert result.response.intent.slots["area"]["value"] == area.id
async def test_expose_flag_automatically_set(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
@@ -695,6 +695,8 @@ async def test_if_position(
assert service_calls[6].data["some"] == "is_pos_not_gt_45 - event - test_event1"
for record in caplog.records:
if record.name == "asyncio" and record.getMessage().startswith("Executing "):
continue
assert record.levelname in ("DEBUG", "INFO")
@@ -857,4 +859,6 @@ async def test_if_tilt_position(
assert service_calls[6].data["some"] == "is_pos_not_gt_45 - event - test_event1"
for record in caplog.records:
if record.name == "asyncio" and record.getMessage().startswith("Executing "):
continue
assert record.levelname in ("DEBUG", "INFO")
@@ -1,251 +0,0 @@
# serializer version: 1
# name: test_entities[button.edifier_r1700bt_bluetooth-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.edifier_r1700bt_bluetooth',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Bluetooth',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Bluetooth',
'platform': 'edifier_infrared',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'bluetooth',
'unique_id': '01JTEST0000000000000000000_bluetooth',
'unit_of_measurement': None,
})
# ---
# name: test_entities[button.edifier_r1700bt_bluetooth-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Edifier R1700BT Bluetooth',
}),
'context': <ANY>,
'entity_id': 'button.edifier_r1700bt_bluetooth',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_entities[button.edifier_r1700bt_fx_off-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.edifier_r1700bt_fx_off',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'FX off',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'FX off',
'platform': 'edifier_infrared',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'fx_off',
'unique_id': '01JTEST0000000000000000000_fx_off',
'unit_of_measurement': None,
})
# ---
# name: test_entities[button.edifier_r1700bt_fx_off-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Edifier R1700BT FX off',
}),
'context': <ANY>,
'entity_id': 'button.edifier_r1700bt_fx_off',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_entities[button.edifier_r1700bt_fx_on-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.edifier_r1700bt_fx_on',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'FX on',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'FX on',
'platform': 'edifier_infrared',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'fx_on',
'unique_id': '01JTEST0000000000000000000_fx_on',
'unit_of_measurement': None,
})
# ---
# name: test_entities[button.edifier_r1700bt_fx_on-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Edifier R1700BT FX on',
}),
'context': <ANY>,
'entity_id': 'button.edifier_r1700bt_fx_on',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_entities[button.edifier_r1700bt_line_1-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.edifier_r1700bt_line_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Line 1',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Line 1',
'platform': 'edifier_infrared',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'line_1',
'unique_id': '01JTEST0000000000000000000_line_1',
'unit_of_measurement': None,
})
# ---
# name: test_entities[button.edifier_r1700bt_line_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Edifier R1700BT Line 1',
}),
'context': <ANY>,
'entity_id': 'button.edifier_r1700bt_line_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_entities[button.edifier_r1700bt_line_2-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.edifier_r1700bt_line_2',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Line 2',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Line 2',
'platform': 'edifier_infrared',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'line_2',
'unique_id': '01JTEST0000000000000000000_line_2',
'unit_of_measurement': None,
})
# ---
# name: test_entities[button.edifier_r1700bt_line_2-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Edifier R1700BT Line 2',
}),
'context': <ANY>,
'entity_id': 'button.edifier_r1700bt_line_2',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
@@ -1,73 +0,0 @@
"""Tests for the Edifier Infrared button platform."""
from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
from tests.components.common import assert_availability_follows_source_entity
from tests.components.infrared import EMITTER_ENTITY_ID
from tests.components.infrared.common import MockInfraredEmitterEntity
BLUETOOTH_BUTTON_ENTITY_ID = "button.edifier_r1700bt_bluetooth"
@pytest.fixture
def platforms() -> list[Platform]:
"""Return platforms to set up."""
return [Platform.BUTTON]
@pytest.mark.usefixtures("init_integration")
async def test_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the button entities are created with correct attributes."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
("entity_id", "expected_code"),
[
("button.edifier_r1700bt_bluetooth", EdifierR1700BTCode.BLUETOOTH),
("button.edifier_r1700bt_line_1", EdifierR1700BTCode.LINE_1),
("button.edifier_r1700bt_line_2", EdifierR1700BTCode.LINE_2),
("button.edifier_r1700bt_fx_on", EdifierR1700BTCode.FX_ON),
("button.edifier_r1700bt_fx_off", EdifierR1700BTCode.FX_OFF),
],
)
@pytest.mark.usefixtures("init_integration")
async def test_button_press_sends_correct_code(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
entity_id: str,
expected_code: EdifierR1700BTCode,
) -> None:
"""Test each button press sends the correct IR code."""
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
assert len(mock_infrared_emitter_entity.send_command_calls) == 1
assert mock_infrared_emitter_entity.send_command_calls[0] == expected_code
@pytest.mark.usefixtures("init_integration")
async def test_button_availability_follows_ir_entity(
hass: HomeAssistant,
) -> None:
"""Test button becomes unavailable when IR entity is unavailable."""
await assert_availability_follows_source_entity(
hass, BLUETOOTH_BUTTON_ENTITY_ID, EMITTER_ENTITY_ID
)
@@ -0,0 +1,36 @@
# serializer version: 1
# name: test_device_registry
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
tuple(
'mac',
'a8:03:2a:b1:23:45',
),
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'electrasmart',
'a8032ab12345',
),
}),
'labels': set({
}),
'manufacturer': 'Electra',
'model': 'Electra A/C',
'model_id': None,
'name': 'Living Room',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
})
# ---
@@ -0,0 +1,72 @@
"""Tests for the Electra Smart integration setup."""
from unittest.mock import AsyncMock, Mock, patch
from electrasmart.device import OperationMode
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.electrasmart.const import (
CONF_IMEI,
CONF_PHONE_NUMBER,
DOMAIN,
)
from homeassistant.const import CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from tests.common import MockConfigEntry
@pytest.fixture(name="mock_device")
def mock_device_fixture() -> Mock:
"""Return a mocked Electra AC device."""
device = Mock(
mac="a8032ab12345",
model="Electra A/C",
manufactor="Electra",
features=[],
is_disconnected=Mock(return_value=False),
is_on=Mock(return_value=False),
is_horizontal_swing=Mock(return_value=False),
is_vertical_swing=Mock(return_value=False),
get_fan_speed=Mock(return_value=OperationMode.FAN_SPEED_AUTO),
get_mode=Mock(return_value=OperationMode.MODE_COOL),
get_sensor_temperature=Mock(return_value=24),
get_temperature=Mock(return_value=22),
get_shabat_mode=Mock(return_value=False),
)
# `name` is a reserved Mock kwarg, so it must be set after construction.
device.name = "Living Room"
return device
async def test_device_registry(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_device: Mock,
snapshot: SnapshotAssertion,
) -> None:
"""Test the device registry entry, including the network MAC connection."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="0521234567",
data={
CONF_TOKEN: "token",
CONF_IMEI: "2b950000024051000000000000000000",
CONF_PHONE_NUMBER: "0521234567",
},
)
entry.add_to_hass(hass)
mock_api = Mock(devices=[mock_device], fetch_devices=AsyncMock())
with patch(
"homeassistant.components.electrasmart.ElectraAPI", return_value=mock_api
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, "a8032ab12345")}
)
assert device_entry == snapshot
@@ -79,7 +79,7 @@
'labels': set({
}),
'manufacturer': 'Google',
'model': 'gemini-2.5-flash-preview-tts',
'model': 'gemini-3.1-flash-tts-preview',
'model_id': None,
'name': 'Google AI TTS',
'name_by_user': None,
@@ -68,17 +68,17 @@ def get_models_pager():
)
model_15_pro.name = "models/gemini-1.5-pro-latest"
model_25_flash_tts = Mock(
model_31_flash_tts = Mock(
supported_actions=["generateContent"],
)
model_25_flash_tts.name = "models/gemini-2.5-flash-preview-tts"
model_31_flash_tts.name = "models/gemini-3.1-flash-tts-preview"
async def models_pager():
yield model_25_flash
yield model_20_flash
yield model_15_flash
yield model_15_pro
yield model_25_flash_tts
yield model_31_flash_tts
return models_pager()
@@ -278,13 +278,6 @@ async def test_creating_subentry(
CONF_RECOMMENDED: False,
CONF_CHAT_MODEL: RECOMMENDED_TTS_MODEL,
CONF_TEMPERATURE: 1.0,
CONF_TOP_P: 1.0,
CONF_TOP_K: 1,
CONF_MAX_TOKENS: 1024,
CONF_HARASSMENT_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE",
CONF_HATE_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE",
CONF_SEXUAL_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE",
CONF_DANGEROUS_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE",
},
),
(
@@ -14,11 +14,7 @@ from homeassistant.components import tts
from homeassistant.components.google_generative_ai_conversation.const import (
CONF_CHAT_MODEL,
DOMAIN,
RECOMMENDED_HARM_BLOCK_THRESHOLD,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_K,
RECOMMENDED_TOP_P,
)
from homeassistant.components.media_player import (
ATTR_MEDIA_CONTENT_ID,
@@ -185,28 +181,6 @@ async def test_tts_service_speak(
)
),
temperature=RECOMMENDED_TEMPERATURE,
top_k=RECOMMENDED_TOP_K,
top_p=RECOMMENDED_TOP_P,
max_output_tokens=RECOMMENDED_MAX_TOKENS,
safety_settings=[
types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_HARASSMENT,
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
],
thinking_config=None,
),
)
@@ -254,27 +228,5 @@ async def test_tts_service_speak_error(
)
),
temperature=RECOMMENDED_TEMPERATURE,
top_k=RECOMMENDED_TOP_K,
top_p=RECOMMENDED_TOP_P,
max_output_tokens=RECOMMENDED_MAX_TOKENS,
safety_settings=[
types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_HARASSMENT,
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
],
thinking_config=None,
),
)
-1
View File
@@ -39,7 +39,6 @@ def mock_config_entry() -> MockConfigEntry:
CONF_INFRARED_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID,
CONF_INFRARED_RECEIVER_ENTITY_ID: MOCK_INFRARED_RECEIVER_ENTITY_ID,
},
unique_id=f"lg_ir_tv_{MOCK_INFRARED_EMITTER_ENTITY_ID}",
)
@@ -22,12 +22,11 @@ from tests.components.infrared import (
@pytest.mark.parametrize(
("config", "expected_title", "unique_id_entity_id"),
("config", "expected_title"),
[
(
{CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id},
"LG TV via Test IR emitter",
mock_infrared_emitter_entity_id,
),
(
{
@@ -35,12 +34,10 @@ from tests.components.infrared import (
CONF_INFRARED_RECEIVER_ENTITY_ID: mock_infrared_receiver_entity_id,
},
"LG TV via Test IR emitter",
mock_infrared_emitter_entity_id,
),
(
{CONF_INFRARED_RECEIVER_ENTITY_ID: mock_infrared_receiver_entity_id},
"LG TV via Test IR receiver",
mock_infrared_receiver_entity_id,
),
],
)
@@ -51,7 +48,6 @@ async def test_user_flow_success(
hass: HomeAssistant,
config: dict[str, str],
expected_title: str,
unique_id_entity_id: str,
) -> None:
"""Test successful user config flow."""
result = await hass.config_entries.flow.async_init(
@@ -69,7 +65,7 @@ async def test_user_flow_success(
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == expected_title
assert result["data"] == {CONF_DEVICE_TYPE: LGDeviceType.TV, **config}
assert result["result"].unique_id == f"lg_ir_tv_{unique_id_entity_id}"
assert result["result"].unique_id is None
@pytest.mark.usefixtures("mock_infrared_emitter_entity")
@@ -90,9 +86,33 @@ async def test_user_flow_requires_emitter_or_receiver(
assert result["errors"] == {"base": "missing_infrared_entity"}
@pytest.mark.usefixtures("mock_infrared_emitter_entity")
@pytest.mark.usefixtures(
"mock_infrared_emitter_entity", "mock_infrared_receiver_entity"
)
@pytest.mark.parametrize(
"user_input",
[
pytest.param(
{CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id},
id="emitter_conflict",
),
pytest.param(
{CONF_INFRARED_RECEIVER_ENTITY_ID: mock_infrared_receiver_entity_id},
id="receiver_conflict",
),
pytest.param(
{
CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id,
CONF_INFRARED_RECEIVER_ENTITY_ID: mock_infrared_receiver_entity_id,
},
id="both_conflict",
),
],
)
async def test_user_flow_already_configured(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
user_input: dict[str, str],
) -> None:
"""Test user flow aborts when entry is already configured."""
mock_config_entry.add_to_hass(hass)
@@ -105,10 +125,7 @@ async def test_user_flow_already_configured(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_DEVICE_TYPE: LGDeviceType.TV,
CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id,
},
user_input={CONF_DEVICE_TYPE: LGDeviceType.TV, **user_input},
)
assert result["type"] is FlowResultType.ABORT
@@ -155,6 +172,5 @@ async def test_user_flow_title_from_entity_name(
CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == expected_title
+42
View File
@@ -1,9 +1,24 @@
"""Tests for the LG Infrared integration setup."""
from homeassistant.components.lg_infrared.const import (
CONF_DEVICE_TYPE,
CONF_INFRARED_ENTITY_ID,
CONF_INFRARED_RECEIVER_ENTITY_ID,
DOMAIN,
LGDeviceType,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
from tests.components.infrared import (
EMITTER_ENTITY_ID as MOCK_INFRARED_EMITTER_ENTITY_ID,
RECEIVER_ENTITY_ID as MOCK_INFRARED_RECEIVER_ENTITY_ID,
)
from tests.components.infrared.common import (
MockInfraredEmitterEntity,
MockInfraredReceiverEntity,
)
async def test_setup_and_unload_entry(
@@ -17,3 +32,30 @@ async def test_setup_and_unload_entry(
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.NOT_LOADED
async def test_migrate_v1_to_v2(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
mock_infrared_receiver_entity: MockInfraredReceiverEntity,
mock_lg_tv_code_to_command: None,
) -> None:
"""Test migration from v1 (legacy unique_id) to v2 (no unique_id)."""
entry = MockConfigEntry(
domain=DOMAIN,
version=1,
unique_id=f"lg_ir_tv_{MOCK_INFRARED_EMITTER_ENTITY_ID}",
data={
CONF_DEVICE_TYPE: LGDeviceType.TV,
CONF_INFRARED_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID,
CONF_INFRARED_RECEIVER_ENTITY_ID: MOCK_INFRARED_RECEIVER_ENTITY_ID,
},
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.LOADED
assert entry.version == 2
assert entry.unique_id is None
+2 -2
View File
@@ -21,6 +21,7 @@ from homeassistant.components.mcp.const import (
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from tests.common import MockConfigEntry
@@ -104,8 +105,7 @@ async def mock_credential(hass: HomeAssistant) -> None:
@pytest.fixture(name="config_entry_token_expiration")
def mock_config_entry_token_expiration() -> datetime.datetime:
"""Fixture to mock the token expiration."""
# pylint: disable-next=home-assistant-enforce-utcnow
return datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1)
return dt_util.utcnow() + datetime.timedelta(days=1)
@pytest.fixture(name="config_entry_with_auth")
+17 -17
View File
@@ -2754,7 +2754,7 @@ async def test_migrate_config_entry(
{
"state_topic": "test-topic",
"value_template": "{{ value_json.value }}",
"advanced_settings": {"expire_after": 1200, "off_delay": 5},
"other_settings": {"expire_after": 1200, "off_delay": 5},
},
(
(
@@ -3418,10 +3418,10 @@ async def test_migrate_config_entry(
(
{
"command_topic": "test-topic",
"advanced_settings": {"max_kelvin": 2000, "min_kelvin": 2000},
"other_settings": {"max_kelvin": 2000, "min_kelvin": 2000},
},
{
"advanced_settings": "max_below_min_kelvin",
"other_settings": "max_below_min_kelvin",
},
),
),
@@ -3700,7 +3700,7 @@ async def test_migrate_config_entry(
{
"state_topic": "test-topic",
"value_template": "{{ value_json.value }}",
"advanced_settings": {"expire_after": 30},
"other_settings": {"expire_after": 30},
},
(
(
@@ -3766,7 +3766,7 @@ async def test_migrate_config_entry(
"available_tones": ["Happy hour", "Cooling alarm"],
"support_duration": True,
"support_volume_set": True,
"siren_advanced_settings": {
"siren_other_settings": {
"command_off_template": "{{ value }}",
},
},
@@ -3827,7 +3827,7 @@ async def test_migrate_config_entry(
"state_topic": "test-topic",
"value_template": "{{ value_json.value }}",
"retain": False,
"text_advanced_settings": {
"text_other_settings": {
"min": 0,
"max": 10,
"mode": "password",
@@ -3849,26 +3849,26 @@ async def test_migrate_config_entry(
(
{
"command_topic": "test-topic",
"text_advanced_settings": {
"text_other_settings": {
"min": 20,
"max": 10,
"mode": "password",
"pattern": "^[a-z_]*$",
},
},
{"text_advanced_settings": "max_below_min"},
{"text_other_settings": "max_below_min"},
),
(
{
"command_topic": "test-topic",
"text_advanced_settings": {
"text_other_settings": {
"min": 0,
"max": 10,
"mode": "password",
"pattern": "(",
},
},
{"text_advanced_settings": "invalid_regular_expression"},
{"text_other_settings": "invalid_regular_expression"},
),
),
"Milk notifier MOTD",
@@ -4761,7 +4761,7 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites(
"device_class": "battery",
"state_class": "measurement",
"unit_of_measurement": "%",
"advanced_settings": {"suggested_display_precision": 1},
"other_settings": {"suggested_display_precision": 1},
},
{
"state_topic": "test-topic1-updated",
@@ -5225,21 +5225,21 @@ async def test_subentry_reconfigure_update_device_properties(
"model_id": {"suggested_value": "mn002"},
"manufacturer": {"suggested_value": "Milk Masters"},
"configuration_url": {"suggested_value": "https://example.com"},
"advanced_settings": None,
"other_settings": None,
"mqtt_settings": None,
}
advanced_settings_key_descriptions = {
other_settings_key_descriptions = {
key: key.description
for key, value in result["data_schema"]
.schema["advanced_settings"]
.schema["other_settings"]
.schema.schema.items()
}
assert advanced_settings_key_descriptions == {
assert other_settings_key_descriptions == {
"sw_version": {"suggested_value": "1.0"},
"hw_version": {"suggested_value": "2.1 rev a"},
}
assert result["data_schema"].schema["advanced_settings"].options == {
assert result["data_schema"].schema["other_settings"].options == {
"collapsed": False
}
@@ -5264,7 +5264,7 @@ async def test_subentry_reconfigure_update_device_properties(
result["flow_id"],
user_input={
"name": "Beer notifier",
"advanced_settings": {"sw_version": "1.1"},
"other_settings": {"sw_version": "1.1"},
"model": "Beer bottle XL",
"model_id": "bn003",
"manufacturer": "Beer Masters",
+3 -6
View File
@@ -1622,8 +1622,7 @@ async def test_remove_stale_media(
event_media = media_files[0]
assert event_media.name.endswith(".mp4")
# pylint: disable-next=home-assistant-enforce-utcnow
event_time1 = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=8)
event_time1 = dt_util.utcnow() - datetime.timedelta(days=8)
extra_media1 = (
device_path / f"{int(event_time1.timestamp())}-camera_motion-test.mp4"
)
@@ -1634,8 +1633,7 @@ async def test_remove_stale_media(
)
extra_media2.write_bytes(mp4.getvalue())
# This event will not be garbage collected because it is too recent
# pylint: disable-next=home-assistant-enforce-utcnow
event_time3 = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=3)
event_time3 = dt_util.utcnow() - datetime.timedelta(days=3)
extra_media3 = (
device_path / f"{int(event_time3.timestamp())}-camera_motion-test.mp4"
)
@@ -1645,8 +1643,7 @@ async def test_remove_stale_media(
# Advance the clock to invoke the garbage collector. This will remove extra
# files that are not valid events that are old enough.
# pylint: disable-next=home-assistant-enforce-utcnow
point_in_time = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1)
point_in_time = dt_util.utcnow() + datetime.timedelta(days=1)
with freeze_time(point_in_time):
async_fire_time_changed(hass, point_in_time)
await hass.async_block_till_done()
@@ -5,7 +5,7 @@ from ipaddress import ip_address
from unittest.mock import MagicMock, Mock, patch
import pytest
from rabbitair import Mode, Model, Speed
from rabbitair import Mode, Model, Quality, Speed
from homeassistant import config_entries
from homeassistant.components.rabbitair.const import DOMAIN
@@ -62,6 +62,7 @@ def get_mock_state(
main_firmware: str | None = TEST_HARDWARE,
power: bool | None = True,
mode: Mode | None = Mode.Auto,
quality: Quality | None = Quality.High,
speed: Speed | None = Speed.Low,
wifi_firmware: str | None = TEST_FIRMWARE,
) -> Mock:
@@ -71,6 +72,7 @@ def get_mock_state(
mock_state.main_firmware = main_firmware
mock_state.power = power
mock_state.mode = mode
mock_state.quality = quality
mock_state.speed = speed
mock_state.wifi_firmware = wifi_firmware
return mock_state
+77
View File
@@ -0,0 +1,77 @@
"""Test the Rabbit Air sensor platform."""
from unittest.mock import patch
import pytest
from rabbitair import Quality
from homeassistant.components.rabbitair.const import DOMAIN
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_MAC
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .test_config_flow import (
TEST_HOST,
TEST_MAC,
TEST_TITLE,
TEST_TOKEN,
TEST_UNIQUE_ID,
get_mock_state,
)
from tests.common import MockConfigEntry
pytestmark = pytest.mark.usefixtures("mock_async_zeroconf")
@pytest.fixture
def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
"""Return a mock config entry."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: TEST_HOST,
CONF_ACCESS_TOKEN: TEST_TOKEN,
CONF_MAC: TEST_MAC,
},
title=TEST_TITLE,
unique_id=TEST_UNIQUE_ID,
)
entry.add_to_hass(hass)
return entry
async def test_air_quality_sensor(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the air quality sensor."""
with patch(
"homeassistant.components.rabbitair.coordinator.Client.get_state",
return_value=get_mock_state(quality=Quality.High),
):
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get("sensor.rabbit_air_air_quality")
assert state
assert state.state == "high"
registry_entry = entity_registry.async_get("sensor.rabbit_air_air_quality")
assert registry_entry
assert registry_entry.unique_id == f"{TEST_UNIQUE_ID}_air_quality"
async def test_no_air_quality_sensor_when_quality_is_none(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test the air quality sensor is not created when quality is unavailable."""
with patch(
"homeassistant.components.rabbitair.coordinator.Client.get_state",
return_value=get_mock_state(quality=None),
):
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert hass.states.get("sensor.rabbit_air_air_quality") is None
@@ -4765,3 +4765,44 @@ async def test_import_statistics_with_last_reset(
},
]
}
async def test_recorded_entities_ws(
hass: HomeAssistant,
async_setup_recorder_instance: RecorderInstanceGenerator,
hass_ws_client: WebSocketGenerator,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test recorded entities WS commands."""
client = await hass_ws_client()
await async_setup_recorder_instance(hass, {"exclude": {"domains": "test2"}})
# Test getting a single entity's settings
await client.send_json_auto_id(
{
"type": "recorder/recorded_entities/get",
"entity_id": "test.recorder",
}
)
response = await client.receive_json()
assert response["result"] == {"recording_disabled_by": None}
await client.send_json_auto_id(
{
"type": "recorder/recorded_entities/get",
"entity_id": "test2.recorder",
}
)
response = await client.receive_json()
assert response["result"] == {"recording_disabled_by": "user"}
# Test getting settings for an unknown entity
await client.send_json_auto_id(
{
"type": "recorder/recorded_entities/get",
"entity_id": "unknown.entity",
}
)
response = await client.receive_json()
assert response["result"] == {"recording_disabled_by": None}
@@ -0,0 +1,36 @@
# serializer version: 1
# name: test_device_registry
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
tuple(
'mac',
'80:7d:3a:bd:1e:32',
),
}),
'disabled_by': None,
'entry_type': None,
'hw_version': '8.0',
'id': <ANY>,
'identifiers': set({
tuple(
'renson',
'80:7d:3a:bd:1e:32',
),
}),
'labels': set({
}),
'manufacturer': 'Renson',
'model': 'Endura Delta',
'model_id': None,
'name': 'Ventilation',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': 'Firmware version 4.9.1',
'via_device_id': None,
})
# ---
+64
View File
@@ -0,0 +1,64 @@
"""Tests for the Renson integration setup."""
from unittest.mock import MagicMock, patch
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.renson.const import DOMAIN
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from tests.common import MockConfigEntry
async def test_device_registry(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the device registry entry, including the network MAC connection."""
all_data = {
"ModifiedItems": [
{"Name": "MAC", "Value": "80:7d:3a:bd:1e:32"},
{"Name": "Device name", "Value": "Endura Delta"},
{"Name": "Firmware version", "Value": "Firmware version 4.9.1"},
{"Name": "Hardware version", "Value": "8.0"},
]
}
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "1.1.1.1"},
)
entry.add_to_hass(hass)
mock_api = MagicMock()
mock_api.connect.return_value = True
mock_api.get_all_data.return_value = all_data
def _get_field_value(data: dict, fieldname: str) -> str:
for item in data["ModifiedItems"]:
if item["Name"] == fieldname:
return item["Value"]
return ""
mock_api.get_field_value.side_effect = _get_field_value
with (
patch(
"homeassistant.components.renson.RensonVentilation",
return_value=mock_api,
),
patch(
"homeassistant.components.renson.PLATFORMS",
[Platform.SENSOR],
),
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, "80:7d:3a:bd:1e:32")}
)
assert device_entry == snapshot
+3 -2
View File
@@ -2,7 +2,7 @@
import asyncio
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from datetime import timedelta
from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, patch
@@ -47,6 +47,7 @@ from homeassistant.helpers import (
)
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from .conftest import (
CONF_BC_CONNECT,
@@ -1205,7 +1206,7 @@ async def test_firmware_update_delay(
call_count: int,
) -> None:
"""Test delay of firmware update check."""
now = datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
now = dt_util.utcnow()
check_delay = (
now
+ timedelta(seconds=seconds)
+15
View File
@@ -49,6 +49,21 @@ async def test_base_station_migration(
assert device_registry.async_get_device(identifiers=new_identifiers) is not None
async def test_base_station_model_is_string(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
config_entry: MockConfigEntry,
patch_simplisafe_api,
) -> None:
"""Test that the base station model is stored as a string in the device registry."""
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
device = device_registry.async_get_device(identifiers={(DOMAIN, "12345")})
assert device is not None
assert isinstance(device.model, str)
async def test_coordinator_update_triggers_reauth_on_invalid_credentials(
hass: HomeAssistant,
config_entry: MockConfigEntry,
+30 -1
View File
@@ -10,8 +10,9 @@ import pytest
from homeassistant.components import sun
from homeassistant.components.sun import entity
from homeassistant.const import EVENT_STATE_CHANGED
from homeassistant.const import EVENT_STATE_CHANGED, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
@@ -245,3 +246,31 @@ async def test_setup_and_remove_config_entry(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
assert hass.states.get(entity.ENTITY_ID) is None
async def test_cleanup_deprecated_solar_rising(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
) -> None:
"""Test that the deprecated solar_rising entity is removed on setup."""
config_entry = MockConfigEntry(domain=sun.DOMAIN)
config_entry.add_to_hass(hass)
entity_registry.async_get_or_create(
Platform.SENSOR,
sun.DOMAIN,
unique_id=f"{config_entry.entry_id}-solar_rising",
config_entry=config_entry,
)
assert entity_registry.async_get_entity_id(
Platform.SENSOR, sun.DOMAIN, f"{config_entry.entry_id}-solar_rising"
)
now = datetime(2016, 6, 1, 8, 0, 0, tzinfo=dt_util.UTC)
with freeze_time(now):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert not entity_registry.async_get_entity_id(
Platform.SENSOR, sun.DOMAIN, f"{config_entry.entry_id}-solar_rising"
)
-1
View File
@@ -9,7 +9,6 @@ UNIQUE_ID = "abc-123"
CONFIG_V1 = {CONF_ACCESS_TOKEN: "abc-123"}
WAKE_UP_ONLINE = {"response": {"state": TeslemetryState.ONLINE}, "error": None}
WAKE_UP_ASLEEP = {"response": {"state": TeslemetryState.ASLEEP}, "error": None}
PRODUCTS = load_json_object_fixture("products.json", DOMAIN)
PRODUCTS_MODERN = load_json_object_fixture("products.json", DOMAIN)
+18
View File
@@ -51,6 +51,7 @@ from .const import (
UNIQUE_ID,
VEHICLE_DATA,
VEHICLE_DATA_ALT,
VEHICLE_DATA_ASLEEP,
)
from tests.common import MockConfigEntry, async_fire_time_changed
@@ -193,6 +194,23 @@ async def test_vehicle_stream(
assert state.state == STATE_OFF
async def test_vehicle_asleep_polling(
hass: HomeAssistant,
mock_vehicle_data: AsyncMock,
mock_legacy: AsyncMock,
) -> None:
"""Polling an offline/asleep vehicle loads and reports disconnected."""
mock_vehicle_data.return_value = VEHICLE_DATA_ASLEEP
entry = await setup_platform(hass, [Platform.BINARY_SENSOR])
assert entry.state is ConfigEntryState.LOADED
state = hass.states.get("binary_sensor.test_status")
assert state is not None
assert state.state == STATE_OFF
async def test_no_live_status(
hass: HomeAssistant,
mock_live_status: AsyncMock,
@@ -0,0 +1,69 @@
# serializer version: 1
# name: test_device_registry[with_mac]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
tuple(
'mac',
'ff:00:00:00:00:00',
),
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'vilfo',
'testadmin.vilfo.com',
'FF-00-00-00-00-00',
),
}),
'labels': set({
}),
'manufacturer': 'Vilfo AB',
'model': 'Vilfo Router',
'model_id': None,
'name': 'Vilfo Router',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': '1.1.0',
'via_device_id': None,
})
# ---
# name: test_device_registry[without_mac]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'vilfo',
'testadmin.vilfo.com',
None,
),
}),
'labels': set({
}),
'manufacturer': 'Vilfo AB',
'model': 'Vilfo Router',
'model_id': None,
'name': 'Vilfo Router',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': '1.1.0',
'via_device_id': None,
})
# ---
+59
View File
@@ -0,0 +1,59 @@
"""Tests for the Vilfo Router integration setup."""
from unittest.mock import patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.vilfo.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from tests.common import MockConfigEntry
@pytest.mark.parametrize(
("mac", "identifiers"),
[
pytest.param(
"FF-00-00-00-00-00",
{(DOMAIN, "testadmin.vilfo.com", "FF-00-00-00-00-00")},
id="with_mac",
),
pytest.param(
None,
{(DOMAIN, "testadmin.vilfo.com", None)},
id="without_mac",
),
],
)
async def test_device_registry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
mac: str | None,
identifiers: set[tuple[str, str | None]],
) -> None:
"""Test the device registry entry.
The network MAC connection is only attached when the router reports a MAC;
a router set up by host may not report one.
"""
mock_config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.vilfo.VilfoClient", autospec=True
) as mock_client:
client = mock_client.return_value
client.mac = mac
client.get_board_information.return_value = {
"version": "1.1.0",
"bootTime": "2024-01-01T00:00:00+00:00",
}
client.get_load.return_value = 30
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
device_entry = device_registry.async_get_device(identifiers=identifiers)
assert device_entry == snapshot

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