mirror of
https://github.com/home-assistant/core.git
synced 2026-01-22 15:46:58 +01:00
Compare commits
72 Commits
homevolt
...
adjust_cli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff7c98542d | ||
|
|
5bbc39bd88 | ||
|
|
6b14eb7ad1 | ||
|
|
83a53dea94 | ||
|
|
4fb89e68a7 | ||
|
|
5202ddf095 | ||
|
|
f7d7a4502e | ||
|
|
c7417d77b5 | ||
|
|
22018f1f80 | ||
|
|
22c6704d81 | ||
|
|
0552934b3c | ||
|
|
bbe1d28e88 | ||
|
|
b700a27c8f | ||
|
|
0566a668a9 | ||
|
|
94f636bc2d | ||
|
|
a6e7546142 | ||
|
|
493319894b | ||
|
|
987396722b | ||
|
|
4f52b0363d | ||
|
|
52e18ed6f6 | ||
|
|
4180175fd3 | ||
|
|
e39ee8cae7 | ||
|
|
c214193087 | ||
|
|
2d84847be5 | ||
|
|
0d69fd4535 | ||
|
|
56f556864c | ||
|
|
c1b03dc553 | ||
|
|
07e76578e6 | ||
|
|
bc45fd4e45 | ||
|
|
0ea03f549c | ||
|
|
0ee46dbf5d | ||
|
|
e12f394f8e | ||
|
|
b40046264d | ||
|
|
22afa1d248 | ||
|
|
8920ffbcdb | ||
|
|
a447c1b42e | ||
|
|
50211f75ed | ||
|
|
27117c9d17 | ||
|
|
7c4cdd57b6 | ||
|
|
6af5698645 | ||
|
|
75db2cde40 | ||
|
|
329dd05434 | ||
|
|
53c53d03e0 | ||
|
|
360b394d03 | ||
|
|
a663d55632 | ||
|
|
3fd266a513 | ||
|
|
442c1d6242 | ||
|
|
0e2aae02f6 | ||
|
|
3227a6e49f | ||
|
|
9d0cfb628b | ||
|
|
4578fe0260 | ||
|
|
0d92708108 | ||
|
|
cceb50071b | ||
|
|
62f296c9dd | ||
|
|
ea1f280494 | ||
|
|
67108a2fc8 | ||
|
|
1ccbd5124e | ||
|
|
818af90a7b | ||
|
|
23bc78fa25 | ||
|
|
0b1cc7638f | ||
|
|
c291a2fbc1 | ||
|
|
7379a4ff4b | ||
|
|
ddcf5cb749 | ||
|
|
4b10a542b0 | ||
|
|
beea9fa74b | ||
|
|
ce8fd16456 | ||
|
|
2172d15489 | ||
|
|
0cfa0ed670 | ||
|
|
f6839913d8 | ||
|
|
8fa01497ee | ||
|
|
e077c65a77 | ||
|
|
7c49656fa8 |
7
.github/workflows/ci.yaml
vendored
7
.github/workflows/ci.yaml
vendored
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["hdfury==1.3.1"]
|
||||
"requirements": ["hdfury==1.4.2"]
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["kostal"],
|
||||
"requirements": ["pykoplenti==1.3.0"]
|
||||
"requirements": ["pykoplenti==1.5.0"]
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -22,7 +22,10 @@
|
||||
number:
|
||||
selector:
|
||||
number:
|
||||
max: 100
|
||||
min: 0
|
||||
mode: box
|
||||
unit_of_measurement: "%"
|
||||
entity:
|
||||
selector:
|
||||
entity:
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aionfty"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiontfy==0.6.1"]
|
||||
"requirements": ["aiontfy==0.7.0"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -88,3 +88,8 @@ publish:
|
||||
type: url
|
||||
autocomplete: url
|
||||
example: https://example.org/logo.png
|
||||
sequence_id:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
example: "Mc3otamDNcpJ"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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."]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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]] = {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/rehlko",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aiokem"],
|
||||
"quality_scale": "silver",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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."]
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"homekit": {
|
||||
"models": ["Sensibo"]
|
||||
},
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pysensibo"],
|
||||
"quality_scale": "platinum",
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
16
requirements_all.txt
generated
@@ -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
|
||||
|
||||
16
requirements_test_all.txt
generated
16
requirements_test_all.txt
generated
@@ -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
Reference in New Issue
Block a user