Compare commits

...

72 Commits

Author SHA1 Message Date
Erik
ff7c98542d Replace climate-control device with thermostat in climate translations 2026-01-22 08:46:37 +01:00
Joost Lekkerkerker
5bbc39bd88 Add integration_type device to screenlogic (#161324) 2026-01-22 07:56:26 +01:00
Robert Resch
6b14eb7ad1 Migrate config entries to string unique id (#161370) 2026-01-21 23:36:21 -05:00
J. Nick Koston
83a53dea94 Fix SSL context mutation by httpx/httpcore with ALPN protocol bucketing (#161330) 2026-01-21 16:53:38 -10:00
Joost Lekkerkerker
4fb89e68a7 Add integration_type hub to sanix (#161322) 2026-01-21 23:18:32 +01:00
Glenn de Haan
5202ddf095 Bump hdfury to 1.4.2 (#161401) 2026-01-21 23:06:06 +01:00
Marc Mueller
f7d7a4502e Update ruff to 0.14.13 (#161399) 2026-01-21 22:43:26 +01:00
Petro31
c7417d77b5 Update template select test framework (#161389) 2026-01-21 22:31:00 +01:00
Petro31
22018f1f80 Update template number tests to new framework (#161395) 2026-01-21 22:30:13 +01:00
Raphael Hehl
22c6704d81 Fix detection of multiple smart object types in single event (#161189)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-21 22:22:34 +01:00
Raphael Hehl
0552934b3c Bump uiprotect to 10.0.1 (#161397)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-01-21 22:20:33 +01:00
Joost Lekkerkerker
bbe1d28e88 Refactor GitHub tests to patch the library instead (#160568) 2026-01-21 22:09:56 +01:00
Robert Resch
b700a27c8f Enable apple tv on Python 3.14 (#161396) 2026-01-21 21:56:51 +01:00
Joost Lekkerkerker
0566a668a9 Add translation for add entry to RDW (#161329) 2026-01-21 21:28:27 +01:00
Marc Mueller
94f636bc2d Update pyatv to 0.17.0 (#161394) 2026-01-21 21:22:26 +01:00
Manu
a6e7546142 Add support for sequence ID to publish action in ntfy integration (#161342) 2026-01-21 17:41:46 +00:00
Thomas55555
493319894b Use device_class for O3 in Google Air Quality (#161380) 2026-01-21 17:34:46 +01:00
Erik Montnemery
987396722b Adjust entity condition strings (#161055) 2026-01-21 16:56:47 +01:00
epenet
4f52b0363d Reorder unit conversion classes alphabetically (#161364) 2026-01-21 15:53:43 +00:00
Daniel Hjelseth Høyer
52e18ed6f6 Simplify tibber config (#160903) 2026-01-21 15:42:25 +01:00
Abílio Costa
4180175fd3 Improve automation variable name (#161340) 2026-01-21 14:27:18 +00:00
Maciej Bieniek
e39ee8cae7 Bump imgw_pib to 2.0.1 (#161376) 2026-01-21 15:26:29 +01:00
epenet
c214193087 Reorder recorder constants alphabetically (#161363) 2026-01-21 14:07:24 +01:00
Paulus Schoutsen
2d84847be5 Migrate apps added to sidebar to use new app panel (#161265)
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-21 14:59:13 +02:00
Joost Lekkerkerker
0d69fd4535 Add integration_type hub to rainbird (#161303) 2026-01-21 13:42:24 +01:00
Joost Lekkerkerker
56f556864c Add integration_type hub to sensibo (#161326) 2026-01-21 13:41:18 +01:00
Manu
c1b03dc553 Bump aiontfy to 0.7.0 (#161341) 2026-01-21 13:40:56 +01:00
epenet
07e76578e6 Add translation for add entry to Renault (#161361) 2026-01-21 13:40:25 +01:00
Manu
bc45fd4e45 Add translation for add entry to Habitica integration (#161372) 2026-01-21 13:39:56 +01:00
Abílio Costa
0ea03f549c Support target conditions in script relation extraction (#161338) 2026-01-21 12:01:15 +00:00
Robert Resch
0ee46dbf5d Replace deprecated test-results-action action with codecov-action (#159202) 2026-01-21 12:33:32 +01:00
Thomas55555
e12f394f8e Bump google-air-quality-api to 3.0.0 (#161347) 2026-01-21 08:49:20 +01:00
Thomas55555
b40046264d Add ppb as a valid UOM for sensor/number Ozone device class (#159328)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-01-21 08:32:23 +01:00
Thomas55555
22afa1d248 Use device_class for NO2 in Google Air Quality (#161359) 2026-01-21 07:51:35 +01:00
Josef Zweck
8920ffbcdb Add translation for add entry to onedrive (#161336) 2026-01-21 07:06:22 +01:00
Thomas55555
a447c1b42e Use SO2 device_class in Google Air Quality (#161349) 2026-01-21 06:55:02 +01:00
Raphael Hehl
50211f75ed Bump uiprotect to 10.0.0 (#161350) 2026-01-21 00:59:46 +01:00
Thomas55555
27117c9d17 Add ppb as a valid UOM for sensor/number NO2 device class (#159426)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2026-01-20 22:35:11 +00:00
Scott K Logan
7c4cdd57b6 Set integration_type for rainforest_raven to 'hub' (#161343) 2026-01-20 21:46:47 +01:00
Josef Zweck
6af5698645 Bump onedrive-personal-sdk to 0.1.1 (#161337) 2026-01-20 20:14:58 +00:00
Erik Montnemery
75db2cde40 Improve light brightness triggers (#161233) 2026-01-20 20:14:15 +00:00
stegm
329dd05434 Bump pykoplenti to 1.5.0 (#161305) 2026-01-20 21:12:49 +01:00
Joost Lekkerkerker
53c53d03e0 Add integration_type hub to rituals_perfume_genie (#161312) 2026-01-20 21:10:11 +01:00
Joost Lekkerkerker
360b394d03 Add integration_type hub to rfxtrx (#161311) 2026-01-20 21:09:09 +01:00
Joost Lekkerkerker
a663d55632 Add integration_type device to renson (#161310) 2026-01-20 21:07:50 +01:00
Joost Lekkerkerker
3fd266a513 Add integration_type hub to rehlko (#161309) 2026-01-20 21:07:21 +01:00
Joost Lekkerkerker
442c1d6242 Add integration_type hub to refoss (#161308) 2026-01-20 21:06:51 +01:00
Joost Lekkerkerker
0e2aae02f6 Add integration_type device to rapt_ble (#161307) 2026-01-20 21:04:45 +01:00
Joost Lekkerkerker
3227a6e49f Add integration_type device to rainforest_raven (#161306) 2026-01-20 21:04:10 +01:00
Joost Lekkerkerker
9d0cfb628b Add integration_type device to radiotherm (#161302) 2026-01-20 21:00:50 +01:00
Joost Lekkerkerker
4578fe0260 Add integration_type device to rabbitair (#161300) 2026-01-20 20:55:41 +01:00
Joost Lekkerkerker
0d92708108 Add integration_type device to qnap_qsw (#161299) 2026-01-20 20:54:58 +01:00
Joost Lekkerkerker
cceb50071b Add integration_type device to ruuvitag_ble (#161319) 2026-01-20 20:53:14 +01:00
Joost Lekkerkerker
62f296c9dd Add integration_type device to ruuvi_gateway (#161318) 2026-01-20 20:52:32 +01:00
Joost Lekkerkerker
ea1f280494 Add integration_type hub to russound_rio (#161317) 2026-01-20 20:48:32 +01:00
Joost Lekkerkerker
67108a2fc8 Add integration_type service to rova (#161316) 2026-01-20 20:47:36 +01:00
Joost Lekkerkerker
1ccbd5124e Add integration_type hub to roon (#161315) 2026-01-20 20:47:07 +01:00
Joost Lekkerkerker
818af90a7b Add integration_type device to roomba (#161314) 2026-01-20 20:45:35 +01:00
Joost Lekkerkerker
23bc78fa25 Add integration_type device to romy (#161313) 2026-01-20 20:44:38 +01:00
Josef Zweck
0b1cc7638f Enable smart chunk size in onedrive (#161170) 2026-01-20 20:41:48 +01:00
Joost Lekkerkerker
c291a2fbc1 Add translation for add entry to NYT Games (#161327) 2026-01-20 20:35:50 +01:00
Joost Lekkerkerker
7379a4ff4b Add integration_type hub to sense (#161325) 2026-01-20 20:33:18 +01:00
Joost Lekkerkerker
ddcf5cb749 Add integration_type hub to schlage (#161323) 2026-01-20 20:29:23 +01:00
Joost Lekkerkerker
4b10a542b0 Add integration_type hub to rympro (#161320) 2026-01-20 20:27:35 +01:00
Joost Lekkerkerker
beea9fa74b Add integration_type service to sabnzbd (#161321) 2026-01-20 20:20:54 +01:00
Joost Lekkerkerker
ce8fd16456 Add translation for add entry to Twitch (#161332) 2026-01-20 20:11:20 +01:00
Joost Lekkerkerker
2172d15489 Add translation for add entry to SmartThings (#161331) 2026-01-20 20:10:47 +01:00
Joost Lekkerkerker
0cfa0ed670 Add translation for add entry to Withings (#161333) 2026-01-20 20:07:54 +01:00
Joost Lekkerkerker
f6839913d8 Add translation for add entry to Youtube (#161334) 2026-01-20 20:07:33 +01:00
Manu
8fa01497ee Add translation for add entry to Xbox integration (#161296) 2026-01-20 19:14:49 +01:00
Abílio Costa
e077c65a77 Support target conditions in automation relation extraction (#161016) 2026-01-20 17:34:21 +00:00
Manu
7c49656fa8 Add translation for add entry to PlayStation Network integration (#161298) 2026-01-20 18:29:23 +01:00
150 changed files with 2860 additions and 1879 deletions

View File

@@ -1187,6 +1187,8 @@ jobs:
- pytest-postgres
- pytest-mariadb
timeout-minutes: 10
permissions:
id-token: write
# codecov/test-results-action currently doesn't support tokenless uploads
# therefore we can't run it on forks
if: |
@@ -1198,8 +1200,9 @@ jobs:
with:
pattern: test-results-*
- name: Upload test results to Codecov
uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
report_type: test_results
fail_ci_if_error: true
verbose: true
token: ${{ secrets.CODECOV_TOKEN }}
use_oidc: true

View File

@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.13.0
rev: v0.14.13
hooks:
- id: ruff-check
args:

View File

@@ -14,7 +14,7 @@
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is armed"
"name": "Alarm is armed"
},
"is_armed_away": {
"description": "Tests if one or more alarms are armed in away mode.",
@@ -24,7 +24,7 @@
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is armed away"
"name": "Alarm is armed away"
},
"is_armed_home": {
"description": "Tests if one or more alarms are armed in home mode.",
@@ -34,7 +34,7 @@
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is armed home"
"name": "Alarm is armed home"
},
"is_armed_night": {
"description": "Tests if one or more alarms are armed in night mode.",
@@ -44,7 +44,7 @@
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is armed night"
"name": "Alarm is armed night"
},
"is_armed_vacation": {
"description": "Tests if one or more alarms are armed in vacation mode.",
@@ -54,7 +54,7 @@
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is armed vacation"
"name": "Alarm is armed vacation"
},
"is_disarmed": {
"description": "Tests if one or more alarms are disarmed.",
@@ -64,7 +64,7 @@
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is disarmed"
"name": "Alarm is disarmed"
},
"is_triggered": {
"description": "Tests if one or more alarms are triggered.",
@@ -74,7 +74,7 @@
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is triggered"
"name": "Alarm is triggered"
}
},
"device_automation": {

View File

@@ -5,9 +5,14 @@ from __future__ import annotations
import asyncio
import logging
from random import randrange
import sys
from typing import Any, cast
from pyatv import connect, exceptions, scan
from pyatv.conf import AppleTV
from pyatv.const import DeviceModel, Protocol
from pyatv.convert import model_str
from pyatv.interface import AppleTV as AppleTVInterface, DeviceListener
from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -24,11 +29,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
)
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send
@@ -42,18 +43,6 @@ from .const import (
SIGNAL_DISCONNECTED,
)
if sys.version_info < (3, 14):
from pyatv import connect, exceptions, scan
from pyatv.conf import AppleTV
from pyatv.const import DeviceModel, Protocol
from pyatv.convert import model_str
from pyatv.interface import AppleTV as AppleTVInterface, DeviceListener
else:
class DeviceListener:
"""Dummy class."""
_LOGGER = logging.getLogger(__name__)
DEFAULT_NAME_TV = "Apple TV"
@@ -64,30 +53,25 @@ BACKOFF_TIME_UPPER_LIMIT = 300 # Five minutes
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE]
if sys.version_info < (3, 14):
AUTH_EXCEPTIONS = (
exceptions.AuthenticationError,
exceptions.InvalidCredentialsError,
exceptions.NoCredentialsError,
)
CONNECTION_TIMEOUT_EXCEPTIONS = (
OSError,
asyncio.CancelledError,
TimeoutError,
exceptions.ConnectionLostError,
exceptions.ConnectionFailedError,
)
DEVICE_EXCEPTIONS = (
exceptions.ProtocolError,
exceptions.NoServiceError,
exceptions.PairingError,
exceptions.BackOffError,
exceptions.DeviceIdMissingError,
)
else:
AUTH_EXCEPTIONS = ()
CONNECTION_TIMEOUT_EXCEPTIONS = ()
DEVICE_EXCEPTIONS = ()
AUTH_EXCEPTIONS = (
exceptions.AuthenticationError,
exceptions.InvalidCredentialsError,
exceptions.NoCredentialsError,
)
CONNECTION_TIMEOUT_EXCEPTIONS = (
OSError,
asyncio.CancelledError,
TimeoutError,
exceptions.ConnectionLostError,
exceptions.ConnectionFailedError,
)
DEVICE_EXCEPTIONS = (
exceptions.ProtocolError,
exceptions.NoServiceError,
exceptions.PairingError,
exceptions.BackOffError,
exceptions.DeviceIdMissingError,
)
type AppleTvConfigEntry = ConfigEntry[AppleTVManager]
@@ -95,10 +79,6 @@ type AppleTvConfigEntry = ConfigEntry[AppleTVManager]
async def async_setup_entry(hass: HomeAssistant, entry: AppleTvConfigEntry) -> bool:
"""Set up a config entry for Apple TV."""
if sys.version_info >= (3, 14):
raise HomeAssistantError(
"Apple TV is not supported on Python 3.14. Please use Python 3.13."
)
manager = AppleTVManager(hass, entry)
if manager.is_on:

View File

@@ -8,7 +8,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["pyatv", "srptools"],
"requirements": ["pyatv==0.16.1;python_version<'3.14'"],
"requirements": ["pyatv==0.17.0"],
"zeroconf": [
"_mediaremotetv._tcp.local.",
"_companion-link._tcp.local.",

View File

@@ -239,6 +239,15 @@ class AppleTvMediaPlayer(
"""
self.async_write_ha_state()
@callback
def volume_device_update(
self, output_device: OutputDevice, old_level: float, new_level: float
) -> None:
"""Output device volume was updated.
This is a callback function from pyatv.interface.AudioListener.
"""
@callback
def outputdevices_update(
self, old_devices: list[OutputDevice], new_devices: list[OutputDevice]

View File

@@ -2,14 +2,35 @@
from __future__ import annotations
import logging
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import ArveConfigEntry, ArveCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_migrate_entry(hass: HomeAssistant, entry: ArveConfigEntry) -> bool:
"""Migrate entry."""
_LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version)
if entry.version == 1:
# 1 -> 1.2: Unique ID from integer to string
if entry.minor_version == 1:
minor_version = 2
hass.config_entries.async_update_entry(
entry, unique_id=str(entry.unique_id), minor_version=minor_version
)
_LOGGER.debug("Migration successful")
return True
async def async_setup_entry(hass: HomeAssistant, entry: ArveConfigEntry) -> bool:
"""Set up Arve from a config entry."""

View File

@@ -19,6 +19,9 @@ _LOGGER = logging.getLogger(__name__)
class ArveConfigFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Arve."""
VERSION = 1
MINOR_VERSION = 2
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -35,7 +38,7 @@ class ArveConfigFlowHandler(ConfigFlow, domain=DOMAIN):
except ArveConnectionError:
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(customer.customerId)
await self.async_set_unique_id(str(customer.customerId))
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="Arve",

View File

@@ -14,7 +14,7 @@
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
"name": "If a satellite is idle"
"name": "Satellite is idle"
},
"is_listening": {
"description": "Tests if one or more Assist satellites are listening.",
@@ -24,7 +24,7 @@
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
"name": "If a satellite is listening"
"name": "Satellite is listening"
},
"is_processing": {
"description": "Tests if one or more Assist satellites are processing.",
@@ -34,7 +34,7 @@
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
"name": "If a satellite is processing"
"name": "Satellite is processing"
},
"is_responding": {
"description": "Tests if one or more Assist satellites are responding.",
@@ -44,7 +44,7 @@
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
"name": "If a satellite is responding"
"name": "Satellite is responding"
}
},
"entity_component": {

View File

@@ -56,7 +56,7 @@ from homeassistant.core import (
valid_entity_id,
)
from homeassistant.exceptions import HomeAssistantError, ServiceNotFound, TemplateError
from homeassistant.helpers import condition, config_validation as cv
from homeassistant.helpers import condition as condition_helper, config_validation as cv
from homeassistant.helpers.entity import ToggleEntity
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.issue_registry import (
@@ -554,7 +554,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
automation_id: str | None,
name: str,
trigger_config: list[ConfigType],
cond_func: IfAction | None,
condition: IfAction | None,
action_script: Script,
initial_state: bool | None,
variables: ScriptVariables | None,
@@ -567,7 +567,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
self._attr_name = name
self._trigger_config = trigger_config
self._async_detach_triggers: CALLBACK_TYPE | None = None
self._cond_func = cond_func
self._condition = condition
self.action_script = action_script
self.action_script.change_listener = self.async_write_ha_state
self._initial_state = initial_state
@@ -602,6 +602,12 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
"""Return a set of referenced labels."""
referenced = self.action_script.referenced_labels
if self._condition is not None:
for conf in self._condition.config:
referenced |= condition_helper.async_extract_targets(
conf, ATTR_LABEL_ID
)
for conf in self._trigger_config:
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_LABEL_ID))
return referenced
@@ -611,6 +617,12 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
"""Return a set of referenced floors."""
referenced = self.action_script.referenced_floors
if self._condition is not None:
for conf in self._condition.config:
referenced |= condition_helper.async_extract_targets(
conf, ATTR_FLOOR_ID
)
for conf in self._trigger_config:
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_FLOOR_ID))
return referenced
@@ -620,6 +632,10 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
"""Return a set of referenced areas."""
referenced = self.action_script.referenced_areas
if self._condition is not None:
for conf in self._condition.config:
referenced |= condition_helper.async_extract_targets(conf, ATTR_AREA_ID)
for conf in self._trigger_config:
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_AREA_ID))
return referenced
@@ -636,9 +652,9 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
"""Return a set of referenced devices."""
referenced = self.action_script.referenced_devices
if self._cond_func is not None:
for conf in self._cond_func.config:
referenced |= condition.async_extract_devices(conf)
if self._condition is not None:
for conf in self._condition.config:
referenced |= condition_helper.async_extract_devices(conf)
for conf in self._trigger_config:
referenced |= set(_trigger_extract_devices(conf))
@@ -650,9 +666,9 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
"""Return a set of referenced entities."""
referenced = self.action_script.referenced_entities
if self._cond_func is not None:
for conf in self._cond_func.config:
referenced |= condition.async_extract_entities(conf)
if self._condition is not None:
for conf in self._condition.config:
referenced |= condition_helper.async_extract_entities(conf)
for conf in self._trigger_config:
for entity_id in _trigger_extract_entities(conf):
@@ -772,8 +788,8 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
if (
not skip_condition
and self._cond_func is not None
and not self._cond_func(variables)
and self._condition is not None
and not self._condition(variables)
):
self._logger.debug(
"Conditions not met, aborting automation. Condition summary: %s",
@@ -1035,12 +1051,12 @@ async def _create_automation_entities(
)
if CONF_CONDITIONS in config_block:
cond_func = await _async_process_if(hass, name, config_block)
condition = await _async_process_if(hass, name, config_block)
if cond_func is None:
if condition is None:
continue
else:
cond_func = None
condition = None
# Add trigger variables to variables
variables = None
@@ -1058,7 +1074,7 @@ async def _create_automation_entities(
automation_id,
name,
config_block[CONF_TRIGGERS],
cond_func,
condition,
action_script,
initial_state,
variables,
@@ -1200,7 +1216,7 @@ async def _async_process_if(
if_configs = config[CONF_CONDITIONS]
try:
if_action = await condition.async_conditions_from_config(
if_action = await condition_helper.async_conditions_from_config(
hass, if_configs, LOGGER, name
)
except HomeAssistantError as ex:

View File

@@ -1,6 +1,6 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted climates to trigger on.",
"trigger_behavior_description": "The behavior of the targeted thermostats to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
@@ -298,22 +298,22 @@
"name": "Set target temperature"
},
"toggle": {
"description": "Toggles climate device, from on to off, or off to on.",
"description": "Toggles thermostat, from on to off, or off to on.",
"name": "[%key:common::action::toggle%]"
},
"turn_off": {
"description": "Turns climate device off.",
"description": "Turns thermostat off.",
"name": "[%key:common::action::turn_off%]"
},
"turn_on": {
"description": "Turns climate device on.",
"description": "Turns thermostat on.",
"name": "[%key:common::action::turn_on%]"
}
},
"title": "Climate",
"triggers": {
"current_humidity_changed": {
"description": "Triggers after the humidity measured by one or more climate-control devices changes.",
"description": "Triggers after the humidity measured by one or more thermostats changes.",
"fields": {
"above": {
"description": "Trigger when the humidity is above this value.",
@@ -324,10 +324,10 @@
"name": "Below"
}
},
"name": "Climate-control device current humidity changed"
"name": "Thermostat current humidity changed"
},
"current_humidity_crossed_threshold": {
"description": "Triggers after the humidity measured by one or more climate-control devices crosses a threshold.",
"description": "Triggers after the humidity measured by one or more thermostats crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
@@ -346,10 +346,10 @@
"name": "Upper threshold"
}
},
"name": "Climate-control device current humidity crossed threshold"
"name": "Thermostat current humidity crossed threshold"
},
"current_temperature_changed": {
"description": "Triggers after the temperature measured by one or more climate-control devices changes.",
"description": "Triggers after the temperature measured by one or more thermostats changes.",
"fields": {
"above": {
"description": "Trigger when the temperature is above this value.",
@@ -360,10 +360,10 @@
"name": "Below"
}
},
"name": "Climate-control device current temperature changed"
"name": "Thermostat current temperature changed"
},
"current_temperature_crossed_threshold": {
"description": "Triggers after the temperature measured by one or more climate-control devices crosses a threshold.",
"description": "Triggers after the temperature measured by one or more thermostats crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
@@ -382,10 +382,10 @@
"name": "Upper threshold"
}
},
"name": "Climate-control device current temperature crossed threshold"
"name": "Thermostat current temperature crossed threshold"
},
"hvac_mode_changed": {
"description": "Triggers after the mode of one or more climate-control devices changes.",
"description": "Triggers after the mode of one or more thermostats changes.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
@@ -396,40 +396,40 @@
"name": "Modes"
}
},
"name": "Climate-control device mode changed"
"name": "Thermostat mode changed"
},
"started_cooling": {
"description": "Triggers after one or more climate-control devices start cooling.",
"description": "Triggers after one or more thermostats start cooling.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
}
},
"name": "Climate-control device started cooling"
"name": "Thermostat started cooling"
},
"started_drying": {
"description": "Triggers after one or more climate-control devices start drying.",
"description": "Triggers after one or more thermostats start drying.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
}
},
"name": "Climate-control device started drying"
"name": "Thermostat started drying"
},
"started_heating": {
"description": "Triggers after one or more climate-control devices start heating.",
"description": "Triggers after one or more thermostats start heating.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
}
},
"name": "Climate-control device started heating"
"name": "Thermostat started heating"
},
"target_humidity_changed": {
"description": "Triggers after the humidity setpoint of one or more climate-control devices changes.",
"description": "Triggers after the humidity setpoint of one or more thermostats changes.",
"fields": {
"above": {
"description": "Trigger when the target humidity is above this value.",
@@ -440,10 +440,10 @@
"name": "Below"
}
},
"name": "Climate-control device target humidity changed"
"name": "Thermostat target humidity changed"
},
"target_humidity_crossed_threshold": {
"description": "Triggers after the humidity setpoint of one or more climate-control devices crosses a threshold.",
"description": "Triggers after the humidity setpoint of one or more thermostats crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
@@ -462,10 +462,10 @@
"name": "Upper threshold"
}
},
"name": "Climate-control device target humidity crossed threshold"
"name": "Thermostat target humidity crossed threshold"
},
"target_temperature_changed": {
"description": "Triggers after the temperature setpoint of one or more climate-control devices changes.",
"description": "Triggers after the temperature setpoint of one or more thermostats changes.",
"fields": {
"above": {
"description": "Trigger when the target temperature is above this value.",
@@ -476,10 +476,10 @@
"name": "Below"
}
},
"name": "Climate-control device target temperature changed"
"name": "Thermostat target temperature changed"
},
"target_temperature_crossed_threshold": {
"description": "Triggers after the temperature setpoint of one or more climate-control devices crosses a threshold.",
"description": "Triggers after the temperature setpoint of one or more thermostats crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
@@ -498,27 +498,27 @@
"name": "Upper threshold"
}
},
"name": "Climate-control device target temperature crossed threshold"
"name": "Thermostat target temperature crossed threshold"
},
"turned_off": {
"description": "Triggers after one or more climate-control devices turn off.",
"description": "Triggers after one or more thermostats turn off.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
}
},
"name": "Climate-control device turned off"
"name": "Thermostat turned off"
},
"turned_on": {
"description": "Triggers after one or more climate-control devices turn on, regardless of the mode.",
"description": "Triggers after one or more thermostats turn on, regardless of the mode.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
}
},
"name": "Climate-control device turned on"
"name": "Thermostat turned on"
}
}
}

View File

@@ -14,7 +14,7 @@
"name": "[%key:component::fan::common::condition_behavior_name%]"
}
},
"name": "If a fan is off"
"name": "Fan is off"
},
"is_on": {
"description": "Tests if one or more fans are on.",
@@ -24,7 +24,7 @@
"name": "[%key:component::fan::common::condition_behavior_name%]"
}
},
"name": "If a fan is on"
"name": "Fan is on"
}
},
"device_automation": {

View File

@@ -7,20 +7,11 @@
"benzene": {
"default": "mdi:molecule"
},
"nitrogen_dioxide": {
"default": "mdi:molecule"
},
"nitrogen_monoxide": {
"default": "mdi:molecule"
},
"non_methane_hydrocarbons": {
"default": "mdi:molecule"
},
"ozone": {
"default": "mdi:molecule"
},
"sulphur_dioxide": {
"default": "mdi:molecule"
}
}
}

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["google_air_quality_api"],
"quality_scale": "bronze",
"requirements": ["google_air_quality_api==2.1.2"]
"requirements": ["google_air_quality_api==3.0.0"]
}

View File

@@ -13,7 +13,11 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
CONF_LATITUDE,
CONF_LONGITUDE,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -114,6 +118,7 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
native_unit_of_measurement_fn=lambda x: x.pollutants.co.concentration.units,
exists_fn=lambda x: "co" in {p.code for p in x.pollutants},
value_fn=lambda x: x.pollutants.co.concentration.value,
suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
),
AirQualitySensorEntityDescription(
key="nh3",
@@ -141,16 +146,16 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
),
AirQualitySensorEntityDescription(
key="no2",
translation_key="nitrogen_dioxide",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.NITROGEN_DIOXIDE,
native_unit_of_measurement_fn=lambda x: x.pollutants.no2.concentration.units,
exists_fn=lambda x: "no2" in {p.code for p in x.pollutants},
value_fn=lambda x: x.pollutants.no2.concentration.value,
),
AirQualitySensorEntityDescription(
key="o3",
translation_key="ozone",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.OZONE,
native_unit_of_measurement_fn=lambda x: x.pollutants.o3.concentration.units,
exists_fn=lambda x: "o3" in {p.code for p in x.pollutants},
value_fn=lambda x: x.pollutants.o3.concentration.value,
@@ -173,8 +178,8 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
),
AirQualitySensorEntityDescription(
key="so2",
translation_key="sulphur_dioxide",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.SULPHUR_DIOXIDE,
native_unit_of_measurement_fn=lambda x: x.pollutants.so2.concentration.units,
exists_fn=lambda x: "so2" in {p.code for p in x.pollutants},
value_fn=lambda x: x.pollutants.so2.concentration.value,

View File

@@ -205,21 +205,12 @@
"so2": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]"
}
},
"nitrogen_dioxide": {
"name": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]"
},
"nitrogen_monoxide": {
"name": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]"
},
"non_methane_hydrocarbons": {
"name": "Non-methane hydrocarbons"
},
"ozone": {
"name": "[%key:component::sensor::entity_component::ozone::name%]"
},
"sulphur_dioxide": {
"name": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]"
},
"uaqi": {
"name": "Universal Air Quality Index"
},

View File

@@ -83,6 +83,9 @@
"invalid_credentials": "Input is incomplete. You must provide either your login details or an API token",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"initiate_flow": {
"user": "[%key:common::config_flow::initiate_flow::account%]"
},
"step": {
"advanced": {
"data": {

View File

@@ -6,7 +6,7 @@ from typing import Any
from aiohttp import web
from homeassistant.components import frontend, panel_custom
from homeassistant.components import frontend
from homeassistant.components.http import HomeAssistantView
from homeassistant.const import ATTR_ICON
from homeassistant.core import HomeAssistant
@@ -33,7 +33,7 @@ async def async_setup_addon_panel(hass: HomeAssistant, hassio: HassIO) -> None:
# _register_panel never suspends and is only
# a coroutine because it would be a breaking change
# to make it a normal function
await _register_panel(hass, addon, data)
_register_panel(hass, addon, data)
class HassIOAddonPanel(HomeAssistantView):
@@ -58,7 +58,7 @@ class HassIOAddonPanel(HomeAssistantView):
data = panels[addon]
# Register panel
await _register_panel(self.hass, addon, data)
_register_panel(self.hass, addon, data)
return web.Response()
async def delete(self, request: web.Request, addon: str) -> web.Response:
@@ -76,18 +76,14 @@ class HassIOAddonPanel(HomeAssistantView):
return {}
async def _register_panel(
hass: HomeAssistant, addon: str, data: dict[str, Any]
) -> None:
def _register_panel(hass: HomeAssistant, addon: str, data: dict[str, Any]):
"""Init coroutine to register the panel."""
await panel_custom.async_register_panel(
frontend.async_register_built_in_panel(
hass,
"app",
frontend_url_path=addon,
webcomponent_name="hassio-main",
sidebar_title=data[ATTR_TITLE],
sidebar_icon=data[ATTR_ICON],
js_url="/api/hassio/app/entrypoint.js",
embed_iframe=True,
require_admin=data[ATTR_ADMIN],
config={"ingress": addon},
config={"addon": addon},
)

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["hdfury==1.3.1"]
"requirements": ["hdfury==1.4.2"]
}

View File

@@ -28,6 +28,7 @@ from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2
from .const import DOMAIN, UPDATE_INTERVAL
from .entity import AqualinkEntity
@@ -66,7 +67,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) ->
username = entry.data[CONF_USERNAME]
password = entry.data[CONF_PASSWORD]
aqualink = AqualinkClient(username, password, httpx_client=get_async_client(hass))
aqualink = AqualinkClient(
username,
password,
httpx_client=get_async_client(hass, alpn_protocols=SSL_ALPN_HTTP11_HTTP2),
)
try:
await aqualink.login()
except AqualinkServiceException as login_exception:

View File

@@ -15,6 +15,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2
from .const import DOMAIN
@@ -36,7 +37,11 @@ class AqualinkFlowHandler(ConfigFlow, domain=DOMAIN):
try:
async with AqualinkClient(
username, password, httpx_client=get_async_client(self.hass)
username,
password,
httpx_client=get_async_client(
self.hass, alpn_protocols=SSL_ALPN_HTTP11_HTTP2
),
):
pass
except AqualinkServiceUnauthorizedException:

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"requirements": ["imgw_pib==1.6.0"]
"requirements": ["imgw_pib==2.0.1"]
}

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["kostal"],
"requirements": ["pykoplenti==1.3.0"]
"requirements": ["pykoplenti==1.5.0"]
}

View File

@@ -49,7 +49,7 @@
"name": "[%key:component::light::common::condition_behavior_name%]"
}
},
"name": "If a light is off"
"name": "Light is off"
},
"is_on": {
"description": "Tests if one or more lights are on.",
@@ -59,7 +59,7 @@
"name": "[%key:component::light::common::condition_behavior_name%]"
}
},
"name": "If a light is on"
"name": "Light is on"
}
},
"device_automation": {

View File

@@ -1,24 +1,47 @@
"""Provides triggers for lights."""
from typing import Any
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import (
EntityNumericalStateAttributeChangedTriggerBase,
EntityNumericalStateAttributeCrossedThresholdTriggerBase,
Trigger,
make_entity_numerical_state_attribute_changed_trigger,
make_entity_numerical_state_attribute_crossed_threshold_trigger,
make_entity_target_state_trigger,
)
from . import ATTR_BRIGHTNESS
from .const import DOMAIN
def _convert_uint8_to_percentage(value: Any) -> float:
"""Convert a uint8 value (0-255) to a percentage (0-100)."""
return (float(value) / 255.0) * 100.0
class BrightnessChangedTrigger(EntityNumericalStateAttributeChangedTriggerBase):
"""Trigger for brightness changed."""
_domain = DOMAIN
_attribute = ATTR_BRIGHTNESS
_converter = staticmethod(_convert_uint8_to_percentage)
class BrightnessCrossedThresholdTrigger(
EntityNumericalStateAttributeCrossedThresholdTriggerBase
):
"""Trigger for brightness crossed threshold."""
_domain = DOMAIN
_attribute = ATTR_BRIGHTNESS
_converter = staticmethod(_convert_uint8_to_percentage)
TRIGGERS: dict[str, type[Trigger]] = {
"brightness_changed": make_entity_numerical_state_attribute_changed_trigger(
DOMAIN, ATTR_BRIGHTNESS
),
"brightness_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
DOMAIN, ATTR_BRIGHTNESS
),
"brightness_changed": BrightnessChangedTrigger,
"brightness_crossed_threshold": BrightnessCrossedThresholdTrigger,
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
}

View File

@@ -22,7 +22,10 @@
number:
selector:
number:
max: 100
min: 0
mode: box
unit_of_measurement: "%"
entity:
selector:
entity:

View File

@@ -2,6 +2,7 @@
from dataclasses import dataclass
from http import HTTPStatus
import logging
import aiohttp
from microBeesPy import MicroBees
@@ -15,6 +16,8 @@ from homeassistant.helpers import config_entry_oauth2_flow
from .const import DOMAIN, PLATFORMS
from .coordinator import MicroBeesUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True)
class HomeAssistantMicroBeesData:
@@ -25,6 +28,23 @@ class HomeAssistantMicroBeesData:
session: config_entry_oauth2_flow.OAuth2Session
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate entry."""
_LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version)
if entry.version == 1:
# 1 -> 1.2: Unique ID from integer to string
if entry.minor_version == 1:
minor_version = 2
hass.config_entries.async_update_entry(
entry, unique_id=str(entry.unique_id), minor_version=minor_version
)
_LOGGER.debug("Migration successful")
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up microBees from a config entry."""
implementation = (

View File

@@ -19,6 +19,8 @@ class OAuth2FlowHandler(
"""Handle a config flow for microBees."""
DOMAIN = DOMAIN
VERSION = 1
MINOR_VERSION = 2
@property
def logger(self) -> logging.Logger:
@@ -47,7 +49,7 @@ class OAuth2FlowHandler(
self.logger.exception("Unexpected error")
return self.async_abort(reason="unknown")
await self.async_set_unique_id(current_user.id)
await self.async_set_unique_id(str(current_user.id))
if self.source != SOURCE_REAUTH:
self._abort_if_unique_id_configured()
return self.async_create_entry(

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
@@ -15,9 +17,28 @@ from .api import AuthenticatedMonzoAPI
from .const import DOMAIN
from .coordinator import MonzoCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate entry."""
_LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version)
if entry.version == 1:
# 1 -> 1.2: Unique ID from integer to string
if entry.minor_version == 1:
minor_version = 2
hass.config_entries.async_update_entry(
entry, unique_id=str(entry.unique_id), minor_version=minor_version
)
_LOGGER.debug("Migration successful")
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Monzo from a config entry."""
implementation = await async_get_config_entry_implementation(hass, entry)

View File

@@ -21,6 +21,8 @@ class MonzoFlowHandler(
"""Handle a config flow."""
DOMAIN = DOMAIN
VERSION = 1
MINOR_VERSION = 2
oauth_data: dict[str, Any]
@@ -51,7 +53,7 @@ class MonzoFlowHandler(
"""Create an entry for the flow."""
self.oauth_data = data
user_id = data[CONF_TOKEN]["user_id"]
await self.async_set_unique_id(user_id)
await self.async_set_unique_id(str(user_id))
if self.source != SOURCE_REAUTH:
self._abort_if_unique_id_configured()
else:

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["aionfty"],
"quality_scale": "platinum",
"requirements": ["aiontfy==0.6.1"]
"requirements": ["aiontfy==0.7.0"]
}

View File

@@ -43,6 +43,7 @@ ATTR_ICON = "icon"
ATTR_MARKDOWN = "markdown"
ATTR_PRIORITY = "priority"
ATTR_TAGS = "tags"
ATTR_SEQUENCE_ID = "sequence_id"
SERVICE_PUBLISH_SCHEMA = cv.make_entity_service_schema(
{
@@ -60,6 +61,7 @@ SERVICE_PUBLISH_SCHEMA = cv.make_entity_service_schema(
vol.Optional(ATTR_EMAIL): vol.Email(),
vol.Optional(ATTR_CALL): cv.string,
vol.Optional(ATTR_ICON): vol.All(vol.Url(), vol.Coerce(URL)),
vol.Optional(ATTR_SEQUENCE_ID): cv.string,
}
)

View File

@@ -88,3 +88,8 @@ publish:
type: url
autocomplete: url
example: https://example.org/logo.png
sequence_id:
required: false
selector:
text:
example: "Mc3otamDNcpJ"

View File

@@ -1,6 +1,7 @@
{
"common": {
"add_topic_description": "Set up a topic for notifications.",
"sequence_id": "Sequence ID",
"topic": "Topic"
},
"config": {
@@ -171,6 +172,9 @@
"icon": { "name": "Icon" },
"message": { "name": "Message" },
"priority": { "name": "Priority" },
"sequence_id": {
"name": "[%key:component::ntfy::common::sequence_id%]"
},
"tags": { "name": "Tags" },
"time": { "name": "Time" },
"title": { "name": "Title" },
@@ -356,6 +360,10 @@
"description": "All messages have a priority that defines how urgently your phone notifies you, depending on the configured vibration patterns, notification sounds, and visibility in the notification drawer or pop-over.",
"name": "Message priority"
},
"sequence_id": {
"description": "Enter a message or sequence ID to update an existing notification, or specify a sequence ID to reference later when updating, clearing (mark as read and dismiss), or deleting a notification.",
"name": "[%key:component::ntfy::common::sequence_id%]"
},
"tags": {
"description": "Add tags or emojis to the notification. Emojis (using shortcodes like smile) will appear in the notification title or message. Other tags will be displayed below the notification content.",
"name": "Tags/Emojis"

View File

@@ -247,7 +247,7 @@ class NumberDeviceClass(StrEnum):
NITROGEN_DIOXIDE = "nitrogen_dioxide"
"""Amount of NO2.
Unit of measurement: `μg/m³`
Unit of measurement: `ppb` (parts per billion), `μg/m³`
"""
NITROGEN_MONOXIDE = "nitrogen_monoxide"
@@ -265,7 +265,7 @@ class NumberDeviceClass(StrEnum):
OZONE = "ozone"
"""Amount of O3.
Unit of measurement: `μg/m³`
Unit of measurement: `ppb` (parts per billion), `μg/m³`
"""
PH = "ph"
@@ -517,10 +517,16 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
NumberDeviceClass.ILLUMINANCE: {LIGHT_LUX},
NumberDeviceClass.IRRADIANCE: set(UnitOfIrradiance),
NumberDeviceClass.MOISTURE: {PERCENTAGE},
NumberDeviceClass.NITROGEN_DIOXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.NITROGEN_DIOXIDE: {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
},
NumberDeviceClass.NITROGEN_MONOXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.NITROUS_OXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.OZONE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.OZONE: {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
},
NumberDeviceClass.PH: {None},
NumberDeviceClass.PM1: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.PM10: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},

View File

@@ -8,6 +8,9 @@
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"initiate_flow": {
"user": "[%key:common::config_flow::initiate_flow::account%]"
},
"step": {
"user": {
"data": {

View File

@@ -178,6 +178,7 @@ class OneDriveBackupAgent(BackupAgent):
file,
upload_chunk_size=upload_chunk_size,
session=async_get_clientsession(self._hass),
smart_chunk_size=True,
)
except HashMismatchError as err:
raise BackupAgentError(

View File

@@ -10,5 +10,5 @@
"iot_class": "cloud_polling",
"loggers": ["onedrive_personal_sdk"],
"quality_scale": "platinum",
"requirements": ["onedrive-personal-sdk==0.1.0"]
"requirements": ["onedrive-personal-sdk==0.1.1"]
}

View File

@@ -25,6 +25,9 @@
"folder_creation_error": "Failed to create folder",
"folder_rename_error": "Failed to rename folder"
},
"initiate_flow": {
"user": "[%key:common::config_flow::initiate_flow::account%]"
},
"step": {
"folder_name": {
"data": {

View File

@@ -13,6 +13,9 @@
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"initiate_flow": {
"user": "[%key:common::config_flow::initiate_flow::account%]"
},
"step": {
"reauth_confirm": {
"data": {

View File

@@ -9,6 +9,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/qnap_qsw",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["aioqsw"],
"requirements": ["aioqsw==0.4.2"]

View File

@@ -5,6 +5,7 @@
"codeowners": ["@rabbit-air"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/rabbitair",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["python-rabbitair==0.0.8"],
"zeroconf": ["_rabbitair._udp.local."]

View File

@@ -13,6 +13,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/radiotherm",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["radiotherm"],
"requirements": ["radiotherm==2.1.0"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@konikvranik", "@allenporter"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/rainbird",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyrainbird"],
"requirements": ["pyrainbird==6.0.1"]

View File

@@ -5,6 +5,7 @@
"config_flow": true,
"dependencies": ["usb"],
"documentation": "https://www.home-assistant.io/integrations/rainforest_raven",
"integration_type": "hub",
"iot_class": "local_polling",
"requirements": ["aioraven==0.7.1"],
"usb": [

View File

@@ -15,6 +15,7 @@
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/rapt_ble",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["rapt-ble==0.1.2"]
}

View File

@@ -7,6 +7,9 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown_license_plate": "Unknown license plate"
},
"initiate_flow": {
"user": "Add vehicle"
},
"step": {
"user": {
"data": {

View File

@@ -59,6 +59,8 @@ from homeassistant.util.unit_conversion import (
InformationConverter,
MassConverter,
MassVolumeConcentrationConverter,
NitrogenDioxideConcentrationConverter,
OzoneConcentrationConverter,
PowerConverter,
PressureConverter,
ReactiveEnergyConverter,
@@ -225,8 +227,10 @@ _PRIMARY_UNIT_CONVERTERS: list[type[BaseUnitConverter]] = [
_SECONDARY_UNIT_CONVERTERS: list[type[BaseUnitConverter]] = [
CarbonMonoxideConcentrationConverter,
TemperatureDeltaConverter,
NitrogenDioxideConcentrationConverter,
OzoneConcentrationConverter,
SulphurDioxideConcentrationConverter,
TemperatureDeltaConverter,
]
STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = {

View File

@@ -33,6 +33,8 @@ from homeassistant.util.unit_conversion import (
InformationConverter,
MassConverter,
MassVolumeConcentrationConverter,
NitrogenDioxideConcentrationConverter,
OzoneConcentrationConverter,
PowerConverter,
PressureConverter,
ReactiveEnergyConverter,
@@ -85,11 +87,14 @@ UNIT_SCHEMA = vol.Schema(
vol.Optional("distance"): vol.In(DistanceConverter.VALID_UNITS),
vol.Optional("duration"): vol.In(DurationConverter.VALID_UNITS),
vol.Optional("electric_current"): vol.In(ElectricCurrentConverter.VALID_UNITS),
vol.Optional("voltage"): vol.In(ElectricPotentialConverter.VALID_UNITS),
vol.Optional("energy"): vol.In(EnergyConverter.VALID_UNITS),
vol.Optional("energy_distance"): vol.In(EnergyDistanceConverter.VALID_UNITS),
vol.Optional("information"): vol.In(InformationConverter.VALID_UNITS),
vol.Optional("mass"): vol.In(MassConverter.VALID_UNITS),
vol.Optional("nitrogen_dioxide"): vol.In(
NitrogenDioxideConcentrationConverter.VALID_UNITS
),
vol.Optional("ozone"): vol.In(OzoneConcentrationConverter.VALID_UNITS),
vol.Optional("power"): vol.In(PowerConverter.VALID_UNITS),
vol.Optional("pressure"): vol.In(PressureConverter.VALID_UNITS),
vol.Optional("reactive_energy"): vol.In(ReactiveEnergyConverter.VALID_UNITS),
@@ -103,6 +108,7 @@ UNIT_SCHEMA = vol.Schema(
TemperatureDeltaConverter.VALID_UNITS
),
vol.Optional("unitless"): vol.In(UnitlessRatioConverter.VALID_UNITS),
vol.Optional("voltage"): vol.In(ElectricPotentialConverter.VALID_UNITS),
vol.Optional("volume"): vol.In(VolumeConverter.VALID_UNITS),
vol.Optional("volume_flow_rate"): vol.In(VolumeFlowRateConverter.VALID_UNITS),
}

View File

@@ -4,6 +4,7 @@
"codeowners": ["@ashionky"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/refoss",
"integration_type": "hub",
"iot_class": "local_polling",
"requirements": ["refoss-ha==1.2.5"],
"single_config_entry": true

View File

@@ -10,6 +10,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/rehlko",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["aiokem"],
"quality_scale": "silver",

View File

@@ -12,6 +12,9 @@
"invalid_credentials": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"initiate_flow": {
"user": "[%key:common::config_flow::initiate_flow::account%]"
},
"step": {
"kamereon": {
"data": {

View File

@@ -4,6 +4,7 @@
"codeowners": ["@jimmyd-be"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/renson",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["renson-endura-delta==1.7.2"]
}

View File

@@ -4,6 +4,7 @@
"codeowners": ["@danielhiversen", "@elupus", "@RobBie1221"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/rfxtrx",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["RFXtrx"],
"requirements": ["pyRFXtrx==0.31.1"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@milanmeu", "@frenck", "@quebulm"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/rituals_perfume_genie",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyrituals"],
"requirements": ["pyrituals==0.0.7"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@xeniter"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/romy",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["romy==0.0.10"],
"zeroconf": ["_aicu-http._tcp.local."]

View File

@@ -22,6 +22,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/roomba",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["paho_mqtt", "roombapy"],
"requirements": ["roombapy==1.9.0"],

View File

@@ -4,6 +4,7 @@
"codeowners": ["@pavoni"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/roon",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["roonapi"],
"requirements": ["roonapi==0.1.6"]

View File

@@ -4,6 +4,7 @@
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/rova",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["rova"],
"requirements": ["rova==0.4.1"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@noahhusby"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/russound_rio",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["aiorussound"],
"quality_scale": "silver",

View File

@@ -10,6 +10,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/ruuvi_gateway",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["aioruuvigateway==0.1.0"]
}

View File

@@ -15,6 +15,7 @@
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/ruuvitag_ble",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["ruuvitag-ble==0.4.0"]
}

View File

@@ -4,6 +4,7 @@
"codeowners": ["@OnFreund", "@elad-bar", "@maorcc"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/rympro",
"integration_type": "hub",
"iot_class": "cloud_polling",
"requirements": ["pyrympro==0.0.9"]
}

View File

@@ -4,6 +4,7 @@
"codeowners": ["@shaiu", "@jpbede"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sabnzbd",
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["pysabnzbd"],
"quality_scale": "bronze",

View File

@@ -4,6 +4,7 @@
"codeowners": ["@tomaszsluszniak"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sanix",
"integration_type": "device",
"iot_class": "cloud_polling",
"requirements": ["sanix==1.0.6"]
}

View File

@@ -4,6 +4,7 @@
"codeowners": ["@dknowles2"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/schlage",
"integration_type": "hub",
"iot_class": "cloud_polling",
"requirements": ["pyschlage==2025.9.0"]
}

View File

@@ -13,6 +13,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/screenlogic",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["screenlogicpy"],
"requirements": ["screenlogicpy==0.10.2"]

View File

@@ -18,6 +18,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/sense",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["sense_energy"],
"requirements": ["sense-energy==0.13.8"]

View File

@@ -12,6 +12,7 @@
"homekit": {
"models": ["Sensibo"]
},
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pysensibo"],
"quality_scale": "platinum",

View File

@@ -63,6 +63,8 @@ from homeassistant.util.unit_conversion import (
InformationConverter,
MassConverter,
MassVolumeConcentrationConverter,
NitrogenDioxideConcentrationConverter,
OzoneConcentrationConverter,
PowerConverter,
PressureConverter,
ReactiveEnergyConverter,
@@ -283,7 +285,7 @@ class SensorDeviceClass(StrEnum):
NITROGEN_DIOXIDE = "nitrogen_dioxide"
"""Amount of NO2.
Unit of measurement: `μg/m³`
Unit of measurement: `ppb` (parts per billion), `μg/m³`
"""
NITROGEN_MONOXIDE = "nitrogen_monoxide"
@@ -301,7 +303,7 @@ class SensorDeviceClass(StrEnum):
OZONE = "ozone"
"""Amount of O3.
Unit of measurement: `μg/m³`
Unit of measurement: `ppb` (parts per billion),`μg/m³`
"""
PH = "ph"
@@ -563,6 +565,8 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] =
SensorDeviceClass.ENERGY_DISTANCE: EnergyDistanceConverter,
SensorDeviceClass.ENERGY_STORAGE: EnergyConverter,
SensorDeviceClass.GAS: VolumeConverter,
SensorDeviceClass.NITROGEN_DIOXIDE: NitrogenDioxideConcentrationConverter,
SensorDeviceClass.OZONE: OzoneConcentrationConverter,
SensorDeviceClass.POWER: PowerConverter,
SensorDeviceClass.POWER_FACTOR: UnitlessRatioConverter,
SensorDeviceClass.PRECIPITATION: DistanceConverter,
@@ -631,10 +635,16 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
SensorDeviceClass.ILLUMINANCE: {LIGHT_LUX},
SensorDeviceClass.IRRADIANCE: set(UnitOfIrradiance),
SensorDeviceClass.MOISTURE: {PERCENTAGE},
SensorDeviceClass.NITROGEN_DIOXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.NITROGEN_DIOXIDE: {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
},
SensorDeviceClass.NITROGEN_MONOXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.NITROUS_OXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.OZONE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.OZONE: {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
},
SensorDeviceClass.PH: {None},
SensorDeviceClass.PM1: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.PM10: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},

View File

@@ -18,6 +18,9 @@
"error": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
"initiate_flow": {
"user": "[%key:common::config_flow::initiate_flow::account%]"
},
"step": {
"pick_implementation": {
"data": {

View File

@@ -49,8 +49,8 @@ DEFAULT_NAME = "Template Select"
SELECT_COMMON_SCHEMA = vol.Schema(
{
vol.Optional(ATTR_OPTIONS): cv.template,
vol.Optional(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA,
vol.Required(ATTR_OPTIONS): cv.template,
vol.Required(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_STATE): cv.template,
}
)

View File

@@ -8,7 +8,6 @@ import logging
import aiohttp
from aiohttp.client_exceptions import ClientError, ClientResponseError
import tibber
from tibber import data_api as tibber_data_api
from homeassistant.const import CONF_ACCESS_TOKEN, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant
@@ -23,13 +22,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util, ssl as ssl_util
from .const import (
AUTH_IMPLEMENTATION,
CONF_LEGACY_ACCESS_TOKEN,
DATA_HASS_CONFIG,
DOMAIN,
TibberConfigEntry,
)
from .const import AUTH_IMPLEMENTATION, DATA_HASS_CONFIG, DOMAIN, TibberConfigEntry
from .coordinator import TibberDataAPICoordinator
from .services import async_setup_services
@@ -44,24 +37,23 @@ _LOGGER = logging.getLogger(__name__)
class TibberRuntimeData:
"""Runtime data for Tibber API entries."""
tibber_connection: tibber.Tibber
session: OAuth2Session
data_api_coordinator: TibberDataAPICoordinator | None = field(default=None)
_client: tibber_data_api.TibberDataAPI | None = None
_client: tibber.Tibber | None = None
async def async_get_client(
self, hass: HomeAssistant
) -> tibber_data_api.TibberDataAPI:
"""Return an authenticated Tibber Data API client."""
async def async_get_client(self, hass: HomeAssistant) -> tibber.Tibber:
"""Return an authenticated Tibber client."""
await self.session.async_ensure_token_valid()
token = self.session.token
access_token = token.get(CONF_ACCESS_TOKEN)
if not access_token:
raise ConfigEntryAuthFailed("Access token missing from OAuth session")
if self._client is None:
self._client = tibber_data_api.TibberDataAPI(
access_token,
self._client = tibber.Tibber(
access_token=access_token,
websession=async_get_clientsession(hass),
time_zone=dt_util.get_default_time_zone(),
ssl=ssl_util.get_default_context(),
)
self._client.set_access_token(access_token)
return self._client
@@ -88,32 +80,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TibberConfigEntry) -> bo
translation_key="data_api_reauth_required",
)
tibber_connection = tibber.Tibber(
access_token=entry.data[CONF_LEGACY_ACCESS_TOKEN],
websession=async_get_clientsession(hass),
time_zone=dt_util.get_default_time_zone(),
ssl=ssl_util.get_default_context(),
)
async def _close(event: Event) -> None:
await tibber_connection.rt_disconnect()
entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close))
try:
await tibber_connection.update_info()
except (
TimeoutError,
aiohttp.ClientError,
tibber.RetryableHttpExceptionError,
) as err:
raise ConfigEntryNotReady("Unable to connect") from err
except tibber.InvalidLoginError as exp:
_LOGGER.error("Failed to login. %s", exp)
return False
except tibber.FatalHttpExceptionError:
return False
try:
implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
@@ -135,10 +101,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: TibberConfigEntry) -> bo
raise ConfigEntryNotReady from err
entry.runtime_data = TibberRuntimeData(
tibber_connection=tibber_connection,
session=session,
)
tibber_connection = await entry.runtime_data.async_get_client(hass)
async def _close(event: Event) -> None:
await tibber_connection.rt_disconnect()
entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close))
try:
await tibber_connection.update_info()
except (
TimeoutError,
aiohttp.ClientError,
tibber.RetryableHttpExceptionError,
) as err:
raise ConfigEntryNotReady("Unable to connect") from err
except tibber.InvalidLoginError as err:
raise ConfigEntryAuthFailed("Invalid login credentials") from err
except tibber.FatalHttpExceptionError as err:
raise ConfigEntryNotReady("Fatal HTTP error from Tibber API") from err
coordinator = TibberDataAPICoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data.data_api_coordinator = coordinator
@@ -154,5 +139,6 @@ async def async_unload_entry(
if unload_ok := await hass.config_entries.async_unload_platforms(
config_entry, PLATFORMS
):
await config_entry.runtime_data.tibber_connection.rt_disconnect()
tibber_connection = await config_entry.runtime_data.async_get_client(hass)
await tibber_connection.rt_disconnect()
return unload_ok

View File

@@ -8,21 +8,16 @@ from typing import Any
import aiohttp
import tibber
from tibber import data_api as tibber_data_api
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, ConfigFlowResult
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
from .const import CONF_LEGACY_ACCESS_TOKEN, DATA_API_DEFAULT_SCOPES, DOMAIN
from .const import DATA_API_DEFAULT_SCOPES, DOMAIN
DATA_SCHEMA = vol.Schema({vol.Required(CONF_LEGACY_ACCESS_TOKEN): str})
ERR_TIMEOUT = "timeout"
ERR_CLIENT = "cannot_connect"
ERR_TOKEN = "invalid_access_token"
TOKEN_URL = "https://developer.tibber.com/settings/access-token"
_LOGGER = logging.getLogger(__name__)
@@ -36,8 +31,7 @@ class TibberConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize the config flow."""
super().__init__()
self._access_token: str | None = None
self._title = ""
self._oauth_data: dict[str, Any] | None = None
@property
def logger(self) -> logging.Logger:
@@ -52,114 +46,70 @@ class TibberConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
"scope": " ".join(DATA_API_DEFAULT_SCOPES),
}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
if user_input is None:
data_schema = self.add_suggested_values_to_schema(
DATA_SCHEMA, {CONF_LEGACY_ACCESS_TOKEN: self._access_token or ""}
)
return self.async_show_form(
step_id=SOURCE_USER,
data_schema=data_schema,
description_placeholders={"url": TOKEN_URL},
errors={},
)
self._access_token = user_input[CONF_LEGACY_ACCESS_TOKEN].replace(" ", "")
tibber_connection = tibber.Tibber(
access_token=self._access_token,
websession=async_get_clientsession(self.hass),
)
self._title = tibber_connection.name or "Tibber"
errors: dict[str, str] = {}
try:
await tibber_connection.update_info()
except TimeoutError:
errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_TIMEOUT
except tibber.InvalidLoginError:
errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_TOKEN
except (
aiohttp.ClientError,
tibber.RetryableHttpExceptionError,
tibber.FatalHttpExceptionError,
):
errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_CLIENT
if errors:
data_schema = self.add_suggested_values_to_schema(
DATA_SCHEMA, {CONF_LEGACY_ACCESS_TOKEN: self._access_token or ""}
)
return self.async_show_form(
step_id=SOURCE_USER,
data_schema=data_schema,
description_placeholders={"url": TOKEN_URL},
errors=errors,
)
await self.async_set_unique_id(tibber_connection.user_id)
if self.source == SOURCE_REAUTH:
reauth_entry = self._get_reauth_entry()
self._abort_if_unique_id_mismatch(
reason="wrong_account",
description_placeholders={"title": reauth_entry.title},
)
else:
self._abort_if_unique_id_configured()
return await self.async_step_pick_implementation()
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle a reauth flow."""
reauth_entry = self._get_reauth_entry()
self._access_token = reauth_entry.data.get(CONF_LEGACY_ACCESS_TOKEN)
self._title = reauth_entry.title
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauthentication by reusing the user step."""
reauth_entry = self._get_reauth_entry()
self._access_token = reauth_entry.data.get(CONF_LEGACY_ACCESS_TOKEN)
self._title = reauth_entry.title
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
)
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Finalize the OAuth flow and create the config entry."""
if self._access_token is None:
return self.async_abort(reason="missing_configuration")
self._oauth_data = data
return await self._async_validate_and_create()
data[CONF_LEGACY_ACCESS_TOKEN] = self._access_token
async def async_step_connection_error(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle connection error retry."""
if user_input is not None:
return await self._async_validate_and_create()
return self.async_show_form(step_id="connection_error")
access_token = data[CONF_TOKEN][CONF_ACCESS_TOKEN]
data_api_client = tibber_data_api.TibberDataAPI(
access_token,
async def _async_validate_and_create(self) -> ConfigFlowResult:
"""Validate the OAuth token and create the config entry."""
assert self._oauth_data is not None
access_token = self._oauth_data[CONF_TOKEN][CONF_ACCESS_TOKEN]
tibber_connection = tibber.Tibber(
access_token=access_token,
websession=async_get_clientsession(self.hass),
)
try:
await data_api_client.get_userinfo()
except (aiohttp.ClientError, TimeoutError):
return self.async_abort(reason="cannot_connect")
await tibber_connection.update_info()
except TimeoutError:
return await self.async_step_connection_error()
except tibber.InvalidLoginError:
return self.async_abort(reason=ERR_TOKEN)
except (
aiohttp.ClientError,
tibber.RetryableHttpExceptionError,
):
return await self.async_step_connection_error()
except tibber.FatalHttpExceptionError:
return self.async_abort(reason=ERR_CLIENT)
await self.async_set_unique_id(tibber_connection.user_id)
title = tibber_connection.name or "Tibber"
if self.source == SOURCE_REAUTH:
reauth_entry = self._get_reauth_entry()
self._abort_if_unique_id_mismatch(
reason="wrong_account",
description_placeholders={"title": reauth_entry.title},
)
return self.async_update_reload_and_abort(
reauth_entry,
data=data,
title=self._title,
data=self._oauth_data,
title=title,
)
return self.async_create_entry(title=self._title, data=data)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=title, data=self._oauth_data)

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN
if TYPE_CHECKING:
from . import TibberRuntimeData
@@ -13,8 +12,6 @@ if TYPE_CHECKING:
type TibberConfigEntry = ConfigEntry[TibberRuntimeData]
CONF_LEGACY_ACCESS_TOKEN = CONF_ACCESS_TOKEN
AUTH_IMPLEMENTATION = "auth_implementation"
DATA_HASS_CONFIG = "tibber_hass_config"
DOMAIN = "tibber"

View File

@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, cast
from aiohttp.client_exceptions import ClientError
import tibber
from tibber.data_api import TibberDataAPI, TibberDevice
from tibber.data_api import TibberDevice
from homeassistant.components.recorder import get_instance
from homeassistant.components.recorder.models import (
@@ -230,28 +230,26 @@ class TibberDataAPICoordinator(DataUpdateCoordinator[dict[str, TibberDevice]]):
return device_sensors.get(sensor_id)
return None
async def _async_get_client(self) -> TibberDataAPI:
"""Get the Tibber Data API client with error handling."""
async def _async_get_client(self) -> tibber.Tibber:
"""Get the Tibber client with error handling."""
try:
return await self._runtime_data.async_get_client(self.hass)
except ConfigEntryAuthFailed:
raise
except (ClientError, TimeoutError, tibber.UserAgentMissingError) as err:
raise UpdateFailed(
f"Unable to create Tibber Data API client: {err}"
) from err
raise UpdateFailed(f"Unable to create Tibber client: {err}") from err
async def _async_setup(self) -> None:
"""Initial load of Tibber Data API devices."""
client = await self._async_get_client()
devices = await client.get_all_devices()
devices = await client.data_api.get_all_devices()
self._build_sensor_lookup(devices)
async def _async_update_data(self) -> dict[str, TibberDevice]:
"""Fetch the latest device capabilities from the Tibber Data API."""
client = await self._async_get_client()
try:
devices: dict[str, TibberDevice] = await client.update_devices()
devices: dict[str, TibberDevice] = await client.data_api.update_devices()
except tibber.exceptions.RateLimitExceededError as err:
raise UpdateFailed(
f"Rate limit exceeded, retry after {err.retry_after} seconds",

View File

@@ -15,6 +15,7 @@ async def async_get_config_entry_diagnostics(
"""Return diagnostics for a config entry."""
runtime = config_entry.runtime_data
tibber_connection = await runtime.async_get_client(hass)
result: dict[str, Any] = {
"homes": [
{
@@ -24,7 +25,7 @@ async def async_get_config_entry_diagnostics(
"last_cons_data_timestamp": home.last_cons_data_timestamp,
"country": home.country,
}
for home in runtime.tibber_connection.get_homes(only_active=False)
for home in tibber_connection.get_homes(only_active=False)
]
}

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
import tibber
from homeassistant.components.notify import (
ATTR_TITLE_DEFAULT,
NotifyEntity,
@@ -37,7 +39,9 @@ class TibberNotificationEntity(NotifyEntity):
async def async_send_message(self, message: str, title: str | None = None) -> None:
"""Send a message to Tibber devices."""
tibber_connection = self._entry.runtime_data.tibber_connection
tibber_connection: tibber.Tibber = (
await self._entry.runtime_data.async_get_client(self.hass)
)
try:
await tibber_connection.send_notification(
title or ATTR_TITLE_DEFAULT, message

View File

@@ -605,7 +605,7 @@ async def _async_setup_graphql_sensors(
) -> None:
"""Set up the Tibber sensor."""
tibber_connection = entry.runtime_data.tibber_connection
tibber_connection = await entry.runtime_data.async_get_client(hass)
entity_registry = er.async_get(hass)

View File

@@ -42,7 +42,7 @@ async def __get_prices(call: ServiceCall) -> ServiceResponse:
translation_domain=DOMAIN,
translation_key="no_config_entry",
)
tibber_connection = entries[0].runtime_data.tibber_connection
tibber_connection = await entries[0].runtime_data.async_get_client(call.hass)
start = __get_date(call.data.get(ATTR_START), "start")
end = __get_date(call.data.get(ATTR_END), "end")

View File

@@ -2,26 +2,21 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"wrong_account": "The connected account does not match {title}. Sign in with the same Tibber account and try again."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]",
"timeout": "[%key:common::config_flow::error::timeout_connect%]"
},
"step": {
"connection_error": {
"description": "Could not connect to Tibber. Check your internet connection and try again.",
"title": "Connection failed"
},
"reauth_confirm": {
"description": "Reconnect your Tibber account to refresh access.",
"title": "[%key:common::config_flow::title::reauth%]"
},
"user": {
"data": {
"access_token": "[%key:common::config_flow::data::access_token%]"
},
"description": "Enter your access token from {url}"
}
}
},

View File

@@ -83,6 +83,14 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
return False
if entry.version == 2:
# 2 -> 2.2: Unique ID from integer to string
if entry.minor_version == 1:
minor_version = 2
hass.config_entries.async_update_entry(
entry, unique_id=str(entry.unique_id), minor_version=minor_version
)
return True

View File

@@ -20,6 +20,7 @@ class ToonFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
DOMAIN = DOMAIN
VERSION = 2
MINOR_VERSION = 2
agreements: list[Agreement]
data: dict[str, Any]
@@ -92,7 +93,7 @@ class ToonFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
if self.migrate_entry:
await self.hass.config_entries.async_remove(self.migrate_entry)
await self.async_set_unique_id(agreement.agreement_id)
await self.async_set_unique_id(str(agreement.agreement_id))
self._abort_if_unique_id_configured()
self.data[CONF_AGREEMENT_ID] = agreement.agreement_id

View File

@@ -10,6 +10,9 @@
"unknown": "[%key:common::config_flow::error::unknown%]",
"wrong_account": "Wrong account: Please authenticate with {username}."
},
"initiate_flow": {
"user": "[%key:common::config_flow::initiate_flow::account%]"
},
"step": {
"reauth_confirm": {
"description": "The Twitch integration needs to re-authenticate your account",

View File

@@ -8,6 +8,7 @@ import dataclasses
from uiprotect.data import (
NVR,
Camera,
Event,
ModelType,
MountType,
ProtectAdoptableDeviceModel,
@@ -644,6 +645,31 @@ class ProtectEventBinarySensor(EventEntityMixin, BinarySensorEntity):
self._attr_is_on = False
self._attr_extra_state_attributes = {}
@callback
def _find_active_event_with_object_type(
self, device: ProtectDeviceType
) -> Event | None:
"""Find an active event containing this sensor's object type.
Fallback for issue #152133: last_smart_detect_event_ids may not update
immediately when a new detection type is added to an ongoing event.
"""
obj_type = self.entity_description.ufp_obj_type
if obj_type is None or not isinstance(device, Camera):
return None
# Check known active event IDs from camera first (fast path)
for event_id in device.last_smart_detect_event_ids.values():
if (
event_id
and (event := self.data.api.bootstrap.events.get(event_id))
and event.end is None
and obj_type in event.smart_detect_types
):
return event
return None
@callback
def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None:
description = self.entity_description
@@ -651,9 +677,15 @@ class ProtectEventBinarySensor(EventEntityMixin, BinarySensorEntity):
prev_event = self._event
prev_event_end = self._event_end
super()._async_update_device_from_protect(device)
if event := description.get_event_obj(device):
event = description.get_event_obj(device)
if event is None:
# Fallback for #152133: check active events directly
event = self._find_active_event_with_object_type(device)
if event:
self._event = event
self._event_end = event.end if event else None
self._event_end = event.end
if not (
event

View File

@@ -41,7 +41,7 @@
"iot_class": "local_push",
"loggers": ["uiprotect", "unifi_discovery"],
"quality_scale": "platinum",
"requirements": ["uiprotect==8.1.1", "unifi-discovery==1.2.0"],
"requirements": ["uiprotect==10.0.1", "unifi-discovery==1.2.0"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",

View File

@@ -21,6 +21,9 @@
"error": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
"initiate_flow": {
"user": "[%key:common::config_flow::initiate_flow::account%]"
},
"step": {
"oauth_discovery": {
"description": "Home Assistant has found a Withings device on your network. Be aware that the setup of Withings is more complicated than many other integrations. Press **Submit** to continue setting up Withings."

View File

@@ -15,6 +15,9 @@
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
},
"initiate_flow": {
"user": "[%key:common::config_flow::initiate_flow::account%]"
},
"step": {
"oauth_discovery": {
"description": "Home Assistant has found an Xbox device on your network. Press **Submit** to continue setting up the Xbox integration.",

View File

@@ -17,6 +17,9 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
},
"initiate_flow": {
"user": "[%key:common::config_flow::initiate_flow::account%]"
},
"step": {
"channels": {
"data": { "channels": "YouTube channels" },

View File

@@ -5375,7 +5375,7 @@
"name": "QNAP"
},
"qnap_qsw": {
"integration_type": "hub",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling",
"name": "QNAP QSW"
@@ -5413,7 +5413,7 @@
},
"rabbitair": {
"name": "Rabbit Air",
"integration_type": "hub",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"
},
@@ -5438,7 +5438,7 @@
},
"radiotherm": {
"name": "Radio Thermostat",
"integration_type": "hub",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"
},
@@ -5473,7 +5473,7 @@
},
"rapt_ble": {
"name": "RAPT Bluetooth",
"integration_type": "hub",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_push"
},
@@ -5571,7 +5571,7 @@
},
"renson": {
"name": "Renson",
"integration_type": "hub",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"
},
@@ -5679,13 +5679,13 @@
},
"romy": {
"name": "ROMY Vacuum Cleaner",
"integration_type": "hub",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"
},
"roomba": {
"name": "iRobot Roomba and Braava",
"integration_type": "hub",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_push"
},
@@ -5720,7 +5720,7 @@
},
"rova": {
"name": "ROVA",
"integration_type": "hub",
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_polling"
},
@@ -5763,13 +5763,13 @@
"name": "Ruuvi",
"integrations": {
"ruuvi_gateway": {
"integration_type": "hub",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling",
"name": "Ruuvi Gateway"
},
"ruuvitag_ble": {
"integration_type": "hub",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_push",
"name": "Ruuvi BLE"
@@ -5784,7 +5784,7 @@
},
"sabnzbd": {
"name": "SABnzbd",
"integration_type": "hub",
"integration_type": "service",
"config_flow": true,
"iot_class": "local_polling"
},
@@ -5824,7 +5824,7 @@
},
"sanix": {
"name": "Sanix",
"integration_type": "hub",
"integration_type": "device",
"config_flow": true,
"iot_class": "cloud_polling"
},

View File

@@ -370,9 +370,13 @@ def _async_get_connector(
return connectors[connector_key]
if verify_ssl:
ssl_context: SSLContext = ssl_util.client_context(ssl_cipher)
ssl_context: SSLContext = ssl_util.client_context(
ssl_cipher, ssl_util.SSL_ALPN_HTTP11
)
else:
ssl_context = ssl_util.client_context_no_verify(ssl_cipher)
ssl_context = ssl_util.client_context_no_verify(
ssl_cipher, ssl_util.SSL_ALPN_HTTP11
)
connector = HomeAssistantTCPConnector(
family=family,

View File

@@ -17,6 +17,7 @@ from typing import (
TYPE_CHECKING,
Any,
Final,
Literal,
Protocol,
TypedDict,
Unpack,
@@ -1346,13 +1347,18 @@ def async_extract_entities(config: ConfigType | Template) -> set[str]:
if entity_ids is not None:
referenced.update(entity_ids)
if target_entities := _get_targets_from_condition_config(
config, CONF_ENTITY_ID
):
referenced.update(target_entities)
return referenced
@callback
def async_extract_devices(config: ConfigType | Template) -> set[str]:
"""Extract devices from a condition."""
referenced = set()
referenced: set[str] = set()
to_process = deque([config])
while to_process:
@@ -1366,15 +1372,57 @@ def async_extract_devices(config: ConfigType | Template) -> set[str]:
to_process.extend(config["conditions"])
continue
if condition != "device":
if condition == "device":
if (device_id := config.get(CONF_DEVICE_ID)) is not None:
referenced.add(device_id)
continue
if (device_id := config.get(CONF_DEVICE_ID)) is not None:
referenced.add(device_id)
if target_devices := _get_targets_from_condition_config(config, CONF_DEVICE_ID):
referenced.update(target_devices)
return referenced
@callback
def async_extract_targets(
config: ConfigType | Template,
target_type: Literal["area_id", "floor_id", "label_id"],
) -> set[str]:
"""Extract targets from a condition."""
referenced: set[str] = set()
to_process = deque([config])
while to_process:
config = to_process.popleft()
if isinstance(config, Template):
continue
condition = config[CONF_CONDITION]
if condition in ("and", "not", "or"):
to_process.extend(config["conditions"])
continue
if targets := _get_targets_from_condition_config(config, target_type):
referenced.update(targets)
return referenced
@callback
def _get_targets_from_condition_config(
config: ConfigType,
target: Literal["entity_id", "device_id", "area_id", "floor_id", "label_id"],
) -> list[str]:
"""Extract targets from a condition target config."""
if not (target_conf := config.get(CONF_TARGET)):
return []
if not (targets := target_conf.get(target)):
return []
return [targets] if isinstance(targets, str) else targets
def _load_conditions_file(integration: Integration) -> dict[str, Any]:
"""Load conditions file for an integration."""
try:

View File

@@ -17,6 +17,9 @@ from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.loader import bind_hass
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.ssl import (
SSL_ALPN_HTTP11,
SSL_ALPN_HTTP11_HTTP2,
SSLALPNProtocols,
SSLCipherList,
client_context,
create_no_verify_ssl_context,
@@ -28,9 +31,9 @@ from .frame import warn_use
# and we want to keep the connection open for a while so we
# don't have to reconnect every time so we use 15s to match aiohttp.
KEEP_ALIVE_TIMEOUT = 15
DATA_ASYNC_CLIENT: HassKey[httpx.AsyncClient] = HassKey("httpx_async_client")
DATA_ASYNC_CLIENT_NOVERIFY: HassKey[httpx.AsyncClient] = HassKey(
"httpx_async_client_noverify"
# Shared httpx clients keyed by (verify_ssl, alpn_protocols)
DATA_ASYNC_CLIENT: HassKey[dict[tuple[bool, SSLALPNProtocols], httpx.AsyncClient]] = (
HassKey("httpx_async_client")
)
DEFAULT_LIMITS = limits = httpx.Limits(keepalive_expiry=KEEP_ALIVE_TIMEOUT)
SERVER_SOFTWARE = (
@@ -42,15 +45,26 @@ USER_AGENT = "User-Agent"
@callback
@bind_hass
def get_async_client(hass: HomeAssistant, verify_ssl: bool = True) -> httpx.AsyncClient:
def get_async_client(
hass: HomeAssistant,
verify_ssl: bool = True,
alpn_protocols: SSLALPNProtocols = SSL_ALPN_HTTP11,
) -> httpx.AsyncClient:
"""Return default httpx AsyncClient.
This method must be run in the event loop.
"""
key = DATA_ASYNC_CLIENT if verify_ssl else DATA_ASYNC_CLIENT_NOVERIFY
if (client := hass.data.get(key)) is None:
client = hass.data[key] = create_async_httpx_client(hass, verify_ssl)
Pass alpn_protocols=SSL_ALPN_HTTP11_HTTP2 to get a client configured for HTTP/2.
Clients are cached separately by ALPN protocol to ensure proper SSL context
configuration (ALPN protocols differ between HTTP versions).
"""
client_key = (verify_ssl, alpn_protocols)
clients = hass.data.setdefault(DATA_ASYNC_CLIENT, {})
if (client := clients.get(client_key)) is None:
client = clients[client_key] = create_async_httpx_client(
hass, verify_ssl, alpn_protocols=alpn_protocols
)
return client
@@ -77,6 +91,7 @@ def create_async_httpx_client(
verify_ssl: bool = True,
auto_cleanup: bool = True,
ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT,
alpn_protocols: SSLALPNProtocols = SSL_ALPN_HTTP11,
**kwargs: Any,
) -> httpx.AsyncClient:
"""Create a new httpx.AsyncClient with kwargs, i.e. for cookies.
@@ -84,13 +99,22 @@ def create_async_httpx_client(
If auto_cleanup is False, the client will be
automatically closed on homeassistant_stop.
Pass alpn_protocols=SSL_ALPN_HTTP11_HTTP2 for HTTP/2 support (automatically
enables httpx http2 mode).
This method must be run in the event loop.
"""
# Use the requested ALPN protocols directly to ensure proper SSL context
# bucketing. httpx/httpcore mutates SSL contexts by calling set_alpn_protocols(),
# so we pre-set the correct protocols to prevent shared context corruption.
ssl_context = (
client_context(ssl_cipher_list)
client_context(ssl_cipher_list, alpn_protocols)
if verify_ssl
else create_no_verify_ssl_context(ssl_cipher_list)
else create_no_verify_ssl_context(ssl_cipher_list, alpn_protocols)
)
# Enable httpx HTTP/2 mode when HTTP/2 protocol is requested
if alpn_protocols == SSL_ALPN_HTTP11_HTTP2:
kwargs.setdefault("http2", True)
client = HassHttpXAsyncClient(
verify=ssl_context,
headers={USER_AGENT: SERVER_SOFTWARE},

View File

@@ -1601,8 +1601,13 @@ class Script:
):
_referenced_extract_ids(data, target, referenced)
elif action == cv.SCRIPT_ACTION_CHECK_CONDITION:
referenced |= condition.async_extract_targets(step, target)
elif action == cv.SCRIPT_ACTION_CHOOSE:
for choice in step[CONF_CHOOSE]:
for cond in choice[CONF_CONDITIONS]:
referenced |= condition.async_extract_targets(cond, target)
Script._find_referenced_target(
target, referenced, choice[CONF_SEQUENCE]
)
@@ -1612,6 +1617,8 @@ class Script:
)
elif action == cv.SCRIPT_ACTION_IF:
for cond in step[CONF_IF]:
referenced |= condition.async_extract_targets(cond, target)
Script._find_referenced_target(target, referenced, step[CONF_THEN])
if CONF_ELSE in step:
Script._find_referenced_target(target, referenced, step[CONF_ELSE])

View File

@@ -594,6 +594,8 @@ class EntityNumericalStateAttributeChangedTriggerBase(EntityTriggerBase):
_above: None | float | str
_below: None | float | str
_converter: Callable[[Any], float] = float
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the state trigger."""
super().__init__(hass, config)
@@ -616,7 +618,7 @@ class EntityNumericalStateAttributeChangedTriggerBase(EntityTriggerBase):
return False
try:
current_value = float(_attribute_value)
current_value = self._converter(_attribute_value)
except (TypeError, ValueError):
# Attribute is not a valid number, don't trigger
return False
@@ -706,6 +708,8 @@ class EntityNumericalStateAttributeCrossedThresholdTriggerBase(EntityTriggerBase
_upper_limit: float | str | None = None
_threshold_type: ThresholdType
_converter: Callable[[Any], float] = float
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the state trigger."""
super().__init__(hass, config)
@@ -741,7 +745,7 @@ class EntityNumericalStateAttributeCrossedThresholdTriggerBase(EntityTriggerBase
return False
try:
current_value = float(_attribute_value)
current_value = self._converter(_attribute_value)
except (TypeError, ValueError):
# Attribute is not a valid number, don't trigger
return False

View File

@@ -8,6 +8,17 @@ import ssl
import certifi
# Type alias for ALPN protocols tuple (None means no ALPN protocols set)
type SSLALPNProtocols = tuple[str, ...] | None
# ALPN protocol configurations
# No ALPN protocols - used for libraries that don't support/need ALPN (e.g., aioimap)
SSL_ALPN_NONE: SSLALPNProtocols = None
# HTTP/1.1 only - used by default and for aiohttp (which doesn't support HTTP/2)
SSL_ALPN_HTTP11: SSLALPNProtocols = ("http/1.1",)
# HTTP/1.1 with HTTP/2 support - used when httpx http2=True
SSL_ALPN_HTTP11_HTTP2: SSLALPNProtocols = ("http/1.1", "h2")
class SSLCipherList(StrEnum):
"""SSL cipher lists."""
@@ -64,7 +75,10 @@ SSL_CIPHER_LISTS = {
@cache
def _client_context_no_verify(ssl_cipher_list: SSLCipherList) -> ssl.SSLContext:
def _client_context_no_verify(
ssl_cipher_list: SSLCipherList,
alpn_protocols: SSLALPNProtocols,
) -> ssl.SSLContext:
# This is a copy of aiohttp's create_default_context() function, with the
# ssl verify turned off.
# https://github.com/aio-libs/aiohttp/blob/33953f110e97eecc707e1402daa8d543f38a189b/aiohttp/connector.py#L911
@@ -78,12 +92,18 @@ def _client_context_no_verify(ssl_cipher_list: SSLCipherList) -> ssl.SSLContext:
sslcontext.set_default_verify_paths()
if ssl_cipher_list != SSLCipherList.PYTHON_DEFAULT:
sslcontext.set_ciphers(SSL_CIPHER_LISTS[ssl_cipher_list])
# Set ALPN protocols to prevent downstream libraries (e.g., httpx/httpcore)
# from mutating the shared SSL context with different protocol settings.
# If alpn_protocols is None, don't set ALPN (for libraries like aioimap).
if alpn_protocols is not None:
sslcontext.set_alpn_protocols(list(alpn_protocols))
return sslcontext
def _create_client_context(
ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT,
alpn_protocols: SSLALPNProtocols = SSL_ALPN_NONE,
) -> ssl.SSLContext:
"""Return an independent SSL context for making requests."""
# Reuse environment variable definition from requests, since it's already a
@@ -96,6 +116,11 @@ def _create_client_context(
)
if ssl_cipher_list != SSLCipherList.PYTHON_DEFAULT:
sslcontext.set_ciphers(SSL_CIPHER_LISTS[ssl_cipher_list])
# Set ALPN protocols to prevent downstream libraries (e.g., httpx/httpcore)
# from mutating the shared SSL context with different protocol settings.
# If alpn_protocols is None, don't set ALPN (for libraries like aioimap).
if alpn_protocols is not None:
sslcontext.set_alpn_protocols(list(alpn_protocols))
return sslcontext
@@ -103,63 +128,63 @@ def _create_client_context(
@cache
def _client_context(
ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT,
alpn_protocols: SSLALPNProtocols = SSL_ALPN_NONE,
) -> ssl.SSLContext:
# Cached version of _create_client_context
return _create_client_context(ssl_cipher_list)
return _create_client_context(ssl_cipher_list, alpn_protocols)
# Create this only once and reuse it
_DEFAULT_SSL_CONTEXT = _client_context(SSLCipherList.PYTHON_DEFAULT)
_DEFAULT_NO_VERIFY_SSL_CONTEXT = _client_context_no_verify(SSLCipherList.PYTHON_DEFAULT)
_NO_VERIFY_SSL_CONTEXTS = {
SSLCipherList.INTERMEDIATE: _client_context_no_verify(SSLCipherList.INTERMEDIATE),
SSLCipherList.MODERN: _client_context_no_verify(SSLCipherList.MODERN),
SSLCipherList.INSECURE: _client_context_no_verify(SSLCipherList.INSECURE),
}
_SSL_CONTEXTS = {
SSLCipherList.INTERMEDIATE: _client_context(SSLCipherList.INTERMEDIATE),
SSLCipherList.MODERN: _client_context(SSLCipherList.MODERN),
SSLCipherList.INSECURE: _client_context(SSLCipherList.INSECURE),
}
# Pre-warm the cache for ALL SSL context configurations at module load time.
# This is critical because creating SSL contexts loads certificates from disk,
# which is blocking I/O that must not happen in the event loop.
_SSL_ALPN_PROTOCOLS = (SSL_ALPN_NONE, SSL_ALPN_HTTP11, SSL_ALPN_HTTP11_HTTP2)
for _cipher in SSLCipherList:
for _alpn in _SSL_ALPN_PROTOCOLS:
_client_context(_cipher, _alpn)
_client_context_no_verify(_cipher, _alpn)
def get_default_context() -> ssl.SSLContext:
"""Return the default SSL context."""
return _DEFAULT_SSL_CONTEXT
return _client_context(SSLCipherList.PYTHON_DEFAULT, SSL_ALPN_HTTP11)
def get_default_no_verify_context() -> ssl.SSLContext:
"""Return the default SSL context that does not verify the server certificate."""
return _DEFAULT_NO_VERIFY_SSL_CONTEXT
return _client_context_no_verify(SSLCipherList.PYTHON_DEFAULT, SSL_ALPN_HTTP11)
def client_context_no_verify(
ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT,
alpn_protocols: SSLALPNProtocols = SSL_ALPN_NONE,
) -> ssl.SSLContext:
"""Return a SSL context with no verification with a specific ssl cipher."""
return _NO_VERIFY_SSL_CONTEXTS.get(ssl_cipher_list, _DEFAULT_NO_VERIFY_SSL_CONTEXT)
return _client_context_no_verify(ssl_cipher_list, alpn_protocols)
def client_context(
ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT,
alpn_protocols: SSLALPNProtocols = SSL_ALPN_NONE,
) -> ssl.SSLContext:
"""Return an SSL context for making requests."""
return _SSL_CONTEXTS.get(ssl_cipher_list, _DEFAULT_SSL_CONTEXT)
return _client_context(ssl_cipher_list, alpn_protocols)
def create_client_context(
ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT,
alpn_protocols: SSLALPNProtocols = SSL_ALPN_NONE,
) -> ssl.SSLContext:
"""Return an independent SSL context for making requests."""
# This explicitly uses the non-cached version to create a client context
return _create_client_context(ssl_cipher_list)
return _create_client_context(ssl_cipher_list, alpn_protocols)
def create_no_verify_ssl_context(
ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT,
alpn_protocols: SSLALPNProtocols = SSL_ALPN_NONE,
) -> ssl.SSLContext:
"""Return an SSL context that does not verify the server certificate."""
return _client_context_no_verify(ssl_cipher_list)
return _client_context_no_verify(ssl_cipher_list, alpn_protocols)
def server_context_modern() -> ssl.SSLContext:

View File

@@ -103,6 +103,8 @@ _AMBIENT_IDEAL_GAS_MOLAR_VOLUME = ( # m3⋅mol⁻¹
)
# Molar masses in g⋅mol⁻¹
_CARBON_MONOXIDE_MOLAR_MASS = 28.01
_NITROGEN_DIOXIDE_MOLAR_MASS = 46.0055
_OZONE_MOLAR_MASS = 48.00
_SULPHUR_DIOXIDE_MOLAR_MASS = 64.066
@@ -186,6 +188,52 @@ class BaseUnitConverter:
return (from_unit in cls._UNIT_INVERSES) != (to_unit in cls._UNIT_INVERSES)
class ApparentPowerConverter(BaseUnitConverter):
"""Utility to convert apparent power values."""
UNIT_CLASS = "apparent_power"
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfApparentPower.MILLIVOLT_AMPERE: 1 * 1000,
UnitOfApparentPower.VOLT_AMPERE: 1,
UnitOfApparentPower.KILO_VOLT_AMPERE: 1 / 1000,
}
VALID_UNITS = {
UnitOfApparentPower.MILLIVOLT_AMPERE,
UnitOfApparentPower.VOLT_AMPERE,
UnitOfApparentPower.KILO_VOLT_AMPERE,
}
class AreaConverter(BaseUnitConverter):
"""Utility to convert area values."""
UNIT_CLASS = "area"
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfArea.SQUARE_METERS: 1,
UnitOfArea.SQUARE_CENTIMETERS: 1 / _CM2_TO_M2,
UnitOfArea.SQUARE_MILLIMETERS: 1 / _MM2_TO_M2,
UnitOfArea.SQUARE_KILOMETERS: 1 / _KM2_TO_M2,
UnitOfArea.SQUARE_INCHES: 1 / _IN2_TO_M2,
UnitOfArea.SQUARE_FEET: 1 / _FT2_TO_M2,
UnitOfArea.SQUARE_YARDS: 1 / _YD2_TO_M2,
UnitOfArea.SQUARE_MILES: 1 / _MI2_TO_M2,
UnitOfArea.ACRES: 1 / _ACRE_TO_M2,
UnitOfArea.HECTARES: 1 / _HECTARE_TO_M2,
}
VALID_UNITS = set(UnitOfArea)
class BloodGlucoseConcentrationConverter(BaseUnitConverter):
"""Utility to convert blood glucose concentration values."""
UNIT_CLASS = "blood_glucose_concentration"
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER: 18,
UnitOfBloodGlucoseConcentration.MILLIMOLE_PER_LITER: 1,
}
VALID_UNITS = set(UnitOfBloodGlucoseConcentration)
class CarbonMonoxideConcentrationConverter(BaseUnitConverter):
"""Convert carbon monoxide ratio to mass per volume.
@@ -211,20 +259,16 @@ class CarbonMonoxideConcentrationConverter(BaseUnitConverter):
}
class SulphurDioxideConcentrationConverter(BaseUnitConverter):
"""Convert sulphur dioxide ratio to mass per volume."""
class ConductivityConverter(BaseUnitConverter):
"""Utility to convert electric current values."""
UNIT_CLASS = "sulphur_dioxide"
UNIT_CLASS = "conductivity"
_UNIT_CONVERSION: dict[str | None, float] = {
CONCENTRATION_PARTS_PER_BILLION: 1e9,
CONCENTRATION_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,
UnitOfConductivity.MICROSIEMENS_PER_CM: 1,
UnitOfConductivity.MILLISIEMENS_PER_CM: 1e-3,
UnitOfConductivity.SIEMENS_PER_CM: 1e-6,
}
VALID_UNITS = set(UnitOfConductivity)
class DataRateConverter(BaseUnitConverter):
@@ -248,25 +292,6 @@ class DataRateConverter(BaseUnitConverter):
VALID_UNITS = set(UnitOfDataRate)
class AreaConverter(BaseUnitConverter):
"""Utility to convert area values."""
UNIT_CLASS = "area"
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfArea.SQUARE_METERS: 1,
UnitOfArea.SQUARE_CENTIMETERS: 1 / _CM2_TO_M2,
UnitOfArea.SQUARE_MILLIMETERS: 1 / _MM2_TO_M2,
UnitOfArea.SQUARE_KILOMETERS: 1 / _KM2_TO_M2,
UnitOfArea.SQUARE_INCHES: 1 / _IN2_TO_M2,
UnitOfArea.SQUARE_FEET: 1 / _FT2_TO_M2,
UnitOfArea.SQUARE_YARDS: 1 / _YD2_TO_M2,
UnitOfArea.SQUARE_MILES: 1 / _MI2_TO_M2,
UnitOfArea.ACRES: 1 / _ACRE_TO_M2,
UnitOfArea.HECTARES: 1 / _HECTARE_TO_M2,
}
VALID_UNITS = set(UnitOfArea)
class DistanceConverter(BaseUnitConverter):
"""Utility to convert distance values."""
@@ -295,27 +320,28 @@ class DistanceConverter(BaseUnitConverter):
}
class BloodGlucoseConcentrationConverter(BaseUnitConverter):
"""Utility to convert blood glucose concentration values."""
class DurationConverter(BaseUnitConverter):
"""Utility to convert duration values."""
UNIT_CLASS = "blood_glucose_concentration"
UNIT_CLASS = "duration"
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER: 18,
UnitOfBloodGlucoseConcentration.MILLIMOLE_PER_LITER: 1,
UnitOfTime.MICROSECONDS: 1000000,
UnitOfTime.MILLISECONDS: 1000,
UnitOfTime.SECONDS: 1,
UnitOfTime.MINUTES: 1 / _MIN_TO_SEC,
UnitOfTime.HOURS: 1 / _HRS_TO_SECS,
UnitOfTime.DAYS: 1 / _DAYS_TO_SECS,
UnitOfTime.WEEKS: 1 / (7 * _DAYS_TO_SECS),
}
VALID_UNITS = set(UnitOfBloodGlucoseConcentration)
class ConductivityConverter(BaseUnitConverter):
"""Utility to convert electric current values."""
UNIT_CLASS = "conductivity"
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfConductivity.MICROSIEMENS_PER_CM: 1,
UnitOfConductivity.MILLISIEMENS_PER_CM: 1e-3,
UnitOfConductivity.SIEMENS_PER_CM: 1e-6,
VALID_UNITS = {
UnitOfTime.MICROSECONDS,
UnitOfTime.MILLISECONDS,
UnitOfTime.SECONDS,
UnitOfTime.MINUTES,
UnitOfTime.HOURS,
UnitOfTime.DAYS,
UnitOfTime.WEEKS,
}
VALID_UNITS = set(UnitOfConductivity)
class ElectricCurrentConverter(BaseUnitConverter):
@@ -444,19 +470,51 @@ class MassConverter(BaseUnitConverter):
}
class ApparentPowerConverter(BaseUnitConverter):
"""Utility to convert apparent power values."""
class MassVolumeConcentrationConverter(BaseUnitConverter):
"""Utility to convert mass volume concentration values."""
UNIT_CLASS = "apparent_power"
UNIT_CLASS = "concentration"
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfApparentPower.MILLIVOLT_AMPERE: 1 * 1000,
UnitOfApparentPower.VOLT_AMPERE: 1,
UnitOfApparentPower.KILO_VOLT_AMPERE: 1 / 1000,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: 1000000.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,
}
VALID_UNITS = {
UnitOfApparentPower.MILLIVOLT_AMPERE,
UnitOfApparentPower.VOLT_AMPERE,
UnitOfApparentPower.KILO_VOLT_AMPERE,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_GRAMS_PER_CUBIC_METER,
}
class NitrogenDioxideConcentrationConverter(BaseUnitConverter):
"""Convert nitrogen dioxide ratio to mass per volume."""
UNIT_CLASS = "nitrogen_dioxide"
_UNIT_CONVERSION: dict[str | None, float] = {
CONCENTRATION_PARTS_PER_BILLION: 1e9,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
_NITROGEN_DIOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
),
}
VALID_UNITS = {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
}
class OzoneConcentrationConverter(BaseUnitConverter):
"""Convert ozone ratio to mass per volume."""
UNIT_CLASS = "ozone"
_UNIT_CONVERSION: dict[str | None, float] = {
CONCENTRATION_PARTS_PER_BILLION: 1e9,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
_OZONE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
),
}
VALID_UNITS = {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
}
@@ -645,6 +703,22 @@ class SpeedConverter(BaseUnitConverter):
return float(0.836 * beaufort ** (3 / 2))
class SulphurDioxideConcentrationConverter(BaseUnitConverter):
"""Convert sulphur dioxide ratio to mass per volume."""
UNIT_CLASS = "sulphur_dioxide"
_UNIT_CONVERSION: dict[str | None, float] = {
CONCENTRATION_PARTS_PER_BILLION: 1e9,
CONCENTRATION_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,
}
class TemperatureConverter(BaseUnitConverter):
"""Utility to convert temperature values."""
@@ -815,22 +889,6 @@ class UnitlessRatioConverter(BaseUnitConverter):
}
class MassVolumeConcentrationConverter(BaseUnitConverter):
"""Utility to convert mass volume concentration values."""
UNIT_CLASS = "concentration"
_UNIT_CONVERSION: dict[str | None, float] = {
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: 1000000.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,
}
VALID_UNITS = {
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_GRAMS_PER_CUBIC_METER,
}
class VolumeConverter(BaseUnitConverter):
"""Utility to convert volume values."""
@@ -893,27 +951,3 @@ class VolumeFlowRateConverter(BaseUnitConverter):
UnitOfVolumeFlowRate.GALLONS_PER_DAY,
UnitOfVolumeFlowRate.MILLILITERS_PER_SECOND,
}
class DurationConverter(BaseUnitConverter):
"""Utility to convert duration values."""
UNIT_CLASS = "duration"
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfTime.MICROSECONDS: 1000000,
UnitOfTime.MILLISECONDS: 1000,
UnitOfTime.SECONDS: 1,
UnitOfTime.MINUTES: 1 / _MIN_TO_SEC,
UnitOfTime.HOURS: 1 / _HRS_TO_SECS,
UnitOfTime.DAYS: 1 / _DAYS_TO_SECS,
UnitOfTime.WEEKS: 1 / (7 * _DAYS_TO_SECS),
}
VALID_UNITS = {
UnitOfTime.MICROSECONDS,
UnitOfTime.MILLISECONDS,
UnitOfTime.SECONDS,
UnitOfTime.MINUTES,
UnitOfTime.HOURS,
UnitOfTime.DAYS,
UnitOfTime.WEEKS,
}

View File

@@ -676,7 +676,7 @@ exclude_lines = [
]
[tool.ruff]
required-version = ">=0.13.0"
required-version = ">=0.14.13"
[tool.ruff.lint]
select = [

16
requirements_all.txt generated
View File

@@ -334,7 +334,7 @@ aionanoleaf==0.2.1
aionotion==2024.03.0
# homeassistant.components.ntfy
aiontfy==0.6.1
aiontfy==0.7.0
# homeassistant.components.nut
aionut==4.3.4
@@ -1104,7 +1104,7 @@ google-nest-sdm==9.1.2
google-photos-library-api==0.12.1
# homeassistant.components.google_air_quality
google_air_quality_api==2.1.2
google_air_quality_api==3.0.0
# homeassistant.components.slide
# homeassistant.components.slide_local
@@ -1184,7 +1184,7 @@ hassil==3.5.0
hdate[astral]==1.1.2
# homeassistant.components.hdfury
hdfury==1.3.1
hdfury==1.4.2
# homeassistant.components.heatmiser
heatmiserV3==2.0.4
@@ -1281,7 +1281,7 @@ ihcsdk==2.8.5
imeon_inverter_api==0.4.0
# homeassistant.components.imgw_pib
imgw_pib==1.6.0
imgw_pib==2.0.1
# homeassistant.components.incomfort
incomfort-client==0.6.11
@@ -1646,7 +1646,7 @@ omnilogic==0.4.5
ondilo==0.5.0
# homeassistant.components.onedrive
onedrive-personal-sdk==0.1.0
onedrive-personal-sdk==0.1.1
# homeassistant.components.onvif
onvif-zeep-async==4.0.4
@@ -1909,7 +1909,7 @@ pyatag==0.3.5.3
pyatmo==9.2.3
# homeassistant.components.apple_tv
pyatv==0.16.1;python_version<'3.14'
pyatv==0.17.0
# homeassistant.components.aussie_broadband
pyaussiebb==0.1.5
@@ -2147,7 +2147,7 @@ pykmtronic==0.3.0
pykodi==0.2.7
# homeassistant.components.kostal_plenticore
pykoplenti==1.3.0
pykoplenti==1.5.0
# homeassistant.components.kraken
pykrakenapi==0.1.8
@@ -3080,7 +3080,7 @@ typedmonarchmoney==0.4.4
uasiren==0.0.1
# homeassistant.components.unifiprotect
uiprotect==8.1.1
uiprotect==10.0.1
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7

View File

@@ -319,7 +319,7 @@ aionanoleaf==0.2.1
aionotion==2024.03.0
# homeassistant.components.ntfy
aiontfy==0.6.1
aiontfy==0.7.0
# homeassistant.components.nut
aionut==4.3.4
@@ -980,7 +980,7 @@ google-nest-sdm==9.1.2
google-photos-library-api==0.12.1
# homeassistant.components.google_air_quality
google_air_quality_api==2.1.2
google_air_quality_api==3.0.0
# homeassistant.components.slide
# homeassistant.components.slide_local
@@ -1051,7 +1051,7 @@ hassil==3.5.0
hdate[astral]==1.1.2
# homeassistant.components.hdfury
hdfury==1.3.1
hdfury==1.4.2
# homeassistant.components.here_travel_time
here-routing==1.2.0
@@ -1127,7 +1127,7 @@ igloohome-api==0.1.1
imeon_inverter_api==0.4.0
# homeassistant.components.imgw_pib
imgw_pib==1.6.0
imgw_pib==2.0.1
# homeassistant.components.incomfort
incomfort-client==0.6.11
@@ -1429,7 +1429,7 @@ omnilogic==0.4.5
ondilo==0.5.0
# homeassistant.components.onedrive
onedrive-personal-sdk==0.1.0
onedrive-personal-sdk==0.1.1
# homeassistant.components.onvif
onvif-zeep-async==4.0.4
@@ -1637,7 +1637,7 @@ pyatag==0.3.5.3
pyatmo==9.2.3
# homeassistant.components.apple_tv
pyatv==0.16.1;python_version<'3.14'
pyatv==0.17.0
# homeassistant.components.aussie_broadband
pyaussiebb==0.1.5
@@ -1821,7 +1821,7 @@ pykmtronic==0.3.0
pykodi==0.2.7
# homeassistant.components.kostal_plenticore
pykoplenti==1.3.0
pykoplenti==1.5.0
# homeassistant.components.kraken
pykrakenapi==0.1.8
@@ -2577,7 +2577,7 @@ typedmonarchmoney==0.4.4
uasiren==0.0.1
# homeassistant.components.unifiprotect
uiprotect==8.1.1
uiprotect==10.0.1
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7

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