Compare commits

..

68 Commits

Author SHA1 Message Date
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
Manu 90fe38c0f2 Replace Advanced settings in Habitica integration (#174064) 2026-06-16 17:55:40 +02:00
Michael Hansen 65c2aaf22f Revert "Update hassil to 3.8.1" (#174061) 2026-06-16 17:43:02 +02:00
Åke Strandberg a691de352c Add volume and temperature sensors to aqvify (#174007) 2026-06-16 17:38:12 +02:00
renovate[bot] 4203781fa5 Update hassil to 3.8.1 (#173957)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-16 10:08:16 -05:00
Erik Montnemery e6b3a97162 Avoid flooding the recorder when priming condition history (#174020) 2026-06-16 17:06:34 +02:00
Erik Montnemery 6ad8ad5715 Call state change listeners immediately instead of deferring them to the event loop (#173974) 2026-06-16 16:50:32 +02:00
Åke Strandberg a45867b896 Use username as config entry title in aqvify (#174008) 2026-06-16 14:30:25 +02:00
Franck Nijhof 000e075a8e Add missing subentry flow translations in scrape (#174006) 2026-06-16 14:26:12 +02:00
Franck Nijhof 0899d016b9 Add missing flow form field translations in ecobee (#174002) 2026-06-16 14:25:40 +02:00
Franck Nijhof 3375f2ed76 Add missing flow form field translation in otp (#173994) 2026-06-16 14:25:29 +02:00
Franck Nijhof 3f5778e71b Add missing flow form field translations in tractive (#174005) 2026-06-16 14:10:47 +02:00
Franck Nijhof 86c39694d3 Add missing flow form field translation in iskra (#174004) 2026-06-16 13:48:58 +02:00
Franck Nijhof a53a6644c0 Fix flow form field translations in modem_callerid (#173999) 2026-06-16 13:47:01 +02:00
Franck Nijhof 18fdfacf45 Fix flow form field translation key in sia (#173998) 2026-06-16 13:46:27 +02:00
Franck Nijhof bd9bd29f2c Add missing flow form field translation in airvisual (#174000) 2026-06-16 13:46:19 +02:00
Franck Nijhof 334c6614cc Fix flow form field translations in local_calendar (#173997) 2026-06-16 13:43:22 +02:00
Franck Nijhof aa772f6ecd Add missing flow form field translation in honeywell (#173996) 2026-06-16 13:41:18 +02:00
Franck Nijhof 87169921ae Fix flow form field translations in hlk_sw16 (#173993) 2026-06-16 13:39:40 +02:00
Franck Nijhof 16338b8b6b Fix flow form field translation keys in here_travel_time (#173992) 2026-06-16 13:38:50 +02:00
Åke Strandberg 519da3c9c9 Add aqvify devices dynamically (#173534) 2026-06-16 13:37:42 +02:00
Åke Strandberg 6f34718c1f Bump pyaqvify to 0.0.11 (#173989) 2026-06-16 13:37:02 +02:00
Tim Laing e4287bb43c Bump PyiCloud to 2.6.5 (#173928) 2026-06-16 13:05:50 +02:00
Mike O'Driscoll d724ebac2a casper_glow: add bluetooth reachability diagnostics (#173921) 2026-06-16 13:02:46 +02:00
Raphael Hehl dc480051db Use console name in UniFi Network discovery title (#173931) 2026-06-16 12:57:51 +02:00
Franck Nijhof 63b6ced9c4 Bump evolutionhttp to 0.0.19 (#173911) 2026-06-16 12:46:21 +02:00
Franck Nijhof 34e9b3ff1e Bump lunatone-rest-api-client to 0.9.2 (#173918) 2026-06-16 12:45:19 +02:00
Robert Resch 210746525e Fix missing full sha as hidden field in requirements check aw (#173900) 2026-06-16 11:05:08 +02:00
Robert Resch 0134e99366 Token views should behave the same (#173500) 2026-06-16 10:46:18 +02:00
Oscar Calvo 06de89d6a3 Fix CCM15 temperature unit to follow the device's C/F setting (#173788) 2026-06-16 10:41:58 +02:00
Paul Bottein 4c267617f8 Publish numeric sensor device classes as generated sensor.json (#173919) 2026-06-16 11:41:27 +03:00
Franck Nijhof a82f1a7a1d Bump pyfireservicerota to 0.0.49 (#173935) 2026-06-16 10:36:34 +02:00
Franck Nijhof d234f65dd9 Bump heatmiserV3 to 2.0.6 (#173913)
Co-authored-by: Erwin Douna <e.douna@gmail.com>
2026-06-16 10:36:03 +02:00
John Pettitt 30148980e1 Add API_GEN_4 support to Subaru integration (#173956) 2026-06-16 10:32:02 +02:00
Franck Nijhof 1fa9a3353c Bump eufylife-ble-client to 0.1.10 (#173934) 2026-06-16 10:30:52 +02:00
Raphael Hehl 2dbbd70085 Use console name in UniFi Protect discovery title (#173966) 2026-06-16 10:25:50 +02:00
Franck Nijhof 73903b0bfc Bump pysesame2 to 1.0.2 (#173904) 2026-06-16 10:20:23 +02:00
Franck Nijhof b09f54ce3b Bump foobot_async to 1.0.1 (#173905) 2026-06-16 10:19:38 +02:00
Franck Nijhof 6d9e41da07 Bump pencompy to 0.0.4 (#173906) 2026-06-16 10:17:46 +02:00
137 changed files with 2936 additions and 1229 deletions
+5 -4
View File
@@ -1,4 +1,4 @@
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"328ce915f8b178bbcfcb1b69f397ac996456a4ab50490805b5b3bdd26cbf58fe","body_hash":"0665c72fe4b0a14b3a0b396dc293ce78235771fe15ebc894662fece33b189135","compiler_version":"v0.79.6","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"7b142e96e0f8b454cdcc9c0c25070cf9a52c44d83a6b1fbc3ad6725b6567337c","body_hash":"3894ded07d5934ac5f29d160ffb1f9115cf72b6da8a7e453a4d4f69e8641a48e","compiler_version":"v0.79.6","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"v0.79.6","version":"v0.79.6"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
# ___ _ _
# / _ \ | | (_)
@@ -1500,11 +1500,12 @@ jobs:
echo "Artifact has no head_sha; running the agent."
exit 0
fi
# Recover the commit recorded in the most recent requirements-check comment.
# Recover the commit recorded in the most recent requirements-check
# comment from the "Checked at commit" link
PRIOR=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" \
--jq '.[] | select(.body | contains("<!-- requirements-check -->")) | .body' \
| grep -oE '<!-- requirements-check-sha: [0-9a-f]{7,40} -->' \
| grep -oE '[0-9a-f]{7,40}' | tail -1 || true)
| grep -oiE '/commit/[0-9a-f]{40}' \
| grep -oiE '[0-9a-f]{40}' | tail -1 || true)
if [ -z "${PRIOR}" ]; then
echo "No previous comment with a recorded commit; running the agent."
exit 0
+6 -7
View File
@@ -51,11 +51,12 @@ jobs:
echo "Artifact has no head_sha; running the agent."
exit 0
fi
# Recover the commit recorded in the most recent requirements-check comment.
# Recover the commit recorded in the most recent requirements-check
# comment from the "Checked at commit" link
PRIOR=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" \
--jq '.[] | select(.body | contains("<!-- requirements-check -->")) | .body' \
| grep -oE '<!-- requirements-check-sha: [0-9a-f]{7,40} -->' \
| grep -oE '[0-9a-f]{7,40}' | tail -1 || true)
| grep -oiE '/commit/[0-9a-f]{40}' \
| grep -oiE '[0-9a-f]{40}' | tail -1 || true)
if [ -z "${PRIOR}" ]; then
echo "No previous comment with a recorded commit; running the agent."
exit 0
@@ -188,10 +189,8 @@ Then stop. Do not improvise a verdict.
Replace every placeholder with the resolved value and emit
`rendered_comment` via `add_comment`. Preserve the leading
`<!-- requirements-check -->` marker and the
`<!-- requirements-check-sha: … -->` marker that follows it — the next
run reads the recorded commit from it to decide whether anything changed.
The PR target is already wired; do not pass `item_number`.
`<!-- requirements-check -->` marker. The PR target is already wired;
do not pass `item_number`.
## Check instructions
+1 -1
View File
@@ -102,7 +102,7 @@ repos:
pass_filenames: false
language: script
types: [text]
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(conditions|quality_scale|services|triggers)\.yaml|homeassistant/brands/.*\.json|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(conditions|quality_scale|services|triggers)\.yaml|homeassistant/brands/.*\.json|script/hassfest/(?!metadata|mypy_config).+\.py|homeassistant/components/sensor/const\.py|requirements.+\.txt)$
- id: hassfest-metadata
name: hassfest-metadata
entry: script/run-in-env.sh python3 -m script.hassfest -p metadata,docker
+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%]"
}
}
},
@@ -37,6 +37,9 @@
"title": "Re-authenticate AirVisual"
},
"user": {
"data": {
"type": "Integration type"
},
"description": "Pick what type of AirVisual data you want to monitor.",
"title": "Configure AirVisual"
}
@@ -59,7 +59,9 @@ class AqvifyConfigFlow(ConfigFlow, domain=DOMAIN):
self._get_reconfigure_entry(), data_updates=user_input
)
self._abort_if_unique_id_configured()
return self.async_create_entry(title="Aqvify", data=user_input)
return self.async_create_entry(
title=account_data.name or "Aqvify", data=user_input
)
return self.async_show_form(
step_id="user",
@@ -152,3 +152,10 @@ class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]):
devices=devices,
device_data=device_data,
)
def async_add_devices(self, added_devices: set[str]) -> tuple[set[str], set[str]]:
"""Return newly discovered device keys and the full current device set."""
current_devices = set(self.data.devices.devices)
new_devices: set[str] = current_devices - added_devices
return (new_devices, current_devices)
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["pyaqvify"],
"quality_scale": "silver",
"requirements": ["pyaqvify==0.0.10"]
"requirements": ["pyaqvify==0.0.11"]
}
+35 -6
View File
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
SensorStateClass,
StateType,
)
from homeassistant.const import UnitOfLength
from homeassistant.const import UnitOfLength, UnitOfTemperature, UnitOfVolume
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -50,6 +50,23 @@ ENTITIES: tuple[AqvifySensorEntityDescription, ...] = (
suggested_display_precision=2,
value_fn=lambda value: value.water_level,
),
AqvifySensorEntityDescription(
key="volume",
native_unit_of_measurement=UnitOfVolume.LITERS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLUME_STORAGE,
suggested_display_precision=0,
value_fn=lambda value: value.volume,
),
AqvifySensorEntityDescription(
key="temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_display_precision=1,
value_fn=lambda value: value.temperature,
entity_registry_enabled_default=False,
),
)
@@ -59,11 +76,23 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Aqvify sensor entities from a config entry."""
async_add_entities(
AqvifySensor(entry.runtime_data, description, device_key)
for description in ENTITIES
for device_key in entry.runtime_data.data.devices.devices
)
coordinator = entry.runtime_data
added_devices: set[str] = set()
def _async_add_new_devices() -> None:
nonlocal added_devices
new_devices_set, current_devices = coordinator.async_add_devices(added_devices)
added_devices = current_devices
async_add_entities(
AqvifySensor(coordinator, description, device_key)
for description in ENTITIES
for device_key in new_devices_set
)
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
_async_add_new_devices()
class AqvifySensor(AqvifyBaseEntity, SensorEntity):
@@ -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",
@@ -123,7 +123,14 @@ class _BrandsBaseView(HomeAssistantView):
)
if not authenticated:
if hdrs.AUTHORIZATION in request.headers:
# A failed request that carried an Authorization header is a real
# Bearer auth attempt — return 401 and let the ban middleware count
# it as a wrong login.
raise web.HTTPUnauthorized
# No Authorization header: most likely a benign signed-URL / query-
# token request whose token has expired (e.g. a browser tab left
# open that re-fetches resources later). Return 403 so it doesn't
# register as a wrong login and ban the user's own IP.
raise web.HTTPForbidden
async def _serve_from_custom_integration(
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/bryant_evolution",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["evolutionhttp==0.0.18"]
"requirements": ["evolutionhttp==0.0.19"]
}
+10 -4
View File
@@ -785,7 +785,9 @@ class CameraView(HomeAssistantView):
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
"""Start a GET request."""
if (camera := self.component.get_entity(entity_id)) is None:
raise web.HTTPNotFound
raise (
web.HTTPNotFound if request[KEY_AUTHENTICATED] else web.HTTPUnauthorized
)
authenticated = (
request[KEY_AUTHENTICATED]
@@ -793,11 +795,15 @@ class CameraView(HomeAssistantView):
)
if not authenticated:
# Attempt with invalid bearer token, raise unauthorized
# so ban middleware can handle it.
if hdrs.AUTHORIZATION in request.headers:
# A failed request that carried an Authorization header is a real
# Bearer auth attempt — return 401 and let the ban middleware count
# it as a wrong login.
raise web.HTTPUnauthorized
# Invalid sigAuth or camera access token
# No Authorization header: most likely a benign signed-URL / query-
# token request whose token has expired (e.g. a browser tab left
# open that re-fetches resources later). Return 403 so it doesn't
# register as a wrong login and ban the user's own IP.
raise web.HTTPForbidden
if not camera.is_on:
@@ -3,6 +3,7 @@
from pycasperglow import CasperGlow
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
from homeassistant.const import CONF_ADDRESS, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
@@ -27,7 +28,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: CasperGlowConfigEntry) -
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={"address": address},
translation_placeholders={
"address": address,
"reason": bluetooth.async_address_reachability_diagnostics(
hass, address.upper(), BluetoothReachabilityIntent.CONNECTION
),
},
)
glow = CasperGlow(ble_device)
@@ -56,7 +56,7 @@
"message": "An error occurred while communicating with the Casper Glow: {error}"
},
"device_not_found": {
"message": "Could not find Casper Glow device with address {address}"
"message": "Could not find Casper Glow device with address {address}: {reason}"
}
}
}
+7 -1
View File
@@ -49,7 +49,6 @@ async def async_setup_entry(
class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity):
"""Climate device for CCM15 coordinator."""
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_has_entity_name = True
_attr_target_temperature_step = PRECISION_WHOLE
_attr_hvac_modes = [
@@ -93,6 +92,13 @@ class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity):
"""Return device data."""
return self.coordinator.get_ac_data(self._ac_index)
@property
def temperature_unit(self) -> str:
"""Return the unit of measurement reported by the device."""
if (data := self.data) is not None and not data.is_celsius:
return UnitOfTemperature.FAHRENHEIT
return UnitOfTemperature.CELSIUS
@property
def current_temperature(self) -> int | None:
"""Return current temperature."""
+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()
+3 -1
View File
@@ -30,7 +30,9 @@
},
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
"api_key": "[%key:common::config_flow::data::api_key%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"description": "Please enter the API key obtained from ecobee.com."
}
@@ -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,
@@ -2,6 +2,12 @@
"domain": "eufylife_ble",
"name": "EufyLife",
"bluetooth": [
{
"local_name": "eufy T9120"
},
{
"local_name": "eufy T9130"
},
{
"local_name": "eufy T9140"
},
@@ -16,6 +22,9 @@
},
{
"local_name": "eufy T9149"
},
{
"local_name": "eufy T9150"
}
],
"codeowners": ["@bdr99"],
@@ -24,5 +33,5 @@
"documentation": "https://www.home-assistant.io/integrations/eufylife_ble",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["eufylife-ble-client==0.1.8"]
"requirements": ["eufylife-ble-client==0.1.10"]
}
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyfireservicerota"],
"requirements": ["pyfireservicerota==0.0.46"]
"requirements": ["pyfireservicerota==0.0.49"]
}
@@ -6,5 +6,5 @@
"iot_class": "cloud_polling",
"loggers": ["foobot_async"],
"quality_scale": "legacy",
"requirements": ["foobot_async==1.0.0"]
"requirements": ["foobot_async==1.0.1"]
}
@@ -17,7 +17,7 @@
"date_description": "The to-do's due date.",
"date_name": "Due date",
"developer_options_description": "Additional features available in developer mode.",
"developer_options_name": "Advanced settings",
"developer_options_name": "Developer options",
"every_x_description": "The number of intervals (days, weeks, months, or years) after which the daily repeats, based on the chosen repetition interval. A value of 0 makes the daily inactive ('Grey Daily').",
"every_x_name": "Repeat every X",
"frequency_daily_description": "The repetition interval of a daily.",
@@ -6,5 +6,5 @@
"iot_class": "local_polling",
"loggers": ["heatmiserV3"],
"quality_scale": "legacy",
"requirements": ["heatmiserV3==2.0.4"]
"requirements": ["heatmiserV3==2.0.6"]
}
@@ -15,7 +15,7 @@
},
"title": "[%key:component::here_travel_time::config::step::destination_menu::title%]"
},
"destination_entity_id": {
"destination_entity": {
"data": {
"destination_entity_id": "Destination using an entity"
},
@@ -34,7 +34,7 @@
},
"title": "[%key:component::here_travel_time::config::step::origin_menu::title%]"
},
"origin_entity_id": {
"origin_entity": {
"data": {
"origin_entity_id": "Origin using an entity"
},
@@ -12,8 +12,7 @@
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "The hostname or IP address of your Hi-Link HLK-SW-16 device."
@@ -10,7 +10,8 @@
"step": {
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"description": "The Honeywell integration needs to re-authenticate your account",
"title": "[%key:common::config_flow::title::reauth%]"
@@ -8,5 +8,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["keyrings.alt", "pyicloud"],
"requirements": ["pyicloud==2.4.1"]
"requirements": ["pyicloud==2.6.5"]
}
+10 -4
View File
@@ -326,7 +326,9 @@ class ImageView(HomeAssistantView):
) -> ImageEntity:
"""Authenticate request and return image entity."""
if (image_entity := self.component.get_entity(entity_id)) is None:
raise web.HTTPNotFound
raise (
web.HTTPNotFound if request[KEY_AUTHENTICATED] else web.HTTPUnauthorized
)
authenticated = (
request[KEY_AUTHENTICATED]
@@ -334,11 +336,15 @@ class ImageView(HomeAssistantView):
)
if not authenticated:
# Attempt with invalid bearer token, raise unauthorized
# so ban middleware can handle it.
if hdrs.AUTHORIZATION in request.headers:
# A failed request that carried an Authorization header is a real
# Bearer auth attempt — return 401 and let the ban middleware count
# it as a wrong login.
raise web.HTTPUnauthorized
# Invalid sigAuth or image entity access token
# No Authorization header: most likely a benign signed-URL / query-
# token request whose token has expired (e.g. a browser tab left
# open that re-fetches resources later). Return 403 so it doesn't
# register as a wrong login and ban the user's own IP.
raise web.HTTPForbidden
return image_entity
+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(
+2 -1
View File
@@ -31,7 +31,8 @@
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
"host": "[%key:common::config_flow::data::host%]",
"protocol": "Protocol"
},
"data_description": {
"host": "Hostname or IP address of your Iskra device."
@@ -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)
@@ -7,7 +7,10 @@
"invalid_ics_file": "There was a problem reading the calendar information. See the error log for additional details."
},
"step": {
"import": {
"import_ics_file": {
"data": {
"ics_file": "ICS file"
},
"description": "You can import events in iCal format (.ics file)."
},
"user": {
@@ -7,7 +7,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["lunatone-rest-api-client==0.9.1"],
"requirements": ["lunatone-rest-api-client==0.9.2"],
"zeroconf": [
{
"properties": {
@@ -16,7 +16,7 @@ from typing import Any, Final, Required, TypedDict, final
from urllib.parse import quote, urlparse
import aiohttp
from aiohttp import web
from aiohttp import hdrs, web
from aiohttp.hdrs import CACHE_CONTROL, CONTENT_TYPE
from aiohttp.typedefs import LooseHeaders
from propcache.api import cached_property
@@ -1271,12 +1271,9 @@ class MediaPlayerImageView(HomeAssistantView):
) -> web.Response:
"""Start a get request."""
if (player := self.component.get_entity(entity_id)) is None:
status = (
HTTPStatus.NOT_FOUND
if request[KEY_AUTHENTICATED]
else HTTPStatus.UNAUTHORIZED
raise (
web.HTTPNotFound if request[KEY_AUTHENTICATED] else web.HTTPUnauthorized
)
return web.Response(status=status)
assert isinstance(player, MediaPlayerEntity)
authenticated = (
@@ -1285,7 +1282,16 @@ class MediaPlayerImageView(HomeAssistantView):
)
if not authenticated:
return web.Response(status=HTTPStatus.UNAUTHORIZED)
if hdrs.AUTHORIZATION in request.headers:
# A failed request that carried an Authorization header is a real
# Bearer auth attempt — return 401 and let the ban middleware count
# it as a wrong login.
raise web.HTTPUnauthorized
# No Authorization header: most likely a benign signed-URL / query-
# token request whose token has expired (e.g. a browser tab left
# open that re-fetches resources later). Return 403 so it doesn't
# register as a wrong login and ban the user's own IP.
raise web.HTTPForbidden
if media_content_type and media_content_id:
media_image_id = request.query.get("media_image_id")
@@ -14,8 +14,7 @@
},
"user": {
"data": {
"name": "[%key:common::config_flow::data::name%]",
"port": "[%key:common::config_flow::data::port%]"
"device": "[%key:common::config_flow::data::device%]"
},
"description": "This is an integration for landline calls using a CX93001 voice modem. This can retrieve caller ID information with an option to reject an incoming call."
}
@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["opower"],
"quality_scale": "platinum",
"requirements": ["opower==0.18.4"]
"requirements": ["opower==0.18.5"]
}
+2 -1
View File
@@ -11,7 +11,8 @@
"step": {
"confirm": {
"data": {
"code": "Verification code (OTP)"
"code": "Verification code (OTP)",
"qr_code": "QR code"
},
"data_description": {
"code": "The six-digit code currently displayed in your authentication app."
@@ -6,5 +6,5 @@
"iot_class": "local_polling",
"loggers": ["pencompy"],
"quality_scale": "legacy",
"requirements": ["pencompy==0.0.3"]
"requirements": ["pencompy==0.0.4"]
}
+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,
+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
@@ -58,6 +58,38 @@
"user": "Add sensor"
},
"step": {
"reconfigure": {
"data": {
"index": "[%key:component::scrape::config_subentries::entity::step::user::data::index%]",
"select": "[%key:component::scrape::config_subentries::entity::step::user::data::select%]"
},
"data_description": {
"index": "[%key:component::scrape::config_subentries::entity::step::user::data_description::index%]",
"select": "[%key:component::scrape::config_subentries::entity::step::user::data_description::select%]"
},
"sections": {
"advanced": {
"data": {
"attribute": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data::attribute%]",
"availability": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data::availability%]",
"device_class": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data::device_class%]",
"state_class": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data::state_class%]",
"unit_of_measurement": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data::unit_of_measurement%]",
"value_template": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data::value_template%]"
},
"data_description": {
"attribute": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data_description::attribute%]",
"availability": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data_description::availability%]",
"device_class": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data_description::device_class%]",
"state_class": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data_description::state_class%]",
"unit_of_measurement": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data_description::unit_of_measurement%]",
"value_template": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data_description::value_template%]"
},
"description": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::description%]",
"name": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::name%]"
}
}
},
"user": {
"data": {
"index": "Index",
@@ -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
@@ -6,5 +6,5 @@
"iot_class": "cloud_polling",
"loggers": ["pysesame2"],
"quality_scale": "legacy",
"requirements": ["pysesame2==1.0.1"]
"requirements": ["pysesame2==1.0.2"]
}
+1 -1
View File
@@ -13,7 +13,7 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"additional_account": {
"add_account": {
"data": {
"account": "[%key:component::sia::config::step::user::data::account%]",
"additional_account": "[%key:component::sia::config::step::user::data::additional_account%]",
@@ -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:
+1
View File
@@ -29,6 +29,7 @@ VEHICLE_STATUS = "vehicle_status"
API_GEN_1 = "g1"
API_GEN_2 = "g2"
API_GEN_3 = "g3"
API_GEN_4 = "g4"
MANUFACTURER = "Subaru"
PLATFORMS = [
+3 -2
View File
@@ -24,6 +24,7 @@ from . import get_device_info
from .const import (
API_GEN_2,
API_GEN_3,
API_GEN_4,
VEHICLE_API_GEN,
VEHICLE_HAS_EV,
VEHICLE_STATUS,
@@ -153,10 +154,10 @@ def create_vehicle_sensors(
sensor_descriptions_to_add = []
sensor_descriptions_to_add.extend(SAFETY_SENSORS)
if vehicle_info[VEHICLE_API_GEN] in [API_GEN_2, API_GEN_3]:
if vehicle_info[VEHICLE_API_GEN] in [API_GEN_2, API_GEN_3, API_GEN_4]:
sensor_descriptions_to_add.extend(API_GEN_2_SENSORS)
if vehicle_info[VEHICLE_API_GEN] == API_GEN_3:
if vehicle_info[VEHICLE_API_GEN] in [API_GEN_3, API_GEN_4]:
sensor_descriptions_to_add.extend(API_GEN_3_SENSORS)
if vehicle_info[VEHICLE_HAS_EV]:
+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(
@@ -12,6 +12,12 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
}
},
"user": {
"data": {
"email": "[%key:common::config_flow::data::email%]",
@@ -24,6 +24,7 @@ from homeassistant.config_entries import (
)
from homeassistant.const import (
CONF_HOST,
CONF_NAME,
CONF_PASSWORD,
CONF_PORT,
CONF_USERNAME,
@@ -192,7 +193,7 @@ class UnifiFlowHandler(ConfigFlow, domain=DOMAIN):
self.context["title_placeholders"] = {
CONF_HOST: reauth_entry.data[CONF_HOST],
CONF_SITE_ID: reauth_entry.title,
CONF_NAME: reauth_entry.title,
}
self.reauth_schema = {
@@ -231,8 +232,13 @@ class UnifiFlowHandler(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured(updates=self.config, reload_on_update=False)
self.context["title_placeholders"] = {
CONF_HOST: host,
CONF_SITE_ID: DEFAULT_SITE_ID,
CONF_NAME: (
discovery_info.get("name")
or discovery_info.get("hostname")
or discovery_info.get("product_name")
or "UniFi Network"
),
CONF_HOST: source_ip,
}
self.context["configuration_url"] = f"https://{host}"
+1 -1
View File
@@ -11,7 +11,7 @@
"service_unavailable": "[%key:common::config_flow::error::cannot_connect%]",
"unknown_client_mac": "No client available on that MAC address"
},
"flow_title": "{site} ({host})",
"flow_title": "{name} ({host})",
"step": {
"site": {
"data": {
@@ -286,8 +286,9 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN):
form_data[CONF_API_KEY] = user_input[CONF_API_KEY]
placeholders = {
"name": discovery_info["hostname"]
or discovery_info["platform"]
"name": discovery_info.get("name")
or discovery_info.get("hostname")
or discovery_info.get("product_name")
or f"NVR {_async_short_mac(discovery_info['hw_addr'])}",
"ip_address": discovery_info["source_ip"],
}
+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
@@ -22,7 +22,6 @@ from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.MEDIA_PLAYER,
Platform.NUMBER,
Platform.SENSOR,
Platform.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,
),
}
-14
View File
@@ -8,20 +8,6 @@
"default": "mdi:headphones"
}
},
"number": {
"day_mode_brightness": {
"default": "mdi:brightness-7"
},
"day_mode_max_volume": {
"default": "mdi:volume-high"
},
"night_mode_brightness": {
"default": "mdi:brightness-4"
},
"night_mode_max_volume": {
"default": "mdi:volume-high"
}
},
"sensor": {
"card_insertion_state": {
"default": "mdi:card-bulleted-outline",
-134
View File
@@ -1,134 +0,0 @@
"""Number platform for the Yoto integration."""
from collections.abc import Callable
from dataclasses import dataclass
from yoto_api import PlayerConfig, YotoPlayer
from homeassistant.components.number import (
NumberEntity,
NumberEntityDescription,
NumberMode,
)
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
from .entity import YotoConfigEntity
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class YotoNumberEntityDescription(NumberEntityDescription):
"""Describes a Yoto number entity.
``config_field`` is the ``set_player_config`` kwarg written on change.
``available_fn`` hides the entity while the value is managed automatically.
"""
value_fn: Callable[[PlayerConfig], int | None]
config_field: str
available_fn: Callable[[PlayerConfig], bool] = lambda config: True
NUMBERS: tuple[YotoNumberEntityDescription, ...] = (
YotoNumberEntityDescription(
key="day_mode_brightness",
translation_key="day_mode_brightness",
entity_category=EntityCategory.CONFIG,
native_min_value=0,
native_max_value=100,
native_step=1,
native_unit_of_measurement=PERCENTAGE,
mode=NumberMode.SLIDER,
value_fn=lambda config: config.day_display_brightness,
config_field="day_display_brightness",
available_fn=lambda config: not config.day_display_brightness_auto,
),
YotoNumberEntityDescription(
key="night_mode_brightness",
translation_key="night_mode_brightness",
entity_category=EntityCategory.CONFIG,
native_min_value=0,
native_max_value=100,
native_step=1,
native_unit_of_measurement=PERCENTAGE,
mode=NumberMode.SLIDER,
value_fn=lambda config: config.night_display_brightness,
config_field="night_display_brightness",
available_fn=lambda config: not config.night_display_brightness_auto,
),
YotoNumberEntityDescription(
key="day_mode_max_volume",
translation_key="day_mode_max_volume",
entity_category=EntityCategory.CONFIG,
native_min_value=0,
native_max_value=16,
native_step=1,
mode=NumberMode.SLIDER,
value_fn=lambda config: config.day_max_volume_limit,
config_field="day_max_volume_limit",
),
YotoNumberEntityDescription(
key="night_mode_max_volume",
translation_key="night_mode_max_volume",
entity_category=EntityCategory.CONFIG,
native_min_value=0,
native_max_value=16,
native_step=1,
mode=NumberMode.SLIDER,
value_fn=lambda config: config.night_max_volume_limit,
config_field="night_max_volume_limit",
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: YotoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Yoto number platform."""
coordinator = entry.runtime_data
async_add_entities(
YotoNumber(coordinator, player, description)
for player in coordinator.client.players.values()
for description in NUMBERS
)
class YotoNumber(YotoConfigEntity, NumberEntity):
"""Representation of a Yoto player config number."""
entity_description: YotoNumberEntityDescription
def __init__(
self,
coordinator: YotoDataUpdateCoordinator,
player: YotoPlayer,
description: YotoNumberEntityDescription,
) -> None:
"""Initialize the number."""
super().__init__(coordinator, player)
self.entity_description = description
self._attr_unique_id = f"{player.id}_{description.key}"
@property
def available(self) -> bool:
"""Return if the entity is available."""
return super().available and self.entity_description.available_fn(
self.player.info.config
)
@property
def native_value(self) -> float | None:
"""Return the configured value."""
return self.entity_description.value_fn(self.player.info.config)
async def async_set_native_value(self, value: float) -> None:
"""Update the configured value."""
await self._async_set_config(
**{self.entity_description.config_field: int(value)}
)
@@ -45,7 +45,7 @@ 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.
@@ -76,4 +76,4 @@ rules:
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo
strict-typing: done
@@ -45,20 +45,6 @@
"name": "Headphones"
}
},
"number": {
"day_mode_brightness": {
"name": "Day mode brightness"
},
"day_mode_max_volume": {
"name": "Day mode maximum volume"
},
"night_mode_brightness": {
"name": "Night mode brightness"
},
"night_mode_max_volume": {
"name": "Night mode maximum volume"
}
},
"sensor": {
"card_insertion_state": {
"name": "Card slot",
+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"
)
+12
View File
@@ -116,6 +116,14 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
"domain": "eq3btsmart",
"local_name": "CC-RT-BLE-EQ",
},
{
"domain": "eufylife_ble",
"local_name": "eufy T9120",
},
{
"domain": "eufylife_ble",
"local_name": "eufy T9130",
},
{
"domain": "eufylife_ble",
"local_name": "eufy T9140",
@@ -136,6 +144,10 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
"domain": "eufylife_ble",
"local_name": "eufy T9149",
},
{
"domain": "eufylife_ble",
"local_name": "eufy T9150",
},
{
"connectable": True,
"domain": "eurotronic_cometblue",
+61
View File
@@ -0,0 +1,61 @@
{
"numeric_device_classes": [
"absolute_humidity",
"apparent_power",
"aqi",
"area",
"atmospheric_pressure",
"battery",
"blood_glucose_concentration",
"carbon_dioxide",
"carbon_monoxide",
"conductivity",
"current",
"data_rate",
"data_size",
"distance",
"duration",
"energy",
"energy_distance",
"energy_storage",
"frequency",
"gas",
"humidity",
"illuminance",
"irradiance",
"moisture",
"monetary",
"nitrogen_dioxide",
"nitrogen_monoxide",
"nitrous_oxide",
"ozone",
"ph",
"pm1",
"pm10",
"pm25",
"pm4",
"power",
"power_factor",
"precipitation",
"precipitation_intensity",
"pressure",
"reactive_energy",
"reactive_power",
"signal_strength",
"sound_pressure",
"speed",
"sulphur_dioxide",
"temperature",
"temperature_delta",
"volatile_organic_compounds",
"volatile_organic_compounds_parts",
"voltage",
"volume",
"volume_flow_rate",
"volume_storage",
"water",
"weight",
"wind_direction",
"wind_speed"
]
}
+113 -22
View File
@@ -115,6 +115,9 @@ from .trace import (
)
from .typing import UNDEFINED, ConfigType, TemplateVarsType, UndefinedType
if TYPE_CHECKING:
from homeassistant.components.recorder import Recorder
ASYNC_FROM_CONFIG_FORMAT = "async_{}_from_config"
FROM_CONFIG_FORMAT = "{}_from_config"
VALIDATE_CONFIG_FORMAT = "{}_validate_config"
@@ -201,6 +204,7 @@ async def async_setup(hass: HomeAssistant) -> None:
hass.data[CONDITION_DISABLED_CONDITIONS] = set()
hass.data[CONDITION_PLATFORM_SUBSCRIPTIONS] = []
hass.data[CONDITIONS] = {}
hass.data[_DATA_HISTORY_PRIMING_MANAGER] = _HistoryPrimingManager(hass)
async def new_triggers_conditions_listener(
_event_data: labs.EventLabsUpdatedData,
@@ -469,6 +473,84 @@ ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL = vol.Schema(
)
_DATA_HISTORY_PRIMING_MANAGER: HassKey[_HistoryPrimingManager] = HassKey(
"condition_history_priming_manager"
)
class _HistoryPrimingManager:
"""Serialize and coalesce the recorder reads that prime condition durations.
At startup many conditions may prime at once. Letting each hit the recorder
independently would force a separate commit per condition and run every read
on the shared DB executor in parallel a flood. So the reads run one at a
time, and a single commit flush is shared by each "generation" of conditions
that arrive while the previous flush is running.
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, and re-attempts if the flush it rode 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:
"""Initialize the manager."""
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](
self, job: Callable[[Recorder], Coroutine[Any, Any, _T]]
) -> _T:
"""Flush the recorder, then run `job`, coordinated with other primings."""
await self._async_flush()
async with self._query_lock:
return await job(get_instance(self._hass))
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.
"""
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.
if self._flushing:
await self._flush_condition.wait()
while True:
async with self._flush_condition:
if not self._flushing:
# First past the lobby this generation: we run the flush.
self._flushing = True
break
# A peer began a fresh flush after we cleared the lobby; ride it.
await self._flush_condition.wait()
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()
class EntityConditionBase(Condition):
"""Base class for entity conditions."""
@@ -593,7 +675,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.
@@ -668,28 +753,34 @@ class EntityConditionBase(Condition):
assert self._duration is not None
lookback = min(self._duration, MAX_HISTORY_PRIMING_LOOKBACK)
start_time = dt_util.utcnow() - lookback
instance = get_instance(self._hass)
try:
async with asyncio.timeout(HISTORY_PRIMING_TIMEOUT):
# The history query only sees committed rows. Wait for the
# recorder to flush its queue first.
if (commit_future := instance.async_get_commit_future()) is not None:
await commit_future
historical_states = await instance.async_add_executor_job(
ft.partial(
history.get_significant_states,
self._hass,
start_time,
entity_ids=list(anchors),
include_start_time_state=True,
# Mandatory: the default (True) drops attribute-only
# changes for entities outside SIGNIFICANT_DOMAINS, which
# are exactly the transitions attribute-based conditions
# depend on.
significant_changes_only=False,
minimal_response=False,
)
async def _read_history(
instance: Recorder,
) -> dict[str, list[State | dict[str, Any]]]:
# The history query only sees committed rows; the priming manager
# flushes the recorder queue before running this.
return await instance.async_add_executor_job(
ft.partial(
history.get_significant_states,
self._hass,
start_time,
entity_ids=list(anchors),
include_start_time_state=True,
# Mandatory: the default (True) drops attribute-only changes
# for entities outside SIGNIFICANT_DOMAINS, which are exactly
# the transitions attribute-based conditions depend on.
significant_changes_only=False,
minimal_response=False,
)
)
manager = self._hass.data[_DATA_HISTORY_PRIMING_MANAGER]
try:
# The timeout also covers waiting for our turn, so under a flood of
# primings a condition falls back to its conservative anchor rather
# than blocking on the queue indefinitely.
async with asyncio.timeout(HISTORY_PRIMING_TIMEOUT):
historical_states = await manager.async_prime(_read_history)
except (SQLAlchemyError, TimeoutError) as err:
# Best effort: keep the conservative anchors rather than failing.
_LOGGER.debug("Error priming condition durations from history: %s", err)
+2 -24
View File
@@ -312,10 +312,6 @@ def async_track_state_change_event(
Unlike async_track_state_change, async_track_state_change_event
passes the full event to the callback.
The action will not be called immediately, but will be scheduled to run
in the next event loop iteration, even if the action is decorated with
@callback.
In order to avoid having to iterate a long list
of EVENT_STATE_CHANGED and fire and create a job
for each one, we keep a dict of entity ids that
@@ -331,16 +327,6 @@ def async_track_state_change_event(
return _async_track_state_change_event(hass, entity_ids, action, job_type)
@callback
def _async_dispatch_entity_id_event_soon[_StateEventDataT: EventStateEventData](
hass: HomeAssistant,
callbacks: dict[str, list[HassJob[[Event[_StateEventDataT]], Any]]],
event: Event[_StateEventDataT],
) -> None:
"""Dispatch to listeners soon to ensure one event loop runs before dispatch."""
hass.loop.call_soon(_async_dispatch_entity_id_event, hass, callbacks, event)
@callback
def _async_dispatch_entity_id_event[_StateEventDataT: EventStateEventData](
hass: HomeAssistant,
@@ -374,7 +360,7 @@ def _async_state_filter[_StateEventDataT: EventStateEventData](
_KEYED_TRACK_STATE_CHANGE = _KeyedEventTracker(
key=_TRACK_STATE_CHANGE_DATA,
event_type=EVENT_STATE_CHANGED,
dispatcher_callable=_async_dispatch_entity_id_event_soon,
dispatcher_callable=_async_dispatch_entity_id_event,
filter_callable=_async_state_filter,
)
@@ -397,7 +383,7 @@ def _async_track_state_change_event(
_KEYED_TRACK_STATE_REPORT = _KeyedEventTracker(
key=_TRACK_STATE_REPORT_DATA,
event_type=EVENT_STATE_REPORTED,
dispatcher_callable=_async_dispatch_entity_id_event_soon,
dispatcher_callable=_async_dispatch_entity_id_event,
filter_callable=_async_state_filter,
)
@@ -860,10 +846,6 @@ def async_track_state_change_filtered(
) -> _TrackStateChangeFiltered:
"""Track state changes with a TrackStates filter that can be updated.
The action will not be called immediately, but will be scheduled to run
in the next event loop iteration, even if the action is decorated with
@callback.
Args:
hass:
Home assistant object.
@@ -1336,10 +1318,6 @@ def async_track_template_result(
evaluation is different from the previous run, the action is passed
the result.
The action will not be called immediately, but will be scheduled to run
in the next event loop iteration, even if the action is decorated with
@callback.
If the template results in an TemplateError, this will be returned to
the listener the first time this happens but not for subsequent errors.
Once the template returns to a non-error condition the result is sent
+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
+3 -1
View File
@@ -31,7 +31,9 @@ DISCOVERY_INTEGRATIONS: dict[str, Iterable[str]] = {
}
DEPRECATED_PACKAGES: dict[str, tuple[str, str]] = {
# old_package_name: (reason, breaks_in_ha_version)
"pyserial-asyncio": ("should be replaced by pyserial-asyncio-fast", "2026.7"),
"pyserial": ("should be replaced by serialx", "2027.1"),
"pyserial-asyncio": ("should be replaced by serialx", "2027.1"),
"pyserial-asyncio-fast": ("should be replaced by serialx", "2027.1"),
}
_LOGGER = logging.getLogger(__name__)
+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
+12 -12
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
@@ -970,7 +970,7 @@ essent-dynamic-pricing==0.3.1
eternalegypt==0.0.18
# homeassistant.components.eufylife_ble
eufylife-ble-client==0.1.8
eufylife-ble-client==0.1.10
# homeassistant.components.eurotronic_cometblue
eurotronic-cometblue-ha==1.4.0
@@ -982,7 +982,7 @@ eurotronic-cometblue-ha==1.4.0
evohome-async==1.2.0
# homeassistant.components.bryant_evolution
evolutionhttp==0.0.18
evolutionhttp==0.0.19
# homeassistant.components.faa_delays
faadelays==2023.9.1
@@ -1037,7 +1037,7 @@ flux-led==1.2.0
fnv-hash-fast==2.0.3
# homeassistant.components.foobot
foobot_async==1.0.0
foobot_async==1.0.1
# homeassistant.components.forecast_solar
forecast-solar==5.0.1
@@ -1244,7 +1244,7 @@ hdate[astral]==1.2.1
hdfury==1.6.0
# homeassistant.components.heatmiser
heatmiserV3==2.0.4
heatmiserV3==2.0.6
# homeassistant.components.hegel
hegel-ip-client==0.1.4
@@ -1516,7 +1516,7 @@ loqedAPI==2.1.11
luftdaten==0.7.4
# homeassistant.components.lunatone
lunatone-rest-api-client==0.9.1
lunatone-rest-api-client==0.9.2
# homeassistant.components.lupusec
lupupy==0.3.2
@@ -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
@@ -1833,7 +1833,7 @@ peblar==0.5.1
peco==0.1.2
# homeassistant.components.pencom
pencompy==0.0.3
pencompy==0.0.4
# homeassistant.components.escea
pescea==1.0.12
@@ -2027,7 +2027,7 @@ pyanglianwater==3.1.2
pyaprilaire==0.9.1
# homeassistant.components.aqvify
pyaqvify==0.0.10
pyaqvify==0.0.11
# homeassistant.components.atag
pyatag==0.3.5.3
@@ -2184,7 +2184,7 @@ pyfido==2.1.2
pyfirefly==0.1.12
# homeassistant.components.fireservicerota
pyfireservicerota==0.0.46
pyfireservicerota==0.0.49
# homeassistant.components.flic
pyflic==2.0.4
@@ -2238,7 +2238,7 @@ pyhomeworks==1.1.2
pyialarm==2.2.0
# homeassistant.components.icloud
pyicloud==2.4.1
pyicloud==2.6.5
# homeassistant.components.imou
pyimouapi==1.2.8
@@ -2537,7 +2537,7 @@ pysensibo==1.2.1
pysenz==1.0.2
# homeassistant.components.sesame
pysesame2==1.0.1
pysesame2==1.0.2
# homeassistant.components.seventeentrack
pyseventeentrack==1.1.3
+5 -9
View File
@@ -12,11 +12,8 @@ agent will refuse to resolve the new kind.
from .models import CheckKind, CheckRunResult, CheckStatus, PackageChange
MARKER = "<!-- requirements-check -->"
# Hidden marker carrying the PR head commit the checks ran against. The agentic
# stage's gate job parses it from the previous comment to decide whether any
# tracked requirement file changed since then; if not, the agent is skipped.
SHA_MARKER_PREFIX = "<!-- requirements-check-sha:"
HEADER = "## Check requirements"
REPO_URL = "https://github.com/home-assistant/core"
# Column / bullet labels per check kind, in display order.
_CHECK_DISPLAY: tuple[tuple[CheckKind, str], ...] = (
@@ -116,13 +113,12 @@ def _details_block(pkg: PackageChange) -> str:
def _intro(result: CheckRunResult) -> str:
"""Marker(s), header, and the optional visible commit line."""
markers = MARKER
"""Marker, header, and the optional commit line the gate reads back."""
parts: list[str] = []
if result.head_sha:
markers = f"{MARKER}\n{SHA_MARKER_PREFIX} {result.head_sha} -->"
parts.append(f"Checked at commit `{result.head_sha[:7]}`.")
return "\n\n".join([f"{markers}\n{HEADER}", *parts])
commit = f"[`{result.head_sha[:7]}`]({REPO_URL}/commit/{result.head_sha})"
parts.append(f"Checked at commit {commit}.")
return "\n\n".join([f"{MARKER}\n{HEADER}", *parts])
def render_comment(result: CheckRunResult) -> str:
+2
View File
@@ -29,6 +29,7 @@ from . import (
mypy_config,
quality_scale,
requirements,
sensor,
services,
ssdp,
translations,
@@ -69,6 +70,7 @@ HASS_PLUGINS = [
mdi_icons,
mypy_config,
metadata,
sensor,
]
ALL_PLUGIN_NAMES = [
+40
View File
@@ -0,0 +1,40 @@
"""Generate the sensor.json file."""
import json
from homeassistant.components.sensor.const import (
NON_NUMERIC_DEVICE_CLASSES,
SensorDeviceClass,
)
from .model import Config, Integration
PATH = "homeassistant/generated/sensor.json"
def _generate() -> str:
"""Generate the sensor data."""
numeric_device_classes = sorted(
device_class.value
for device_class in set(SensorDeviceClass) - NON_NUMERIC_DEVICE_CLASSES
)
return json.dumps({"numeric_device_classes": numeric_device_classes}, indent=2)
def validate(integrations: dict[str, Integration], config: Config) -> None:
"""Validate sensor.json."""
path = config.root / PATH
config.cache["sensor"] = content = _generate()
if path.read_text() != content + "\n":
config.add_error(
"sensor",
"File sensor.json is not up to date. Run python3 -m script.hassfest",
fixable=True,
)
def generate(integrations: dict[str, Integration], config: Config) -> None:
"""Generate sensor.json."""
path = config.root / PATH
path.write_text(f"{config.cache['sensor']}\n")
@@ -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(
@@ -0,0 +1,14 @@
[
{
"deviceKey": "DeviceKey_1",
"name": "Device 1"
},
{
"deviceKey": "DeviceKey_2",
"name": "Device 2"
},
{
"deviceKey": "DeviceKey_3",
"name": "Device 3"
}
]
@@ -1,3 +1,4 @@
{
"accountId": "test_account_id"
"accountId": "test_account_id",
"name": "Mr Aquarius"
}
@@ -1,6 +1,8 @@
{
"dateTime": "2026-06-04T09:36:06+00:00",
"dateTime": "2026-06-14T09:36:06+00:00",
"waterLevel": -0.136786005,
"meterValue": 0.823213995,
"status": null
"status": null,
"temperature": 12.3,
"volume": 345.0
}
@@ -0,0 +1,3 @@
{
"accountId": "test_account_id"
}
@@ -3,15 +3,19 @@
dict({
'device_data': dict({
'DeviceKey_1': dict({
'dateTime': '2026-06-04T09:36:06+00:00',
'dateTime': '2026-06-14T09:36:06+00:00',
'meterValue': 0.823213995,
'status': None,
'temperature': 12.3,
'volume': 345.0,
'waterLevel': -0.136786005,
}),
'DeviceKey_2': dict({
'dateTime': '2026-06-04T09:36:06+00:00',
'dateTime': '2026-06-14T09:36:06+00:00',
'meterValue': 0.823213995,
'status': None,
'temperature': 12.3,
'volume': 345.0,
'waterLevel': -0.136786005,
}),
}),
@@ -57,6 +57,122 @@
'state': '0.823213995',
})
# ---
# name: test_sensor_snapshot[sensor.device_1_stored_volume-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.device_1_stored_volume',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Stored volume',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.VOLUME_STORAGE: 'volume_storage'>,
'original_icon': None,
'original_name': 'Stored volume',
'platform': 'aqvify',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'test_account_id_DeviceKey_1_volume',
'unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
})
# ---
# name: test_sensor_snapshot[sensor.device_1_stored_volume-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'volume_storage',
'friendly_name': 'Device 1 Stored volume',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
}),
'context': <ANY>,
'entity_id': 'sensor.device_1_stored_volume',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '345.0',
})
# ---
# name: test_sensor_snapshot[sensor.device_1_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.device_1_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Temperature',
'platform': 'aqvify',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'test_account_id_DeviceKey_1_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensor_snapshot[sensor.device_1_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Device 1 Temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.device_1_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '12.3',
})
# ---
# name: test_sensor_snapshot[sensor.device_1_water_level-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -173,6 +289,122 @@
'state': '0.823213995',
})
# ---
# name: test_sensor_snapshot[sensor.device_2_stored_volume-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.device_2_stored_volume',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Stored volume',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.VOLUME_STORAGE: 'volume_storage'>,
'original_icon': None,
'original_name': 'Stored volume',
'platform': 'aqvify',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'test_account_id_DeviceKey_2_volume',
'unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
})
# ---
# name: test_sensor_snapshot[sensor.device_2_stored_volume-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'volume_storage',
'friendly_name': 'Device 2 Stored volume',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
}),
'context': <ANY>,
'entity_id': 'sensor.device_2_stored_volume',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '345.0',
})
# ---
# name: test_sensor_snapshot[sensor.device_2_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.device_2_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Temperature',
'platform': 'aqvify',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'test_account_id_DeviceKey_2_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensor_snapshot[sensor.device_2_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Device 2 Temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.device_2_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '12.3',
})
# ---
# name: test_sensor_snapshot[sensor.device_2_water_level-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
+32 -2
View File
@@ -12,7 +12,7 @@ from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_load_json_object_fixture
async def test_full_flow(
@@ -32,6 +32,36 @@ async def test_full_flow(
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Mr Aquarius"
assert result["data"] == {
CONF_API_KEY: "test-api-key",
}
assert result["result"].unique_id == "test_account_id"
assert len(mock_setup_entry.mock_calls) == 1
async def test_missing_username(
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_aqvify_client: MagicMock
) -> None:
"""Test full flow with missing name in account."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
mock_aqvify_client.async_get_account_id.return_value = AqvifyAccount(
await async_load_json_object_fixture(hass, "empty_name_account.json", DOMAIN)
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_API_KEY: "test-api-key",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Aqvify"
assert result["data"] == {
@@ -88,7 +118,7 @@ async def test_form_invalid(
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Aqvify"
assert result["title"] == "Mr Aquarius"
assert result["data"] == {
CONF_API_KEY: "test-api-key",
}
+29 -2
View File
@@ -84,8 +84,10 @@ async def test_device_registry_integration(
device_registry, mock_config_entry.entry_id
)
# Snapshot the devices to ensure they have the correct structure
assert device_entries == snapshot
sorted_devices = sorted(
device_entries, key=lambda dev_entry: dev_entry.serial_number
)
assert sorted_devices == snapshot
async def test_setup_entry_auth_error_triggers_reauth(
@@ -132,6 +134,31 @@ async def test_autoremove_stale_devices(
assert hass.states.get("sensor.device_2_water_level") is None
async def test_devices_multiple_created_count(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_aqvify_client: MagicMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that added devices are created."""
await setup_integration(hass, mock_config_entry)
assert len(device_registry.devices) == 2
assert hass.states.get("sensor.device_3_water_level") is None
mock_aqvify_client.async_get_devices.return_value = AqvifyDevices(
await async_load_json_array_fixture(hass, "added_devices.json", DOMAIN)
)
freezer.tick(timedelta(seconds=240))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(device_registry.devices) == 3
assert hass.states.get("sensor.device_3_water_level").state == EXPECTED_WATER_LEVEL
@pytest.mark.parametrize(
("exception", "log_message", "expected_state"),
[

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