mirror of
https://github.com/home-assistant/core.git
synced 2026-06-17 01:12:51 +02:00
Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1601b5151c | |||
| da0e23093d | |||
| 7863468a34 | |||
| 4ff5ee0520 | |||
| 6d8e3ab0c9 | |||
| faa3a4ddef | |||
| 9cd7ea97e9 | |||
| 6012ec97b3 | |||
| c58b281eda | |||
| 05001e581a | |||
| 20dbfd19e2 | |||
| 179cb6e385 | |||
| 163fe9f20c | |||
| f7d8bb112f | |||
| c973bd90b2 | |||
| 92e947ac28 | |||
| a514683efa | |||
| 41fe4f4f69 | |||
| e613f2b1e7 | |||
| 5c4f48a069 | |||
| 219455ab4b | |||
| 75815fbc15 | |||
| 33d9249d34 | |||
| 7cefe94467 | |||
| c95ea00479 | |||
| 730b6065ff | |||
| 1589ad2c6a | |||
| d0df0de267 | |||
| aec09fadd4 | |||
| e2d68fcf58 | |||
| 90fe38c0f2 | |||
| 65c2aaf22f | |||
| a691de352c | |||
| 4203781fa5 | |||
| e6b3a97162 | |||
| 6ad8ad5715 | |||
| a45867b896 | |||
| 000e075a8e | |||
| 0899d016b9 | |||
| 3375f2ed76 | |||
| 3f5778e71b | |||
| 86c39694d3 | |||
| a53a6644c0 | |||
| 18fdfacf45 | |||
| bd9bd29f2c | |||
| 334c6614cc | |||
| aa772f6ecd | |||
| 87169921ae | |||
| 16338b8b6b | |||
| 519da3c9c9 | |||
| 6f34718c1f | |||
| e4287bb43c | |||
| d724ebac2a | |||
| dc480051db | |||
| 63b6ced9c4 | |||
| 34e9b3ff1e | |||
| 210746525e | |||
| 0134e99366 | |||
| 06de89d6a3 | |||
| 4c267617f8 | |||
| a82f1a7a1d | |||
| d234f65dd9 | |||
| 30148980e1 | |||
| 1fa9a3353c | |||
| 2dbbd70085 | |||
| 73903b0bfc | |||
| b09f54ce3b | |||
| 6d9e41da07 |
+5
-4
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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"
|
||||
)
|
||||
|
||||
Generated
+12
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+12
-12
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user