Compare commits

...

92 Commits

Author SHA1 Message Date
Franck Nijhof b28cdcfc49 2024.6.1 (#119096) 2024-06-07 21:20:44 +02:00
Franck Nijhof 3f70e2b6f0 Bump version to 2024.6.1 2024-06-07 20:26:53 +02:00
Joost Lekkerkerker ed22e98861 Fix Azure Data Explorer strings (#119067) 2024-06-07 20:24:03 +02:00
Marc Mueller 093f07c04e Add type ignore comments (#119052) 2024-06-07 20:24:00 +02:00
Joost Lekkerkerker b5693ca604 Fix AirGradient name (#119046) 2024-06-07 20:23:57 +02:00
J. Nick Koston 20b77aa15f Fix remember_the_milk calling configurator async api from the wrong thread (#119029) 2024-06-07 20:23:53 +02:00
J. Nick Koston 1cbd3ab930 Fix refactoring error in snmp switch (#119028) 2024-06-07 20:23:50 +02:00
Matthias Alphart 31b44b7846 Fix KNX climate.set_hvac_mode not turning on (#119012) 2024-06-07 20:23:47 +02:00
Matthias Alphart de3a0841d8 Increase test coverage for KNX Climate (#117903)
* Increase test coverage fro KNX Climate

* fix test type annotation
2024-06-07 20:23:40 +02:00
Mike Degatano 581fb2f9f4 Always have addon url in detached_addon_missing (#119011) 2024-06-07 20:21:25 +02:00
Shay Levy 5bb4e4f5d9 Hold connection lock in Shelly RPC reconnect (#119009) 2024-06-07 20:21:22 +02:00
J. Nick Koston cfa619b67e Remove isal from after_dependencies in http (#119000) 2024-06-07 20:21:18 +02:00
Michael Hansen 56db7fc7dc Fix exposure checks on some intents (#118988)
* Check exposure in climate intent

* Check exposure in todo list

* Check exposure for weather

* Check exposure in humidity intents

* Add extra checks to weather tests

* Add more checks to todo intent test

* Move climate intents to async_match_targets

* Update test_intent.py

* Update test_intent.py

* Remove patch
2024-06-07 20:20:41 +02:00
Joost Lekkerkerker 1f6be7b4d1 Fix unit of measurement for airgradient sensor (#118981) 2024-06-07 20:18:45 +02:00
Maciej Bieniek 52d1432d81 Bump imgw-pib library to version 1.0.4 (#118978)
Co-authored-by: Maciej Bieniek <478555+bieniu@users.noreply.github.com>
2024-06-07 20:18:41 +02:00
David Knowles 14da1e9b23 Bump pydrawise to 2024.6.3 (#118977) 2024-06-07 20:18:38 +02:00
G Johansson d6e1d05e87 Bump python-holidays to 0.50 (#118965) 2024-06-07 20:18:35 +02:00
G Johansson 62f73cfcca Fix Alarm control panel not require code in several integrations (#118961) 2024-06-07 20:18:32 +02:00
Maciej Bieniek 6e9a53d02e Bump imgw-pib backend library to version 1.0.2 (#118953)
Bump imgw-pib to version 1.0.2

Co-authored-by: Maciej Bieniek <478555+bieniu@users.noreply.github.com>
2024-06-07 20:18:28 +02:00
J. Nick Koston 394c13af1d Revert "Bump orjson to 3.10.3 (#116945)" (#118920)
This reverts commit dc50095d06.
2024-06-07 20:17:01 +02:00
Jan-Philipp Benecke 86b13e8ae3 Fix flaky Google Assistant test (#118914)
* Fix flaky Google Assistant test

* Trigger full ci
2024-06-07 20:16:58 +02:00
Rami Mosleh 5a7332a135 Check if imap message text has a value instead of checking if its not None (#118901)
* Check if message_text has a value instead of checking if its not None

* Strip message_text to ensure that its actually empty or not

* Add test with multipart payload having empty plain text
2024-06-07 20:16:55 +02:00
Michael Hansen 0f9a91d369 Prioritize literal text with name slots in sentence matching (#118900)
Prioritize literal text with name slots
2024-06-07 20:16:52 +02:00
Marc Mueller 00dd86fb4b Update requests to 2.32.3 (#118868)
Co-authored-by: Robert Resch <robert@resch.dev>
2024-06-07 20:16:47 +02:00
Franck Nijhof 460909a7f6 2024.6.0 (#118400) 2024-06-05 20:06:01 +02:00
Franck Nijhof 21fd012447 Bump version to 2024.6.0 2024-06-05 19:00:08 +02:00
Robert Resch c27f0c560e Replace slave by meter in v2c (#118893) 2024-06-05 18:59:52 +02:00
Michael Hansen 0f4a1b421e Bump intents to 2024.6.5 (#118890) 2024-06-05 18:59:49 +02:00
Erik Montnemery 5e35ce2996 Improve WS command validate_config (#118864)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
Co-authored-by: Robert Resch <robert@resch.dev>
2024-06-05 18:59:44 +02:00
Franck Nijhof e5804307e7 Bump version to 2024.6.0b9 2024-06-05 15:51:19 +02:00
Bram Kragten 3b74b63b23 Update frontend to 20240605.0 (#118875) 2024-06-05 15:51:08 +02:00
Marc Mueller 06df32d9d4 Fix TypeAliasType not callable in senz (#118872) 2024-06-05 15:51:05 +02:00
Jan Bouwhuis 63947e4980 Improve repair issue when notify service is still being used (#118855)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2024-06-05 15:51:02 +02:00
Ethem Cem Özkan ac6a377478 Bump python-roborock to 2.2.3 (#118853)
Co-authored-by: G Johansson <goran.johansson@shiftit.se>
2024-06-05 15:50:59 +02:00
Paulus Schoutsen 18af423a78 Fix the radio browser doing I/O in the event loop (#118842) 2024-06-05 15:50:56 +02:00
Franck Nijhof f1445bc8f5 Fix capitalization of protocols in Reolink option flow (#118839) 2024-06-05 15:50:53 +02:00
starkillerOG 3784c99305 Conserve Reolink battery by not waking the camera on each update (#118773)
* update to new cmd_list type

* Wake battery cams each 1 hour

* fix styling

* fix epoch

* fix timezone

* force full update when using generic update service

* improve comment

* Use time.time() instead of datetime

* fix import order
2024-06-05 15:50:50 +02:00
Pete Sage 0084d6c5bd Fix Hydrawise sensor availability (#118669)
Co-authored-by: Robert Resch <robert@resch.dev>
2024-06-05 15:50:47 +02:00
Franck Nijhof f1e6375406 Bump version to 2024.6.0b8 2024-06-04 21:32:36 +02:00
Jan-Philipp Benecke 9157905f80 Initialize the Sentry SDK within an import executor job to not block event loop (#118830) 2024-06-04 21:31:49 +02:00
J. Nick Koston b02c9aa2ef Ensure name of task is logged for unhandled loop exceptions (#118822) 2024-06-04 21:31:45 +02:00
Bram Kragten 6e30fd7633 Update frontend to 20240604.0 (#118811) 2024-06-04 21:31:42 +02:00
Stefan Agner 74b29c2e54 Bump Python Matter Server library to 6.1.0 (#118806) 2024-06-04 21:31:38 +02:00
Maciej Bieniek b1b26af92b Check if Shelly entry.runtime_data is available (#118805)
* Check if runtime_data is available

* Add tests

* Use `is` operator

---------

Co-authored-by: Maciej Bieniek <478555+bieniu@users.noreply.github.com>
2024-06-04 21:31:33 +02:00
arturyak b107ffd30d Add missing FAN_ONLY mode to ccm15 (#118804) 2024-06-04 21:31:28 +02:00
Joost Lekkerkerker 776675404a Set unique id in aladdin connect config flow (#118798) 2024-06-04 21:30:08 +02:00
Paulus Schoutsen 38ee32fed2 Include script description in LLM exposed entities (#118749)
* Include script description in LLM exposed entities

* Fix race in test

* Fix type

* Expose script

* Remove fields
2024-06-04 21:21:26 +02:00
Tsvi Mostovicz 111d11aaca Fix updating options in Jewish Calendar (#118643) 2024-06-04 21:21:23 +02:00
Jack Boswell ff8752ea4f Fix calculation of Starlink sleep end setting (#115507)
Co-authored-by: J. Nick Koston <nick@koston.org>
2024-06-04 21:21:13 +02:00
Franck Nijhof 2151f7ebf3 Bump version to 2024.6.0b7 2024-06-04 12:20:22 +02:00
Richard Kroegel 50efce4e53 Allow per-sensor unit conversion on BMW sensors (#110272)
* Update BMW sensors to use device_class

* Test adjustments

* Trigger CI

* Remove unneeded cast

* Set suggested_display_precision to 0

* Rebase for climate_status

* Change charging_status to ENUM device class

* Add test for Enum translations

* Pin Enum sensor values

* Use snapshot_platform helper

* Remove translation tests

* Formatting

* Remove comment

* Use const.STATE_UNKOWN

* Fix typo

* Update strings

* Loop through Enum sensors

* Revert enum sensor changes

---------

Co-authored-by: Richard <rikroe@users.noreply.github.com>
2024-06-04 12:20:08 +02:00
Richard Kroegel c8538f3c08 Use snapshot_platform helper for BMW tests (#118735)
* Use snapshot_platform helper

* Remove comments

---------

Co-authored-by: Richard <rikroe@users.noreply.github.com>
2024-06-04 12:19:01 +02:00
Richard Kroegel 4bfff12570 Set lock state to unkown on BMW API error (#118559)
* Revert to previous lock state on BMW API error

* Set lock state to unkown on error and force refresh from API

---------

Co-authored-by: Richard <rikroe@users.noreply.github.com>
2024-06-04 12:02:47 +02:00
Richard Kroegel f2b1635969 Refactor fixture calling for BMW tests (#118708)
* Refactor BMW tests to use pytest.mark.usefixtures

* Fix freeze_time

---------

Co-authored-by: Richard <rikroe@users.noreply.github.com>
2024-06-04 12:01:40 +02:00
Joost Lekkerkerker b3b8ae31fd Move Aladdin stale device removal to init module (#118784) 2024-06-04 11:58:35 +02:00
Joost Lekkerkerker ba96fc272b Re-enable sensor platform for Aladdin Connect (#118782) 2024-06-04 11:58:32 +02:00
Joost Lekkerkerker c702174fa0 Add coordinator to Aladdin Connect (#118781) 2024-06-04 11:58:29 +02:00
Joost Lekkerkerker 5d6fe7387e Use model from Aladdin Connect lib (#118778)
* Use model from Aladdin Connect lib

* Fix
2024-06-04 11:58:25 +02:00
Joost Lekkerkerker c76b7a48d3 Initial cleanup for Aladdin connect (#118777) 2024-06-04 11:58:22 +02:00
Joost Lekkerkerker 954e8ff9b3 Bump airgradient to 0.4.3 (#118776) 2024-06-04 11:58:18 +02:00
Joakim Sørensen 8c332ddbdb Update hass-nabucasa to version 0.81.1 (#118768) 2024-06-04 11:58:15 +02:00
Jan Bouwhuis 01c4ca2749 Recover mqtt abbrevations optimizations (#118762)
Co-authored-by: J. Nick Koston <nick@koston.org>
2024-06-04 11:58:12 +02:00
Michael Hansen 4b4b5362d9 Clean up exposed domains (#118753)
* Remove lock and script

* Add media player

* Fix tests
2024-06-04 11:58:08 +02:00
Jan Bouwhuis 70d7cedf08 Do not log mqtt origin info if the log level does not allow it (#118752) 2024-06-04 11:58:05 +02:00
Michael Hansen 7bbfb1a22b Bump intents to 2024.6.3 (#118748) 2024-06-04 11:58:02 +02:00
Paulus Schoutsen d68d871054 Update OpenAI prompt on each interaction (#118747) 2024-06-04 11:57:58 +02:00
Jan Bouwhuis 69bdefb02d Revert "Allow MQTT device based auto discovery" (#118746)
Revert "Allow MQTT device based auto discovery (#109030)"

This reverts commit 585892f067.
2024-06-04 11:57:55 +02:00
Paulus Schoutsen ebaec6380f Google Gen AI: Copy messages to avoid changing the trace data (#118745) 2024-06-04 11:57:51 +02:00
starkillerOG 9cf6e9b21a Bump reolink-aio to 0.9.1 (#118655)
Co-authored-by: J. Nick Koston <nick@koston.org>
2024-06-04 11:55:34 +02:00
David Bonnes eb1a9eda60 Harden evohome against failures to retrieve zone schedules (#118517) 2024-06-04 11:55:21 +02:00
Franck Nijhof 26344ffd74 Bump version to 2024.6.0b6 2024-06-03 21:27:31 +02:00
Paulus Schoutsen 2940104008 Remove dispatcher from Tag entity (#118671)
* Remove dispatcher from Tag entity

* type

* Don't use  helper

* Del is faster than pop

* Use id in update

---------

Co-authored-by: G Johansson <goran.johansson@shiftit.se>
2024-06-03 21:27:08 +02:00
Joost Lekkerkerker 8072a268a1 Require firmware version 3.1.1 for airgradient (#118744) 2024-06-03 21:26:19 +02:00
Bram Kragten b5f557ad73 Update frontend to 20240603.0 (#118736) 2024-06-03 21:26:16 +02:00
Michael Hansen f977b54312 Resolve areas/floors to ids in intent_script (#118734) 2024-06-03 21:26:13 +02:00
Jan-Philipp Benecke 11b2f201f3 Rename Discovergy to inexogy (#118724) 2024-06-03 21:26:10 +02:00
Erik Montnemery 8cc3c147fe Tweak light service schema (#118720) 2024-06-03 21:26:07 +02:00
epenet fd9ea2f224 Bump renault-api to 0.2.3 (#118718) 2024-06-03 21:26:04 +02:00
Diogo Gomes f064f44a09 Address reviews comments in #117147 (#118714) 2024-06-03 21:26:01 +02:00
Erik Montnemery f3d1157bc4 Remove tag_id from tag store (#118713) 2024-06-03 21:25:58 +02:00
mkmer 85982d2b87 Remove unintended translation key from blink (#118712) 2024-06-03 21:25:55 +02:00
Erik Montnemery cc83443ad1 Don't store tag_id in tag storage (#118707) 2024-06-03 21:25:52 +02:00
tronikos 8a516207e9 Use ISO format when passing date to LLMs (#118705) 2024-06-03 21:25:49 +02:00
Mick Vleeshouwer f805df8390 Bump pyoverkiz to 1.13.11 (#118703) 2024-06-03 21:25:46 +02:00
Joost Lekkerkerker ea85ed6992 Disable both option in Airgradient select (#118702) 2024-06-03 21:25:43 +02:00
Joost Lekkerkerker 54425b756e Configure device in airgradient config flow (#118699) 2024-06-03 21:25:40 +02:00
Paul Bottein 7b43b587a7 Bump python-roborock to 2.2.2 (#118697) 2024-06-03 21:25:37 +02:00
Matrix 7e71975358 Fixing device model compatibility issues. (#118686) 2024-06-03 21:25:34 +02:00
J. Nick Koston e0232510d7 Revert "Add websocket API to get list of recorded entities (#92640)" (#118644)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2024-06-03 21:21:45 +02:00
Paulus Schoutsen 84f9bb1d63 Automatically fill in slots based on LLM context (#118619)
* Automatically fill in slots from LLM context

* Add tests

* Apply suggestions from code review

Co-authored-by: Allen Porter <allen@thebends.org>

---------

Co-authored-by: Allen Porter <allen@thebends.org>
2024-06-03 21:21:41 +02:00
David Knowles b436fe94ae Bump pydrawise to 2024.6.2 (#118608) 2024-06-03 21:21:38 +02:00
epenet aff5da5762 Address late review comment in samsungtv (#118539)
Address late comment in samsungtv
2024-06-03 21:21:29 +02:00
184 changed files with 6220 additions and 3440 deletions
-1
View File
@@ -62,7 +62,6 @@ omit =
homeassistant/components/aladdin_connect/api.py
homeassistant/components/aladdin_connect/application_credentials.py
homeassistant/components/aladdin_connect/cover.py
homeassistant/components/aladdin_connect/model.py
homeassistant/components/aladdin_connect/sensor.py
homeassistant/components/alarmdecoder/__init__.py
homeassistant/components/alarmdecoder/alarm_control_panel.py
+10 -3
View File
@@ -134,8 +134,15 @@ COOLDOWN_TIME = 60
DEBUGGER_INTEGRATIONS = {"debugpy"}
# Core integrations are unconditionally loaded
CORE_INTEGRATIONS = {"homeassistant", "persistent_notification"}
LOGGING_INTEGRATIONS = {
# Integrations that are loaded right after the core is set up
LOGGING_AND_HTTP_DEPS_INTEGRATIONS = {
# isal is loaded right away before `http` to ensure if its
# enabled, that `isal` is up to date.
"isal",
# Set log levels
"logger",
# Error logging
@@ -214,8 +221,8 @@ CRITICAL_INTEGRATIONS = {
}
SETUP_ORDER = (
# Load logging as soon as possible
("logging", LOGGING_INTEGRATIONS),
# Load logging and http deps as soon as possible
("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS),
# Setup frontend and recorder
("frontend, recorder", {*FRONTEND_INTEGRATIONS, *RECORDER_INTEGRATIONS}),
# Start up debuggers. Start these first in case they want to wait.
@@ -43,6 +43,7 @@ class AgentBaseStation(AlarmControlPanelEntity):
| AlarmControlPanelEntityFeature.ARM_AWAY
| AlarmControlPanelEntityFeature.ARM_NIGHT
)
_attr_code_arm_required = False
_attr_has_entity_name = True
_attr_name = None
@@ -2,7 +2,9 @@
from typing import Any
from airgradient import AirGradientClient, AirGradientError
from airgradient import AirGradientClient, AirGradientError, ConfigurationControl
from awesomeversion import AwesomeVersion
from mashumaro import MissingField
import voluptuous as vol
from homeassistant.components import zeroconf
@@ -12,6 +14,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
MIN_VERSION = AwesomeVersion("3.1.1")
class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN):
"""AirGradient config flow."""
@@ -19,6 +23,14 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize the config flow."""
self.data: dict[str, Any] = {}
self.client: AirGradientClient | None = None
async def set_configuration_source(self) -> None:
"""Set configuration source to local if it hasn't been set yet."""
assert self.client
config = await self.client.get_config()
if config.configuration_control is ConfigurationControl.NOT_INITIALIZED:
await self.client.set_configuration_control(ConfigurationControl.LOCAL)
async def async_step_zeroconf(
self, discovery_info: zeroconf.ZeroconfServiceInfo
@@ -30,9 +42,12 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(discovery_info.properties["serialno"])
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
if AwesomeVersion(discovery_info.properties["fw_ver"]) < MIN_VERSION:
return self.async_abort(reason="invalid_version")
session = async_get_clientsession(self.hass)
air_gradient = AirGradientClient(host, session=session)
await air_gradient.get_current_measures()
self.client = AirGradientClient(host, session=session)
await self.client.get_current_measures()
self.context["title_placeholders"] = {
"model": self.data[CONF_MODEL],
@@ -44,6 +59,7 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Confirm discovery."""
if user_input is not None:
await self.set_configuration_source()
return self.async_create_entry(
title=self.data[CONF_MODEL],
data={CONF_HOST: self.data[CONF_HOST]},
@@ -64,14 +80,17 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
if user_input:
session = async_get_clientsession(self.hass)
air_gradient = AirGradientClient(user_input[CONF_HOST], session=session)
self.client = AirGradientClient(user_input[CONF_HOST], session=session)
try:
current_measures = await air_gradient.get_current_measures()
current_measures = await self.client.get_current_measures()
except AirGradientError:
errors["base"] = "cannot_connect"
except MissingField:
return self.async_abort(reason="invalid_version")
else:
await self.async_set_unique_id(current_measures.serial_number)
self._abort_if_unique_id_configured()
await self.set_configuration_source()
return self.async_create_entry(
title=current_measures.model,
data={CONF_HOST: user_input[CONF_HOST]},
@@ -1,11 +1,11 @@
{
"domain": "airgradient",
"name": "Airgradient",
"name": "AirGradient",
"codeowners": ["@airgradienthq", "@joostlek"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/airgradient",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["airgradient==0.4.2"],
"requirements": ["airgradient==0.4.3"],
"zeroconf": ["_airgradient._tcp.local."]
}
@@ -22,7 +22,7 @@ from .entity import AirGradientEntity
class AirGradientSelectEntityDescription(SelectEntityDescription):
"""Describes AirGradient select entity."""
value_fn: Callable[[Config], str]
value_fn: Callable[[Config], str | None]
set_value_fn: Callable[[AirGradientClient, str], Awaitable[None]]
requires_display: bool = False
@@ -30,9 +30,11 @@ class AirGradientSelectEntityDescription(SelectEntityDescription):
CONFIG_CONTROL_ENTITY = AirGradientSelectEntityDescription(
key="configuration_control",
translation_key="configuration_control",
options=[x.value for x in ConfigurationControl],
options=[ConfigurationControl.CLOUD.value, ConfigurationControl.LOCAL.value],
entity_category=EntityCategory.CONFIG,
value_fn=lambda config: config.configuration_control,
value_fn=lambda config: config.configuration_control
if config.configuration_control is not ConfigurationControl.NOT_INITIALIZED
else None,
set_value_fn=lambda client, value: client.set_configuration_control(
ConfigurationControl(value)
),
@@ -96,7 +98,7 @@ class AirGradientSelect(AirGradientEntity, SelectEntity):
self._attr_unique_id = f"{coordinator.serial_number}-{description.key}"
@property
def current_option(self) -> str:
def current_option(self) -> str | None:
"""Return the state of the select."""
return self.entity_description.value_fn(self.coordinator.data)
@@ -103,6 +103,7 @@ SENSOR_TYPES: tuple[AirGradientSensorEntityDescription, ...] = (
AirGradientSensorEntityDescription(
key="pm003",
translation_key="pm003_count",
native_unit_of_measurement="particles/dL",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.pm003_count,
),
@@ -15,7 +15,8 @@
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"invalid_version": "This firmware version is unsupported. Please upgrade the firmware of the device to at least version 3.1.1."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -28,8 +29,7 @@
"name": "Configuration source",
"state": {
"cloud": "Cloud",
"local": "Local",
"both": "Both"
"local": "Local"
}
},
"display_temperature_unit": {
@@ -48,7 +48,7 @@
"name": "Nitrogen index"
},
"pm003_count": {
"name": "PM0.3 count"
"name": "PM0.3"
},
"raw_total_volatile_organic_component": {
"name": "Raw total VOC"
@@ -2,52 +2,93 @@
from __future__ import annotations
from genie_partner_sdk.client import AladdinConnectClient
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
OAuth2Session,
async_get_config_entry_implementation,
)
from . import api
from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION
from .api import AsyncConfigEntryAuth
from .const import DOMAIN
from .coordinator import AladdinConnectCoordinator
PLATFORMS: list[Platform] = [Platform.COVER]
PLATFORMS: list[Platform] = [Platform.COVER, Platform.SENSOR]
type AladdinConnectConfigEntry = ConfigEntry[AladdinConnectCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(
hass: HomeAssistant, entry: AladdinConnectConfigEntry
) -> bool:
"""Set up Aladdin Connect Genie from a config entry."""
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
)
implementation = await async_get_config_entry_implementation(hass, entry)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
session = OAuth2Session(hass, entry, implementation)
auth = AsyncConfigEntryAuth(async_get_clientsession(hass), session)
coordinator = AladdinConnectCoordinator(hass, AladdinConnectClient(auth))
# If using an aiohttp-based API lib
entry.runtime_data = api.AsyncConfigEntryAuth(
aiohttp_client.async_get_clientsession(hass), session
)
await coordinator.async_setup()
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
async_remove_stale_devices(hass, entry)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(
hass: HomeAssistant, entry: AladdinConnectConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
async def async_migrate_entry(
hass: HomeAssistant, config_entry: AladdinConnectConfigEntry
) -> bool:
"""Migrate old config."""
if config_entry.version < CONFIG_FLOW_VERSION:
if config_entry.version < 2:
config_entry.async_start_reauth(hass)
new_data = {**config_entry.data}
hass.config_entries.async_update_entry(
config_entry,
data=new_data,
version=CONFIG_FLOW_VERSION,
minor_version=CONFIG_FLOW_MINOR_VERSION,
version=2,
minor_version=1,
)
return True
def async_remove_stale_devices(
hass: HomeAssistant, config_entry: AladdinConnectConfigEntry
) -> None:
"""Remove stale devices from device registry."""
device_registry = dr.async_get(hass)
device_entries = dr.async_entries_for_config_entry(
device_registry, config_entry.entry_id
)
all_device_ids = {door.unique_id for door in config_entry.runtime_data.doors}
for device_entry in device_entries:
device_id: str | None = None
for identifier in device_entry.identifiers:
if identifier[0] == DOMAIN:
device_id = identifier[1]
break
if device_id is None or device_id not in all_device_ids:
# If device_id is None an invalid device entry was found for this config entry.
# If the device_id is not in existing device ids it's a stale device entry.
# Remove config entry from this device entry in either case.
device_registry.async_update_device(
device_entry.id, remove_config_entry_id=config_entry.entry_id
)
@@ -1,9 +1,11 @@
"""API for Aladdin Connect Genie bound to Home Assistant OAuth."""
from typing import cast
from aiohttp import ClientSession
from genie_partner_sdk.auth import Auth
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
API_URL = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1"
API_KEY = "k6QaiQmcTm2zfaNns5L1Z8duBtJmhDOW8JawlCC3"
@@ -15,7 +17,7 @@ class AsyncConfigEntryAuth(Auth): # type: ignore[misc]
def __init__(
self,
websession: ClientSession,
oauth_session: config_entry_oauth2_flow.OAuth2Session,
oauth_session: OAuth2Session,
) -> None:
"""Initialize Aladdin Connect Genie auth."""
super().__init__(
@@ -25,7 +27,6 @@ class AsyncConfigEntryAuth(Auth): # type: ignore[misc]
async def async_get_access_token(self) -> str:
"""Return a valid access token."""
if not self._oauth_session.valid_token:
await self._oauth_session.async_ensure_token_valid()
await self._oauth_session.async_ensure_token_valid()
return str(self._oauth_session.token["access_token"])
return cast(str, self._oauth_session.token["access_token"])
@@ -4,22 +4,21 @@ from collections.abc import Mapping
import logging
from typing import Any
import voluptuous as vol
import jwt
from homeassistant.config_entries import ConfigEntry, ConfigFlowResult
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN
from .const import DOMAIN
class OAuth2FlowHandler(
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
):
class AladdinConnectOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Config flow to handle Aladdin Connect Genie OAuth2 authentication."""
DOMAIN = DOMAIN
VERSION = CONFIG_FLOW_VERSION
MINOR_VERSION = CONFIG_FLOW_MINOR_VERSION
VERSION = 2
MINOR_VERSION = 1
reauth_entry: ConfigEntry | None = None
@@ -37,20 +36,33 @@ class OAuth2FlowHandler(
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({}),
)
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:
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
"""Create an oauth config entry or update existing entry for reauth."""
if self.reauth_entry:
token_payload = jwt.decode(
data[CONF_TOKEN][CONF_ACCESS_TOKEN], options={"verify_signature": False}
)
if not self.reauth_entry:
await self.async_set_unique_id(token_payload["sub"])
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=token_payload["username"],
data=data,
)
if self.reauth_entry.unique_id == token_payload["username"]:
return self.async_update_reload_and_abort(
self.reauth_entry,
data=data,
unique_id=token_payload["sub"],
)
return await super().async_oauth_create_entry(data)
if self.reauth_entry.unique_id == token_payload["sub"]:
return self.async_update_reload_and_abort(self.reauth_entry, data=data)
return self.async_abort(reason="wrong_account")
@property
def logger(self) -> logging.Logger:
@@ -1,14 +1,6 @@
"""Constants for the Aladdin Connect Genie integration."""
from typing import Final
from homeassistant.components.cover import CoverEntityFeature
DOMAIN = "aladdin_connect"
CONFIG_FLOW_VERSION = 2
CONFIG_FLOW_MINOR_VERSION = 1
OAUTH2_AUTHORIZE = "https://app.aladdinconnect.com/login.html"
OAUTH2_TOKEN = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1/oauth2/token"
SUPPORTED_FEATURES: Final = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
@@ -0,0 +1,38 @@
"""Define an object to coordinate fetching Aladdin Connect data."""
from datetime import timedelta
import logging
from genie_partner_sdk.client import AladdinConnectClient
from genie_partner_sdk.model import GarageDoor
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
class AladdinConnectCoordinator(DataUpdateCoordinator[None]):
"""Aladdin Connect Data Update Coordinator."""
def __init__(self, hass: HomeAssistant, acc: AladdinConnectClient) -> None:
"""Initialize."""
super().__init__(
hass,
logger=_LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=15),
)
self.acc = acc
self.doors: list[GarageDoor] = []
async def async_setup(self) -> None:
"""Fetch initial data."""
self.doors = await self.acc.get_doors()
async def _async_update_data(self) -> None:
"""Fetch data from API endpoint."""
for door in self.doors:
await self.acc.update_door(door.device_id, door.door_number)
@@ -1,115 +1,64 @@
"""Cover Entity for Genie Garage Door."""
from datetime import timedelta
from typing import Any
from genie_partner_sdk.client import AladdinConnectClient
from genie_partner_sdk.model import GarageDoor
from homeassistant.components.cover import CoverDeviceClass, CoverEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.components.cover import (
CoverDeviceClass,
CoverEntity,
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import api
from .const import DOMAIN, SUPPORTED_FEATURES
from .model import GarageDoor
SCAN_INTERVAL = timedelta(seconds=15)
from . import AladdinConnectConfigEntry, AladdinConnectCoordinator
from .entity import AladdinConnectEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: AladdinConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Aladdin Connect platform."""
session: api.AsyncConfigEntryAuth = config_entry.runtime_data
acc = AladdinConnectClient(session)
doors = await acc.get_doors()
if doors is None:
raise PlatformNotReady("Error from Aladdin Connect getting doors")
device_registry = dr.async_get(hass)
doors_to_add = []
for door in doors:
existing = device_registry.async_get(door.unique_id)
if existing is None:
doors_to_add.append(door)
coordinator = config_entry.runtime_data
async_add_entities(
(AladdinDevice(acc, door, config_entry) for door in doors_to_add),
)
remove_stale_devices(hass, config_entry, doors)
async_add_entities(AladdinDevice(coordinator, door) for door in coordinator.doors)
def remove_stale_devices(
hass: HomeAssistant, config_entry: ConfigEntry, devices: list[GarageDoor]
) -> None:
"""Remove stale devices from device registry."""
device_registry = dr.async_get(hass)
device_entries = dr.async_entries_for_config_entry(
device_registry, config_entry.entry_id
)
all_device_ids = {door.unique_id for door in devices}
for device_entry in device_entries:
device_id: str | None = None
for identifier in device_entry.identifiers:
if identifier[0] == DOMAIN:
device_id = identifier[1]
break
if device_id is None or device_id not in all_device_ids:
# If device_id is None an invalid device entry was found for this config entry.
# If the device_id is not in existing device ids it's a stale device entry.
# Remove config entry from this device entry in either case.
device_registry.async_update_device(
device_entry.id, remove_config_entry_id=config_entry.entry_id
)
class AladdinDevice(CoverEntity):
class AladdinDevice(AladdinConnectEntity, CoverEntity):
"""Representation of Aladdin Connect cover."""
_attr_device_class = CoverDeviceClass.GARAGE
_attr_supported_features = SUPPORTED_FEATURES
_attr_has_entity_name = True
_attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
_attr_name = None
def __init__(
self, acc: AladdinConnectClient, device: GarageDoor, entry: ConfigEntry
self, coordinator: AladdinConnectCoordinator, device: GarageDoor
) -> None:
"""Initialize the Aladdin Connect cover."""
self._acc = acc
self._device_id = device.device_id
self._number = device.door_number
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.unique_id)},
name=device.name,
manufacturer="Overhead Door",
)
super().__init__(coordinator, device)
self._attr_unique_id = device.unique_id
async def async_open_cover(self, **kwargs: Any) -> None:
"""Issue open command to cover."""
await self._acc.open_door(self._device_id, self._number)
await self.coordinator.acc.open_door(
self._device.device_id, self._device.door_number
)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Issue close command to cover."""
await self._acc.close_door(self._device_id, self._number)
async def async_update(self) -> None:
"""Update status of cover."""
await self._acc.update_door(self._device_id, self._number)
await self.coordinator.acc.close_door(
self._device.device_id, self._device.door_number
)
@property
def is_closed(self) -> bool | None:
"""Update is closed attribute."""
value = self._acc.get_door_status(self._device_id, self._number)
value = self.coordinator.acc.get_door_status(
self._device.device_id, self._device.door_number
)
if value is None:
return None
return bool(value == "closed")
@@ -117,7 +66,9 @@ class AladdinDevice(CoverEntity):
@property
def is_closing(self) -> bool | None:
"""Update is closing attribute."""
value = self._acc.get_door_status(self._device_id, self._number)
value = self.coordinator.acc.get_door_status(
self._device.device_id, self._device.door_number
)
if value is None:
return None
return bool(value == "closing")
@@ -125,7 +76,9 @@ class AladdinDevice(CoverEntity):
@property
def is_opening(self) -> bool | None:
"""Update is opening attribute."""
value = self._acc.get_door_status(self._device_id, self._number)
value = self.coordinator.acc.get_door_status(
self._device.device_id, self._device.door_number
)
if value is None:
return None
return bool(value == "opening")
@@ -0,0 +1,27 @@
"""Defines a base Aladdin Connect entity."""
from genie_partner_sdk.model import GarageDoor
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AladdinConnectCoordinator
class AladdinConnectEntity(CoordinatorEntity[AladdinConnectCoordinator]):
"""Defines a base Aladdin Connect entity."""
_attr_has_entity_name = True
def __init__(
self, coordinator: AladdinConnectCoordinator, device: GarageDoor
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._device = device
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.unique_id)},
name=device.name,
manufacturer="Overhead Door",
)
@@ -1,30 +0,0 @@
"""Models for Aladdin connect cover platform."""
from __future__ import annotations
from typing import TypedDict
class GarageDoorData(TypedDict):
"""Aladdin door data."""
device_id: str
door_number: int
name: str
status: str
link_status: str
battery_level: int
class GarageDoor:
"""Aladdin Garage Door Entity."""
def __init__(self, data: GarageDoorData) -> None:
"""Create `GarageDoor` from dictionary of data."""
self.device_id = data["device_id"]
self.door_number = data["door_number"]
self.unique_id = f"{self.device_id}-{self.door_number}"
self.name = data["name"]
self.status = data["status"]
self.link_status = data["link_status"]
self.battery_level = data["battery_level"]
@@ -4,9 +4,9 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import cast
from genie_partner_sdk.client import AladdinConnectClient
from genie_partner_sdk.model import GarageDoor
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -14,22 +14,19 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import api
from .const import DOMAIN
from .model import GarageDoor
from . import AladdinConnectConfigEntry, AladdinConnectCoordinator
from .entity import AladdinConnectEntity
@dataclass(frozen=True, kw_only=True)
class AccSensorEntityDescription(SensorEntityDescription):
"""Describes AladdinConnect sensor entity."""
value_fn: Callable
value_fn: Callable[[AladdinConnectClient, str, int], float | None]
SENSORS: tuple[AccSensorEntityDescription, ...] = (
@@ -45,52 +42,39 @@ SENSORS: tuple[AccSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
hass: HomeAssistant,
entry: AladdinConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Aladdin Connect sensor devices."""
coordinator = entry.runtime_data
session: api.AsyncConfigEntryAuth = hass.data[DOMAIN][entry.entry_id]
acc = AladdinConnectClient(session)
entities = []
doors = await acc.get_doors()
for door in doors:
entities.extend(
[AladdinConnectSensor(acc, door, description) for description in SENSORS]
)
async_add_entities(entities)
async_add_entities(
AladdinConnectSensor(coordinator, door, description)
for description in SENSORS
for door in coordinator.doors
)
class AladdinConnectSensor(SensorEntity):
class AladdinConnectSensor(AladdinConnectEntity, SensorEntity):
"""A sensor implementation for Aladdin Connect devices."""
entity_description: AccSensorEntityDescription
_attr_has_entity_name = True
def __init__(
self,
acc: AladdinConnectClient,
coordinator: AladdinConnectCoordinator,
device: GarageDoor,
description: AccSensorEntityDescription,
) -> None:
"""Initialize a sensor for an Aladdin Connect device."""
self._device_id = device.device_id
self._number = device.door_number
self._acc = acc
super().__init__(coordinator, device)
self.entity_description = description
self._attr_unique_id = f"{device.unique_id}-{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.unique_id)},
name=device.name,
manufacturer="Overhead Door",
)
@property
def native_value(self) -> float | None:
"""Return the state of the sensor."""
return cast(
float,
self.entity_description.value_fn(self._acc, self._device_id, self._number),
return self.entity_description.value_fn(
self.coordinator.acc, self._device.device_id, self._device.door_number
)
@@ -5,12 +5,13 @@
"title": "Setup your Azure Data Explorer integration",
"description": "Enter connection details.",
"data": {
"clusteringesturi": "Cluster Ingest URI",
"cluster_ingest_uri": "Cluster ingest URI",
"database": "Database name",
"table": "Table name",
"client_id": "Client ID",
"client_secret": "Client secret",
"authority_id": "Authority ID"
"authority_id": "Authority ID",
"use_queued_ingestion": "Use queued ingestion"
}
}
},
@@ -46,6 +46,7 @@ class BlinkSyncModuleHA(
"""Representation of a Blink Alarm Control Panel."""
_attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY
_attr_code_arm_required = False
_attr_has_entity_name = True
_attr_name = None
+1 -1
View File
@@ -85,7 +85,7 @@
},
"save_recent_clips": {
"name": "Save recent clips",
"description": "Saves all recent video clips to local directory with file pattern \"%Y%m%d_%H%M%S_{name}.mp4\".",
"description": "Saves all recent video clips to local directory with file pattern \"%Y%m%d_%H%M%S_[camera name].mp4\".",
"fields": {
"file_path": {
"name": "Output directory",
@@ -65,11 +65,13 @@ class BMWLock(BMWBaseEntity, LockEntity):
try:
await self.vehicle.remote_services.trigger_remote_door_lock()
except MyBMWAPIError as ex:
self._attr_is_locked = False
# Set the state to unknown if the command fails
self._attr_is_locked = None
self.async_write_ha_state()
raise HomeAssistantError(ex) from ex
self.coordinator.async_update_listeners()
finally:
# Always update the listeners to get the latest state
self.coordinator.async_update_listeners()
async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the car."""
@@ -83,11 +85,13 @@ class BMWLock(BMWBaseEntity, LockEntity):
try:
await self.vehicle.remote_services.trigger_remote_door_unlock()
except MyBMWAPIError as ex:
self._attr_is_locked = True
# Set the state to unknown if the command fails
self._attr_is_locked = None
self.async_write_ha_state()
raise HomeAssistantError(ex) from ex
self.coordinator.async_update_listeners()
finally:
# Always update the listeners to get the latest state
self.coordinator.async_update_listeners()
@callback
def _handle_coordinator_update(self) -> None:
@@ -6,9 +6,8 @@ from collections.abc import Callable
from dataclasses import dataclass
import datetime
import logging
from typing import cast
from bimmer_connected.models import ValueWithUnit
from bimmer_connected.models import StrEnum, ValueWithUnit
from bimmer_connected.vehicle import MyBMWVehicle
from homeassistant.components.sensor import (
@@ -18,14 +17,19 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import LENGTH, PERCENTAGE, VOLUME, UnitOfElectricCurrent
from homeassistant.const import (
PERCENTAGE,
STATE_UNKNOWN,
UnitOfElectricCurrent,
UnitOfLength,
UnitOfVolume,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util import dt as dt_util
from . import BMWBaseEntity
from .const import CLIMATE_ACTIVITY_STATE, DOMAIN, UNIT_MAP
from .const import CLIMATE_ACTIVITY_STATE, DOMAIN
from .coordinator import BMWDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -36,34 +40,18 @@ class BMWSensorEntityDescription(SensorEntityDescription):
"""Describes BMW sensor entity."""
key_class: str | None = None
unit_type: str | None = None
value: Callable = lambda x, y: x
is_available: Callable[[MyBMWVehicle], bool] = lambda v: v.is_lsc_enabled
def convert_and_round(
state: ValueWithUnit,
converter: Callable[[float | None, str], float],
precision: int,
) -> float | None:
"""Safely convert and round a value from ValueWithUnit."""
if state.value and state.unit:
return round(
converter(state.value, UNIT_MAP.get(state.unit, state.unit)), precision
)
if state.value:
return state.value
return None
SENSOR_TYPES: list[BMWSensorEntityDescription] = [
# --- Generic ---
BMWSensorEntityDescription(
key="ac_current_limit",
translation_key="ac_current_limit",
key_class="charging_profile",
unit_type=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
entity_registry_enabled_default=False,
suggested_display_precision=0,
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
),
BMWSensorEntityDescription(
@@ -85,74 +73,81 @@ SENSOR_TYPES: list[BMWSensorEntityDescription] = [
key="charging_status",
translation_key="charging_status",
key_class="fuel_and_battery",
value=lambda x, y: x.value,
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
),
BMWSensorEntityDescription(
key="charging_target",
translation_key="charging_target",
key_class="fuel_and_battery",
unit_type=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
suggested_display_precision=0,
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
),
BMWSensorEntityDescription(
key="remaining_battery_percent",
translation_key="remaining_battery_percent",
key_class="fuel_and_battery",
unit_type=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
),
# --- Specific ---
BMWSensorEntityDescription(
key="mileage",
translation_key="mileage",
unit_type=LENGTH,
value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2),
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.KILOMETERS,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=0,
),
BMWSensorEntityDescription(
key="remaining_range_total",
translation_key="remaining_range_total",
key_class="fuel_and_battery",
unit_type=LENGTH,
value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2),
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.KILOMETERS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
BMWSensorEntityDescription(
key="remaining_range_electric",
translation_key="remaining_range_electric",
key_class="fuel_and_battery",
unit_type=LENGTH,
value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2),
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.KILOMETERS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
),
BMWSensorEntityDescription(
key="remaining_range_fuel",
translation_key="remaining_range_fuel",
key_class="fuel_and_battery",
unit_type=LENGTH,
value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2),
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.KILOMETERS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain,
),
BMWSensorEntityDescription(
key="remaining_fuel",
translation_key="remaining_fuel",
key_class="fuel_and_battery",
unit_type=VOLUME,
value=lambda x, hass: convert_and_round(x, hass.config.units.volume, 2),
device_class=SensorDeviceClass.VOLUME,
native_unit_of_measurement=UnitOfVolume.LITERS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain,
),
BMWSensorEntityDescription(
key="remaining_fuel_percent",
translation_key="remaining_fuel_percent",
key_class="fuel_and_battery",
unit_type=PERCENTAGE,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain,
),
BMWSensorEntityDescription(
@@ -161,7 +156,6 @@ SENSOR_TYPES: list[BMWSensorEntityDescription] = [
key_class="climate",
device_class=SensorDeviceClass.ENUM,
options=CLIMATE_ACTIVITY_STATE,
value=lambda x, _: x.lower() if x != "UNKNOWN" else None,
is_available=lambda v: v.is_remote_climate_stop_enabled,
),
]
@@ -201,13 +195,6 @@ class BMWSensor(BMWBaseEntity, SensorEntity):
self.entity_description = description
self._attr_unique_id = f"{vehicle.vin}-{description.key}"
# Set the correct unit of measurement based on the unit_type
if description.unit_type:
self._attr_native_unit_of_measurement = (
coordinator.hass.config.units.as_dict().get(description.unit_type)
or description.unit_type
)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
@@ -225,8 +212,18 @@ class BMWSensor(BMWBaseEntity, SensorEntity):
# For datetime without tzinfo, we assume it to be the same timezone as the HA instance
if isinstance(state, datetime.datetime) and state.tzinfo is None:
state = state.replace(tzinfo=dt_util.get_default_time_zone())
# For enum types, we only want the value
elif isinstance(state, ValueWithUnit):
state = state.value
# Get lowercase values from StrEnum
elif isinstance(state, StrEnum):
state = state.value.lower()
if state == STATE_UNKNOWN:
state = None
self._attr_native_value = cast(
StateType, self.entity_description.value(state, self.hass)
)
# special handling for charging_status to avoid a breaking change
if self.entity_description.key == "charging_status" and state:
state = state.upper()
self._attr_native_value = state
super()._handle_coordinator_update()
@@ -57,6 +57,7 @@ class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity):
HVACMode.HEAT,
HVACMode.COOL,
HVACMode.DRY,
HVACMode.FAN_ONLY,
HVACMode.AUTO,
]
_attr_fan_modes = [FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH]
+21 -69
View File
@@ -4,11 +4,10 @@ from __future__ import annotations
import voluptuous as vol
from homeassistant.core import HomeAssistant, State
from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent
from homeassistant.helpers.entity_component import EntityComponent
from . import DOMAIN, ClimateEntity
from . import DOMAIN
INTENT_GET_TEMPERATURE = "HassClimateGetTemperature"
@@ -23,7 +22,10 @@ class GetTemperatureIntent(intent.IntentHandler):
intent_type = INTENT_GET_TEMPERATURE
description = "Gets the current temperature of a climate device or entity"
slot_schema = {vol.Optional("area"): str, vol.Optional("name"): str}
slot_schema = {
vol.Optional("area"): intent.non_empty_string,
vol.Optional("name"): intent.non_empty_string,
}
platforms = {DOMAIN}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
@@ -31,74 +33,24 @@ class GetTemperatureIntent(intent.IntentHandler):
hass = intent_obj.hass
slots = self.async_validate_slots(intent_obj.slots)
component: EntityComponent[ClimateEntity] = hass.data[DOMAIN]
entities: list[ClimateEntity] = list(component.entities)
climate_entity: ClimateEntity | None = None
climate_state: State | None = None
name: str | None = None
if "name" in slots:
name = slots["name"]["value"]
if not entities:
raise intent.IntentHandleError("No climate entities")
area: str | None = None
if "area" in slots:
area = slots["area"]["value"]
name_slot = slots.get("name", {})
entity_name: str | None = name_slot.get("value")
entity_text: str | None = name_slot.get("text")
area_slot = slots.get("area", {})
area_id = area_slot.get("value")
if area_id:
# Filter by area and optionally name
area_name = area_slot.get("text")
for maybe_climate in intent.async_match_states(
hass, name=entity_name, area_name=area_id, domains=[DOMAIN]
):
climate_state = maybe_climate
break
if climate_state is None:
raise intent.NoStatesMatchedError(
reason=intent.MatchFailedReason.AREA,
name=entity_text or entity_name,
area=area_name or area_id,
floor=None,
domains={DOMAIN},
device_classes=None,
)
climate_entity = component.get_entity(climate_state.entity_id)
elif entity_name:
# Filter by name
for maybe_climate in intent.async_match_states(
hass, name=entity_name, domains=[DOMAIN]
):
climate_state = maybe_climate
break
if climate_state is None:
raise intent.NoStatesMatchedError(
reason=intent.MatchFailedReason.NAME,
name=entity_name,
area=None,
floor=None,
domains={DOMAIN},
device_classes=None,
)
climate_entity = component.get_entity(climate_state.entity_id)
else:
# First entity
climate_entity = entities[0]
climate_state = hass.states.get(climate_entity.entity_id)
assert climate_entity is not None
if climate_state is None:
raise intent.IntentHandleError(f"No state for {climate_entity.name}")
assert climate_state is not None
match_constraints = intent.MatchTargetsConstraints(
name=name, area_name=area, domains=[DOMAIN], assistant=intent_obj.assistant
)
match_result = intent.async_match_targets(hass, match_constraints)
if not match_result.is_match:
raise intent.MatchFailedError(
result=match_result, constraints=match_constraints
)
response = intent_obj.create_response()
response.response_type = intent.IntentResponseType.QUERY_ANSWER
response.async_set_states(matched_states=[climate_state])
response.async_set_states(matched_states=match_result.states)
return response
+1 -1
View File
@@ -8,5 +8,5 @@
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["hass_nabucasa"],
"requirements": ["hass-nabucasa==0.81.0"]
"requirements": ["hass-nabucasa==0.81.1"]
}
@@ -429,8 +429,15 @@ class DefaultAgent(ConversationEntity):
intent_context=intent_context,
language=language,
):
if ("name" in result.entities) and (
not result.entities["name"].is_wildcard
# Prioritize results with a "name" slot, but still prefer ones with
# more literal text matched.
if (
("name" in result.entities)
and (not result.entities["name"].is_wildcard)
and (
(name_result is None)
or (result.text_chunks_matched > name_result.text_chunks_matched)
)
):
name_result = result
@@ -871,7 +878,7 @@ class DefaultAgent(ConversationEntity):
if device_area is None:
return None
return {"area": {"value": device_area.id, "text": device_area.name}}
return {"area": {"value": device_area.name, "text": device_area.name}}
def _get_error_text(
self,
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["hassil==1.7.1", "home-assistant-intents==2024.5.28"]
"requirements": ["hassil==1.7.1", "home-assistant-intents==2024.6.5"]
}
+1 -1
View File
@@ -3,4 +3,4 @@
from __future__ import annotations
DOMAIN = "discovergy"
MANUFACTURER = "Discovergy"
MANUFACTURER = "inexogy"
@@ -1,6 +1,6 @@
{
"domain": "discovergy",
"name": "Discovergy",
"name": "inexogy",
"codeowners": ["@jpbede"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/discovergy",
@@ -26,7 +26,7 @@
},
"system_health": {
"info": {
"api_endpoint_reachable": "Discovergy API endpoint reachable"
"api_endpoint_reachable": "inexogy API endpoint reachable"
}
},
"entity": {
+3 -1
View File
@@ -43,7 +43,9 @@ class EcobeeNotificationService(BaseNotificationService):
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message and raise issue."""
migrate_notify_issue(self.hass, DOMAIN, "Ecobee", "2024.11.0")
migrate_notify_issue(
self.hass, DOMAIN, "Ecobee", "2024.11.0", service_name=self._service_name
)
await self.hass.async_add_executor_job(
partial(self.send_message, message, **kwargs)
)
@@ -67,6 +67,7 @@ class EgardiaAlarm(AlarmControlPanelEntity):
"""Representation of a Egardia alarm."""
_attr_state: str | None
_attr_code_arm_required = False
_attr_supported_features = (
AlarmControlPanelEntityFeature.ARM_HOME
| AlarmControlPanelEntityFeature.ARM_AWAY
+4 -2
View File
@@ -741,16 +741,18 @@ class EvoChild(EvoDevice):
assert isinstance(self._evo_device, evo.HotWater | evo.Zone) # mypy check
try:
self._schedule = await self._evo_broker.call_client_api( # type: ignore[assignment]
schedule = await self._evo_broker.call_client_api(
self._evo_device.get_schedule(), update_state=False
)
except evo.InvalidSchedule as err:
_LOGGER.warning(
"%s: Unable to retrieve the schedule: %s",
"%s: Unable to retrieve a valid schedule: %s",
self._evo_device,
err,
)
self._schedule = {}
else:
self._schedule = schedule or {}
_LOGGER.debug("Schedule['%s'] = %s", self.name, self._schedule)
+3 -1
View File
@@ -69,7 +69,9 @@ class FileNotificationService(BaseNotificationService):
"""Send a message to a file."""
# The use of the legacy notify service was deprecated with HA Core 2024.6.0
# and will be removed with HA Core 2024.12
migrate_notify_issue(self.hass, DOMAIN, "File", "2024.12.0")
migrate_notify_issue(
self.hass, DOMAIN, "File", "2024.12.0", service_name=self._service_name
)
await self.hass.async_add_executor_job(
partial(self.send_message, message, **kwargs)
)
@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20240530.0"]
"requirements": ["home-assistant-frontend==20240605.0"]
}
@@ -165,7 +165,7 @@ class GoogleAssistantConversationAgent(conversation.AbstractConversationAgent):
await session.async_ensure_token_valid()
self.assistant = None
if not self.assistant or user_input.language != self.language:
credentials = Credentials(session.token[CONF_ACCESS_TOKEN])
credentials = Credentials(session.token[CONF_ACCESS_TOKEN]) # type: ignore[no-untyped-call]
self.language = user_input.language
self.assistant = TextAssistant(credentials, self.language)
@@ -72,7 +72,7 @@ async def async_send_text_commands(
entry.async_start_reauth(hass)
raise
credentials = Credentials(session.token[CONF_ACCESS_TOKEN])
credentials = Credentials(session.token[CONF_ACCESS_TOKEN]) # type: ignore[no-untyped-call]
language_code = entry.options.get(CONF_LANGUAGE_CODE, default_language_code(hass))
with TextAssistant(
credentials, language_code, audio_out=bool(media_players)
@@ -225,7 +225,7 @@ class GoogleGenerativeAIConversationEntity(
messages = self.history[conversation_id]
else:
conversation_id = ulid.ulid_now()
messages = [{}, {}]
messages = [{}, {"role": "model", "parts": "Ok"}]
if (
user_input.context
@@ -272,8 +272,11 @@ class GoogleGenerativeAIConversationEntity(
response=intent_response, conversation_id=conversation_id
)
messages[0] = {"role": "user", "parts": prompt}
messages[1] = {"role": "model", "parts": "Ok"}
# Make a copy, because we attach it to the trace event.
messages = [
{"role": "user", "parts": prompt},
*messages[1:],
]
LOGGER.debug("Input: '%s' with history: %s", user_input.text, messages)
trace.async_conversation_trace_append(
@@ -93,7 +93,9 @@ async def async_setup_service(hass: HomeAssistant) -> None:
def _append_to_sheet(call: ServiceCall, entry: ConfigEntry) -> None:
"""Run append in the executor."""
service = Client(Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN]))
service = Client(
Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN]) # type: ignore[no-untyped-call]
)
try:
sheet = service.open_by_key(entry.unique_id)
except RefreshError:
@@ -61,7 +61,9 @@ class OAuth2FlowHandler(
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
"""Create an entry for the flow, or update existing entry."""
service = Client(Credentials(data[CONF_TOKEN][CONF_ACCESS_TOKEN]))
service = Client(
Credentials(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) # type: ignore[no-untyped-call]
)
if self.reauth_entry:
_LOGGER.debug("service.open_by_key")
+3 -4
View File
@@ -267,15 +267,14 @@ class SupervisorIssues:
placeholders = {PLACEHOLDER_KEY_REFERENCE: issue.reference}
if issue.key == ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING:
placeholders[PLACEHOLDER_KEY_ADDON_URL] = (
f"/hassio/addon/{issue.reference}"
)
addons = get_addons_info(self._hass)
if addons and issue.reference in addons:
placeholders[PLACEHOLDER_KEY_ADDON] = addons[issue.reference][
"name"
]
if "url" in addons[issue.reference]:
placeholders[PLACEHOLDER_KEY_ADDON_URL] = addons[
issue.reference
]["url"]
else:
placeholders[PLACEHOLDER_KEY_ADDON] = issue.reference
@@ -51,6 +51,7 @@ class HiveAlarmControlPanelEntity(HiveEntity, AlarmControlPanelEntity):
| AlarmControlPanelEntityFeature.ARM_AWAY
| AlarmControlPanelEntityFeature.TRIGGER
)
_attr_code_arm_required = False
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send disarm command."""
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.49", "babel==2.13.1"]
"requirements": ["holidays==0.50", "babel==2.13.1"]
}
@@ -35,9 +35,8 @@ DEFAULT_EXPOSED_DOMAINS = {
"fan",
"humidifier",
"light",
"lock",
"media_player",
"scene",
"script",
"switch",
"todo",
"vacuum",
@@ -1,7 +1,6 @@
{
"domain": "http",
"name": "HTTP",
"after_dependencies": ["isal"],
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/http",
"integration_type": "system",
+23 -22
View File
@@ -35,7 +35,7 @@ class HumidityHandler(intent.IntentHandler):
intent_type = INTENT_HUMIDITY
description = "Set desired humidity level"
slot_schema = {
vol.Required("name"): cv.string,
vol.Required("name"): intent.non_empty_string,
vol.Required("humidity"): vol.All(vol.Coerce(int), vol.Range(0, 100)),
}
platforms = {DOMAIN}
@@ -44,18 +44,19 @@ class HumidityHandler(intent.IntentHandler):
"""Handle the hass intent."""
hass = intent_obj.hass
slots = self.async_validate_slots(intent_obj.slots)
states = list(
intent.async_match_states(
hass,
name=slots["name"]["value"],
states=hass.states.async_all(DOMAIN),
)
match_constraints = intent.MatchTargetsConstraints(
name=slots["name"]["value"],
domains=[DOMAIN],
assistant=intent_obj.assistant,
)
match_result = intent.async_match_targets(hass, match_constraints)
if not match_result.is_match:
raise intent.MatchFailedError(
result=match_result, constraints=match_constraints
)
if not states:
raise intent.IntentHandleError("No entities matched")
state = states[0]
state = match_result.states[0]
service_data = {ATTR_ENTITY_ID: state.entity_id}
humidity = slots["humidity"]["value"]
@@ -89,7 +90,7 @@ class SetModeHandler(intent.IntentHandler):
intent_type = INTENT_MODE
description = "Set humidifier mode"
slot_schema = {
vol.Required("name"): cv.string,
vol.Required("name"): intent.non_empty_string,
vol.Required("mode"): cv.string,
}
platforms = {DOMAIN}
@@ -98,18 +99,18 @@ class SetModeHandler(intent.IntentHandler):
"""Handle the hass intent."""
hass = intent_obj.hass
slots = self.async_validate_slots(intent_obj.slots)
states = list(
intent.async_match_states(
hass,
name=slots["name"]["value"],
states=hass.states.async_all(DOMAIN),
)
match_constraints = intent.MatchTargetsConstraints(
name=slots["name"]["value"],
domains=[DOMAIN],
assistant=intent_obj.assistant,
)
match_result = intent.async_match_targets(hass, match_constraints)
if not match_result.is_match:
raise intent.MatchFailedError(
result=match_result, constraints=match_constraints
)
if not states:
raise intent.IntentHandleError("No entities matched")
state = states[0]
state = match_result.states[0]
service_data = {ATTR_ENTITY_ID: state.entity_id}
intent.async_test_feature(state, HumidifierEntityFeature.MODES, "modes")
@@ -24,13 +24,17 @@ class HydrawiseBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes Hydrawise binary sensor."""
value_fn: Callable[[HydrawiseBinarySensor], bool | None]
always_available: bool = False
CONTROLLER_BINARY_SENSORS: tuple[HydrawiseBinarySensorEntityDescription, ...] = (
HydrawiseBinarySensorEntityDescription(
key="status",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
value_fn=lambda status_sensor: status_sensor.coordinator.last_update_success,
value_fn=lambda status_sensor: status_sensor.coordinator.last_update_success
and status_sensor.controller.online,
# Connectivtiy sensor is always available
always_available=True,
),
)
@@ -98,3 +102,10 @@ class HydrawiseBinarySensor(HydrawiseEntity, BinarySensorEntity):
def _update_attrs(self) -> None:
"""Update state attributes."""
self._attr_is_on = self.entity_description.value_fn(self)
@property
def available(self) -> bool:
"""Set the entity availability."""
if self.entity_description.always_available:
return True
return super().available
@@ -70,3 +70,8 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]):
self.controller = self.coordinator.data.controllers[self.controller.id]
self._update_attrs()
super()._handle_coordinator_update()
@property
def available(self) -> bool:
"""Set the entity availability."""
return super().available and self.controller.online
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/hydrawise",
"iot_class": "cloud_polling",
"loggers": ["pydrawise"],
"requirements": ["pydrawise==2024.4.1"]
"requirements": ["pydrawise==2024.6.3"]
}
@@ -37,6 +37,7 @@ class IAlarmPanel(
AlarmControlPanelEntityFeature.ARM_HOME
| AlarmControlPanelEntityFeature.ARM_AWAY
)
_attr_code_arm_required = False
def __init__(self, coordinator: IAlarmDataUpdateCoordinator) -> None:
"""Create the entity with a DataUpdateCoordinator."""
+3 -3
View File
@@ -195,13 +195,13 @@ class ImapMessage:
):
message_untyped_text = str(part.get_payload())
if message_text is not None:
if message_text is not None and message_text.strip():
return message_text
if message_html is not None:
if message_html:
return message_html
if message_untyped_text is not None:
if message_untyped_text:
return message_untyped_text
return str(self.email_message.get_payload())
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/imgw_pib",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"requirements": ["imgw_pib==1.0.1"]
"requirements": ["imgw_pib==1.0.4"]
}
@@ -119,10 +119,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
"""Set up a configuration entry for Jewish calendar."""
language = config_entry.data.get(CONF_LANGUAGE, DEFAULT_LANGUAGE)
diaspora = config_entry.data.get(CONF_DIASPORA, DEFAULT_DIASPORA)
candle_lighting_offset = config_entry.data.get(
candle_lighting_offset = config_entry.options.get(
CONF_CANDLE_LIGHT_MINUTES, DEFAULT_CANDLE_LIGHT
)
havdalah_offset = config_entry.data.get(
havdalah_offset = config_entry.options.get(
CONF_HAVDALAH_OFFSET_MINUTES, DEFAULT_HAVDALAH_OFFSET_MINUTES
)
@@ -154,6 +154,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
async_update_unique_ids(ent_reg, config_entry.entry_id, old_prefix)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
# Trigger update of states for all platforms
await hass.config_entries.async_reload(config_entry.entry_id)
config_entry.async_on_unload(config_entry.add_update_listener(update_listener))
return True
@@ -100,10 +100,23 @@ class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle the initial step."""
if user_input is not None:
_options = {}
if CONF_CANDLE_LIGHT_MINUTES in user_input:
_options[CONF_CANDLE_LIGHT_MINUTES] = user_input[
CONF_CANDLE_LIGHT_MINUTES
]
del user_input[CONF_CANDLE_LIGHT_MINUTES]
if CONF_HAVDALAH_OFFSET_MINUTES in user_input:
_options[CONF_HAVDALAH_OFFSET_MINUTES] = user_input[
CONF_HAVDALAH_OFFSET_MINUTES
]
del user_input[CONF_HAVDALAH_OFFSET_MINUTES]
if CONF_LOCATION in user_input:
user_input[CONF_LATITUDE] = user_input[CONF_LOCATION][CONF_LATITUDE]
user_input[CONF_LONGITUDE] = user_input[CONF_LOCATION][CONF_LONGITUDE]
return self.async_create_entry(title=DEFAULT_NAME, data=user_input)
return self.async_create_entry(
title=DEFAULT_NAME, data=user_input, options=_options
)
return self.async_show_form(
step_id="user",
+1 -4
View File
@@ -283,16 +283,13 @@ class KNXClimate(KnxEntity, ClimateEntity):
)
if knx_controller_mode in self._device.mode.controller_modes:
await self._device.mode.set_controller_mode(knx_controller_mode)
self.async_write_ha_state()
return
if self._device.supports_on_off:
if hvac_mode == HVACMode.OFF:
await self._device.turn_off()
elif not self._device.is_on:
# for default hvac mode, otherwise above would have triggered
await self._device.turn_on()
self.async_write_ha_state()
self.async_write_ha_state()
@property
def preset_mode(self) -> str | None:
+3 -1
View File
@@ -60,7 +60,9 @@ class KNXNotificationService(BaseNotificationService):
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a notification to knx bus."""
migrate_notify_issue(self.hass, DOMAIN, "KNX", "2024.11.0")
migrate_notify_issue(
self.hass, DOMAIN, "KNX", "2024.11.0", service_name=self._service_name
)
if "target" in kwargs:
await self._async_send_to_device(message, kwargs["target"])
else:
+31 -3
View File
@@ -23,6 +23,7 @@ turn_on:
- light.ColorMode.RGB
- light.ColorMode.RGBW
- light.ColorMode.RGBWW
example: "[255, 100, 100]"
selector:
color_rgb:
rgbw_color:
@@ -250,6 +251,7 @@ turn_on:
- light.ColorMode.RGB
- light.ColorMode.RGBW
- light.ColorMode.RGBWW
advanced: true
selector:
color_temp:
unit: "mired"
@@ -265,7 +267,6 @@ turn_on:
- light.ColorMode.RGB
- light.ColorMode.RGBW
- light.ColorMode.RGBWW
advanced: true
selector:
color_temp:
unit: "kelvin"
@@ -419,10 +420,35 @@ toggle:
- light.ColorMode.RGB
- light.ColorMode.RGBW
- light.ColorMode.RGBWW
advanced: true
example: "[255, 100, 100]"
selector:
color_rgb:
rgbw_color:
filter:
attribute:
supported_color_modes:
- light.ColorMode.HS
- light.ColorMode.XY
- light.ColorMode.RGB
- light.ColorMode.RGBW
- light.ColorMode.RGBWW
advanced: true
example: "[255, 100, 100, 50]"
selector:
object:
rgbww_color:
filter:
attribute:
supported_color_modes:
- light.ColorMode.HS
- light.ColorMode.XY
- light.ColorMode.RGB
- light.ColorMode.RGBW
- light.ColorMode.RGBWW
advanced: true
example: "[255, 100, 100, 50, 70]"
selector:
object:
color_name:
filter:
attribute:
@@ -625,6 +651,9 @@ toggle:
advanced: true
selector:
color_temp:
unit: "mired"
min: 153
max: 500
kelvin:
filter:
attribute:
@@ -635,7 +664,6 @@ toggle:
- light.ColorMode.RGB
- light.ColorMode.RGBW
- light.ColorMode.RGBWW
advanced: true
selector:
color_temp:
unit: "kelvin"
@@ -342,6 +342,14 @@
"name": "[%key:component::light::services::turn_on::fields::rgb_color::name%]",
"description": "[%key:component::light::services::turn_on::fields::rgb_color::description%]"
},
"rgbw_color": {
"name": "[%key:component::light::services::turn_on::fields::rgbw_color::name%]",
"description": "[%key:component::light::services::turn_on::fields::rgbw_color::description%]"
},
"rgbww_color": {
"name": "[%key:component::light::services::turn_on::fields::rgbww_color::name%]",
"description": "[%key:component::light::services::turn_on::fields::rgbww_color::description%]"
},
"color_name": {
"name": "[%key:component::light::services::turn_on::fields::color_name::name%]",
"description": "[%key:component::light::services::turn_on::fields::color_name::description%]"
@@ -48,6 +48,7 @@ class LupusecAlarm(LupusecDevice, AlarmControlPanelEntity):
AlarmControlPanelEntityFeature.ARM_HOME
| AlarmControlPanelEntityFeature.ARM_AWAY
)
_attr_code_arm_required = False
def __init__(
self, data: lupupy.Lupusec, device: lupupy.devices.LupusecAlarm, entry_id: str
@@ -6,6 +6,6 @@
"dependencies": ["websocket_api"],
"documentation": "https://www.home-assistant.io/integrations/matter",
"iot_class": "local_push",
"requirements": ["python-matter-server==6.1.0b1"],
"requirements": ["python-matter-server==6.1.0"],
"zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."]
}
@@ -33,7 +33,6 @@ ABBREVIATIONS = {
"cmd_on_tpl": "command_on_template",
"cmd_t": "command_topic",
"cmd_tpl": "command_template",
"cmp": "components",
"cod_arm_req": "code_arm_required",
"cod_dis_req": "code_disarm_required",
"cod_form": "code_format",
-1
View File
@@ -86,7 +86,6 @@ CONF_TEMP_MIN = "min_temp"
CONF_CERTIFICATE = "certificate"
CONF_CLIENT_KEY = "client_key"
CONF_CLIENT_CERT = "client_cert"
CONF_COMPONENTS = "components"
CONF_TLS_INSECURE = "tls_insecure"
# Device and integration info options
+41 -177
View File
@@ -10,8 +10,6 @@ import re
import time
from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE, CONF_PLATFORM
from homeassistant.core import HassJobType, HomeAssistant, callback
@@ -21,7 +19,7 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo, ReceivePayloadType
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
from homeassistant.helpers.typing import DiscoveryInfoType
from homeassistant.loader import async_get_mqtt
from homeassistant.util.json import json_loads_object
@@ -34,21 +32,19 @@ from .const import (
ATTR_DISCOVERY_PAYLOAD,
ATTR_DISCOVERY_TOPIC,
CONF_AVAILABILITY,
CONF_COMPONENTS,
CONF_ORIGIN,
CONF_TOPIC,
DOMAIN,
SUPPORTED_COMPONENTS,
)
from .models import DATA_MQTT, MqttComponentConfig, MqttOriginInfo, ReceiveMessage
from .schemas import DEVICE_DISCOVERY_SCHEMA, MQTT_ORIGIN_INFO_SCHEMA, SHARED_OPTIONS
from .models import DATA_MQTT, MqttOriginInfo, ReceiveMessage
from .schemas import MQTT_ORIGIN_INFO_SCHEMA
from .util import async_forward_entry_setup_and_setup_discovery
ABBREVIATIONS_SET = set(ABBREVIATIONS)
DEVICE_ABBREVIATIONS_SET = set(DEVICE_ABBREVIATIONS)
ORIGIN_ABBREVIATIONS_SET = set(ORIGIN_ABBREVIATIONS)
_LOGGER = logging.getLogger(__name__)
TOPIC_MATCHER = re.compile(
@@ -72,7 +68,6 @@ TOPIC_BASE = "~"
class MQTTDiscoveryPayload(dict[str, Any]):
"""Class to hold and MQTT discovery payload and discovery data."""
device_discovery: bool = False
discovery_data: DiscoveryInfoType
@@ -91,13 +86,9 @@ def async_log_discovery_origin_info(
message: str, discovery_payload: MQTTDiscoveryPayload, level: int = logging.INFO
) -> None:
"""Log information about the discovery and origin."""
# We only log origin info once per device discovery
if not _LOGGER.isEnabledFor(level):
# bail early if logging is disabled
return
if discovery_payload.device_discovery:
_LOGGER.log(level, message)
return
if CONF_ORIGIN not in discovery_payload:
_LOGGER.log(level, message)
return
@@ -177,65 +168,6 @@ def _replace_topic_base(discovery_payload: dict[str, Any]) -> None:
availability_conf[CONF_TOPIC] = f"{topic[:-1]}{base}"
@callback
def _generate_device_cleanup_config(
hass: HomeAssistant, object_id: str, node_id: str | None
) -> dict[str, Any]:
"""Generate a cleanup message on device cleanup."""
mqtt_data = hass.data[DATA_MQTT]
device_node_id: str = f"{node_id} {object_id}" if node_id else object_id
config: dict[str, Any] = {CONF_DEVICE: {}, CONF_COMPONENTS: {}}
comp_config = config[CONF_COMPONENTS]
for platform, discover_id in mqtt_data.discovery_already_discovered:
ids = discover_id.split(" ")
component_node_id = ids.pop(0)
component_object_id = " ".join(ids)
if not ids:
continue
if device_node_id == component_node_id:
comp_config[component_object_id] = {CONF_PLATFORM: platform}
return config if comp_config else {}
@callback
def _parse_device_payload(
hass: HomeAssistant,
payload: ReceivePayloadType,
object_id: str,
node_id: str | None,
) -> dict[str, Any]:
"""Parse a device discovery payload."""
device_payload: dict[str, Any] = {}
if payload == "":
if not (
device_payload := _generate_device_cleanup_config(hass, object_id, node_id)
):
_LOGGER.warning(
"No device components to cleanup for %s, node_id '%s'",
object_id,
node_id,
)
return device_payload
try:
device_payload = MQTTDiscoveryPayload(json_loads_object(payload))
except ValueError:
_LOGGER.warning("Unable to parse JSON %s: '%s'", object_id, payload)
return {}
_replace_all_abbreviations(device_payload)
try:
DEVICE_DISCOVERY_SCHEMA(device_payload)
except vol.Invalid as exc:
_LOGGER.warning(
"Invalid MQTT device discovery payload for %s, %s: '%s'",
object_id,
exc,
payload,
)
return {}
return device_payload
@callback
def _valid_origin_info(discovery_payload: MQTTDiscoveryPayload) -> bool:
"""Parse and validate origin info from a single component discovery payload."""
@@ -253,16 +185,6 @@ def _valid_origin_info(discovery_payload: MQTTDiscoveryPayload) -> bool:
return True
@callback
def _merge_common_options(
component_config: MQTTDiscoveryPayload, device_config: dict[str, Any]
) -> None:
"""Merge common options with the component config options."""
for option in SHARED_OPTIONS:
if option in device_config and option not in component_config:
component_config[option] = device_config.get(option)
async def async_start( # noqa: C901
hass: HomeAssistant, discovery_topic: str, config_entry: ConfigEntry
) -> None:
@@ -306,7 +228,8 @@ async def async_start( # noqa: C901
_LOGGER.warning(
(
"Received message on illegal discovery topic '%s'. The topic"
" contains not allowed characters. For more information see "
" contains "
"not allowed characters. For more information see "
"https://www.home-assistant.io/integrations/mqtt/#discovery-topic"
),
topic,
@@ -315,114 +238,55 @@ async def async_start( # noqa: C901
component, node_id, object_id = match.groups()
discovered_components: list[MqttComponentConfig] = []
if component == CONF_DEVICE:
# Process device based discovery message
# and regenate cleanup config.
device_discovery_payload = _parse_device_payload(
hass, payload, object_id, node_id
)
if not device_discovery_payload:
return
device_config: dict[str, Any]
origin_config: dict[str, Any] | None
component_configs: dict[str, dict[str, Any]]
device_config = device_discovery_payload[CONF_DEVICE]
origin_config = device_discovery_payload.get(CONF_ORIGIN)
component_configs = device_discovery_payload[CONF_COMPONENTS]
for component_id, config in component_configs.items():
component = config.pop(CONF_PLATFORM)
# The object_id in the device discovery topic is the unique identifier.
# It is used as node_id for the components it contains.
component_node_id = object_id
# The component_id in the discovery playload is used as object_id
# If we have an additional node_id in the discovery topic,
# we extend the component_id with it.
component_object_id = (
f"{node_id} {component_id}" if node_id else component_id
)
_replace_all_abbreviations(config)
# We add wrapper to the discovery payload with the discovery data.
# If the dict is empty after removing the platform, the payload is
# assumed to remove the existing config and we do not want to add
# device or orig or shared availability attributes.
if discovery_payload := MQTTDiscoveryPayload(config):
discovery_payload.device_discovery = True
discovery_payload[CONF_DEVICE] = device_config
discovery_payload[CONF_ORIGIN] = origin_config
# Only assign shared config options
# when they are not set at entity level
_merge_common_options(discovery_payload, device_discovery_payload)
discovered_components.append(
MqttComponentConfig(
component,
component_object_id,
component_node_id,
discovery_payload,
)
)
_LOGGER.debug(
"Process device discovery payload %s", device_discovery_payload
)
device_discovery_id = f"{node_id} {object_id}" if node_id else object_id
message = f"Processing device discovery for '{device_discovery_id}'"
async_log_discovery_origin_info(
message, MQTTDiscoveryPayload(device_discovery_payload)
)
if component not in SUPPORTED_COMPONENTS:
_LOGGER.warning("Integration %s is not supported", component)
return
else:
# Process component based discovery message
if payload:
try:
discovery_payload = MQTTDiscoveryPayload(
json_loads_object(payload) if payload else {}
)
discovery_payload = MQTTDiscoveryPayload(json_loads_object(payload))
except ValueError:
_LOGGER.warning("Unable to parse JSON %s: '%s'", object_id, payload)
return
_replace_all_abbreviations(discovery_payload)
if not _valid_origin_info(discovery_payload):
return
discovered_components.append(
MqttComponentConfig(component, object_id, node_id, discovery_payload)
)
discovery_pending_discovered = mqtt_data.discovery_pending_discovered
for component_config in discovered_components:
component = component_config.component
node_id = component_config.node_id
object_id = component_config.object_id
discovery_payload = component_config.discovery_payload
if component not in SUPPORTED_COMPONENTS:
_LOGGER.warning("Integration %s is not supported", component)
return
if TOPIC_BASE in discovery_payload:
_replace_topic_base(discovery_payload)
else:
discovery_payload = MQTTDiscoveryPayload({})
# If present, the node_id will be included in the discovery_id.
discovery_id = f"{node_id} {object_id}" if node_id else object_id
discovery_hash = (component, discovery_id)
# If present, the node_id will be included in the discovered object id
discovery_id = f"{node_id} {object_id}" if node_id else object_id
discovery_hash = (component, discovery_id)
if discovery_payload:
# Attach MQTT topic to the payload, used for debug prints
discovery_data = {
ATTR_DISCOVERY_HASH: discovery_hash,
ATTR_DISCOVERY_PAYLOAD: discovery_payload,
ATTR_DISCOVERY_TOPIC: topic,
}
setattr(discovery_payload, "discovery_data", discovery_data)
if discovery_payload:
# Attach MQTT topic to the payload, used for debug prints
setattr(
discovery_payload,
"__configuration_source__",
f"MQTT (topic: '{topic}')",
)
discovery_data = {
ATTR_DISCOVERY_HASH: discovery_hash,
ATTR_DISCOVERY_PAYLOAD: discovery_payload,
ATTR_DISCOVERY_TOPIC: topic,
}
setattr(discovery_payload, "discovery_data", discovery_data)
if discovery_hash in discovery_pending_discovered:
pending = discovery_pending_discovered[discovery_hash]["pending"]
pending.appendleft(discovery_payload)
_LOGGER.debug(
"Component has already been discovered: %s %s, queuing update",
component,
discovery_id,
)
return
discovery_payload[CONF_PLATFORM] = "mqtt"
async_process_discovery_payload(component, discovery_id, discovery_payload)
if discovery_hash in mqtt_data.discovery_pending_discovered:
pending = mqtt_data.discovery_pending_discovered[discovery_hash]["pending"]
pending.appendleft(discovery_payload)
_LOGGER.debug(
"Component has already been discovered: %s %s, queuing update",
component,
discovery_id,
)
return
async_process_discovery_payload(component, discovery_id, discovery_payload)
@callback
def async_process_discovery_payload(
@@ -430,7 +294,7 @@ async def async_start( # noqa: C901
) -> None:
"""Process the payload of a new discovery."""
_LOGGER.debug("Process component discovery payload %s", payload)
_LOGGER.debug("Process discovery payload %s", payload)
discovery_hash = (component, discovery_id)
already_discovered = discovery_hash in mqtt_data.discovery_already_discovered
-35
View File
@@ -682,7 +682,6 @@ class MqttDiscoveryDeviceUpdateMixin(ABC):
self._config_entry = config_entry
self._config_entry_id = config_entry.entry_id
self._skip_device_removal: bool = False
self._migrate_discovery: str | None = None
discovery_hash = get_discovery_hash(discovery_data)
self._remove_discovery_updated = async_dispatcher_connect(
@@ -721,24 +720,6 @@ class MqttDiscoveryDeviceUpdateMixin(ABC):
discovery_hash,
discovery_payload,
)
if not discovery_payload and self._migrate_discovery is not None:
# Ignore empty update from migrated and removed discovery config.
self._discovery_data[ATTR_DISCOVERY_TOPIC] = self._migrate_discovery
self._migrate_discovery = None
_LOGGER.info("Component successfully migrated: %s", discovery_hash)
send_discovery_done(self.hass, self._discovery_data)
return
if discovery_payload and (
(discovery_topic := discovery_payload.discovery_data[ATTR_DISCOVERY_TOPIC])
!= self._discovery_data[ATTR_DISCOVERY_TOPIC]
):
# Make sure the migrated discovery topic is removed.
self._migrate_discovery = discovery_topic
_LOGGER.debug("Migrating component: %s", discovery_hash)
self.hass.async_create_task(
async_remove_discovery_payload(self.hass, self._discovery_data)
)
if (
discovery_payload
and discovery_payload != self._discovery_data[ATTR_DISCOVERY_PAYLOAD]
@@ -835,7 +816,6 @@ class MqttDiscoveryUpdateMixin(Entity):
mqtt_data = hass.data[DATA_MQTT]
self._registry_hooks = mqtt_data.discovery_registry_hooks
discovery_hash: tuple[str, str] = discovery_data[ATTR_DISCOVERY_HASH]
self._migrate_discovery: str | None = None
if discovery_hash in self._registry_hooks:
self._registry_hooks.pop(discovery_hash)()
@@ -918,27 +898,12 @@ class MqttDiscoveryUpdateMixin(Entity):
old_payload = self._discovery_data[ATTR_DISCOVERY_PAYLOAD]
debug_info.update_entity_discovery_data(self.hass, payload, self.entity_id)
if not payload:
if self._migrate_discovery is not None:
# Ignore empty update of the migrated and removed discovery config.
self._discovery_data[ATTR_DISCOVERY_TOPIC] = self._migrate_discovery
self._migrate_discovery = None
_LOGGER.info("Component successfully migrated: %s", self.entity_id)
send_discovery_done(self.hass, self._discovery_data)
return
# Empty payload: Remove component
_LOGGER.info("Removing component: %s", self.entity_id)
self.hass.async_create_task(
self._async_process_discovery_update_and_remove()
)
elif self._discovery_update:
discovery_topic = payload.discovery_data[ATTR_DISCOVERY_TOPIC]
if discovery_topic != self._discovery_data[ATTR_DISCOVERY_TOPIC]:
# Make sure the migrated discovery topic is removed.
self._migrate_discovery = discovery_topic
_LOGGER.debug("Migrating component: %s", self.entity_id)
self.hass.async_create_task(
async_remove_discovery_payload(self.hass, self._discovery_data)
)
if old_payload != payload:
# Non-empty, changed payload: Notify component
_LOGGER.info("Updating component: %s", self.entity_id)
-10
View File
@@ -424,15 +424,5 @@ class MqttData:
tags: dict[str, dict[str, MQTTTagScanner]] = field(default_factory=dict)
@dataclass(slots=True)
class MqttComponentConfig:
"""(component, object_id, node_id, discovery_payload)."""
component: str
object_id: str
node_id: str | None
discovery_payload: MQTTDiscoveryPayload
DATA_MQTT: HassKey[MqttData] = HassKey("mqtt")
DATA_MQTT_AVAILABLE: HassKey[asyncio.Future[bool]] = HassKey("mqtt_client_available")
+1 -50
View File
@@ -2,8 +2,6 @@
from __future__ import annotations
import logging
import voluptuous as vol
from homeassistant.const import (
@@ -12,7 +10,6 @@ from homeassistant.const import (
CONF_ICON,
CONF_MODEL,
CONF_NAME,
CONF_PLATFORM,
CONF_UNIQUE_ID,
CONF_VALUE_TEMPLATE,
)
@@ -27,13 +24,10 @@ from .const import (
CONF_AVAILABILITY_MODE,
CONF_AVAILABILITY_TEMPLATE,
CONF_AVAILABILITY_TOPIC,
CONF_COMMAND_TOPIC,
CONF_COMPONENTS,
CONF_CONFIGURATION_URL,
CONF_CONNECTIONS,
CONF_DEPRECATED_VIA_HUB,
CONF_ENABLED_BY_DEFAULT,
CONF_ENCODING,
CONF_HW_VERSION,
CONF_IDENTIFIERS,
CONF_JSON_ATTRS_TEMPLATE,
@@ -43,9 +37,7 @@ from .const import (
CONF_ORIGIN,
CONF_PAYLOAD_AVAILABLE,
CONF_PAYLOAD_NOT_AVAILABLE,
CONF_QOS,
CONF_SERIAL_NUMBER,
CONF_STATE_TOPIC,
CONF_SUGGESTED_AREA,
CONF_SUPPORT_URL,
CONF_SW_VERSION,
@@ -53,33 +45,8 @@ from .const import (
CONF_VIA_DEVICE,
DEFAULT_PAYLOAD_AVAILABLE,
DEFAULT_PAYLOAD_NOT_AVAILABLE,
SUPPORTED_COMPONENTS,
)
from .util import valid_publish_topic, valid_qos_schema, valid_subscribe_topic
_LOGGER = logging.getLogger(__name__)
# Device discovery options that are also available at entity component level
SHARED_OPTIONS = [
CONF_AVAILABILITY,
CONF_AVAILABILITY_MODE,
CONF_AVAILABILITY_TEMPLATE,
CONF_AVAILABILITY_TOPIC,
CONF_COMMAND_TOPIC,
CONF_PAYLOAD_AVAILABLE,
CONF_PAYLOAD_NOT_AVAILABLE,
CONF_STATE_TOPIC,
]
MQTT_ORIGIN_INFO_SCHEMA = vol.All(
vol.Schema(
{
vol.Required(CONF_NAME): cv.string,
vol.Optional(CONF_SW_VERSION): cv.string,
vol.Optional(CONF_SUPPORT_URL): cv.configuration_url,
}
),
)
from .util import valid_subscribe_topic
MQTT_AVAILABILITY_SINGLE_SCHEMA = vol.Schema(
{
@@ -181,19 +148,3 @@ MQTT_ENTITY_COMMON_SCHEMA = MQTT_AVAILABILITY_SCHEMA.extend(
vol.Optional(CONF_UNIQUE_ID): cv.string,
}
)
COMPONENT_CONFIG_SCHEMA = vol.Schema(
{vol.Required(CONF_PLATFORM): vol.In(SUPPORTED_COMPONENTS)}
).extend({}, extra=True)
DEVICE_DISCOVERY_SCHEMA = MQTT_AVAILABILITY_SCHEMA.extend(
{
vol.Required(CONF_DEVICE): MQTT_ENTITY_DEVICE_INFO_SCHEMA,
vol.Required(CONF_COMPONENTS): vol.Schema({str: COMPONENT_CONFIG_SCHEMA}),
vol.Required(CONF_ORIGIN): MQTT_ORIGIN_INFO_SCHEMA,
vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic,
vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic,
vol.Optional(CONF_QOS): valid_qos_schema,
vol.Optional(CONF_ENCODING): cv.string,
}
)
+2 -2
View File
@@ -57,7 +57,7 @@ class AsyncConfigEntryAuth(AbstractAuth):
# even when it is expired to fully hand off this responsibility and
# know it is working at startup (then if not, fail loudly).
token = self._oauth_session.token
creds = Credentials(
creds = Credentials( # type: ignore[no-untyped-call]
token=token["access_token"],
refresh_token=token["refresh_token"],
token_uri=OAUTH2_TOKEN,
@@ -92,7 +92,7 @@ class AccessTokenAuthImpl(AbstractAuth):
async def async_get_creds(self) -> Credentials:
"""Return an OAuth credential for Pub/Sub Subscriber."""
return Credentials(
return Credentials( # type: ignore[no-untyped-call]
token=self._access_token,
token_uri=OAUTH2_TOKEN,
scopes=SDM_SCOPES,
+23 -1
View File
@@ -12,9 +12,31 @@ from .const import DOMAIN
@callback
def migrate_notify_issue(
hass: HomeAssistant, domain: str, integration_title: str, breaks_in_ha_version: str
hass: HomeAssistant,
domain: str,
integration_title: str,
breaks_in_ha_version: str,
service_name: str | None = None,
) -> None:
"""Ensure an issue is registered."""
if service_name is not None:
ir.async_create_issue(
hass,
DOMAIN,
f"migrate_notify_{domain}_{service_name}",
breaks_in_ha_version=breaks_in_ha_version,
issue_domain=domain,
is_fixable=True,
is_persistent=True,
translation_key="migrate_notify_service",
translation_placeholders={
"domain": domain,
"integration_title": integration_title,
"service_name": service_name,
},
severity=ir.IssueSeverity.WARNING,
)
return
ir.async_create_issue(
hass,
DOMAIN,
@@ -72,6 +72,17 @@
}
}
}
},
"migrate_notify_service": {
"title": "Legacy service `notify.{service_name}` stll being used",
"fix_flow": {
"step": {
"confirm": {
"description": "The {integration_title} `notify.{service_name}` service is migrated, but it seems the old `notify` service is still being used.\n\nA new `notify` entity is available now to replace each legacy `notify` service.\n\nUpdate any automations or scripts to use the new `notify.send_message` service exposed with this new entity. When this is done, select Submit and restart Home Assistant.",
"title": "Migrate legacy {integration_title} notify service for domain `{domain}`"
}
}
}
}
}
}
@@ -100,6 +100,7 @@ class NX584Alarm(AlarmControlPanelEntity):
AlarmControlPanelEntityFeature.ARM_HOME
| AlarmControlPanelEntityFeature.ARM_AWAY
)
_attr_code_arm_required = False
def __init__(self, name: str, alarm_client: client.Client, url: str) -> None:
"""Init the nx584 alarm panel."""
@@ -146,58 +146,58 @@ class OpenAIConversationEntity(
messages = self.history[conversation_id]
else:
conversation_id = ulid.ulid_now()
messages = []
if (
user_input.context
and user_input.context.user_id
and (
user := await self.hass.auth.async_get_user(
user_input.context.user_id
)
if (
user_input.context
and user_input.context.user_id
and (
user := await self.hass.auth.async_get_user(user_input.context.user_id)
)
):
user_name = user.name
try:
if llm_api:
api_prompt = llm_api.api_prompt
else:
api_prompt = llm.async_render_no_api_prompt(self.hass)
prompt = "\n".join(
(
template.Template(
llm.BASE_PROMPT
+ options.get(CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT),
self.hass,
).async_render(
{
"ha_name": self.hass.config.location_name,
"user_name": user_name,
"llm_context": llm_context,
},
parse_result=False,
),
api_prompt,
)
):
user_name = user.name
)
try:
if llm_api:
api_prompt = llm_api.api_prompt
else:
api_prompt = llm.async_render_no_api_prompt(self.hass)
except TemplateError as err:
LOGGER.error("Error rendering prompt: %s", err)
intent_response = intent.IntentResponse(language=user_input.language)
intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN,
f"Sorry, I had a problem with my template: {err}",
)
return conversation.ConversationResult(
response=intent_response, conversation_id=conversation_id
)
prompt = "\n".join(
(
template.Template(
llm.BASE_PROMPT
+ options.get(CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT),
self.hass,
).async_render(
{
"ha_name": self.hass.config.location_name,
"user_name": user_name,
"llm_context": llm_context,
},
parse_result=False,
),
api_prompt,
)
)
except TemplateError as err:
LOGGER.error("Error rendering prompt: %s", err)
intent_response = intent.IntentResponse(language=user_input.language)
intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN,
f"Sorry, I had a problem with my template: {err}",
)
return conversation.ConversationResult(
response=intent_response, conversation_id=conversation_id
)
messages = [ChatCompletionSystemMessageParam(role="system", content=prompt)]
messages.append(
ChatCompletionUserMessageParam(role="user", content=user_input.text)
)
# Create a copy of the variable because we attach it to the trace
messages = [
ChatCompletionSystemMessageParam(role="system", content=prompt),
*messages[1:],
ChatCompletionUserMessageParam(role="user", content=user_input.text),
]
LOGGER.debug("Prompt: %s", messages)
trace.async_conversation_trace_append(
@@ -240,6 +240,7 @@ class OverkizAlarmControlPanel(OverkizDescriptiveEntity, AlarmControlPanelEntity
"""Representation of an Overkiz Alarm Control Panel."""
entity_description: OverkizAlarmDescription
_attr_code_arm_required = False
def __init__(
self,
@@ -19,7 +19,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"],
"requirements": ["pyoverkiz==1.13.10"],
"requirements": ["pyoverkiz==1.13.11"],
"zeroconf": [
{
"type": "_kizbox._tcp.local.",
@@ -55,6 +55,7 @@ class MinutPointAlarmControl(AlarmControlPanelEntity):
"""The platform class required by Home Assistant."""
_attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY
_attr_code_arm_required = False
def __init__(self, point_client: MinutPointClient, home_id: str) -> None:
"""Initialize the entity."""
@@ -5,6 +5,7 @@ from __future__ import annotations
import mimetypes
from radios import FilterBy, Order, RadioBrowser, Station
from radios.radio_browser import pycountry
from homeassistant.components.media_player import MediaClass, MediaType
from homeassistant.components.media_source.error import Unresolvable
@@ -145,6 +146,8 @@ class RadioMediaSource(MediaSource):
# We show country in the root additionally, when there is no item
if not item.identifier or category == "country":
# Trigger the lazy loading of the country database to happen inside the executor
await self.hass.async_add_executor_job(lambda: len(pycountry.countries))
countries = await radios.countries(order=Order.NAME)
return [
BrowseMediaSource(
@@ -3,7 +3,7 @@
from __future__ import annotations
from datetime import datetime as dt
from typing import TYPE_CHECKING, Any, Literal, cast
from typing import Any, Literal, cast
import voluptuous as vol
@@ -44,11 +44,7 @@ from .statistics import (
statistics_during_period,
validate_statistics,
)
from .util import PERIOD_SCHEMA, get_instance, resolve_period, session_scope
if TYPE_CHECKING:
from .core import Recorder
from .util import PERIOD_SCHEMA, get_instance, resolve_period
UNIT_SCHEMA = vol.Schema(
{
@@ -85,7 +81,6 @@ def async_setup(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_info)
websocket_api.async_register_command(hass, ws_update_statistics_metadata)
websocket_api.async_register_command(hass, ws_validate_statistics)
websocket_api.async_register_command(hass, ws_get_recorded_entities)
def _ws_get_statistic_during_period(
@@ -518,40 +513,3 @@ def ws_info(
"thread_running": is_running,
}
connection.send_result(msg["id"], recorder_info)
def _get_recorded_entities(
hass: HomeAssistant, msg_id: int, instance: Recorder
) -> bytes:
"""Get the list of entities being recorded."""
with session_scope(hass=hass, read_only=True) as session:
return json_bytes(
messages.result_message(
msg_id,
{
"entity_ids": list(
instance.states_meta_manager.get_metadata_id_to_entity_id(
session
).values()
)
},
)
)
@websocket_api.websocket_command(
{
vol.Required("type"): "recorder/recorded_entities",
}
)
@websocket_api.async_response
async def ws_get_recorded_entities(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Get the list of entities being recorded."""
instance = get_instance(hass)
return connection.send_message(
await instance.async_add_executor_job(
_get_recorded_entities, hass, msg["id"], instance
)
)
@@ -137,7 +137,7 @@ def _register_new_account(
configurator.request_done(hass, request_id)
request_id = configurator.async_request_config(
request_id = configurator.request_config(
hass,
f"{DOMAIN} - {account_name}",
callback=register_account_callback,
@@ -81,7 +81,7 @@ BINARY_SENSOR_TYPES: tuple[RenaultBinarySensorEntityDescription, ...] = tuple(
key="hvac_status",
coordinator="hvac_status",
on_key="hvacStatus",
on_value="on",
on_value=2,
translation_key="hvac_status",
),
RenaultBinarySensorEntityDescription(
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["renault_api"],
"quality_scale": "platinum",
"requirements": ["renault-api==0.2.2"]
"requirements": ["renault-api==0.2.3"]
}
@@ -25,7 +25,7 @@ from homeassistant.const import (
)
from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, selector
from homeassistant.helpers.device_registry import format_mac
from .const import CONF_USE_HTTPS, DOMAIN
@@ -60,7 +60,24 @@ class ReolinkOptionsFlowHandler(OptionsFlow):
vol.Required(
CONF_PROTOCOL,
default=self.config_entry.options[CONF_PROTOCOL],
): vol.In(["rtsp", "rtmp", "flv"]),
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(
value="rtsp",
label="RTSP",
),
selector.SelectOptionDict(
value="rtmp",
label="RTMP",
),
selector.SelectOptionDict(
value="flv",
label="FLV",
),
],
),
),
}
),
)
+31 -5
View File
@@ -89,11 +89,22 @@ class ReolinkHostCoordinatorEntity(ReolinkBaseCoordinatorEntity[None]):
async def async_added_to_hass(self) -> None:
"""Entity created."""
await super().async_added_to_hass()
if (
self.entity_description.cmd_key is not None
and self.entity_description.cmd_key not in self._host.update_cmd_list
):
self._host.update_cmd_list.append(self.entity_description.cmd_key)
cmd_key = self.entity_description.cmd_key
if cmd_key is not None:
self._host.async_register_update_cmd(cmd_key)
async def async_will_remove_from_hass(self) -> None:
"""Entity removed."""
cmd_key = self.entity_description.cmd_key
if cmd_key is not None:
self._host.async_unregister_update_cmd(cmd_key)
await super().async_will_remove_from_hass()
async def async_update(self) -> None:
"""Force full update from the generic entity update service."""
self._host.last_wake = 0
await super().async_update()
class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity):
@@ -128,3 +139,18 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity):
sw_version=self._host.api.camera_sw_version(dev_ch),
configuration_url=self._conf_url,
)
async def async_added_to_hass(self) -> None:
"""Entity created."""
await super().async_added_to_hass()
cmd_key = self.entity_description.cmd_key
if cmd_key is not None:
self._host.async_register_update_cmd(cmd_key, self._channel)
async def async_will_remove_from_hass(self) -> None:
"""Entity removed."""
cmd_key = self.entity_description.cmd_key
if cmd_key is not None:
self._host.async_unregister_update_cmd(cmd_key, self._channel)
await super().async_will_remove_from_hass()
+32 -3
View File
@@ -3,8 +3,10 @@
from __future__ import annotations
import asyncio
from collections import defaultdict
from collections.abc import Mapping
import logging
from time import time
from typing import Any, Literal
import aiohttp
@@ -21,7 +23,7 @@ from homeassistant.const import (
CONF_PROTOCOL,
CONF_USERNAME,
)
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.dispatcher import async_dispatcher_send
@@ -39,6 +41,10 @@ POLL_INTERVAL_NO_PUSH = 5
LONG_POLL_COOLDOWN = 0.75
LONG_POLL_ERROR_COOLDOWN = 30
# Conserve battery by not waking the battery cameras each minute during normal update
# Most props are cached in the Home Hub and updated, but some are skipped
BATTERY_WAKE_UPDATE_INTERVAL = 3600 # seconds
_LOGGER = logging.getLogger(__name__)
@@ -67,7 +73,10 @@ class ReolinkHost:
timeout=DEFAULT_TIMEOUT,
)
self.update_cmd_list: list[str] = []
self.last_wake: float = 0
self._update_cmd: defaultdict[str, defaultdict[int | None, int]] = defaultdict(
lambda: defaultdict(int)
)
self.webhook_id: str | None = None
self._onvif_push_supported: bool = True
@@ -84,6 +93,20 @@ class ReolinkHost:
self._long_poll_task: asyncio.Task | None = None
self._lost_subscription: bool = False
@callback
def async_register_update_cmd(self, cmd: str, channel: int | None = None) -> None:
"""Register the command to update the state."""
self._update_cmd[cmd][channel] += 1
@callback
def async_unregister_update_cmd(self, cmd: str, channel: int | None = None) -> None:
"""Unregister the command to update the state."""
self._update_cmd[cmd][channel] -= 1
if not self._update_cmd[cmd][channel]:
del self._update_cmd[cmd][channel]
if not self._update_cmd[cmd]:
del self._update_cmd[cmd]
@property
def unique_id(self) -> str:
"""Create the unique ID, base for all entities."""
@@ -320,7 +343,13 @@ class ReolinkHost:
async def update_states(self) -> None:
"""Call the API of the camera device to update the internal states."""
await self._api.get_states(cmd_list=self.update_cmd_list)
wake = False
if time() - self.last_wake > BATTERY_WAKE_UPDATE_INTERVAL:
# wake the battery cameras for a complete update
wake = True
self.last_wake = time()
await self._api.get_states(cmd_list=self._update_cmd, wake=wake)
async def disconnect(self) -> None:
"""Disconnect from the API, so the connection will be released."""
@@ -18,5 +18,5 @@
"documentation": "https://www.home-assistant.io/integrations/reolink",
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"requirements": ["reolink-aio==0.8.11"]
"requirements": ["reolink-aio==0.9.1"]
}
+5 -3
View File
@@ -109,12 +109,14 @@ SELECT_ENTITIES = (
ReolinkSelectEntityDescription(
key="status_led",
cmd_key="GetPowerLed",
translation_key="status_led",
translation_key="doorbell_led",
entity_category=EntityCategory.CONFIG,
get_options=[state.name for state in StatusLedEnum],
get_options=lambda api, ch: api.doorbell_led_list(ch),
supported=lambda api, ch: api.supported(ch, "doorbell_led"),
value=lambda api, ch: StatusLedEnum(api.doorbell_led(ch)).name,
method=lambda api, ch, name: api.set_status_led(ch, StatusLedEnum[name].value),
method=lambda api, ch, name: (
api.set_status_led(ch, StatusLedEnum[name].value, doorbell=True)
),
),
)
@@ -383,8 +383,8 @@
"pantiltfirst": "Pan/tilt first"
}
},
"status_led": {
"name": "Status LED",
"doorbell_led": {
"name": "Doorbell LED",
"state": {
"stayoff": "Stay off",
"auto": "Auto",
@@ -7,7 +7,7 @@
"iot_class": "local_polling",
"loggers": ["roborock"],
"requirements": [
"python-roborock==2.1.1",
"python-roborock==2.2.3",
"vacuum-map-parser-roborock==0.1.2"
]
}
@@ -325,6 +325,11 @@ class SamsungTVLegacyBridge(SamsungTVBridge):
"""Try to gather infos of this device."""
return None
def _notify_reauth_callback(self) -> None:
"""Notify access denied callback."""
if self._reauth_callback is not None:
self.hass.loop.call_soon_threadsafe(self._reauth_callback)
def _get_remote(self) -> Remote:
"""Create or return a remote control instance."""
if self._remote is None:
+28 -18
View File
@@ -21,6 +21,7 @@ from homeassistant.helpers import config_validation as cv, entity_platform, inst
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.system_info import async_get_system_info
from homeassistant.loader import Integration, async_get_custom_components
from homeassistant.setup import SetupPhases, async_pause_setup
from .const import (
CONF_DSN,
@@ -41,7 +42,6 @@ from .const import (
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
LOGGER_INFO_REGEX = re.compile(r"^(\w+)\.?(\w+)?\.?(\w+)?\.?(\w+)?(?:\..*)?$")
@@ -81,23 +81,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
),
}
sentry_sdk.init(
dsn=entry.data[CONF_DSN],
environment=entry.options.get(CONF_ENVIRONMENT),
integrations=[sentry_logging, AioHttpIntegration(), SqlalchemyIntegration()],
release=current_version,
before_send=lambda event, hint: process_before_send(
hass,
entry.options,
channel,
huuid,
system_info,
custom_components,
event,
hint,
),
**tracing,
)
with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES):
# sentry_sdk.init imports modules based on the selected integrations
def _init_sdk():
"""Initialize the Sentry SDK."""
sentry_sdk.init(
dsn=entry.data[CONF_DSN],
environment=entry.options.get(CONF_ENVIRONMENT),
integrations=[
sentry_logging,
AioHttpIntegration(),
SqlalchemyIntegration(),
],
release=current_version,
before_send=lambda event, hint: process_before_send(
hass,
entry.options,
channel,
huuid,
system_info,
custom_components,
event,
hint,
),
**tracing,
)
await hass.async_add_import_executor_job(_init_sdk)
async def update_system_info(now):
nonlocal system_info
+1 -1
View File
@@ -57,7 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except RequestError as err:
raise ConfigEntryNotReady from err
coordinator = SENZDataUpdateCoordinator(
coordinator: SENZDataUpdateCoordinator = DataUpdateCoordinator(
hass,
_LOGGER,
name=account.username,
+10 -6
View File
@@ -584,11 +584,13 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]):
raise UpdateFailed(
f"Sleeping device did not update within {self.sleep_period} seconds interval"
)
if self.device.connected:
return
if not await self._async_device_connect_task():
raise UpdateFailed("Device reconnect error")
async with self._connection_lock:
if self.device.connected: # Already connected
return
if not await self._async_device_connect_task():
raise UpdateFailed("Device reconnect error")
async def _async_disconnected(self, reconnect: bool) -> None:
"""Handle device disconnected."""
@@ -737,7 +739,8 @@ def get_block_coordinator_by_device_id(
entry = hass.config_entries.async_get_entry(config_entry)
if (
entry
and entry.state == ConfigEntryState.LOADED
and entry.state is ConfigEntryState.LOADED
and hasattr(entry, "runtime_data")
and isinstance(entry.runtime_data, ShellyEntryData)
and (coordinator := entry.runtime_data.block)
):
@@ -756,7 +759,8 @@ def get_rpc_coordinator_by_device_id(
entry = hass.config_entries.async_get_entry(config_entry)
if (
entry
and entry.state == ConfigEntryState.LOADED
and entry.state is ConfigEntryState.LOADED
and hasattr(entry, "runtime_data")
and isinstance(entry.runtime_data, ShellyEntryData)
and (coordinator := entry.runtime_data.rpc)
):
+43 -33
View File
@@ -8,6 +8,8 @@ from typing import Any
import pysnmp.hlapi.asyncio as hlapi
from pysnmp.hlapi.asyncio import (
CommunityData,
ObjectIdentity,
ObjectType,
UdpTransportTarget,
UsmUserData,
getCmd,
@@ -63,7 +65,12 @@ from .const import (
MAP_PRIV_PROTOCOLS,
SNMP_VERSIONS,
)
from .util import RequestArgsType, async_create_request_cmd_args
from .util import (
CommandArgsType,
RequestArgsType,
async_create_command_cmd_args,
async_create_request_cmd_args,
)
_LOGGER = logging.getLogger(__name__)
@@ -125,23 +132,23 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the SNMP switch."""
name = config.get(CONF_NAME)
host = config.get(CONF_HOST)
port = config.get(CONF_PORT)
name: str = config[CONF_NAME]
host: str = config[CONF_HOST]
port: int = config[CONF_PORT]
community = config.get(CONF_COMMUNITY)
baseoid: str = config[CONF_BASEOID]
command_oid = config.get(CONF_COMMAND_OID)
command_payload_on = config.get(CONF_COMMAND_PAYLOAD_ON)
command_payload_off = config.get(CONF_COMMAND_PAYLOAD_OFF)
command_oid: str | None = config.get(CONF_COMMAND_OID)
command_payload_on: str | None = config.get(CONF_COMMAND_PAYLOAD_ON)
command_payload_off: str | None = config.get(CONF_COMMAND_PAYLOAD_OFF)
version: str = config[CONF_VERSION]
username = config.get(CONF_USERNAME)
authkey = config.get(CONF_AUTH_KEY)
authproto: str = config[CONF_AUTH_PROTOCOL]
privkey = config.get(CONF_PRIV_KEY)
privproto: str = config[CONF_PRIV_PROTOCOL]
payload_on = config.get(CONF_PAYLOAD_ON)
payload_off = config.get(CONF_PAYLOAD_OFF)
vartype = config.get(CONF_VARTYPE)
payload_on: str = config[CONF_PAYLOAD_ON]
payload_off: str = config[CONF_PAYLOAD_OFF]
vartype: str = config[CONF_VARTYPE]
if version == "3":
if not authkey:
@@ -159,9 +166,11 @@ async def async_setup_platform(
else:
auth_data = CommunityData(community, mpModel=SNMP_VERSIONS[version])
transport = UdpTransportTarget((host, port))
request_args = await async_create_request_cmd_args(
hass, auth_data, UdpTransportTarget((host, port)), baseoid
hass, auth_data, transport, baseoid
)
command_args = await async_create_command_cmd_args(hass, auth_data, transport)
async_add_entities(
[
@@ -177,6 +186,7 @@ async def async_setup_platform(
command_payload_off,
vartype,
request_args,
command_args,
)
],
True,
@@ -188,21 +198,22 @@ class SnmpSwitch(SwitchEntity):
def __init__(
self,
name,
host,
port,
baseoid,
commandoid,
payload_on,
payload_off,
command_payload_on,
command_payload_off,
vartype,
request_args,
name: str,
host: str,
port: int,
baseoid: str,
commandoid: str | None,
payload_on: str,
payload_off: str,
command_payload_on: str | None,
command_payload_off: str | None,
vartype: str,
request_args: RequestArgsType,
command_args: CommandArgsType,
) -> None:
"""Initialize the switch."""
self._name = name
self._attr_name = name
self._baseoid = baseoid
self._vartype = vartype
@@ -215,7 +226,8 @@ class SnmpSwitch(SwitchEntity):
self._payload_on = payload_on
self._payload_off = payload_off
self._target = UdpTransportTarget((host, port))
self._request_args: RequestArgsType = request_args
self._request_args = request_args
self._command_args = command_args
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the switch."""
@@ -226,7 +238,7 @@ class SnmpSwitch(SwitchEntity):
"""Turn off the switch."""
await self._execute_command(self._command_payload_off)
async def _execute_command(self, command):
async def _execute_command(self, command: str) -> None:
# User did not set vartype and command is not a digit
if self._vartype == "none" and not self._command_payload_on.isdigit():
await self._set(command)
@@ -265,14 +277,12 @@ class SnmpSwitch(SwitchEntity):
self._state = None
@property
def name(self):
"""Return the switch's name."""
return self._name
@property
def is_on(self):
def is_on(self) -> bool | None:
"""Return true if switch is on; False if off. None if unknown."""
return self._state
async def _set(self, value):
await setCmd(*self._request_args, value)
async def _set(self, value: Any) -> None:
"""Set the state of the switch."""
await setCmd(
*self._command_args, ObjectType(ObjectIdentity(self._commandoid), value)
)
+29 -7
View File
@@ -25,6 +25,14 @@ DATA_SNMP_ENGINE = "snmp_engine"
_LOGGER = logging.getLogger(__name__)
type CommandArgsType = tuple[
SnmpEngine,
UsmUserData | CommunityData,
UdpTransportTarget | Udp6TransportTarget,
ContextData,
]
type RequestArgsType = tuple[
SnmpEngine,
UsmUserData | CommunityData,
@@ -34,20 +42,34 @@ type RequestArgsType = tuple[
]
async def async_create_command_cmd_args(
hass: HomeAssistant,
auth_data: UsmUserData | CommunityData,
target: UdpTransportTarget | Udp6TransportTarget,
) -> CommandArgsType:
"""Create command arguments.
The ObjectType needs to be created dynamically by the caller.
"""
engine = await async_get_snmp_engine(hass)
return (engine, auth_data, target, ContextData())
async def async_create_request_cmd_args(
hass: HomeAssistant,
auth_data: UsmUserData | CommunityData,
target: UdpTransportTarget | Udp6TransportTarget,
object_id: str,
) -> RequestArgsType:
"""Create request arguments."""
return (
await async_get_snmp_engine(hass),
auth_data,
target,
ContextData(),
ObjectType(ObjectIdentity(object_id)),
"""Create request arguments.
The same ObjectType is used for all requests.
"""
engine, auth_data, target, context_data = await async_create_command_cmd_args(
hass, auth_data, target
)
object_type = ObjectType(ObjectIdentity(object_id))
return (engine, auth_data, target, context_data, object_type)
@singleton(DATA_SNMP_ENGINE)
@@ -62,6 +62,7 @@ class SpcAlarm(AlarmControlPanelEntity):
| AlarmControlPanelEntityFeature.ARM_AWAY
| AlarmControlPanelEntityFeature.ARM_NIGHT
)
_attr_code_arm_required = False
def __init__(self, area: Area, api: SpcWebGateway) -> None:
"""Initialize the SPC alarm panel."""
@@ -119,12 +119,16 @@ class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]):
async def async_set_sleep_duration(self, end: int) -> None:
"""Set Starlink system sleep schedule end time."""
duration = end - self.data.sleep[0]
if duration < 0:
# If the duration pushed us into the next day, add one days worth to correct that.
duration += 1440
async with asyncio.timeout(4):
try:
await self.hass.async_add_executor_job(
set_sleep_config,
self.data.sleep[0],
end,
duration,
self.data.sleep[2],
self.channel_context,
)
@@ -62,6 +62,8 @@ class StarlinkTimeEntity(StarlinkEntity, TimeEntity):
def _utc_minutes_to_time(utc_minutes: int, timezone: tzinfo) -> time:
hour = math.floor(utc_minutes / 60)
if hour > 23:
hour -= 24
minute = utc_minutes % 60
try:
utc = datetime.now(UTC).replace(
+39 -31
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Callable
import logging
from typing import TYPE_CHECKING, Any, final
import uuid
@@ -9,15 +10,11 @@ import uuid
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.const import CONF_NAME
from homeassistant.const import CONF_ID, CONF_NAME
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import collection, entity_registry as er
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.storage import Store
@@ -34,7 +31,7 @@ LAST_SCANNED = "last_scanned"
LAST_SCANNED_BY_DEVICE_ID = "last_scanned_by_device_id"
STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1
STORAGE_VERSION_MINOR = 2
STORAGE_VERSION_MINOR = 3
TAG_DATA: HassKey[TagStorageCollection] = HassKey(DOMAIN)
SIGNAL_TAG_CHANGED = "signal_tag_changed"
@@ -107,8 +104,14 @@ class TagStore(Store[collection.SerializedStorageCollection]):
# Version 1.2 moves name to entity registry
for tag in data["items"]:
# Copy name in tag store to the entity registry
_create_entry(entity_registry, tag[TAG_ID], tag.get(CONF_NAME))
_create_entry(entity_registry, tag[CONF_ID], tag.get(CONF_NAME))
tag["migrated"] = True
if old_major_version == 1 and old_minor_version < 3:
# Version 1.3 removes tag_id from the store
for tag in data["items"]:
if TAG_ID not in tag:
continue
del tag[TAG_ID]
if old_major_version > 1:
raise NotImplementedError
@@ -136,24 +139,26 @@ class TagStorageCollection(collection.DictStorageCollection):
data = self.CREATE_SCHEMA(data)
if not data[TAG_ID]:
data[TAG_ID] = str(uuid.uuid4())
# Move tag id to id
data[CONF_ID] = data.pop(TAG_ID)
# make last_scanned JSON serializeable
if LAST_SCANNED in data:
data[LAST_SCANNED] = data[LAST_SCANNED].isoformat()
# Create entity in entity_registry when creating the tag
# This is done early to store name only once in entity registry
_create_entry(self.entity_registry, data[TAG_ID], data.get(CONF_NAME))
_create_entry(self.entity_registry, data[CONF_ID], data.get(CONF_NAME))
return data
@callback
def _get_suggested_id(self, info: dict[str, str]) -> str:
"""Suggest an ID based on the config."""
return info[TAG_ID]
return info[CONF_ID]
async def _update_data(self, item: dict, update_data: dict) -> dict:
"""Return a new updated data object."""
data = {**item, **self.UPDATE_SCHEMA(update_data)}
tag_id = data[TAG_ID]
tag_id = item[CONF_ID]
# make last_scanned JSON serializeable
if LAST_SCANNED in update_data:
data[LAST_SCANNED] = data[LAST_SCANNED].isoformat()
@@ -211,7 +216,7 @@ class TagDictStorageCollectionWebsocket(
item = {k: v for k, v in item.items() if k != "migrated"}
if (
entity_id := self.entity_registry.async_get_entity_id(
DOMAIN, DOMAIN, item[TAG_ID]
DOMAIN, DOMAIN, item[CONF_ID]
)
) and (entity := self.entity_registry.async_get(entity_id)):
item[CONF_NAME] = entity.name or entity.original_name
@@ -237,6 +242,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
).async_setup(hass)
entity_registry = er.async_get(hass)
entity_update_handlers: dict[str, Callable[[str | None, str | None], None]] = {}
async def tag_change_listener(
change_type: str, item_id: str, updated_config: dict
@@ -249,14 +255,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
if change_type == collection.CHANGE_ADDED:
# When tags are added to storage
entity = _create_entry(entity_registry, updated_config[TAG_ID], None)
entity = _create_entry(entity_registry, updated_config[CONF_ID], None)
if TYPE_CHECKING:
assert entity.original_name
await component.async_add_entities(
[
TagEntity(
entity_update_handlers,
entity.name or entity.original_name,
updated_config[TAG_ID],
updated_config[CONF_ID],
updated_config.get(LAST_SCANNED),
updated_config.get(DEVICE_ID),
)
@@ -265,18 +272,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
elif change_type == collection.CHANGE_UPDATED:
# When tags are changed or updated in storage
async_dispatcher_send(
hass,
f"{SIGNAL_TAG_CHANGED}-{updated_config[TAG_ID]}",
updated_config.get(DEVICE_ID),
updated_config.get(LAST_SCANNED),
)
if handler := entity_update_handlers.get(updated_config[CONF_ID]):
handler(
updated_config.get(DEVICE_ID),
updated_config.get(LAST_SCANNED),
)
# Deleted tags
elif change_type == collection.CHANGE_REMOVED:
# When tags are removed from storage
entity_id = entity_registry.async_get_entity_id(
DOMAIN, DOMAIN, updated_config[TAG_ID]
DOMAIN, DOMAIN, updated_config[CONF_ID]
)
if entity_id:
entity_registry.async_remove(entity_id)
@@ -287,21 +293,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
for tag in storage_collection.async_items():
if _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug("Adding tag: %s", tag)
entity_id = entity_registry.async_get_entity_id(DOMAIN, DOMAIN, tag[TAG_ID])
entity_id = entity_registry.async_get_entity_id(DOMAIN, DOMAIN, tag[CONF_ID])
if entity_id := entity_registry.async_get_entity_id(
DOMAIN, DOMAIN, tag[TAG_ID]
DOMAIN, DOMAIN, tag[CONF_ID]
):
entity = entity_registry.async_get(entity_id)
else:
entity = _create_entry(entity_registry, tag[TAG_ID], None)
entity = _create_entry(entity_registry, tag[CONF_ID], None)
if TYPE_CHECKING:
assert entity
assert entity.original_name
name = entity.name or entity.original_name
entities.append(
TagEntity(
entity_update_handlers,
name,
tag[TAG_ID],
tag[CONF_ID],
tag.get(LAST_SCANNED),
tag.get(DEVICE_ID),
)
@@ -363,12 +370,14 @@ class TagEntity(Entity):
def __init__(
self,
entity_update_handlers: dict[str, Callable[[str | None, str | None], None]],
name: str,
tag_id: str,
last_scanned: str | None,
device_id: str | None,
) -> None:
"""Initialize the Tag event."""
self._entity_update_handlers = entity_update_handlers
self._attr_name = name
self._tag_id = tag_id
self._attr_unique_id = tag_id
@@ -411,10 +420,9 @@ class TagEntity(Entity):
async def async_added_to_hass(self) -> None:
"""Handle entity which will be added."""
await super().async_added_to_hass()
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{SIGNAL_TAG_CHANGED}-{self._tag_id}",
self.async_handle_event,
)
)
self._entity_update_handlers[self._tag_id] = self.async_handle_event
async def async_will_remove_from_hass(self) -> None:
"""Handle entity being removed."""
await super().async_will_remove_from_hass()
del self._entity_update_handlers[self._tag_id]
+7 -1
View File
@@ -50,7 +50,13 @@ class TibberNotificationService(BaseNotificationService):
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to Tibber devices."""
migrate_notify_issue(self.hass, TIBBER_DOMAIN, "Tibber", "2024.12.0")
migrate_notify_issue(
self.hass,
TIBBER_DOMAIN,
"Tibber",
"2024.12.0",
service_name=self._service_name,
)
title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
try:
await self._notify(title=title, message=message)
+10 -10
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from . import DOMAIN, TodoItem, TodoItemStatus, TodoListEntity
@@ -22,7 +21,7 @@ class ListAddItemIntent(intent.IntentHandler):
intent_type = INTENT_LIST_ADD_ITEM
description = "Add item to a todo list"
slot_schema = {"item": cv.string, "name": cv.string}
slot_schema = {"item": intent.non_empty_string, "name": intent.non_empty_string}
platforms = {DOMAIN}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
@@ -37,18 +36,19 @@ class ListAddItemIntent(intent.IntentHandler):
target_list: TodoListEntity | None = None
# Find matching list
for list_state in intent.async_match_states(
hass, name=list_name, domains=[DOMAIN]
):
target_list = component.get_entity(list_state.entity_id)
if target_list is not None:
break
match_constraints = intent.MatchTargetsConstraints(
name=list_name, domains=[DOMAIN], assistant=intent_obj.assistant
)
match_result = intent.async_match_targets(hass, match_constraints)
if not match_result.is_match:
raise intent.MatchFailedError(
result=match_result, constraints=match_constraints
)
target_list = component.get_entity(match_result.states[0].entity_id)
if target_list is None:
raise intent.IntentHandleError(f"No to-do list: {list_name}")
assert target_list is not None
# Add to list
await target_list.async_create_todo_item(
TodoItem(summary=item, status=TodoItemStatus.NEEDS_ACTION)
@@ -88,6 +88,7 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
"""Tuya Alarm Entity."""
_attr_name = None
_attr_code_arm_required = False
def __init__(
self,
+1 -1
View File
@@ -16,7 +16,7 @@
"fv_power": {
"default": "mdi:solar-power-variant"
},
"slave_error": {
"meter_error": {
"default": "mdi:alert"
},
"battery_power": {

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