Compare commits

..

96 Commits

Author SHA1 Message Date
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
Paulus Schoutsen b5783e6f5c Bump version to 2024.6.0b5 2024-06-03 01:10:10 +00:00
G Johansson 1708b60ecf Fix entity state dispatching for Tag entities (#118662) 2024-06-03 01:10:06 +00:00
puddly 3c012c497b Bump ZHA dependencies (#118658)
* Bump bellows to 0.39.0

* Do not create a backup if there is no active ZHA gateway object

* Bump universal-silabs-flasher as well
2024-06-03 01:10:05 +00:00
Joost Lekkerkerker 4d2dc9a40e Fix incorrect placeholder in SharkIQ (#118640)
Update strings.json
2024-06-03 01:10:05 +00:00
Jan Bouwhuis 3653a51288 Fix handling undecoded mqtt sensor payloads (#118633) 2024-06-03 01:10:04 +00:00
J. Nick Koston 9366a4e69b Include a traceback for non-strict event loop blocking detection (#118620) 2024-06-03 01:10:03 +00:00
Luca Angemi 1d1af7ec11 Fix telegram bot send_document (#118616) 2024-06-03 01:10:02 +00:00
tronikos 236b19c5b3 Use gemini-1.5-flash-latest in google_generative_ai_conversation.generate_content (#118594) 2024-06-03 01:10:02 +00:00
tronikos 1afbfd687f Strip Google AI text responses (#118593)
* Strip Google AI test responses

* strip each part
2024-06-03 01:10:01 +00:00
Paulus Schoutsen 20159d0277 Add base prompt for LLMs (#118592) 2024-06-03 01:10:00 +00:00
tronikos 4df3d43e45 Stop instructing LLM to not pass the domain as a list (#118590) 2024-06-03 01:10:00 +00:00
Michael 1a588760b9 Avoid future exception during setup of Synology DSM (#118583)
* avoid future exception during integration setup

* clear future flag during setup

* always clear the flag (with comment)
2024-06-03 01:09:58 +00:00
Jan-Philipp Benecke 6ba9e7d5fd Run ruff format for device registry (#118582) 2024-06-03 01:09:58 +00:00
epenet 4b06c5d2fb Update device connections in samsungtv (#118556) 2024-06-03 01:09:57 +00:00
Adam Pasztor bfc1c62a49 Bump pyads to 3.4.0 (#116934)
Co-authored-by: J. Nick Koston <nick@koston.org>
2024-06-03 01:09:57 +00:00
Thomas Ytterdal c52fabcf77 Ignore myuplink sensors without a description that provide non-numeric values (#115525)
Ignore sensors without a description that provide non-numeric values

Co-authored-by: Jan-Philipp Benecke <jan-philipp@bnck.me>
2024-06-03 01:09:56 +00:00
Paulus Schoutsen b39d7b39e1 Bump version to 2024.6.0b4 2024-05-31 19:34:58 +00:00
Paulus Schoutsen c01c155037 Fix openAI tool calls (#118577) 2024-05-31 19:34:38 +00:00
epenet b459559c8b Add ability to replace connections in DeviceRegistry (#118555)
* Add ability to replace connections in DeviceRegistry

* Add more tests

* Improve coverage

* Apply suggestion

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>

---------

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
2024-05-31 19:34:38 +00:00
Maciej Bieniek d823e56659 In Brother integration use SnmpEngine from SNMP integration (#118554)
Co-authored-by: Maciej Bieniek <478555+bieniu@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2024-05-31 19:34:37 +00:00
Michael Chisholm e401a0da7f Fix KeyError in dlna_dmr SSDP config flow when checking existing config entries (#118549)
Fix KeyError checking existing dlna_dmr config entries
2024-05-31 19:34:36 +00:00
Tsvi Mostovicz 3f6df28ef3 Fix YAML deprecation breaking version in jewish calendar and media extractor (#118546)
* Fix YAML deprecation breaking version

* Update

* fix media extractor deprecation as well

* Add issue_domain
2024-05-31 19:34:35 +00:00
Joost Lekkerkerker 9b63779063 Fix typo in OWM strings (#118538)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2024-05-31 19:34:35 +00:00
Josef Zweck 4998fe5e6d Migrate openai_conversation to entry.runtime_data (#118535)
* switch to entry.runtime_data

* check for missing config entry

* Update homeassistant/components/openai_conversation/__init__.py

---------

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
2024-05-31 19:34:34 +00:00
Brett Adams a59c890779 Fix off_grid_vehicle_charging_reserve_percent in Teselemetry (#118532) 2024-05-31 19:34:34 +00:00
Luca Angemi a2cdb349f4 Fix telegram doing blocking I/O in the event loop (#118531) 2024-05-31 19:34:33 +00:00
J. Nick Koston 267228cae0 Fix openweathermap config entry migration (#118526)
* Fix openweathermap config entry migration

The options keys were accidentally migrated to data so
they could no longer be changed in the options flow

* more fixes

* adjust

* reduce

* fix

* adjust
2024-05-31 19:34:32 +00:00
J. Nick Koston ba769f4d9f Fix snmp doing blocking I/O in the event loop (#118521) 2024-05-31 19:34:31 +00:00
Denis Shulyaka c09bc726d1 Add OpenAI Conversation system prompt user_name and llm_context variables (#118512)
* OpenAI Conversation: Add variables to the system prompt

* User name and llm_context

* test for user name

* test for user id

---------

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2024-05-31 19:34:31 +00:00
Josef Zweck c441f689bf Add typing for OpenAI client and fallout (#118514)
* typing for client and consequences

* Update homeassistant/components/openai_conversation/conversation.py

---------

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
2024-05-31 19:34:07 +00:00
Denis Shulyaka 395e1ae31e Add Google Generative AI Conversation system prompt user_name and llm_context variables (#118510)
* Google Generative AI Conversation: Add variables to the system prompt

* User name and llm_context

* test for template variables

* test for template variables

---------

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2024-05-31 19:32:47 +00:00
Bas Brussee 2e45d678b8 Revert "Fix Tibber sensors state class" (#118409)
Revert "Fix Tibber sensors state class (#117085)"

This reverts commit 658c1f3d97.
2024-05-31 19:32:46 +00:00
Paulus Schoutsen 17cb25a5b6 Rename llm.ToolContext to llm.LLMContext (#118566) 2024-05-31 19:32:07 +00:00
182 changed files with 6209 additions and 3464 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
+1 -1
View File
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/ads",
"iot_class": "local_push",
"loggers": ["pyads"],
"requirements": ["pyads==3.2.2"]
"requirements": ["pyads==3.4.0"]
}
@@ -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]},
@@ -6,6 +6,6 @@
"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)
@@ -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": {
@@ -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
)
+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()
+4 -18
View File
@@ -3,16 +3,14 @@
from __future__ import annotations
from brother import Brother, SnmpError
from pysnmp.hlapi.asyncio.cmdgen import lcd
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.components.snmp import async_get_snmp_engine
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_TYPE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN, SNMP_ENGINE
from .coordinator import BrotherDataUpdateCoordinator
from .utils import get_snmp_engine
PLATFORMS = [Platform.SENSOR]
@@ -24,7 +22,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> b
host = entry.data[CONF_HOST]
printer_type = entry.data[CONF_TYPE]
snmp_engine = get_snmp_engine(hass)
snmp_engine = await async_get_snmp_engine(hass)
try:
brother = await Brother.create(
host, printer_type=printer_type, snmp_engine=snmp_engine
@@ -44,16 +42,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> b
async def async_unload_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
loaded_entries = [
entry
for entry in hass.config_entries.async_entries(DOMAIN)
if entry.state == ConfigEntryState.LOADED
]
# We only want to remove the SNMP engine when unloading the last config entry
if unload_ok and len(loaded_entries) == 1:
lcd.unconfigure(hass.data[SNMP_ENGINE], None)
hass.data.pop(SNMP_ENGINE)
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -8,13 +8,13 @@ from brother import Brother, SnmpError, UnsupportedModelError
import voluptuous as vol
from homeassistant.components import zeroconf
from homeassistant.components.snmp import async_get_snmp_engine
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_TYPE
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util.network import is_host_valid
from .const import DOMAIN, PRINTER_TYPES
from .utils import get_snmp_engine
DATA_SCHEMA = vol.Schema(
{
@@ -45,7 +45,7 @@ class BrotherConfigFlow(ConfigFlow, domain=DOMAIN):
if not is_host_valid(user_input[CONF_HOST]):
raise InvalidHost
snmp_engine = get_snmp_engine(self.hass)
snmp_engine = await async_get_snmp_engine(self.hass)
brother = await Brother.create(
user_input[CONF_HOST], snmp_engine=snmp_engine
@@ -79,7 +79,7 @@ class BrotherConfigFlow(ConfigFlow, domain=DOMAIN):
# Do not probe the device if the host is already configured
self._async_abort_entries_match({CONF_HOST: self.host})
snmp_engine = get_snmp_engine(self.hass)
snmp_engine = await async_get_snmp_engine(self.hass)
model = discovery_info.properties.get("product")
try:
@@ -9,6 +9,4 @@ DOMAIN: Final = "brother"
PRINTER_TYPES: Final = ["laser", "ink"]
SNMP_ENGINE: Final = "snmp_engine"
UPDATE_INTERVAL = timedelta(seconds=30)
@@ -1,6 +1,7 @@
{
"domain": "brother",
"name": "Brother Printer",
"after_dependencies": ["snmp"],
"codeowners": ["@bieniu"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/brother",
-33
View File
@@ -1,33 +0,0 @@
"""Brother helpers functions."""
from __future__ import annotations
import logging
import pysnmp.hlapi.asyncio as hlapi
from pysnmp.hlapi.asyncio.cmdgen import lcd
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import singleton
from .const import SNMP_ENGINE
_LOGGER = logging.getLogger(__name__)
@singleton.singleton(SNMP_ENGINE)
def get_snmp_engine(hass: HomeAssistant) -> hlapi.SnmpEngine:
"""Get SNMP engine."""
_LOGGER.debug("Creating SNMP engine")
snmp_engine = hlapi.SnmpEngine()
@callback
def shutdown_listener(ev: Event) -> None:
if hass.data.get(SNMP_ENGINE):
_LOGGER.debug("Unconfiguring SNMP engine")
lcd.unconfigure(hass.data[SNMP_ENGINE], None)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_listener)
return snmp_engine
@@ -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]
+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"]
}
@@ -871,7 +871,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.3"]
}
+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": {
@@ -149,7 +149,7 @@ class DlnaDmrFlowHandler(ConfigFlow, domain=DOMAIN):
# case the device doesn't have a static and unique UDN (breaking the
# UPnP spec).
for entry in self._async_current_entries(include_ignore=True):
if self._location == entry.data[CONF_URL]:
if self._location == entry.data.get(CONF_URL):
return self.async_abort(reason="already_configured")
if self._mac and self._mac == entry.data.get(CONF_MAC):
return self.async_abort(reason="already_configured")
+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)
)
+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"]
}
@@ -66,8 +66,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
}
)
model_name = "gemini-pro-vision" if image_filenames else "gemini-pro"
model = genai.GenerativeModel(model_name=model_name)
model = genai.GenerativeModel(model_name=RECOMMENDED_CHAT_MODEL)
try:
response = await model.generate_content_async(prompt_parts)
@@ -163,20 +163,22 @@ class GoogleGenerativeAIConversationEntity(
intent_response = intent.IntentResponse(language=user_input.language)
llm_api: llm.APIInstance | None = None
tools: list[dict[str, Any]] | None = None
user_name: str | None = None
llm_context = llm.LLMContext(
platform=DOMAIN,
context=user_input.context,
user_prompt=user_input.text,
language=user_input.language,
assistant=conversation.DOMAIN,
device_id=user_input.device_id,
)
if self.entry.options.get(CONF_LLM_HASS_API):
try:
llm_api = await llm.async_get_api(
self.hass,
self.entry.options[CONF_LLM_HASS_API],
llm.ToolContext(
platform=DOMAIN,
context=user_input.context,
user_prompt=user_input.text,
language=user_input.language,
assistant=conversation.DOMAIN,
device_id=user_input.device_id,
),
llm_context,
)
except HomeAssistantError as err:
LOGGER.error("Error getting LLM API: %s", err)
@@ -223,7 +225,16 @@ class GoogleGenerativeAIConversationEntity(
messages = self.history[conversation_id]
else:
conversation_id = ulid.ulid_now()
messages = [{}, {}]
messages = [{}, {"role": "model", "parts": "Ok"}]
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:
@@ -234,13 +245,16 @@ class GoogleGenerativeAIConversationEntity(
prompt = "\n".join(
(
template.Template(
self.entry.options.get(
llm.BASE_PROMPT
+ self.entry.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,
),
@@ -258,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(
@@ -341,7 +358,7 @@ class GoogleGenerativeAIConversationEntity(
chat_request = glm.Content(parts=tool_responses)
intent_response.async_set_speech(
" ".join([part.text for part in chat_response.parts if part.text])
" ".join([part.text.strip() for part in chat_response.parts if part.text])
)
return conversation.ConversationResult(
response=intent_response, conversation_id=conversation_id
@@ -35,9 +35,8 @@ DEFAULT_EXPOSED_DOMAINS = {
"fan",
"humidifier",
"light",
"lock",
"media_player",
"scene",
"script",
"switch",
"todo",
"vacuum",
@@ -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.2"]
}
@@ -96,7 +96,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
is_fixable=False,
breaks_in_ha_version="2024.10.0",
issue_domain=DOMAIN,
breaks_in_ha_version="2024.12.0",
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
@@ -118,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
)
@@ -153,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",
+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%]"
@@ -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."]
}
@@ -72,7 +72,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2024.11.0",
breaks_in_ha_version="2024.12.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
@@ -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,
}
)
+14 -10
View File
@@ -237,28 +237,32 @@ class MqttSensor(MqttEntity, RestoreSensor):
payload = msg.payload
if payload is PayloadSentinel.DEFAULT:
return
new_value = str(payload)
if not isinstance(payload, str):
_LOGGER.warning(
"Invalid undecoded state message '%s' received from '%s'",
payload,
msg.topic,
)
return
if self._numeric_state_expected:
if new_value == "":
if payload == "":
_LOGGER.debug("Ignore empty state from '%s'", msg.topic)
elif new_value == PAYLOAD_NONE:
elif payload == PAYLOAD_NONE:
self._attr_native_value = None
else:
self._attr_native_value = new_value
self._attr_native_value = payload
return
if self.device_class in {
None,
SensorDeviceClass.ENUM,
} and not check_state_too_long(_LOGGER, new_value, self.entity_id, msg):
self._attr_native_value = new_value
} and not check_state_too_long(_LOGGER, payload, self.entity_id, msg):
self._attr_native_value = payload
return
try:
if (payload_datetime := dt_util.parse_datetime(new_value)) is None:
if (payload_datetime := dt_util.parse_datetime(payload)) is None:
raise ValueError
except ValueError:
_LOGGER.warning(
"Invalid state message '%s' from '%s'", msg.payload, msg.topic
)
_LOGGER.warning("Invalid state message '%s' from '%s'", payload, msg.topic)
self._attr_native_value = None
return
if self.device_class == SensorDeviceClass.DATE:
@@ -160,6 +160,11 @@ async def async_setup_entry(
if find_matching_platform(device_point) == Platform.SENSOR:
description = get_description(device_point)
entity_class = MyUplinkDevicePointSensor
# Ignore sensors without a description that provide non-numeric values
if description is None and not isinstance(
device_point.value, (int, float)
):
continue
if (
description is not None
and description.device_class == SensorDeviceClass.ENUM
+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}`"
}
}
}
}
}
}
@@ -2,6 +2,8 @@
from __future__ import annotations
from typing import Literal, cast
import openai
import voluptuous as vol
@@ -13,7 +15,11 @@ from homeassistant.core import (
ServiceResponse,
SupportsResponse,
)
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.exceptions import (
ConfigEntryNotReady,
HomeAssistantError,
ServiceValidationError,
)
from homeassistant.helpers import (
config_validation as cv,
issue_registry as ir,
@@ -27,13 +33,25 @@ SERVICE_GENERATE_IMAGE = "generate_image"
PLATFORMS = (Platform.CONVERSATION,)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
type OpenAIConfigEntry = ConfigEntry[openai.AsyncClient]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up OpenAI Conversation."""
async def render_image(call: ServiceCall) -> ServiceResponse:
"""Render an image with dall-e."""
client = hass.data[DOMAIN][call.data["config_entry"]]
entry_id = call.data["config_entry"]
entry = hass.config_entries.async_get_entry(entry_id)
if entry is None or entry.domain != DOMAIN:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_config_entry",
translation_placeholders={"config_entry": entry_id},
)
client: openai.AsyncClient = entry.runtime_data
if call.data["size"] in ("256", "512", "1024"):
ir.async_create_issue(
@@ -51,6 +69,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
else:
size = call.data["size"]
size = cast(
Literal["256x256", "512x512", "1024x1024", "1792x1024", "1024x1792"],
size,
) # size is selector, so no need to check further
try:
response = await client.images.generate(
model="dall-e-3",
@@ -90,7 +113,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bool:
"""Set up OpenAI Conversation from a config entry."""
client = openai.AsyncOpenAI(api_key=entry.data[CONF_API_KEY])
try:
@@ -101,7 +124,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except openai.OpenAIError as err:
raise ConfigEntryNotReady(err) from err
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = client
entry.runtime_data = client
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -110,8 +133,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload OpenAI."""
if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
return False
hass.data[DOMAIN].pop(entry.entry_id)
return True
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -1,15 +1,27 @@
"""Conversation support for OpenAI."""
import json
from typing import Any, Literal
from typing import Literal
import openai
from openai._types import NOT_GIVEN
from openai.types.chat import (
ChatCompletionAssistantMessageParam,
ChatCompletionMessage,
ChatCompletionMessageParam,
ChatCompletionMessageToolCallParam,
ChatCompletionSystemMessageParam,
ChatCompletionToolMessageParam,
ChatCompletionToolParam,
ChatCompletionUserMessageParam,
)
from openai.types.chat.chat_completion_message_tool_call_param import Function
from openai.types.shared_params import FunctionDefinition
import voluptuous as vol
from voluptuous_openapi import convert
from homeassistant.components import assist_pipeline, conversation
from homeassistant.components.conversation import trace
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, TemplateError
@@ -17,6 +29,7 @@ from homeassistant.helpers import device_registry as dr, intent, llm, template
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import ulid
from . import OpenAIConfigEntry
from .const import (
CONF_CHAT_MODEL,
CONF_MAX_TOKENS,
@@ -37,7 +50,7 @@ MAX_TOOL_ITERATIONS = 10
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: OpenAIConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up conversation entities."""
@@ -45,13 +58,12 @@ async def async_setup_entry(
async_add_entities([agent])
def _format_tool(tool: llm.Tool) -> dict[str, Any]:
def _format_tool(tool: llm.Tool) -> ChatCompletionToolParam:
"""Format tool specification."""
tool_spec = {"name": tool.name}
tool_spec = FunctionDefinition(name=tool.name, parameters=convert(tool.parameters))
if tool.description:
tool_spec["description"] = tool.description
tool_spec["parameters"] = convert(tool.parameters)
return {"type": "function", "function": tool_spec}
return ChatCompletionToolParam(type="function", function=tool_spec)
class OpenAIConversationEntity(
@@ -62,10 +74,10 @@ class OpenAIConversationEntity(
_attr_has_entity_name = True
_attr_name = None
def __init__(self, entry: ConfigEntry) -> None:
def __init__(self, entry: OpenAIConfigEntry) -> None:
"""Initialize the agent."""
self.entry = entry
self.history: dict[str, list[dict]] = {}
self.history: dict[str, list[ChatCompletionMessageParam]] = {}
self._attr_unique_id = entry.entry_id
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
@@ -100,21 +112,23 @@ class OpenAIConversationEntity(
options = self.entry.options
intent_response = intent.IntentResponse(language=user_input.language)
llm_api: llm.APIInstance | None = None
tools: list[dict[str, Any]] | None = None
tools: list[ChatCompletionToolParam] | None = None
user_name: str | None = None
llm_context = llm.LLMContext(
platform=DOMAIN,
context=user_input.context,
user_prompt=user_input.text,
language=user_input.language,
assistant=conversation.DOMAIN,
device_id=user_input.device_id,
)
if options.get(CONF_LLM_HASS_API):
try:
llm_api = await llm.async_get_api(
self.hass,
options[CONF_LLM_HASS_API],
llm.ToolContext(
platform=DOMAIN,
context=user_input.context,
user_prompt=user_input.text,
language=user_input.language,
assistant=conversation.DOMAIN,
device_id=user_input.device_id,
),
llm_context,
)
except HomeAssistantError as err:
LOGGER.error("Error getting LLM API: %s", err)
@@ -132,48 +146,65 @@ class OpenAIConversationEntity(
messages = self.history[conversation_id]
else:
conversation_id = ulid.ulid_now()
try:
if llm_api:
api_prompt = llm_api.api_prompt
else:
api_prompt = llm.async_render_no_api_prompt(self.hass)
messages = []
prompt = "\n".join(
(
template.Template(
options.get(CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT),
self.hass,
).async_render(
{
"ha_name": self.hass.config.location_name,
},
parse_result=False,
),
api_prompt,
)
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,
)
)
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
)
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 = [{"role": "system", "content": prompt}]
messages.append({"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(
trace.ConversationTraceEventType.AGENT_DETAIL, {"messages": messages}
)
client = self.hass.data[DOMAIN][self.entry.entry_id]
client = self.entry.runtime_data
# To prevent infinite loops, we limit the number of iterations
for _iteration in range(MAX_TOOL_ITERATIONS):
@@ -181,7 +212,7 @@ class OpenAIConversationEntity(
result = await client.chat.completions.create(
model=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
messages=messages,
tools=tools or None,
tools=tools or NOT_GIVEN,
max_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
top_p=options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
@@ -199,7 +230,33 @@ class OpenAIConversationEntity(
LOGGER.debug("Response %s", result)
response = result.choices[0].message
messages.append(response)
def message_convert(
message: ChatCompletionMessage,
) -> ChatCompletionMessageParam:
"""Convert from class to TypedDict."""
tool_calls: list[ChatCompletionMessageToolCallParam] = []
if message.tool_calls:
tool_calls = [
ChatCompletionMessageToolCallParam(
id=tool_call.id,
function=Function(
arguments=tool_call.function.arguments,
name=tool_call.function.name,
),
type=tool_call.type,
)
for tool_call in message.tool_calls
]
param = ChatCompletionAssistantMessageParam(
role=message.role,
content=message.content,
)
if tool_calls:
param["tool_calls"] = tool_calls
return param
messages.append(message_convert(response))
tool_calls = response.tool_calls
if not tool_calls or not llm_api:
@@ -223,18 +280,17 @@ class OpenAIConversationEntity(
LOGGER.debug("Tool response: %s", tool_response)
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"name": tool_call.function.name,
"content": json.dumps(tool_response),
}
ChatCompletionToolMessageParam(
role="tool",
tool_call_id=tool_call.id,
content=json.dumps(tool_response),
)
)
self.history[conversation_id] = messages
intent_response = intent.IntentResponse(language=user_input.language)
intent_response.async_set_speech(response.content)
intent_response.async_set_speech(response.content or "")
return conversation.ConversationResult(
response=intent_response, conversation_id=conversation_id
)
@@ -60,6 +60,11 @@
}
}
},
"exceptions": {
"invalid_config_entry": {
"message": "Invalid config entry provided. Got {config_entry}"
}
},
"issues": {
"image_size_deprecated_format": {
"title": "Deprecated size format for image generation service",
@@ -4,7 +4,6 @@ from __future__ import annotations
from dataclasses import dataclass
import logging
from typing import Any
from pyopenweathermap import OWMClient
@@ -22,6 +21,7 @@ from homeassistant.core import HomeAssistant
from .const import CONFIG_FLOW_VERSION, OWM_MODE_V25, PLATFORMS
from .coordinator import WeatherUpdateCoordinator
from .repairs import async_create_issue, async_delete_issue
from .utils import build_data_and_options
_LOGGER = logging.getLogger(__name__)
@@ -44,8 +44,8 @@ async def async_setup_entry(
api_key = entry.data[CONF_API_KEY]
latitude = entry.data.get(CONF_LATITUDE, hass.config.latitude)
longitude = entry.data.get(CONF_LONGITUDE, hass.config.longitude)
language = _get_config_value(entry, CONF_LANGUAGE)
mode = _get_config_value(entry, CONF_MODE)
language = entry.options[CONF_LANGUAGE]
mode = entry.options[CONF_MODE]
if mode == OWM_MODE_V25:
async_create_issue(hass, entry.entry_id)
@@ -77,10 +77,14 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.debug("Migrating OpenWeatherMap entry from version %s", version)
if version < 4:
new_data = {**data, **options, CONF_MODE: OWM_MODE_V25}
if version < 5:
combined_data = {**data, **options, CONF_MODE: OWM_MODE_V25}
new_data, new_options = build_data_and_options(combined_data)
config_entries.async_update_entry(
entry, data=new_data, options={}, version=CONFIG_FLOW_VERSION
entry,
data=new_data,
options=new_options,
version=CONFIG_FLOW_VERSION,
)
_LOGGER.info("Migration to version %s successful", CONFIG_FLOW_VERSION)
@@ -98,9 +102,3 @@ async def async_unload_entry(
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
def _get_config_value(config_entry: ConfigEntry, key: str) -> Any:
if config_entry.options and key in config_entry.options:
return config_entry.options[key]
return config_entry.data[key]
@@ -30,7 +30,7 @@ from .const import (
LANGUAGES,
OWM_MODES,
)
from .utils import validate_api_key
from .utils import build_data_and_options, validate_api_key
class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -64,8 +64,9 @@ class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN):
)
if not errors:
data, options = build_data_and_options(user_input)
return self.async_create_entry(
title=user_input[CONF_NAME], data=user_input
title=user_input[CONF_NAME], data=data, options=options
)
schema = vol.Schema(
@@ -25,7 +25,7 @@ DEFAULT_NAME = "OpenWeatherMap"
DEFAULT_LANGUAGE = "en"
ATTRIBUTION = "Data provided by OpenWeatherMap"
MANUFACTURER = "OpenWeather"
CONFIG_FLOW_VERSION = 4
CONFIG_FLOW_VERSION = 5
ATTR_API_PRECIPITATION = "precipitation"
ATTR_API_PRECIPITATION_KIND = "precipitation_kind"
ATTR_API_DATETIME = "datetime"
@@ -38,7 +38,7 @@
"step": {
"migrate": {
"title": "OpenWeatherMap API V2.5 deprecated",
"description": "OWM API v2.5 will be closed in June 2024.\nYou need to migrate all your OpenWeatherMap integration to mode v3.0.\n\nBefore the migration, you must have active subscription (be aware subscripiton activation take up to 2h). After your subscription is activated click **Submit** to migrate the integration to API V3.0. Read documentation for more information."
"description": "OWM API v2.5 will be closed in June 2024.\nYou need to migrate all your OpenWeatherMap integrations to v3.0.\n\nBefore the migration, you must have an active subscription (be aware that subscription activation can take up to 2h). After your subscription is activated, select **Submit** to migrate the integration to API V3.0. Read the documentation for more information."
}
},
"error": {
@@ -1,7 +1,15 @@
"""Util functions for OpenWeatherMap."""
from typing import Any
from pyopenweathermap import OWMClient, RequestError
from homeassistant.const import CONF_LANGUAGE, CONF_MODE
from .const import DEFAULT_LANGUAGE, DEFAULT_OWM_MODE
OPTION_DEFAULTS = {CONF_LANGUAGE: DEFAULT_LANGUAGE, CONF_MODE: DEFAULT_OWM_MODE}
async def validate_api_key(api_key, mode):
"""Validate API key."""
@@ -18,3 +26,15 @@ async def validate_api_key(api_key, mode):
errors["base"] = "invalid_api_key"
return errors, description_placeholders
def build_data_and_options(
combined_data: dict[str, Any],
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Split combined data and options."""
data = {k: v for k, v in combined_data.items() if k not in OPTION_DEFAULTS}
options = {
option: combined_data.get(option, default)
for option, default in OPTION_DEFAULTS.items()
}
return (data, options)
@@ -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.",
@@ -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
)
)
@@ -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"
]
}
@@ -301,9 +301,12 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
for device in dr.async_entries_for_config_entry(
dev_reg, config_entry.entry_id
):
for connection in device.connections:
if connection == (dr.CONNECTION_NETWORK_MAC, "none"):
dev_reg.async_remove_device(device.id)
new_connections = device.connections.copy()
new_connections.discard((dr.CONNECTION_NETWORK_MAC, "none"))
if new_connections != device.connections:
dev_reg.async_update_device(
device.id, new_connections=new_connections
)
minor_version = 2
hass.config_entries.async_update_entry(config_entry, minor_version=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,
@@ -43,7 +43,7 @@
},
"exceptions": {
"invalid_room": {
"message": "The room { room } is unavailable to your vacuum. Make sure all rooms match the Shark App, including capitalization."
"message": "The room {room} is unavailable to your vacuum. Make sure all rooms match the Shark App, including capitalization."
}
},
"services": {
@@ -737,7 +737,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 +757,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)
):
@@ -1 +1,5 @@
"""The snmp component."""
from .util import async_get_snmp_engine
__all__ = ["async_get_snmp_engine"]
+27 -27
View File
@@ -4,14 +4,11 @@ from __future__ import annotations
import binascii
import logging
from typing import TYPE_CHECKING
from pysnmp.error import PySnmpError
from pysnmp.hlapi.asyncio import (
CommunityData,
ContextData,
ObjectIdentity,
ObjectType,
SnmpEngine,
Udp6TransportTarget,
UdpTransportTarget,
UsmUserData,
@@ -43,6 +40,7 @@ from .const import (
DEFAULT_VERSION,
SNMP_VERSIONS,
)
from .util import RequestArgsType, async_create_request_cmd_args
_LOGGER = logging.getLogger(__name__)
@@ -62,7 +60,7 @@ async def async_get_scanner(
) -> SnmpScanner | None:
"""Validate the configuration and return an SNMP scanner."""
scanner = SnmpScanner(config[DOMAIN])
await scanner.async_init()
await scanner.async_init(hass)
return scanner if scanner.success_init else None
@@ -99,33 +97,29 @@ class SnmpScanner(DeviceScanner):
if not privkey:
privproto = "none"
request_args = [
SnmpEngine(),
UsmUserData(
community,
authKey=authkey or None,
privKey=privkey or None,
authProtocol=authproto,
privProtocol=privproto,
),
target,
ContextData(),
]
self._auth_data = UsmUserData(
community,
authKey=authkey or None,
privKey=privkey or None,
authProtocol=authproto,
privProtocol=privproto,
)
else:
request_args = [
SnmpEngine(),
CommunityData(community, mpModel=SNMP_VERSIONS[DEFAULT_VERSION]),
target,
ContextData(),
]
self._auth_data = CommunityData(
community, mpModel=SNMP_VERSIONS[DEFAULT_VERSION]
)
self.request_args = request_args
self._target = target
self.request_args: RequestArgsType | None = None
self.baseoid = baseoid
self.last_results = []
self.success_init = False
async def async_init(self):
async def async_init(self, hass: HomeAssistant) -> None:
"""Make a one-off read to check if the target device is reachable and readable."""
self.request_args = await async_create_request_cmd_args(
hass, self._auth_data, self._target, self.baseoid
)
data = await self.async_get_snmp_data()
self.success_init = data is not None
@@ -156,12 +150,18 @@ class SnmpScanner(DeviceScanner):
async def async_get_snmp_data(self):
"""Fetch MAC addresses from access point via SNMP."""
devices = []
if TYPE_CHECKING:
assert self.request_args is not None
engine, auth_data, target, context_data, object_type = self.request_args
walker = bulkWalkCmd(
*self.request_args,
engine,
auth_data,
target,
context_data,
0,
50,
ObjectType(ObjectIdentity(self.baseoid)),
object_type,
lexicographicMode=False,
)
async for errindication, errstatus, errindex, res in walker:
+14 -28
View File
@@ -11,10 +11,6 @@ from pysnmp.error import PySnmpError
import pysnmp.hlapi.asyncio as hlapi
from pysnmp.hlapi.asyncio import (
CommunityData,
ContextData,
ObjectIdentity,
ObjectType,
SnmpEngine,
Udp6TransportTarget,
UdpTransportTarget,
UsmUserData,
@@ -71,6 +67,7 @@ from .const import (
MAP_PRIV_PROTOCOLS,
SNMP_VERSIONS,
)
from .util import async_create_request_cmd_args
_LOGGER = logging.getLogger(__name__)
@@ -119,7 +116,7 @@ async def async_setup_platform(
host = config.get(CONF_HOST)
port = config.get(CONF_PORT)
community = config.get(CONF_COMMUNITY)
baseoid = config.get(CONF_BASEOID)
baseoid: str = config[CONF_BASEOID]
version = config[CONF_VERSION]
username = config.get(CONF_USERNAME)
authkey = config.get(CONF_AUTH_KEY)
@@ -145,27 +142,18 @@ async def async_setup_platform(
authproto = "none"
if not privkey:
privproto = "none"
request_args = [
SnmpEngine(),
UsmUserData(
username,
authKey=authkey or None,
privKey=privkey or None,
authProtocol=getattr(hlapi, MAP_AUTH_PROTOCOLS[authproto]),
privProtocol=getattr(hlapi, MAP_PRIV_PROTOCOLS[privproto]),
),
target,
ContextData(),
]
auth_data = UsmUserData(
username,
authKey=authkey or None,
privKey=privkey or None,
authProtocol=getattr(hlapi, MAP_AUTH_PROTOCOLS[authproto]),
privProtocol=getattr(hlapi, MAP_PRIV_PROTOCOLS[privproto]),
)
else:
request_args = [
SnmpEngine(),
CommunityData(community, mpModel=SNMP_VERSIONS[version]),
target,
ContextData(),
]
get_result = await getCmd(*request_args, ObjectType(ObjectIdentity(baseoid)))
auth_data = CommunityData(community, mpModel=SNMP_VERSIONS[version])
request_args = await async_create_request_cmd_args(hass, auth_data, target, baseoid)
get_result = await getCmd(*request_args)
errindication, _, _, _ = get_result
if errindication and not accept_errors:
@@ -244,9 +232,7 @@ class SnmpData:
async def async_update(self):
"""Get the latest data from the remote SNMP capable host."""
get_result = await getCmd(
*self._request_args, ObjectType(ObjectIdentity(self._baseoid))
)
get_result = await getCmd(*self._request_args)
errindication, errstatus, errindex, restable = get_result
if errindication and not self._accept_errors:
+33 -56
View File
@@ -8,10 +8,6 @@ from typing import Any
import pysnmp.hlapi.asyncio as hlapi
from pysnmp.hlapi.asyncio import (
CommunityData,
ContextData,
ObjectIdentity,
ObjectType,
SnmpEngine,
UdpTransportTarget,
UsmUserData,
getCmd,
@@ -67,6 +63,7 @@ from .const import (
MAP_PRIV_PROTOCOLS,
SNMP_VERSIONS,
)
from .util import RequestArgsType, async_create_request_cmd_args
_LOGGER = logging.getLogger(__name__)
@@ -132,40 +129,54 @@ async def async_setup_platform(
host = config.get(CONF_HOST)
port = config.get(CONF_PORT)
community = config.get(CONF_COMMUNITY)
baseoid = config.get(CONF_BASEOID)
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)
version = config.get(CONF_VERSION)
version: str = config[CONF_VERSION]
username = config.get(CONF_USERNAME)
authkey = config.get(CONF_AUTH_KEY)
authproto = config.get(CONF_AUTH_PROTOCOL)
authproto: str = config[CONF_AUTH_PROTOCOL]
privkey = config.get(CONF_PRIV_KEY)
privproto = config.get(CONF_PRIV_PROTOCOL)
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)
if version == "3":
if not authkey:
authproto = "none"
if not privkey:
privproto = "none"
auth_data = UsmUserData(
username,
authKey=authkey or None,
privKey=privkey or None,
authProtocol=getattr(hlapi, MAP_AUTH_PROTOCOLS[authproto]),
privProtocol=getattr(hlapi, MAP_PRIV_PROTOCOLS[privproto]),
)
else:
auth_data = CommunityData(community, mpModel=SNMP_VERSIONS[version])
request_args = await async_create_request_cmd_args(
hass, auth_data, UdpTransportTarget((host, port)), baseoid
)
async_add_entities(
[
SnmpSwitch(
name,
host,
port,
community,
baseoid,
command_oid,
version,
username,
authkey,
authproto,
privkey,
privproto,
payload_on,
payload_off,
command_payload_on,
command_payload_off,
vartype,
request_args,
)
],
True,
@@ -180,21 +191,15 @@ class SnmpSwitch(SwitchEntity):
name,
host,
port,
community,
baseoid,
commandoid,
version,
username,
authkey,
authproto,
privkey,
privproto,
payload_on,
payload_off,
command_payload_on,
command_payload_off,
vartype,
):
request_args,
) -> None:
"""Initialize the switch."""
self._name = name
@@ -206,35 +211,11 @@ class SnmpSwitch(SwitchEntity):
self._command_payload_on = command_payload_on or payload_on
self._command_payload_off = command_payload_off or payload_off
self._state = None
self._state: bool | None = None
self._payload_on = payload_on
self._payload_off = payload_off
if version == "3":
if not authkey:
authproto = "none"
if not privkey:
privproto = "none"
self._request_args = [
SnmpEngine(),
UsmUserData(
username,
authKey=authkey or None,
privKey=privkey or None,
authProtocol=getattr(hlapi, MAP_AUTH_PROTOCOLS[authproto]),
privProtocol=getattr(hlapi, MAP_PRIV_PROTOCOLS[privproto]),
),
UdpTransportTarget((host, port)),
ContextData(),
]
else:
self._request_args = [
SnmpEngine(),
CommunityData(community, mpModel=SNMP_VERSIONS[version]),
UdpTransportTarget((host, port)),
ContextData(),
]
self._target = UdpTransportTarget((host, port))
self._request_args: RequestArgsType = request_args
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the switch."""
@@ -259,9 +240,7 @@ class SnmpSwitch(SwitchEntity):
async def async_update(self) -> None:
"""Update the state."""
get_result = await getCmd(
*self._request_args, ObjectType(ObjectIdentity(self._baseoid))
)
get_result = await getCmd(*self._request_args)
errindication, errstatus, errindex, restable = get_result
if errindication:
@@ -296,6 +275,4 @@ class SnmpSwitch(SwitchEntity):
return self._state
async def _set(self, value):
await setCmd(
*self._request_args, ObjectType(ObjectIdentity(self._commandoid), value)
)
await setCmd(*self._request_args, value)
+76
View File
@@ -0,0 +1,76 @@
"""Support for displaying collected data over SNMP."""
from __future__ import annotations
import logging
from pysnmp.hlapi.asyncio import (
CommunityData,
ContextData,
ObjectIdentity,
ObjectType,
SnmpEngine,
Udp6TransportTarget,
UdpTransportTarget,
UsmUserData,
)
from pysnmp.hlapi.asyncio.cmdgen import lcd, vbProcessor
from pysnmp.smi.builder import MibBuilder
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers.singleton import singleton
DATA_SNMP_ENGINE = "snmp_engine"
_LOGGER = logging.getLogger(__name__)
type RequestArgsType = tuple[
SnmpEngine,
UsmUserData | CommunityData,
UdpTransportTarget | Udp6TransportTarget,
ContextData,
ObjectType,
]
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)),
)
@singleton(DATA_SNMP_ENGINE)
async def async_get_snmp_engine(hass: HomeAssistant) -> SnmpEngine:
"""Get the SNMP engine."""
engine = await hass.async_add_executor_job(_get_snmp_engine)
@callback
def _async_shutdown_listener(ev: Event) -> None:
_LOGGER.debug("Unconfiguring SNMP engine")
lcd.unconfigure(engine, None)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_shutdown_listener)
return engine
def _get_snmp_engine() -> SnmpEngine:
"""Return a cached instance of SnmpEngine."""
engine = SnmpEngine()
mib_controller = vbProcessor.getMibViewController(engine)
# Actually load the MIBs from disk so we do
# not do it in the event loop
builder: MibBuilder = mib_controller.mibBuilder
if "PYSNMP-MIB" not in builder.mibSymbols:
builder.loadModules()
return engine
@@ -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(
@@ -104,6 +104,11 @@ class SynoApi:
except BaseException as err:
if not self._login_future.done():
self._login_future.set_exception(err)
with suppress(BaseException):
# Clear the flag as its normal that nothing
# will wait for this future to be resolved
# if there are no concurrent login attempts
await self._login_future
raise
finally:
self._login_future = None
+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,
SIGNAL_TAG_CHANGED,
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,
SIGNAL_TAG_CHANGED,
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]
@@ -284,6 +284,14 @@ SERVICE_MAP = {
}
def _read_file_as_bytesio(file_path: str) -> io.BytesIO:
"""Read a file and return it as a BytesIO object."""
with open(file_path, "rb") as file:
data = io.BytesIO(file.read())
data.name = file_path
return data
async def load_data(
hass,
url=None,
@@ -342,7 +350,9 @@ async def load_data(
)
elif filepath is not None:
if hass.config.is_allowed_path(filepath):
return open(filepath, "rb")
return await hass.async_add_executor_job(
_read_file_as_bytesio, filepath
)
_LOGGER.warning("'%s' are not secure to load data from!", filepath)
else:
@@ -82,7 +82,7 @@ ENERGY_INFO_DESCRIPTIONS: tuple[TeslemetryNumberBatteryEntityDescription, ...] =
requires="components_battery",
),
TeslemetryNumberBatteryEntityDescription(
key="off_grid_vehicle_charging_reserve",
key="off_grid_vehicle_charging_reserve_percent",
func=lambda api, value: api.off_grid_vehicle_charging_reserve(int(value)),
requires="components_off_grid_vehicle_charging_reserve_supported",
),
@@ -254,7 +254,7 @@
"charge_state_charge_limit_soc": {
"name": "Charge limit"
},
"off_grid_vehicle_charging_reserve": {
"off_grid_vehicle_charging_reserve_percent": {
"name": "Off grid reserve"
}
},
+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)
+2 -2
View File
@@ -118,7 +118,7 @@ RT_SENSORS: tuple[SensorEntityDescription, ...] = (
translation_key="accumulated_consumption",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
state_class=SensorStateClass.TOTAL,
),
SensorEntityDescription(
key="accumulatedConsumptionLastHour",
@@ -138,7 +138,7 @@ RT_SENSORS: tuple[SensorEntityDescription, ...] = (
translation_key="accumulated_production",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
state_class=SensorStateClass.TOTAL,
),
SensorEntityDescription(
key="accumulatedProductionLastHour",
+4 -4
View File
@@ -35,7 +35,7 @@ class V2CSensorEntityDescription(SensorEntityDescription):
value_fn: Callable[[TrydanData], StateType]
_SLAVE_ERROR_OPTIONS = [error.name.lower() for error in SlaveCommunicationState]
_METER_ERROR_OPTIONS = [error.name.lower() for error in SlaveCommunicationState]
TRYDAN_SENSORS = (
V2CSensorEntityDescription(
@@ -80,12 +80,12 @@ TRYDAN_SENSORS = (
value_fn=lambda evse_data: evse_data.fv_power,
),
V2CSensorEntityDescription(
key="slave_error",
translation_key="slave_error",
key="meter_error",
translation_key="meter_error",
value_fn=lambda evse_data: evse_data.slave_error.name.lower(),
entity_registry_enabled_default=False,
device_class=SensorDeviceClass.ENUM,
options=_SLAVE_ERROR_OPTIONS,
options=_METER_ERROR_OPTIONS,
),
V2CSensorEntityDescription(
key="battery_power",
+5 -5
View File
@@ -54,18 +54,18 @@
"battery_power": {
"name": "Battery power"
},
"slave_error": {
"name": "Slave error",
"meter_error": {
"name": "Meter error",
"state": {
"no_error": "No error",
"communication": "Communication",
"reading": "Reading",
"slave": "Slave",
"slave": "Meter",
"waiting_wifi": "Waiting for Wi-Fi",
"waiting_communication": "Waiting communication",
"wrong_ip": "Wrong IP",
"slave_not_found": "Slave not found",
"wrong_slave": "Wrong slave",
"slave_not_found": "Meter not found",
"wrong_slave": "Wrong Meter",
"no_response": "No response",
"clamp_not_connected": "Clamp not connected",
"illegal_function": "Illegal function",

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