Compare commits

...

90 Commits

Author SHA1 Message Date
Joost Lekkerkerker 9c7397fc32 Update test_sensor.py 2025-06-28 10:47:23 +02:00
Joost Lekkerkerker 581fd34264 Create new clientsession instead of using the shared session 2025-06-28 10:32:53 +02:00
Daniel Hjelseth Høyer d2e8a48b2c Bump pytibber to 0.31.6 (#147703) 2025-06-28 10:11:17 +02:00
epenet ea6332ee42 Move backup services to separate module (#146427) 2025-06-27 20:54:56 +02:00
Erik Montnemery 91c3b43d7f Improve comment for helpers.entity.entity_sources (#146529) 2025-06-27 20:54:19 +02:00
Thomas55555 1d82d44794 Add device prefix to summary in Husqvarna Automower (#147405) 2025-06-27 20:34:50 +02:00
Thomas55555 571376badc Bump aioautomower to 1.0.1 (#147683) 2025-06-27 20:28:45 +02:00
Manu 32236b2f4d Add reconfiguration flow to PlayStation Network (#147552) 2025-06-27 20:17:06 +02:00
Samuel Xiao 18c1953bc5 Add lock models to switchbot cloud (#147569) 2025-06-27 20:16:21 +02:00
Bernardus Jansen d874c28dc9 Add previously missing state classes to dsmr sensors (#147633) 2025-06-27 19:45:36 +02:00
Brett Adams 19d89c8952 Fix energy history in Teslemetry (#147646) 2025-06-27 19:43:03 +02:00
Ludovic BOUÉ e3ba1f34ca Matter TemperatureControl (#145706)
* TemperatureControl

* Add tests

* Commands.SetTemperature

* Update homeassistant/components/matter/number.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Apply suggestions from code review

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update number.py

* Update number.py

* Update number.py

* Update homeassistant/components/matter/number.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Refactor MatterRangeNumber to streamline command handling in async_set_native_value

* testing requested changes

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-06-27 19:41:39 +02:00
Thomas55555 b630fb0520 Respect availability of parent class in Husqvarna Automower (#147649) 2025-06-27 19:38:42 +02:00
Ville Skyttä 5129f89086 Finish config flow in huawei_lte SSDP test (#147542) 2025-06-27 19:00:01 +02:00
Ville Skyttä 0be0e22e76 Simplify rflink dimmable set_level parsing (#147636) 2025-06-27 18:59:10 +02:00
epenet b8500b338a Improve tests for binary sensor template (#147657) 2025-06-27 18:58:16 +02:00
Simone Chemelli 4cab3a0465 Bump aioamazondevices to 3.1.22 (#147681) 2025-06-27 18:44:01 +02:00
hanwg ff711324d5 Add codeowner for Telegram bot (#147680) 2025-06-27 18:18:01 +02:00
Michael 113e7dc003 Add data descriptions to PEGELONLINE integration (#147594) 2025-06-27 18:16:38 +02:00
Shay Levy 2120ff6a0a Fix Shelly entity removal (#147665) 2025-06-27 18:50:35 +03:00
Marc Mueller 8ee5c30754 Update ruff to 0.12.1 (#147677) 2025-06-27 17:40:08 +02:00
Paul Bottein a1518b96c4 Update frontend to 20250627.0 (#147668) 2025-06-27 17:28:14 +02:00
Petar Petrov bba7f5c3f0 Z-WaveJS config flow: Change keys question (#147518)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2025-06-27 17:27:43 +02:00
Manu 8a5671af76 Remove dweet.io integration (#147645) 2025-06-27 17:23:42 +02:00
Raphael Hehl 8a18dea8c7 UniFi Protect removing early access checks and issue creation (#147432)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-27 17:15:34 +02:00
Thomas55555 4b02f22724 Bump aioautomower to 1.0.0 (#147676) 2025-06-27 17:02:52 +02:00
mkmer 7229c2ca2c Bump aiosomecomfort to 0.0.33 (#147673) 2025-06-27 16:32:25 +02:00
Norbert Rittel d83eddf13b Fix sentence-casing and spacing of button in thermopro (#147671) 2025-06-27 15:53:18 +02:00
Josef Zweck 4a192a7b09 Bump jellyfin-apiclient-python to 1.11.0 (#147658) 2025-06-27 11:07:14 +02:00
Josef Zweck 58c434887e Fix: Unhandled NoneType sessions in jellyfin (#147659) 2025-06-27 11:00:23 +02:00
Abílio Costa 78c2405e61 Bump whirlpool to 0.21.1 (#147611) 2025-06-27 10:33:49 +02:00
Josef Zweck 8cc4105984 Make jellyfin not single config entry (#147656) 2025-06-27 10:31:13 +02:00
Josef Zweck 917f1e4c6f Make entities unavailable when machine is physically off in lamarzocco (#147426) 2025-06-27 10:03:14 +02:00
hanwg 3879f6d2ef Fix Telegram bot yaml import for webhooks containing None value for URL (#147586) 2025-06-27 10:03:03 +02:00
Norbert Rittel 78060e4833 Clarify descriptions of subaru.unlock_specific_door action (#147655) 2025-06-27 10:01:44 +02:00
Guido Schmitz fda66c4be4 Handle deleted devices dynamically in devolo Home Control (#147585) 2025-06-27 09:52:00 +02:00
Michael 21131d00b3 Fix config schema to make credentials optional in NUT flows (#147593) 2025-06-27 09:51:28 +02:00
puddly a84313de33 Allow setup of Zigbee/Thread for ZBT-1 and Yellow without internet access (#147549)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2025-06-27 09:50:45 +02:00
Manu c73346e6b3 Bump pynecil to v4.1.1 (#147648) 2025-06-27 09:31:35 +02:00
Franck Nijhof 55a37a2936 Extend GitHub Copilot instructions with new learnings from reviews (#147652) 2025-06-27 09:01:09 +02:00
Abílio Costa e481f14335 Simplify reolink light tests (#147637) 2025-06-27 08:58:09 +02:00
Petar Petrov 1ca03c8ae9 Do not factory reset old Z-Wave controller during migration (#147576)
* Do not factory reset old Z-Wave controller during migration

* PR comments

* remove obsolete test
2025-06-27 08:02:12 +02:00
Ville Skyttä 61b43ca1fc Remove unnecessary wilight trigger regex use (#147638) 2025-06-26 23:16:21 +01:00
Joost Lekkerkerker 1b2be083c2 Make sure Google Generative AI integration migration is clean (#147625) 2025-06-26 23:03:36 +02:00
Joost Lekkerkerker 4bdf3d6f30 Make sure OpenAI integration migration is clean (#147627) 2025-06-26 23:03:11 +02:00
Joost Lekkerkerker 43535ede8b Make sure Anthropic integration migration is clean (#147629) 2025-06-26 23:02:59 +02:00
Joost Lekkerkerker 9bd0762799 Make sure Ollama integration migration is clean (#147630) 2025-06-26 23:02:35 +02:00
Ville Skyttä 1bb653b4f7 Remove unused config regexps (#147631) 2025-06-26 23:02:14 +02:00
Franck Nijhof 2655edcfc8 Extend GitHub Copilot instructions and make it suitable for Claude Code (#147632) 2025-06-26 23:00:02 +02:00
Franck Nijhof 7a08edc3dd Add Claude to gitignore (#147622) 2025-06-26 21:06:34 +02:00
Abílio Costa b3131355b0 Use non-autospec mock for Reolink's light tests (#147621) 2025-06-26 21:05:23 +02:00
Abílio Costa 06d04c001d Use non-autospec mock for Reolink's host tests (#147619) 2025-06-26 20:55:46 +02:00
Jack Powell babecdf32c Add Diagnostics to PlayStation Network (#147607)
* Add Diagnostics support to PlayStation_Network

* Remove unused constant

* minor cleanup

* Redact additional data

* Redact additional data
2025-06-26 20:52:07 +02:00
Renat Sibgatulin 17cd39748b Create a new client session for air-Q to fix cookie polution (#147027) 2025-06-26 19:59:49 +02:00
Simone Chemelli c2f1e86a4e Add action exceptions to Alexa Devices (#147546) 2025-06-26 19:59:02 +02:00
Joost Lekkerkerker 61a32466b6 Hide Telegram bot proxy URL behind section (#147613)
Co-authored-by: Manu <4445816+tr4nt0r@users.noreply.github.com>
2025-06-26 19:55:38 +02:00
Manu aef08091f8 Fix asset url in Habitica integration (#147612) 2025-06-26 19:52:58 +02:00
Joost Lekkerkerker 1416f0f1e0 Fix meaters not being added after a reload (#147614) 2025-06-26 19:52:29 +02:00
HarvsG af7b1a76bc Add description placeholders to SchemaFlowFormStep (#147544)
* test description placeholders

* Update test_schema_config_entry_flow.py

* fix copy and paste indentation

* Apply suggestions from code review

---------

Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-06-26 19:51:31 +02:00
Maximilian Arzberger bf88fcd5bf Add Manual Charge Switch for Installers for Kostal Plenticore (#146932)
* Add Manual Charge Switch for Installers

* Update stale docstring

* Installer config fixture

* fix ruff
2025-06-26 19:50:27 +02:00
Joost Lekkerkerker 35478e3162 Set Google AI model as device model (#147582)
* Set Google AI model as device model

* fix
2025-06-26 19:44:15 +02:00
Joost Lekkerkerker 69af74a593 Improve explanation on how to get API token in Telegram (#147605) 2025-06-26 18:21:56 +02:00
tronikos b4dd912bee Refactor in Google AI TTS in preparation for STT (#147562) 2025-06-26 11:53:16 -04:00
Bram Kragten b5821ef499 Update frontend to 20250626.0 (#147601) 2025-06-26 17:46:45 +02:00
Fabio Natanael Kepler 1a92d4530e Fix playing TTS and local media source over DLNA (#134903)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-06-26 17:12:15 +02:00
Joost Lekkerkerker 7b80c1c693 Add default conversation name for OpenAI integration (#147597) 2025-06-26 17:11:48 +02:00
Joost Lekkerkerker e7cc03c1d9 Add default title to migrated Claude entry (#147598) 2025-06-26 17:11:13 +02:00
Luca Angemi 69f0b6244a Remove default icon for wind direction sensor for Buienradar (#147603)
* Fix wind direction state class sensor

* Remove default icon for wind direction sensor
2025-06-26 17:05:59 +02:00
Joost Lekkerkerker 01205f8a14 Add default title to migrated Ollama entry (#147599) 2025-06-26 17:05:26 +02:00
hanwg 68924d23ab Fix Telegram bot default target when sending messages (#147470)
* handle targets

* updated error message

* validate chat id for single target

* add validation for chat id

* handle empty target

* handle empty target
2025-06-26 16:43:09 +02:00
Artur Pragacz 40f553a007 Migrate device connections to a normalized form (#140383)
* Normalize device connections migration

* Update version

* Slightly improve tests

* Update homeassistant/helpers/device_registry.py

* Add validators

* Fix validator

* Move format mac function too

* Add validator test

---------

Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-06-26 15:33:34 +02:00
Robin Lintermann bc46894b74 Fixed issue when tests (should) fail in Smarla (#146102)
* Fixed issue when tests (should) fail

* Use usefixture decorator

* Throw ConfigEntryError instead of AuthFailed
2025-06-26 15:30:03 +02:00
Anders Peter Fugmann 6f4615f012 Bump dependency on pyW215 for DLink integration to 0.8.0 (#147534) 2025-06-26 12:56:46 +02:00
Joost Lekkerkerker 4244d2f66f Set right model in OpenAI conversation (#147575) 2025-06-26 12:49:33 +02:00
Petar Petrov a73dafe097 Hide unnamed paths when selecting a USB Z-Wave adapter (#147571)
* Hide unnamed paths when selecting a USB Z-Wave adapter

* remove pointless sorting
2025-06-26 12:15:02 +02:00
Stefan Agner be49296547 Deduplicate shared logic in Matter vacuum commands (#147578)
Get the run mode by tag in a single place to avoid code duplication.
Also raise an error if the run mode (unexpectedly) is not found.
2025-06-26 11:54:52 +02:00
Marcel van der Veldt d55ecd885e Do not make the favorite button unavailable when no content playing on a Music Assistant player (#147579) 2025-06-26 11:49:06 +02:00
Luca Angemi 076248c455 Fix wind direction state class sensor for AEMET (#147535) 2025-06-26 11:07:07 +02:00
Petar Petrov 13ce27c94c Remove obsolete routing info when migrating a Z-Wave network (#147568) 2025-06-26 11:06:36 +02:00
Joost Lekkerkerker 4b9b08ece5 Show current Lametric version if there is no newer version (#147538) 2025-06-26 10:55:31 +02:00
Simone Chemelli 79df38eff2 Improve config flow strings for Alexa Devices (#147523) 2025-06-26 10:52:14 +02:00
tronikos fb133664e4 Include subentries in Google Generative AI diagnostics (#147558) 2025-06-26 10:50:47 +02:00
Marcel van der Veldt 38669ce96c Fix sending commands to Matter vacuum (#147567) 2025-06-26 10:47:24 +02:00
Petar Petrov 651b33d49b Bump zwave-js-server-python to 0.65.0 (#147561)
* Bump zwave-js-server-python to 0.65.0

* update tests
2025-06-26 10:11:25 +03:00
Erik Montnemery 3b64db5f76 Set end date for when allowing unique id collisions in config entries (#147516)
* Set end date for when allowing unique id collisions in config entries

* Update test
2025-06-26 08:20:26 +02:00
tronikos 0f95fe566c Use default title for migrated Google Generative AI entries (#147551) 2025-06-25 22:30:41 -04:00
Simone Chemelli 6290facffb Fix unload for Alexa Devices (#147548) 2025-06-26 01:55:58 +02:00
tronikos f0a78aadbe Fixes in Google AI TTS (#147501)
* Fix Google AI not using correct config options after subentries migration

* Fixes in Google AI TTS

* Fix tests by @IvanLH

* Change type name.

---------

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
2025-06-25 18:12:23 -04:00
Pete Sage 345ec97dd5 Add enum sensor for Sonos Power Source (#147449)
* feat: add power source sensor

* fix: translations

* fix:cleanup

* fix: simpify

* fix: improve coverage

* fix: improve coverage

* fix: add missing test

* fix: call it charging_base

* fix: disable entity by default

* update snapshots

* Update homeassistant/components/sonos/strings.json

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* fix: update test

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-06-25 23:49:06 +02:00
Franck Nijhof 1286b5d9d8 Bump version to 2025.8.0dev0 (#147531) 2025-06-25 21:38:35 +02:00
195 changed files with 5638 additions and 2345 deletions
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -40,7 +40,7 @@ env:
CACHE_VERSION: 3
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2025.7"
HA_SHORT_VERSION: "2025.8"
DEFAULT_PYTHON: "3.13"
ALL_PYTHON_VERSIONS: "['3.13']"
# 10.3 is the oldest supported version
+5 -1
View File
@@ -137,4 +137,8 @@ tmp_cache
.ropeproject
# Will be created from script/split_tests.py
pytest_buckets.txt
pytest_buckets.txt
# AI tooling
.claude
+1 -1
View File
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.0
rev: v0.12.1
hooks:
- id: ruff-check
args:
Symlink
+1
View File
@@ -0,0 +1 @@
.github/copilot-instructions.md
Generated
+2
View File
@@ -1553,6 +1553,8 @@ build.json @home-assistant/supervisor
/tests/components/technove/ @Moustachauve
/homeassistant/components/tedee/ @patrickhilker @zweckj
/tests/components/tedee/ @patrickhilker @zweckj
/homeassistant/components/telegram_bot/ @hanwg
/tests/components/telegram_bot/ @hanwg
/homeassistant/components/tellduslive/ @fredrike
/tests/components/tellduslive/ @fredrike
/homeassistant/components/template/ @Petro31 @home-assistant/core
+2 -2
View File
@@ -607,7 +607,7 @@ async def async_enable_logging(
)
threading.excepthook = lambda args: logging.getLogger().exception(
"Uncaught thread exception",
exc_info=( # type: ignore[arg-type] # noqa: LOG014
exc_info=( # type: ignore[arg-type]
args.exc_type,
args.exc_value,
args.exc_traceback,
@@ -1061,5 +1061,5 @@ async def _async_setup_multi_components(
_LOGGER.error(
"Error setting up integration %s - received exception",
domain,
exc_info=(type(result), result, result.__traceback__), # noqa: LOG014
exc_info=(type(result), result, result.__traceback__),
)
+1 -1
View File
@@ -336,7 +336,7 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
keys=[AOD_WEATHER, AOD_WIND_DIRECTION],
name="Wind bearing",
native_unit_of_measurement=DEGREE,
state_class=SensorStateClass.MEASUREMENT,
state_class=SensorStateClass.MEASUREMENT_ANGLE,
device_class=SensorDeviceClass.WIND_DIRECTION,
),
AemetSensorEntityDescription(
+2 -2
View File
@@ -10,7 +10,7 @@ from aioairq.core import AirQ, identify_warming_up_sensors
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
@@ -39,7 +39,7 @@ class AirQCoordinator(DataUpdateCoordinator):
name=DOMAIN,
update_interval=timedelta(seconds=UPDATE_INTERVAL),
)
session = async_get_clientsession(hass)
session = async_create_clientsession(hass)
self.airq = AirQ(
entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD], session
)
@@ -29,5 +29,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
async def async_unload_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
"""Unload a config entry."""
await entry.runtime_data.api.close()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
coordinator = entry.runtime_data
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
await coordinator.api.close()
return unload_ok
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "bronze",
"requirements": ["aioamazondevices==3.1.19"]
"requirements": ["aioamazondevices==3.1.22"]
}
@@ -15,6 +15,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
from .utils import alexa_api_call
PARALLEL_UPDATES = 1
@@ -70,6 +71,7 @@ class AmazonNotifyEntity(AmazonEntity, NotifyEntity):
entity_description: AmazonNotifyEntityDescription
@alexa_api_call
async def async_send_message(
self, message: str, title: str | None = None, **kwargs: Any
) -> None:
@@ -26,7 +26,7 @@ rules:
unique-config-entry: done
# Silver
action-exceptions: todo
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
@@ -1,8 +1,7 @@
{
"common": {
"data_country": "Country code",
"data_code": "One-time password (OTP code)",
"data_description_country": "The country of your Amazon account.",
"data_description_country": "The country where your Amazon account is registered.",
"data_description_username": "The email address of your Amazon account.",
"data_description_password": "The password of your Amazon account.",
"data_description_code": "The one-time password to log in to your account. Currently, only tokens from OTP applications are supported."
@@ -12,10 +11,10 @@
"step": {
"user": {
"data": {
"country": "[%key:component::alexa_devices::common::data_country%]",
"country": "[%key:common::config_flow::data::country%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"code": "[%key:component::alexa_devices::common::data_description_code%]"
"code": "[%key:component::alexa_devices::common::data_code%]"
},
"data_description": {
"country": "[%key:component::alexa_devices::common::data_description_country%]",
@@ -71,5 +70,13 @@
"name": "Do not disturb"
}
}
},
"exceptions": {
"cannot_connect": {
"message": "Error connecting: {error}"
},
"cannot_retrieve_data": {
"message": "Error retrieving data: {error}"
}
}
}
@@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
from .utils import alexa_api_call
PARALLEL_UPDATES = 1
@@ -60,6 +61,7 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
entity_description: AmazonSwitchEntityDescription
@alexa_api_call
async def _switch_set_state(self, state: bool) -> None:
"""Set desired switch state."""
method = getattr(self.coordinator.api, self.entity_description.method)
@@ -0,0 +1,40 @@
"""Utils for Alexa Devices."""
from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps
from typing import Any, Concatenate
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
from homeassistant.exceptions import HomeAssistantError
from .const import DOMAIN
from .entity import AmazonEntity
def alexa_api_call[_T: AmazonEntity, **_P](
func: Callable[Concatenate[_T, _P], Awaitable[None]],
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]:
"""Catch Alexa API call exceptions."""
@wraps(func)
async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
"""Wrap all command methods."""
try:
await func(self, *args, **kwargs)
except CannotConnect as err:
self.coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except CannotRetrieveData as err:
self.coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data",
translation_placeholders={"error": repr(err)},
) from err
return cmd_wrapper
+14 -1
View File
@@ -17,7 +17,13 @@ from homeassistant.helpers import (
)
from homeassistant.helpers.typing import ConfigType
from .const import CONF_CHAT_MODEL, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL
from .const import (
CONF_CHAT_MODEL,
DEFAULT_CONVERSATION_NAME,
DOMAIN,
LOGGER,
RECOMMENDED_CHAT_MODEL,
)
PLATFORMS = (Platform.CONVERSATION,)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -117,12 +123,19 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
device.id,
remove_config_entry_id=entry.entry_id,
)
else:
device_registry.async_update_device(
device.id,
remove_config_entry_id=entry.entry_id,
remove_config_subentry_id=None,
)
if not use_existing:
await hass.config_entries.async_remove(entry.entry_id)
else:
hass.config_entries.async_update_entry(
entry,
title=DEFAULT_CONVERSATION_NAME,
options={},
version=2,
)
+3 -24
View File
@@ -2,7 +2,7 @@
from homeassistant.config_entries import SOURCE_SYSTEM
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, discovery_flow
from homeassistant.helpers.backup import DATA_BACKUP
from homeassistant.helpers.hassio import is_hassio
@@ -45,6 +45,7 @@ from .manager import (
WrittenBackup,
)
from .models import AddonInfo, AgentBackup, BackupNotFound, Folder
from .services import async_setup_services
from .util import suggested_filename, suggested_filename_from_name_date
from .websocket import async_register_websocket_handlers
@@ -113,29 +114,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async_register_websocket_handlers(hass, with_hassio)
async def async_handle_create_service(call: ServiceCall) -> None:
"""Service handler for creating backups."""
agent_id = list(backup_manager.local_backup_agents)[0]
await backup_manager.async_create_backup(
agent_ids=[agent_id],
include_addons=None,
include_all_addons=False,
include_database=True,
include_folders=None,
include_homeassistant=True,
name=None,
password=None,
)
async def async_handle_create_automatic_service(call: ServiceCall) -> None:
"""Service handler for creating automatic backups."""
await backup_manager.async_create_automatic_backup()
if not with_hassio:
hass.services.async_register(DOMAIN, "create", async_handle_create_service)
hass.services.async_register(
DOMAIN, "create_automatic", async_handle_create_automatic_service
)
async_setup_services(hass)
async_register_http_views(hass)
@@ -0,0 +1,36 @@
"""The Backup integration."""
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers.hassio import is_hassio
from .const import DATA_MANAGER, DOMAIN
async def _async_handle_create_service(call: ServiceCall) -> None:
"""Service handler for creating backups."""
backup_manager = call.hass.data[DATA_MANAGER]
agent_id = list(backup_manager.local_backup_agents)[0]
await backup_manager.async_create_backup(
agent_ids=[agent_id],
include_addons=None,
include_all_addons=False,
include_database=True,
include_folders=None,
include_homeassistant=True,
name=None,
password=None,
)
async def _async_handle_create_automatic_service(call: ServiceCall) -> None:
"""Service handler for creating automatic backups."""
await call.hass.data[DATA_MANAGER].async_create_automatic_backup()
def async_setup_services(hass: HomeAssistant) -> None:
"""Register services."""
if not is_hassio(hass):
hass.services.async_register(DOMAIN, "create", _async_handle_create_service)
hass.services.async_register(
DOMAIN, "create_automatic", _async_handle_create_automatic_service
)
@@ -168,7 +168,6 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
key="windazimuth",
translation_key="windazimuth",
native_unit_of_measurement=DEGREE,
icon="mdi:compass-outline",
device_class=SensorDeviceClass.WIND_DIRECTION,
state_class=SensorStateClass.MEASUREMENT_ANGLE,
),
@@ -9,7 +9,7 @@ from devolo_home_control_api.devices.zwave import Zwave
from devolo_home_control_api.homecontrol import HomeControl
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity import Entity
from .const import DOMAIN
@@ -35,7 +35,7 @@ class DevoloDeviceEntity(Entity):
) # This is not doing I/O. It fetches an internal state of the API
self._attr_should_poll = False
self._attr_unique_id = element_uid
self._attr_device_info = DeviceInfo(
self._attr_device_info = dr.DeviceInfo(
configuration_url=f"https://{urlparse(device_instance.href).netloc}",
identifiers={(DOMAIN, self._device_instance.uid)},
manufacturer=device_instance.brand,
@@ -88,6 +88,16 @@ class DevoloDeviceEntity(Entity):
elif len(message) == 3 and message[2] == "status":
# Maybe the API wants to tell us, that the device went on- or offline.
self._attr_available = self._device_instance.is_online()
elif message[1] == "del" and self.platform.config_entry:
device_registry = dr.async_get(self.hass)
device = device_registry.async_get_device(
identifiers={(DOMAIN, self._device_instance.uid)}
)
if device:
device_registry.async_update_device(
device.id,
remove_config_entry_id=self.platform.config_entry.entry_id,
)
else:
_LOGGER.debug("No valid message received: %s", message)
+1 -1
View File
@@ -12,5 +12,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pyW215"],
"requirements": ["pyW215==0.7.0"]
"requirements": ["pyW215==0.8.0"]
}
+8
View File
@@ -241,6 +241,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
obis_reference="SHORT_POWER_FAILURE_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
@@ -249,6 +250,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
obis_reference="LONG_POWER_FAILURE_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
@@ -257,6 +259,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
obis_reference="VOLTAGE_SAG_L1_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
@@ -265,6 +268,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
obis_reference="VOLTAGE_SAG_L2_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
@@ -273,6 +277,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
obis_reference="VOLTAGE_SAG_L3_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
@@ -281,6 +286,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
obis_reference="VOLTAGE_SWELL_L1_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
@@ -289,6 +295,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
obis_reference="VOLTAGE_SWELL_L2_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
@@ -297,6 +304,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
obis_reference="VOLTAGE_SWELL_L3_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
@@ -1,79 +0,0 @@
"""Support for sending data to Dweet.io."""
from datetime import timedelta
import logging
import dweepy
import voluptuous as vol
from homeassistant.const import (
ATTR_FRIENDLY_NAME,
CONF_NAME,
CONF_WHITELIST,
EVENT_STATE_CHANGED,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, state as state_helper
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import Throttle
_LOGGER = logging.getLogger(__name__)
DOMAIN = "dweet"
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1)
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_NAME): cv.string,
vol.Required(CONF_WHITELIST, default=[]): vol.All(
cv.ensure_list, [cv.entity_id]
),
}
)
},
extra=vol.ALLOW_EXTRA,
)
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Dweet.io component."""
conf = config[DOMAIN]
name = conf.get(CONF_NAME)
whitelist = conf.get(CONF_WHITELIST)
json_body = {}
def dweet_event_listener(event):
"""Listen for new messages on the bus and sends them to Dweet.io."""
state = event.data.get("new_state")
if (
state is None
or state.state in (STATE_UNKNOWN, "")
or state.entity_id not in whitelist
):
return
try:
_state = state_helper.state_as_number(state)
except ValueError:
_state = state.state
json_body[state.attributes.get(ATTR_FRIENDLY_NAME)] = _state
send_data(name, json_body)
hass.bus.listen(EVENT_STATE_CHANGED, dweet_event_listener)
return True
@Throttle(MIN_TIME_BETWEEN_UPDATES)
def send_data(name, msg):
"""Send the collected data to Dweet.io."""
try:
dweepy.dweet_for(name, msg)
except dweepy.DweepyError:
_LOGGER.error("Error saving data to Dweet.io: %s", msg)
@@ -1,10 +0,0 @@
{
"domain": "dweet",
"name": "dweet.io",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/dweet",
"iot_class": "cloud_polling",
"loggers": ["dweepy"],
"quality_scale": "legacy",
"requirements": ["dweepy==0.3.0"]
}
-124
View File
@@ -1,124 +0,0 @@
"""Support for showing values from Dweet.io."""
from __future__ import annotations
from datetime import timedelta
import json
import logging
import dweepy
import voluptuous as vol
from homeassistant.components.sensor import (
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorEntity,
)
from homeassistant.const import (
CONF_DEVICE,
CONF_NAME,
CONF_UNIT_OF_MEASUREMENT,
CONF_VALUE_TEMPLATE,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
DEFAULT_NAME = "Dweet.io Sensor"
SCAN_INTERVAL = timedelta(minutes=1)
PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_DEVICE): cv.string,
vol.Required(CONF_VALUE_TEMPLATE): cv.template,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
}
)
def setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Dweet sensor."""
name = config.get(CONF_NAME)
device = config.get(CONF_DEVICE)
value_template = config.get(CONF_VALUE_TEMPLATE)
unit = config.get(CONF_UNIT_OF_MEASUREMENT)
try:
content = json.dumps(dweepy.get_latest_dweet_for(device)[0]["content"])
except dweepy.DweepyError:
_LOGGER.error("Device/thing %s could not be found", device)
return
if value_template and value_template.render_with_possible_json_value(content) == "":
_LOGGER.error("%s was not found", value_template)
return
dweet = DweetData(device)
add_entities([DweetSensor(hass, dweet, name, value_template, unit)], True)
class DweetSensor(SensorEntity):
"""Representation of a Dweet sensor."""
def __init__(self, hass, dweet, name, value_template, unit_of_measurement):
"""Initialize the sensor."""
self.hass = hass
self.dweet = dweet
self._name = name
self._value_template = value_template
self._state = None
self._unit_of_measurement = unit_of_measurement
@property
def name(self):
"""Return the name of the sensor."""
return self._name
@property
def native_unit_of_measurement(self):
"""Return the unit the value is expressed in."""
return self._unit_of_measurement
@property
def native_value(self):
"""Return the state."""
return self._state
def update(self) -> None:
"""Get the latest data from REST API."""
self.dweet.update()
if self.dweet.data is None:
self._state = None
else:
values = json.dumps(self.dweet.data[0]["content"])
self._state = self._value_template.render_with_possible_json_value(
values, None
)
class DweetData:
"""The class for handling the data retrieval."""
def __init__(self, device):
"""Initialize the sensor."""
self._device = device
self.data = None
def update(self):
"""Get the latest data from Dweet.io."""
try:
self.data = dweepy.get_latest_dweet_for(self._device)
except dweepy.DweepyError:
_LOGGER.warning("Device %s doesn't contain any data", self._device)
self.data = None
@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250625.0"]
"requirements": ["home-assistant-frontend==20250627.0"]
}
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import mimetypes
from pathlib import Path
from types import MappingProxyType
from google.genai import Client
from google.genai.errors import APIError, ClientError
@@ -36,10 +37,13 @@ from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_PROMPT,
DEFAULT_TITLE,
DEFAULT_TTS_NAME,
DOMAIN,
FILE_POLLING_INTERVAL_SECONDS,
LOGGER,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_TTS_OPTIONS,
TIMEOUT_MILLIS,
)
@@ -242,6 +246,16 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
parent_entry = api_keys_entries[entry.data[CONF_API_KEY]]
hass.config_entries.async_add_subentry(parent_entry, subentry)
if use_existing:
hass.config_entries.async_add_subentry(
parent_entry,
ConfigSubentry(
data=MappingProxyType(RECOMMENDED_TTS_OPTIONS),
subentry_type="tts",
title=DEFAULT_TTS_NAME,
unique_id=None,
),
)
conversation_entity = entity_registry.async_get_entity_id(
"conversation",
DOMAIN,
@@ -270,12 +284,19 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
device.id,
remove_config_entry_id=entry.entry_id,
)
else:
device_registry.async_update_device(
device.id,
remove_config_entry_id=entry.entry_id,
remove_config_subentry_id=None,
)
if not use_existing:
await hass.config_entries.async_remove(entry.entry_id)
else:
hass.config_entries.async_update_entry(
entry,
title=DEFAULT_TITLE,
options={},
version=2,
)
@@ -47,13 +47,18 @@ from .const import (
CONF_TOP_P,
CONF_USE_GOOGLE_SEARCH_TOOL,
DEFAULT_CONVERSATION_NAME,
DEFAULT_TITLE,
DEFAULT_TTS_NAME,
DOMAIN,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_CONVERSATION_OPTIONS,
RECOMMENDED_HARM_BLOCK_THRESHOLD,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_K,
RECOMMENDED_TOP_P,
RECOMMENDED_TTS_MODEL,
RECOMMENDED_TTS_OPTIONS,
RECOMMENDED_USE_GOOGLE_SEARCH_TOOL,
TIMEOUT_MILLIS,
)
@@ -66,12 +71,6 @@ STEP_API_DATA_SCHEMA = vol.Schema(
}
)
RECOMMENDED_OPTIONS = {
CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
}
async def validate_input(data: dict[str, Any]) -> None:
"""Validate the user input allows us to connect.
@@ -118,15 +117,21 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
data=user_input,
)
return self.async_create_entry(
title="Google Generative AI",
title=DEFAULT_TITLE,
data=user_input,
subentries=[
{
"subentry_type": "conversation",
"data": RECOMMENDED_OPTIONS,
"data": RECOMMENDED_CONVERSATION_OPTIONS,
"title": DEFAULT_CONVERSATION_NAME,
"unique_id": None,
}
},
{
"subentry_type": "tts",
"data": RECOMMENDED_TTS_OPTIONS,
"title": DEFAULT_TTS_NAME,
"unique_id": None,
},
],
)
return self.async_show_form(
@@ -172,10 +177,13 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this integration."""
return {"conversation": ConversationSubentryFlowHandler}
return {
"conversation": LLMSubentryFlowHandler,
"tts": LLMSubentryFlowHandler,
}
class ConversationSubentryFlowHandler(ConfigSubentryFlow):
class LLMSubentryFlowHandler(ConfigSubentryFlow):
"""Flow for managing conversation subentries."""
last_rendered_recommended = False
@@ -202,7 +210,11 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
if user_input is None:
if self._is_new:
options = RECOMMENDED_OPTIONS.copy()
options: dict[str, Any]
if self._subentry_type == "tts":
options = RECOMMENDED_TTS_OPTIONS.copy()
else:
options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
else:
# If this is a reconfiguration, we need to copy the existing options
# so that we can show the current values in the form.
@@ -216,7 +228,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
if not user_input.get(CONF_LLM_HASS_API):
user_input.pop(CONF_LLM_HASS_API, None)
# Don't allow to save options that enable the Google Seearch tool with an Assist API
# Don't allow to save options that enable the Google Search tool with an Assist API
if not (
user_input.get(CONF_LLM_HASS_API)
and user_input.get(CONF_USE_GOOGLE_SEARCH_TOOL, False) is True
@@ -240,7 +252,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
options = user_input
schema = await google_generative_ai_config_option_schema(
self.hass, self._is_new, options, self._genai_client
self.hass, self._is_new, self._subentry_type, options, self._genai_client
)
return self.async_show_form(
step_id="set_options", data_schema=vol.Schema(schema), errors=errors
@@ -253,6 +265,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
async def google_generative_ai_config_option_schema(
hass: HomeAssistant,
is_new: bool,
subentry_type: str,
options: Mapping[str, Any],
genai_client: genai.Client,
) -> dict:
@@ -270,26 +283,39 @@ async def google_generative_ai_config_option_schema(
suggested_llm_apis = [suggested_llm_apis]
if is_new:
if CONF_NAME in options:
default_name = options[CONF_NAME]
elif subentry_type == "tts":
default_name = DEFAULT_TTS_NAME
else:
default_name = DEFAULT_CONVERSATION_NAME
schema: dict[vol.Required | vol.Optional, Any] = {
vol.Required(CONF_NAME, default=DEFAULT_CONVERSATION_NAME): str,
vol.Required(CONF_NAME, default=default_name): str,
}
else:
schema = {}
if subentry_type == "conversation":
schema.update(
{
vol.Optional(
CONF_PROMPT,
description={
"suggested_value": options.get(
CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT
)
},
): TemplateSelector(),
vol.Optional(
CONF_LLM_HASS_API,
description={"suggested_value": suggested_llm_apis},
): SelectSelector(
SelectSelectorConfig(options=hass_apis, multiple=True)
),
}
)
schema.update(
{
vol.Optional(
CONF_PROMPT,
description={
"suggested_value": options.get(
CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT
)
},
): TemplateSelector(),
vol.Optional(
CONF_LLM_HASS_API,
description={"suggested_value": suggested_llm_apis},
): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)),
vol.Required(
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
): bool,
@@ -310,7 +336,7 @@ async def google_generative_ai_config_option_schema(
if (
api_model.display_name
and api_model.name
and "tts" not in api_model.name
and ("tts" in api_model.name) == (subentry_type == "tts")
and "vision" not in api_model.name
and api_model.supported_actions
and "generateContent" in api_model.supported_actions
@@ -341,12 +367,17 @@ async def google_generative_ai_config_option_schema(
)
)
if subentry_type == "tts":
default_model = RECOMMENDED_TTS_MODEL
else:
default_model = RECOMMENDED_CHAT_MODEL
schema.update(
{
vol.Optional(
CONF_CHAT_MODEL,
description={"suggested_value": options.get(CONF_CHAT_MODEL)},
default=RECOMMENDED_CHAT_MODEL,
default=default_model,
): SelectSelector(
SelectSelectorConfig(mode=SelectSelectorMode.DROPDOWN, options=models)
),
@@ -396,13 +427,18 @@ async def google_generative_ai_config_option_schema(
},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
vol.Optional(
CONF_USE_GOOGLE_SEARCH_TOOL,
description={
"suggested_value": options.get(CONF_USE_GOOGLE_SEARCH_TOOL),
},
default=RECOMMENDED_USE_GOOGLE_SEARCH_TOOL,
): bool,
}
)
if subentry_type == "conversation":
schema.update(
{
vol.Optional(
CONF_USE_GOOGLE_SEARCH_TOOL,
description={
"suggested_value": options.get(CONF_USE_GOOGLE_SEARCH_TOOL),
},
default=RECOMMENDED_USE_GOOGLE_SEARCH_TOOL,
): bool,
}
)
return schema
@@ -2,17 +2,21 @@
import logging
from homeassistant.const import CONF_LLM_HASS_API
from homeassistant.helpers import llm
DOMAIN = "google_generative_ai_conversation"
DEFAULT_TITLE = "Google Generative AI"
LOGGER = logging.getLogger(__package__)
CONF_PROMPT = "prompt"
DEFAULT_CONVERSATION_NAME = "Google AI Conversation"
DEFAULT_TTS_NAME = "Google AI TTS"
ATTR_MODEL = "model"
CONF_RECOMMENDED = "recommended"
CONF_CHAT_MODEL = "chat_model"
RECOMMENDED_CHAT_MODEL = "models/gemini-2.5-flash"
RECOMMENDED_TTS_MODEL = "gemini-2.5-flash-preview-tts"
RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts"
CONF_TEMPERATURE = "temperature"
RECOMMENDED_TEMPERATURE = 1.0
CONF_TOP_P = "top_p"
@@ -31,3 +35,12 @@ RECOMMENDED_USE_GOOGLE_SEARCH_TOOL = False
TIMEOUT_MILLIS = 10000
FILE_POLLING_INTERVAL_SECONDS = 0.05
RECOMMENDED_CONVERSATION_OPTIONS = {
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
CONF_RECOMMENDED: True,
}
RECOMMENDED_TTS_OPTIONS = {
CONF_RECOMMENDED: True,
}
@@ -21,6 +21,7 @@ async def async_get_config_entry_diagnostics(
"title": entry.title,
"data": entry.data,
"options": entry.options,
"subentries": dict(entry.subentries),
},
TO_REDACT,
)
@@ -301,7 +301,12 @@ async def _transform_stream(
class GoogleGenerativeAILLMBaseEntity(Entity):
"""Google Generative AI base entity."""
def __init__(self, entry: ConfigEntry, subentry: ConfigSubentry) -> None:
def __init__(
self,
entry: ConfigEntry,
subentry: ConfigSubentry,
default_model: str = RECOMMENDED_CHAT_MODEL,
) -> None:
"""Initialize the agent."""
self.entry = entry
self.subentry = subentry
@@ -312,7 +317,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
identifiers={(DOMAIN, subentry.subentry_id)},
name=subentry.title,
manufacturer="Google",
model="Generative AI",
model=subentry.data.get(CONF_CHAT_MODEL, default_model).split("/")[-1],
entry_type=dr.DeviceEntryType.SERVICE,
)
@@ -0,0 +1,73 @@
"""Helper classes for Google Generative AI integration."""
from __future__ import annotations
from contextlib import suppress
import io
import wave
from homeassistant.exceptions import HomeAssistantError
from .const import LOGGER
def convert_to_wav(audio_data: bytes, mime_type: str) -> bytes:
"""Generate a WAV file header for the given audio data and parameters.
Args:
audio_data: The raw audio data as a bytes object.
mime_type: Mime type of the audio data.
Returns:
A bytes object representing the WAV file header.
"""
parameters = _parse_audio_mime_type(mime_type)
wav_buffer = io.BytesIO()
with wave.open(wav_buffer, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(parameters["bits_per_sample"] // 8)
wf.setframerate(parameters["rate"])
wf.writeframes(audio_data)
return wav_buffer.getvalue()
# Below code is from https://aistudio.google.com/app/generate-speech
# when you select "Get SDK code to generate speech".
def _parse_audio_mime_type(mime_type: str) -> dict[str, int]:
"""Parse bits per sample and rate from an audio MIME type string.
Assumes bits per sample is encoded like "L16" and rate as "rate=xxxxx".
Args:
mime_type: The audio MIME type string (e.g., "audio/L16;rate=24000").
Returns:
A dictionary with "bits_per_sample" and "rate" keys. Values will be
integers if found, otherwise None.
"""
if not mime_type.startswith("audio/L"):
LOGGER.warning("Received unexpected MIME type %s", mime_type)
raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}")
bits_per_sample = 16
rate = 24000
# Extract rate from parameters
parts = mime_type.split(";")
for param in parts: # Skip the main type part
param = param.strip()
if param.lower().startswith("rate="):
# Handle cases like "rate=" with no value or non-integer value and keep rate as default
with suppress(ValueError, IndexError):
rate_str = param.split("=", 1)[1]
rate = int(rate_str)
elif param.startswith("audio/L"):
# Keep bits_per_sample as default if conversion fails
with suppress(ValueError, IndexError):
bits_per_sample = int(param.split("L", 1)[1])
return {"bits_per_sample": bits_per_sample, "rate": rate}
@@ -29,7 +29,6 @@
"reconfigure": "Reconfigure conversation agent"
},
"entry_type": "Conversation agent",
"step": {
"set_options": {
"data": {
@@ -61,6 +60,34 @@
"error": {
"invalid_google_search_option": "Google Search can only be enabled if nothing is selected in the \"Control Home Assistant\" setting."
}
},
"tts": {
"initiate_flow": {
"user": "Add Text-to-Speech service",
"reconfigure": "Reconfigure Text-to-Speech service"
},
"entry_type": "Text-to-Speech",
"step": {
"set_options": {
"data": {
"name": "[%key:common::config_flow::data::name%]",
"recommended": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::recommended%]",
"chat_model": "[%key:common::generic::model%]",
"temperature": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::temperature%]",
"top_p": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_p%]",
"top_k": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_k%]",
"max_tokens": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::max_tokens%]",
"harassment_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::harassment_block_threshold%]",
"hate_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::hate_block_threshold%]",
"sexual_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::sexual_block_threshold%]",
"dangerous_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::dangerous_block_threshold%]"
}
}
},
"abort": {
"entry_not_loaded": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::abort::entry_not_loaded%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
}
},
"services": {
@@ -2,13 +2,12 @@
from __future__ import annotations
from contextlib import suppress
import io
import logging
from collections.abc import Mapping
from typing import Any
import wave
from google.genai import types
from google.genai.errors import APIError, ClientError
from propcache.api import cached_property
from homeassistant.components.tts import (
ATTR_VOICE,
@@ -16,15 +15,14 @@ from homeassistant.components.tts import (
TtsAudioType,
Voice,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ATTR_MODEL, DOMAIN, RECOMMENDED_TTS_MODEL
_LOGGER = logging.getLogger(__name__)
from .const import CONF_CHAT_MODEL, LOGGER, RECOMMENDED_TTS_MODEL
from .entity import GoogleGenerativeAILLMBaseEntity
from .helpers import convert_to_wav
async def async_setup_entry(
@@ -32,15 +30,23 @@ async def async_setup_entry(
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up TTS entity."""
tts_entity = GoogleGenerativeAITextToSpeechEntity(config_entry)
async_add_entities([tts_entity])
"""Set up TTS entities."""
for subentry in config_entry.subentries.values():
if subentry.subentry_type != "tts":
continue
async_add_entities(
[GoogleGenerativeAITextToSpeechEntity(config_entry, subentry)],
config_subentry_id=subentry.subentry_id,
)
class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity):
class GoogleGenerativeAITextToSpeechEntity(
TextToSpeechEntity, GoogleGenerativeAILLMBaseEntity
):
"""Google Generative AI text-to-speech entity."""
_attr_supported_options = [ATTR_VOICE, ATTR_MODEL]
_attr_supported_options = [ATTR_VOICE]
# See https://ai.google.dev/gemini-api/docs/speech-generation#languages
_attr_supported_languages = [
"ar-EG",
@@ -68,6 +74,8 @@ class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity):
"uk-UA",
"vi-VN",
]
# Unused, but required by base class.
# The Gemini TTS models detect the input language automatically.
_attr_default_language = "en-US"
# See https://ai.google.dev/gemini-api/docs/speech-generation#voices
_supported_voices = [
@@ -106,110 +114,44 @@ class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity):
)
]
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize Google Generative AI Conversation speech entity."""
self.entry = entry
self._attr_name = "Google Generative AI TTS"
self._attr_unique_id = f"{entry.entry_id}_tts"
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
manufacturer="Google",
model="Generative AI",
entry_type=dr.DeviceEntryType.SERVICE,
)
self._genai_client = entry.runtime_data
self._default_voice_id = self._supported_voices[0].voice_id
def __init__(self, config_entry: ConfigEntry, subentry: ConfigSubentry) -> None:
"""Initialize the TTS entity."""
super().__init__(config_entry, subentry, RECOMMENDED_TTS_MODEL)
@callback
def async_get_supported_voices(self, language: str) -> list[Voice] | None:
def async_get_supported_voices(self, language: str) -> list[Voice]:
"""Return a list of supported voices for a language."""
return self._supported_voices
@cached_property
def default_options(self) -> Mapping[str, Any]:
"""Return a mapping with the default options."""
return {
ATTR_VOICE: self._supported_voices[0].voice_id,
}
async def async_get_tts_audio(
self, message: str, language: str, options: dict[str, Any]
) -> TtsAudioType:
"""Load tts audio file from the engine."""
try:
response = self._genai_client.models.generate_content(
model=options.get(ATTR_MODEL, RECOMMENDED_TTS_MODEL),
contents=message,
config=types.GenerateContentConfig(
response_modalities=["AUDIO"],
speech_config=types.SpeechConfig(
voice_config=types.VoiceConfig(
prebuilt_voice_config=types.PrebuiltVoiceConfig(
voice_name=options.get(
ATTR_VOICE, self._default_voice_id
)
)
)
),
),
config = self.create_generate_content_config()
config.response_modalities = ["AUDIO"]
config.speech_config = types.SpeechConfig(
voice_config=types.VoiceConfig(
prebuilt_voice_config=types.PrebuiltVoiceConfig(
voice_name=options[ATTR_VOICE]
)
)
)
try:
response = await self._genai_client.aio.models.generate_content(
model=self.subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_TTS_MODEL),
contents=message,
config=config,
)
data = response.candidates[0].content.parts[0].inline_data.data
mime_type = response.candidates[0].content.parts[0].inline_data.mime_type
except Exception as exc:
_LOGGER.warning(
"Error during processing of TTS request %s", exc, exc_info=True
)
except (APIError, ClientError, ValueError) as exc:
LOGGER.error("Error during TTS: %s", exc, exc_info=True)
raise HomeAssistantError(exc) from exc
return "wav", self._convert_to_wav(data, mime_type)
def _convert_to_wav(self, audio_data: bytes, mime_type: str) -> bytes:
"""Generate a WAV file header for the given audio data and parameters.
Args:
audio_data: The raw audio data as a bytes object.
mime_type: Mime type of the audio data.
Returns:
A bytes object representing the WAV file header.
"""
parameters = self._parse_audio_mime_type(mime_type)
wav_buffer = io.BytesIO()
with wave.open(wav_buffer, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(parameters["bits_per_sample"] // 8)
wf.setframerate(parameters["rate"])
wf.writeframes(audio_data)
return wav_buffer.getvalue()
def _parse_audio_mime_type(self, mime_type: str) -> dict[str, int]:
"""Parse bits per sample and rate from an audio MIME type string.
Assumes bits per sample is encoded like "L16" and rate as "rate=xxxxx".
Args:
mime_type: The audio MIME type string (e.g., "audio/L16;rate=24000").
Returns:
A dictionary with "bits_per_sample" and "rate" keys. Values will be
integers if found, otherwise None.
"""
if not mime_type.startswith("audio/L"):
_LOGGER.warning("Received unexpected MIME type %s", mime_type)
raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}")
bits_per_sample = 16
rate = 24000
# Extract rate from parameters
parts = mime_type.split(";")
for param in parts: # Skip the main type part
param = param.strip()
if param.lower().startswith("rate="):
# Handle cases like "rate=" with no value or non-integer value and keep rate as default
with suppress(ValueError, IndexError):
rate_str = param.split("=", 1)[1]
rate = int(rate_str)
elif param.startswith("audio/L"):
# Keep bits_per_sample as default if conversion fails
with suppress(ValueError, IndexError):
bits_per_sample = int(param.split("L", 1)[1])
return {"bits_per_sample": bits_per_sample, "rate": rate}
return "wav", convert_to_wav(data, mime_type)
+1 -1
View File
@@ -9,7 +9,7 @@ ASSETS_URL = "https://habitica-assets.s3.amazonaws.com/mobileApp/images/"
SITE_DATA_URL = "https://habitica.com/user/settings/siteData"
FORGOT_PASSWORD_URL = "https://habitica.com/forgot-password"
SIGN_UP_URL = "https://habitica.com/register"
HABITICANS_URL = "https://habitica.com/static/img/home-main@3x.ffc32b12.png"
HABITICANS_URL = "https://cdn.habitica.com/assets/home-main@3x-Dwnue45Z.png"
DOMAIN = "habitica"
@@ -7,7 +7,10 @@ import asyncio
import logging
from typing import Any
from ha_silabs_firmware_client import FirmwareUpdateClient
from aiohttp import ClientError
from ha_silabs_firmware_client import FirmwareUpdateClient, ManifestMissing
from universal_silabs_flasher.common import Version
from universal_silabs_flasher.firmware import NabuCasaMetadata
from homeassistant.components.hassio import (
AddonError,
@@ -149,15 +152,78 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
assert self._device is not None
if not self.firmware_install_task:
session = async_get_clientsession(self.hass)
client = FirmwareUpdateClient(fw_update_url, session)
manifest = await client.async_update_data()
fw_meta = next(
fw for fw in manifest.firmwares if fw.filename.startswith(fw_type)
# We 100% need to install new firmware only if the wrong firmware is
# currently installed
firmware_install_required = self._probed_firmware_info is None or (
self._probed_firmware_info.firmware_type
!= expected_installed_firmware_type
)
fw_data = await client.async_fetch_firmware(fw_meta)
session = async_get_clientsession(self.hass)
client = FirmwareUpdateClient(fw_update_url, session)
try:
manifest = await client.async_update_data()
fw_manifest = next(
fw for fw in manifest.firmwares if fw.filename.startswith(fw_type)
)
except (StopIteration, TimeoutError, ClientError, ManifestMissing) as err:
_LOGGER.warning(
"Failed to fetch firmware update manifest", exc_info=True
)
# Not having internet access should not prevent setup
if not firmware_install_required:
_LOGGER.debug(
"Skipping firmware upgrade due to index download failure"
)
return self.async_show_progress_done(next_step_id=next_step_id)
raise AbortFlow(
"fw_download_failed",
description_placeholders={
**self._get_translation_placeholders(),
"firmware_name": firmware_name,
},
) from err
if not firmware_install_required:
assert self._probed_firmware_info is not None
# Make sure we do not downgrade the firmware
fw_metadata = NabuCasaMetadata.from_json(fw_manifest.metadata)
fw_version = fw_metadata.get_public_version()
probed_fw_version = Version(self._probed_firmware_info.firmware_version)
if probed_fw_version >= fw_version:
_LOGGER.debug(
"Not downgrading firmware, installed %s is newer than available %s",
probed_fw_version,
fw_version,
)
return self.async_show_progress_done(next_step_id=next_step_id)
try:
fw_data = await client.async_fetch_firmware(fw_manifest)
except (TimeoutError, ClientError, ValueError) as err:
_LOGGER.warning("Failed to fetch firmware update", exc_info=True)
# If we cannot download new firmware, we shouldn't block setup
if not firmware_install_required:
_LOGGER.debug(
"Skipping firmware upgrade due to image download failure"
)
return self.async_show_progress_done(next_step_id=next_step_id)
# Otherwise, fail
raise AbortFlow(
"fw_download_failed",
description_placeholders={
**self._get_translation_placeholders(),
"firmware_name": firmware_name,
},
) from err
self.firmware_install_task = self.hass.async_create_task(
async_flash_silabs_firmware(
hass=self.hass,
@@ -215,6 +281,14 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
},
)
async def async_step_pre_confirm_zigbee(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Pre-confirm Zigbee setup."""
# This step is necessary to prevent `user_input` from being passed through
return await self.async_step_confirm_zigbee()
async def async_step_confirm_zigbee(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -409,7 +483,15 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
finally:
self.addon_start_task = None
return self.async_show_progress_done(next_step_id="confirm_otbr")
return self.async_show_progress_done(next_step_id="pre_confirm_otbr")
async def async_step_pre_confirm_otbr(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Pre-confirm OTBR setup."""
# This step is necessary to prevent `user_input` from being passed through
return await self.async_step_confirm_otbr()
async def async_step_confirm_otbr(
self, user_input: dict[str, Any] | None = None
@@ -36,7 +36,8 @@
"otbr_addon_already_running": "The OpenThread Border Router add-on is already running, it cannot be installed again.",
"zha_still_using_stick": "This {model} is in use by the Zigbee Home Automation integration. Please migrate your Zigbee network to another adapter or delete the integration and try again.",
"otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router add-on. If you use the Thread network, make sure you have alternative border routers. Uninstall the add-on and try again.",
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device."
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device.",
"fw_download_failed": "{firmware_name} firmware for your {model} failed to download. Make sure Home Assistant has internet access and try again."
},
"progress": {
"install_firmware": "Please wait while {firmware_name} firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes."
@@ -93,7 +93,7 @@ class SkyConnectFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
firmware_name="Zigbee",
expected_installed_firmware_type=ApplicationType.EZSP,
step_id="install_zigbee_firmware",
next_step_id="confirm_zigbee",
next_step_id="pre_confirm_zigbee",
)
async def async_step_install_thread_firmware(
@@ -92,7 +92,8 @@
"otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]",
"zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]",
"otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]",
"unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]"
"unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]",
"fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]"
},
"progress": {
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
@@ -145,7 +146,8 @@
"otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]",
"zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]",
"otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]",
"unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]"
"unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]",
"fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]"
},
"progress": {
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
@@ -117,7 +117,8 @@
"otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]",
"zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]",
"otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]",
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or addon is currently trying to communicate with the device."
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device.",
"fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]"
},
"progress": {
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/honeywell",
"iot_class": "cloud_polling",
"loggers": ["somecomfort"],
"requirements": ["AIOSomecomfort==0.0.32"]
"requirements": ["AIOSomecomfort==0.0.33"]
}
+1 -1
View File
@@ -223,7 +223,7 @@ async def async_setup_auth(
# We first start with a string check to avoid parsing query params
# for every request.
elif (
request.method == "GET"
request.method in ["GET", "HEAD"]
and SIGN_QUERY_PARAM in request.query_string
and async_validate_signed_request(request)
):
@@ -90,7 +90,9 @@ class AutomowerButtonEntity(AutomowerAvailableEntity, ButtonEntity):
@property
def available(self) -> bool:
"""Return the available attribute of the entity."""
return self.entity_description.available_fn(self.mower_attributes)
return super().available and self.entity_description.available_fn(
self.mower_attributes
)
@handle_sending_exception()
async def async_press(self) -> None:
@@ -2,15 +2,18 @@
from datetime import datetime
import logging
from typing import TYPE_CHECKING
from aioautomower.model import make_name_string
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import AutomowerConfigEntry
from .const import DOMAIN
from .coordinator import AutomowerDataUpdateCoordinator
from .entity import AutomowerBaseEntity
@@ -51,6 +54,19 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity):
self._attr_unique_id = mower_id
self._event: CalendarEvent | None = None
@property
def device_name(self) -> str:
"""Return the prefix for the event summary."""
device_registry = dr.async_get(self.hass)
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, self.mower_id)}
)
if TYPE_CHECKING:
assert device_entry is not None
assert device_entry.name is not None
return device_entry.name_by_user or device_entry.name
@property
def event(self) -> CalendarEvent | None:
"""Return the current or next upcoming event."""
@@ -66,7 +82,7 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity):
program_event.work_area_id
]
return CalendarEvent(
summary=make_name_string(work_area_name, program_event.schedule_no),
summary=f"{self.device_name} {make_name_string(work_area_name, program_event.schedule_no)}",
start=program_event.start,
end=program_event.end,
rrule=program_event.rrule_str,
@@ -93,7 +109,7 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity):
]
calendar_events.append(
CalendarEvent(
summary=make_name_string(work_area_name, program_event.schedule_no),
summary=f"{self.device_name} {make_name_string(work_area_name, program_event.schedule_no)}",
start=program_event.start.replace(tzinfo=start_date.tzinfo),
end=program_event.end.replace(tzinfo=start_date.tzinfo),
rrule=program_event.rrule_str,
@@ -8,5 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["aioautomower"],
"quality_scale": "silver",
"requirements": ["aioautomower==2025.6.0"]
"requirements": ["aioautomower==1.0.1"]
}
+34 -3
View File
@@ -288,8 +288,10 @@ class ImageView(HomeAssistantView):
"""Initialize an image view."""
self.component = component
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
"""Start a GET request."""
async def _authenticate_request(
self, request: web.Request, entity_id: str
) -> ImageEntity:
"""Authenticate request and return image entity."""
if (image_entity := self.component.get_entity(entity_id)) is None:
raise web.HTTPNotFound
@@ -306,6 +308,31 @@ class ImageView(HomeAssistantView):
# Invalid sigAuth or image entity access token
raise web.HTTPForbidden
return image_entity
async def head(self, request: web.Request, entity_id: str) -> web.Response:
"""Start a HEAD request.
This is sent by some DLNA renderers, like Samsung ones, prior to sending
the GET request.
"""
image_entity = await self._authenticate_request(request, entity_id)
# Don't use `handle` as we don't care about the stream case, we only want
# to verify that the image exists.
try:
image = await _async_get_image(image_entity, IMAGE_TIMEOUT)
except (HomeAssistantError, ValueError) as ex:
raise web.HTTPInternalServerError from ex
return web.Response(
content_type=image.content_type,
headers={"Content-Length": str(len(image.content))},
)
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
"""Start a GET request."""
image_entity = await self._authenticate_request(request, entity_id)
return await self.handle(request, image_entity)
async def handle(
@@ -317,7 +344,11 @@ class ImageView(HomeAssistantView):
except (HomeAssistantError, ValueError) as ex:
raise web.HTTPInternalServerError from ex
return web.Response(body=image.content, content_type=image.content_type)
return web.Response(
body=image.content,
content_type=image.content_type,
headers={"Content-Length": str(len(image.content))},
)
async def async_get_still_stream(
@@ -14,5 +14,5 @@
"iot_class": "local_polling",
"loggers": ["pynecil"],
"quality_scale": "platinum",
"requirements": ["pynecil==4.1.0"]
"requirements": ["pynecil==4.1.1"]
}
@@ -66,8 +66,7 @@ def _connect_to_address(
) -> dict[str, Any]:
"""Connect to the Jellyfin server."""
result: dict[str, Any] = connection_manager.connect_to_address(url)
if result["State"] != CONNECTION_STATE["ServerSignIn"]:
if CONNECTION_STATE(result["State"]) != CONNECTION_STATE.ServerSignIn:
raise CannotConnect
return result
@@ -54,6 +54,9 @@ class JellyfinDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, An
self.api_client.jellyfin.sessions
)
if sessions is None:
return {}
sessions_by_id: dict[str, dict[str, Any]] = {
session["Id"]: session
for session in sessions
@@ -7,6 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["jellyfin_apiclient_python"],
"requirements": ["jellyfin-apiclient-python==1.10.0"],
"single_config_entry": true
"requirements": ["jellyfin-apiclient-python==1.11.0"]
}
@@ -14,6 +14,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import CONF_SERVICE_CODE
from .coordinator import PlenticoreConfigEntry, SettingDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -29,6 +30,7 @@ class PlenticoreSwitchEntityDescription(SwitchEntityDescription):
on_label: str
off_value: str
off_label: str
installer_required: bool = False
SWITCH_SETTINGS_DATA = [
@@ -42,6 +44,17 @@ SWITCH_SETTINGS_DATA = [
off_value="2",
off_label="Automatic economical",
),
PlenticoreSwitchEntityDescription(
module_id="devices:local",
key="Battery:ManualCharge",
name="Battery Manual Charge",
is_on="1",
on_value="1",
on_label="On",
off_value="0",
off_label="Off",
installer_required=True,
),
]
@@ -73,7 +86,13 @@ async def async_setup_entry(
description.key,
)
continue
if entry.data.get(CONF_SERVICE_CODE) is None and description.installer_required:
_LOGGER.debug(
"Skipping installer required setting data %s/%s",
description.module_id,
description.key,
)
continue
entities.append(
PlenticoreDataSwitch(
settings_data_update_coordinator,
+19 -1
View File
@@ -2,8 +2,10 @@
from collections.abc import Callable
from dataclasses import dataclass
from typing import cast
from pylamarzocco.const import FirmwareType
from pylamarzocco.const import FirmwareType, MachineState, WidgetType
from pylamarzocco.models import MachineStatus
from homeassistant.const import CONF_ADDRESS, CONF_MAC
from homeassistant.helpers.device_registry import (
@@ -32,6 +34,7 @@ class LaMarzoccoBaseEntity(
"""Common elements for all entities."""
_attr_has_entity_name = True
_unavailable_when_machine_off = True
def __init__(
self,
@@ -63,6 +66,21 @@ class LaMarzoccoBaseEntity(
if connections:
self._attr_device_info.update(DeviceInfo(connections=connections))
@property
def available(self) -> bool:
"""Return True if entity is available."""
machine_state = (
cast(
MachineStatus,
self.coordinator.device.dashboard.config[WidgetType.CM_MACHINE_STATUS],
).status
if WidgetType.CM_MACHINE_STATUS in self.coordinator.device.dashboard.config
else MachineState.OFF
)
return super().available and not (
self._unavailable_when_machine_off and machine_state is MachineState.OFF
)
class LaMarzoccoEntity(LaMarzoccoBaseEntity):
"""Common elements for all entities."""
@@ -58,10 +58,6 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
CoffeeBoiler, machine.dashboard.config[WidgetType.CM_COFFEE_BOILER]
).target_temperature
),
available_fn=(
lambda coordinator: WidgetType.CM_COFFEE_BOILER
in coordinator.device.dashboard.config
),
),
LaMarzoccoNumberEntityDescription(
key="smart_standby_time",
@@ -57,10 +57,6 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
).ready_start_time
),
entity_category=EntityCategory.DIAGNOSTIC,
available_fn=(
lambda coordinator: WidgetType.CM_COFFEE_BOILER
in coordinator.device.dashboard.config
),
),
LaMarzoccoSensorEntityDescription(
key="steam_boiler_ready_time",
@@ -188,6 +184,8 @@ class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity):
class LaMarzoccoStatisticSensorEntity(LaMarzoccoSensorEntity):
"""Sensor for La Marzocco statistics."""
_unavailable_when_machine_off = False
@property
def native_value(self) -> StateType | datetime | None:
"""Return the value of the sensor."""
+1 -1
View File
@@ -42,5 +42,5 @@ class LaMetricUpdate(LaMetricEntity, UpdateEntity):
def latest_version(self) -> str | None:
"""Return the latest version of the entity."""
if not self.coordinator.data.update:
return None
return self.coordinator.data.os_version
return self.coordinator.data.update.version
+79
View File
@@ -2,9 +2,12 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, cast
from chip.clusters import Objects as clusters
from chip.clusters.ClusterObjects import ClusterAttributeDescriptor, ClusterCommand
from matter_server.common import custom_clusters
from homeassistant.components.number import (
@@ -44,6 +47,23 @@ class MatterNumberEntityDescription(NumberEntityDescription, MatterEntityDescrip
"""Describe Matter Number Input entities."""
@dataclass(frozen=True, kw_only=True)
class MatterRangeNumberEntityDescription(
NumberEntityDescription, MatterEntityDescription
):
"""Describe Matter Number Input entities with min and max values."""
ha_to_native_value: Callable[[Any], Any]
# attribute descriptors to get the min and max value
min_attribute: type[ClusterAttributeDescriptor]
max_attribute: type[ClusterAttributeDescriptor]
# command: a custom callback to create the command to send to the device
# the callback's argument will be the index of the selected list value
command: Callable[[int], ClusterCommand]
class MatterNumber(MatterEntity, NumberEntity):
"""Representation of a Matter Attribute as a Number entity."""
@@ -67,6 +87,42 @@ class MatterNumber(MatterEntity, NumberEntity):
self._attr_native_value = value
class MatterRangeNumber(MatterEntity, NumberEntity):
"""Representation of a Matter Attribute as a Number entity with min and max values."""
entity_description: MatterRangeNumberEntityDescription
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
send_value = self.entity_description.ha_to_native_value(value)
# custom command defined to set the new value
await self.send_device_command(
self.entity_description.command(send_value),
)
@callback
def _update_from_device(self) -> None:
"""Update from device."""
value = self.get_matter_attribute_value(self._entity_info.primary_attribute)
if value_convert := self.entity_description.measurement_to_ha:
value = value_convert(value)
self._attr_native_value = value
self._attr_native_min_value = (
cast(
int,
self.get_matter_attribute_value(self.entity_description.min_attribute),
)
/ 100
)
self._attr_native_max_value = (
cast(
int,
self.get_matter_attribute_value(self.entity_description.max_attribute),
)
/ 100
)
# Discovery schema(s) to map Matter Attributes to HA entities
DISCOVERY_SCHEMAS = [
MatterDiscoverySchema(
@@ -213,4 +269,27 @@ DISCOVERY_SCHEMAS = [
entity_class=MatterNumber,
required_attributes=(clusters.DoorLock.Attributes.AutoRelockTime,),
),
MatterDiscoverySchema(
platform=Platform.NUMBER,
entity_description=MatterRangeNumberEntityDescription(
key="TemperatureControlTemperatureSetpoint",
name=None,
translation_key="temperature_setpoint",
command=lambda value: clusters.TemperatureControl.Commands.SetTemperature(
targetTemperature=value
),
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
measurement_to_ha=lambda x: None if x is None else x / 100,
ha_to_native_value=lambda x: round(x * 100),
min_attribute=clusters.TemperatureControl.Attributes.MinTemperature,
max_attribute=clusters.TemperatureControl.Attributes.MaxTemperature,
mode=NumberMode.SLIDER,
),
entity_class=MatterRangeNumber,
required_attributes=(
clusters.TemperatureControl.Attributes.TemperatureSetpoint,
clusters.TemperatureControl.Attributes.MinTemperature,
clusters.TemperatureControl.Attributes.MaxTemperature,
),
),
]
@@ -183,6 +183,9 @@
"temperature_offset": {
"name": "Temperature offset"
},
"temperature_setpoint": {
"name": "Temperature setpoint"
},
"pir_occupied_to_unoccupied_delay": {
"name": "Occupied to unoccupied delay"
},
+51 -26
View File
@@ -17,6 +17,7 @@ from homeassistant.components.vacuum import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import MatterEntity
@@ -62,14 +63,36 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
_last_accepted_commands: list[int] | None = None
_supported_run_modes: (
dict[int, clusters.RvcCleanMode.Structs.ModeOptionStruct] | None
dict[int, clusters.RvcRunMode.Structs.ModeOptionStruct] | None
) = None
entity_description: StateVacuumEntityDescription
_platform_translation_key = "vacuum"
def _get_run_mode_by_tag(
self, tag: ModeTag
) -> clusters.RvcRunMode.Structs.ModeOptionStruct | None:
"""Get the run mode by tag."""
supported_run_modes = self._supported_run_modes or {}
for mode in supported_run_modes.values():
for t in mode.modeTags:
if t.value == tag.value:
return mode
return None
async def async_stop(self, **kwargs: Any) -> None:
"""Stop the vacuum cleaner."""
await self.send_device_command(clusters.OperationalState.Commands.Stop())
# We simply set the RvcRunMode to the first runmode
# that has the idle tag to stop the vacuum cleaner.
# this is compatible with both Matter 1.2 and 1.3+ devices.
mode = self._get_run_mode_by_tag(ModeTag.IDLE)
if mode is None:
raise HomeAssistantError(
"No supported run mode found to stop the vacuum cleaner."
)
await self.send_device_command(
clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode)
)
async def async_return_to_base(self, **kwargs: Any) -> None:
"""Set the vacuum cleaner to return to the dock."""
@@ -83,15 +106,31 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
"""Start or resume the cleaning task."""
if TYPE_CHECKING:
assert self._last_accepted_commands is not None
accepted_operational_commands = self._last_accepted_commands
if (
clusters.RvcOperationalState.Commands.Resume.command_id
in self._last_accepted_commands
in accepted_operational_commands
and self.state == VacuumActivity.PAUSED
):
# vacuum is paused and supports resume command
await self.send_device_command(
clusters.RvcOperationalState.Commands.Resume()
)
else:
await self.send_device_command(clusters.OperationalState.Commands.Start())
return
# We simply set the RvcRunMode to the first runmode
# that has the cleaning tag to start the vacuum cleaner.
# this is compatible with both Matter 1.2 and 1.3+ devices.
mode = self._get_run_mode_by_tag(ModeTag.CLEANING)
if mode is None:
raise HomeAssistantError(
"No supported run mode found to start the vacuum cleaner."
)
await self.send_device_command(
clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode)
)
async def async_pause(self) -> None:
"""Pause the cleaning task."""
@@ -130,6 +169,8 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
state = VacuumActivity.CLEANING
elif ModeTag.IDLE in tags:
state = VacuumActivity.IDLE
elif ModeTag.MAPPING in tags:
state = VacuumActivity.CLEANING
self._attr_activity = state
@callback
@@ -143,7 +184,10 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
return
self._last_accepted_commands = accepted_operational_commands
supported_features: VacuumEntityFeature = VacuumEntityFeature(0)
supported_features |= VacuumEntityFeature.START
supported_features |= VacuumEntityFeature.STATE
supported_features |= VacuumEntityFeature.STOP
# optional battery attribute = battery feature
if self.get_matter_attribute_value(
clusters.PowerSource.Attributes.BatPercentRemaining
@@ -153,7 +197,7 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
if self.get_matter_attribute_value(clusters.Identify.Attributes.IdentifyType):
supported_features |= VacuumEntityFeature.LOCATE
# create a map of supported run modes
run_modes: list[clusters.RvcCleanMode.Structs.ModeOptionStruct] = (
run_modes: list[clusters.RvcRunMode.Structs.ModeOptionStruct] = (
self.get_matter_attribute_value(
clusters.RvcRunMode.Attributes.SupportedModes
)
@@ -165,22 +209,6 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
in accepted_operational_commands
):
supported_features |= VacuumEntityFeature.PAUSE
if (
clusters.OperationalState.Commands.Stop.command_id
in accepted_operational_commands
):
supported_features |= VacuumEntityFeature.STOP
if (
clusters.OperationalState.Commands.Start.command_id
in accepted_operational_commands
):
# note that start has been replaced by resume in rev2 of the spec
supported_features |= VacuumEntityFeature.START
if (
clusters.RvcOperationalState.Commands.Resume.command_id
in accepted_operational_commands
):
supported_features |= VacuumEntityFeature.START
if (
clusters.RvcOperationalState.Commands.GoHome.command_id
in accepted_operational_commands
@@ -202,10 +230,7 @@ DISCOVERY_SCHEMAS = [
clusters.RvcRunMode.Attributes.CurrentMode,
clusters.RvcOperationalState.Attributes.OperationalState,
),
optional_attributes=(
clusters.RvcCleanMode.Attributes.CurrentMode,
clusters.PowerSource.Attributes.BatPercentRemaining,
),
optional_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,),
device_type=(device_types.RoboticVacuumCleaner,),
allow_none_value=True,
),
+5 -1
View File
@@ -25,4 +25,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: MeaterConfigEntry) -> bo
async def async_unload_entry(hass: HomeAssistant, entry: MeaterConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[MEATER_DATA] = (
hass.data[MEATER_DATA] - entry.runtime_data.found_probes
)
return unload_ok
@@ -44,6 +44,7 @@ class MeaterCoordinator(DataUpdateCoordinator[dict[str, MeaterProbe]]):
)
session = async_get_clientsession(hass)
self.client = MeaterApi(session)
self.found_probes: set[str] = set()
async def _async_setup(self) -> None:
"""Set up the Meater Coordinator."""
@@ -73,5 +74,6 @@ class MeaterCoordinator(DataUpdateCoordinator[dict[str, MeaterProbe]]):
raise UpdateFailed(
"Too many requests have been made to the API, rate limiting is in place"
) from err
return {device.id: device for device in devices}
res = {device.id: device for device in devices}
self.found_probes.update(set(res.keys()))
return res
@@ -210,10 +210,8 @@ class LocalMediaView(http.HomeAssistantView):
self.hass = hass
self.source = source
async def get(
self, request: web.Request, source_dir_id: str, location: str
) -> web.FileResponse:
"""Start a GET request."""
async def _validate_media_path(self, source_dir_id: str, location: str) -> Path:
"""Validate media path and return it if valid."""
try:
raise_if_invalid_path(location)
except ValueError as err:
@@ -233,6 +231,25 @@ class LocalMediaView(http.HomeAssistantView):
if not mime_type or mime_type.split("/")[0] not in MEDIA_MIME_TYPES:
raise web.HTTPNotFound
return media_path
async def head(
self, request: web.Request, source_dir_id: str, location: str
) -> None:
"""Handle a HEAD request.
This is sent by some DLNA renderers, like Samsung ones, prior to sending
the GET request.
Check whether the location exists or not.
"""
await self._validate_media_path(source_dir_id, location)
async def get(
self, request: web.Request, source_dir_id: str, location: str
) -> web.FileResponse:
"""Handle a GET request."""
media_path = await self._validate_media_path(source_dir_id, location)
return web.FileResponse(media_path)
@@ -41,12 +41,6 @@ class MusicAssistantFavoriteButton(MusicAssistantEntity, ButtonEntity):
translation_key="favorite_now_playing",
)
@property
def available(self) -> bool:
"""Return availability of entity."""
# mark the button as unavailable if the player has no current media item
return super().available and self.player.current_media is not None
@catch_musicassistant_error
async def async_press(self) -> None:
"""Handle the button press command."""
+4 -2
View File
@@ -39,10 +39,12 @@ def _base_schema(
base_schema = {
vol.Optional(CONF_HOST, default=nut_config.get(CONF_HOST) or DEFAULT_HOST): str,
vol.Optional(CONF_PORT, default=nut_config.get(CONF_PORT) or DEFAULT_PORT): int,
vol.Optional(CONF_USERNAME, default=nut_config.get(CONF_USERNAME) or ""): str,
vol.Optional(
CONF_USERNAME, default=nut_config.get(CONF_USERNAME, vol.UNDEFINED)
): str,
vol.Optional(
CONF_PASSWORD,
default=PASSWORD_NOT_CHANGED if use_password_not_changed else "",
default=PASSWORD_NOT_CHANGED if use_password_not_changed else vol.UNDEFINED,
): str,
}
@@ -27,6 +27,7 @@ from .const import (
CONF_NUM_CTX,
CONF_PROMPT,
CONF_THINK,
DEFAULT_NAME,
DEFAULT_TIMEOUT,
DOMAIN,
)
@@ -132,12 +133,19 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
device.id,
remove_config_entry_id=entry.entry_id,
)
else:
device_registry.async_update_device(
device.id,
remove_config_entry_id=entry.entry_id,
remove_config_subentry_id=None,
)
if not use_existing:
await hass.config_entries.async_remove(entry.entry_id)
else:
hass.config_entries.async_update_entry(
entry,
title=DEFAULT_NAME,
options={},
version=2,
)
+2
View File
@@ -2,6 +2,8 @@
DOMAIN = "ollama"
DEFAULT_NAME = "Ollama"
CONF_MODEL = "model"
CONF_PROMPT = "prompt"
CONF_THINK = "think"
@@ -49,6 +49,7 @@ from .const import (
CONF_REASONING_EFFORT,
CONF_TEMPERATURE,
CONF_TOP_P,
DEFAULT_NAME,
DOMAIN,
LOGGER,
RECOMMENDED_CHAT_MODEL,
@@ -345,12 +346,19 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
device.id,
remove_config_entry_id=entry.entry_id,
)
else:
device_registry.async_update_device(
device.id,
remove_config_entry_id=entry.entry_id,
remove_config_subentry_id=None,
)
if not use_existing:
await hass.config_entries.async_remove(entry.entry_id)
else:
hass.config_entries.async_update_entry(
entry,
title=DEFAULT_NAME,
options={},
version=2,
)
@@ -6,12 +6,12 @@ DOMAIN = "openai_conversation"
LOGGER: logging.Logger = logging.getLogger(__package__)
DEFAULT_CONVERSATION_NAME = "OpenAI Conversation"
DEFAULT_NAME = "OpenAI Conversation"
CONF_CHAT_MODEL = "chat_model"
CONF_FILENAMES = "filenames"
CONF_MAX_TOKENS = "max_tokens"
CONF_PROMPT = "prompt"
CONF_PROMPT = "prompt"
CONF_REASONING_EFFORT = "reasoning_effort"
CONF_RECOMMENDED = "recommended"
CONF_TEMPERATURE = "temperature"
@@ -247,7 +247,7 @@ class OpenAIConversationEntity(
identifiers={(DOMAIN, subentry.subentry_id)},
name=subentry.title,
manufacturer="OpenAI",
model=entry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
model=subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
entry_type=dr.DeviceEntryType.SERVICE,
)
if self.subentry.data.get(CONF_LLM_HASS_API):
@@ -2,17 +2,23 @@
"config": {
"step": {
"user": {
"description": "Select the area in which you want to search for water measuring stations",
"data": {
"location": "[%key:common::config_flow::data::location%]",
"radius": "Search radius"
},
"data_description": {
"location": "Pick the location where to search for water measuring stations.",
"radius": "The radius to search for water measuring stations around the selected location."
}
},
"select_station": {
"title": "Select the measuring station to add",
"title": "Select the station to add",
"description": "Found {stations_count} stations in radius",
"data": {
"station": "Station"
},
"data_description": {
"station": "Select the water measuring station you want to add to Home Assistant."
}
}
},
@@ -14,7 +14,7 @@ from psnawp_api.models.user import User
from psnawp_api.utils.misc import parse_npsso_token
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_NAME
from .const import CONF_NPSSO, DOMAIN, NPSSO_LINK, PSN_LINK
@@ -76,13 +76,23 @@ class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN):
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Reconfigure flow for PlayStation Network integration."""
return await self.async_step_reauth_confirm(user_input)
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauthentication dialog."""
errors: dict[str, str] = {}
entry = self._get_reauth_entry()
entry = (
self._get_reauth_entry()
if self.source == SOURCE_REAUTH
else self._get_reconfigure_entry()
)
if user_input is not None:
try:
@@ -113,7 +123,7 @@ class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN):
)
return self.async_show_form(
step_id="reauth_confirm",
step_id="reauth_confirm" if self.source == SOURCE_REAUTH else "reconfigure",
data_schema=self.add_suggested_values_to_schema(
data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input
),
@@ -0,0 +1,55 @@
"""Diagnostics support for PlayStation Network."""
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from psnawp_api.models.trophies import PlatformType
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from .coordinator import PlaystationNetworkConfigEntry, PlaystationNetworkCoordinator
TO_REDACT = {
"account_id",
"firstName",
"lastName",
"middleName",
"onlineId",
"url",
"username",
}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: PlaystationNetworkConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator: PlaystationNetworkCoordinator = entry.runtime_data
return {
"data": async_redact_data(
_serialize_platform_types(asdict(coordinator.data)), TO_REDACT
),
}
def _serialize_platform_types(data: Any) -> Any:
"""Recursively convert PlatformType enums to strings in dicts and sets."""
if isinstance(data, dict):
return {
(
platform.value if isinstance(platform, PlatformType) else platform
): _serialize_platform_types(record)
for platform, record in data.items()
}
if isinstance(data, set):
return [
record.value if isinstance(record, PlatformType) else record
for record in data
]
if isinstance(data, PlatformType):
return data.value
return data
@@ -44,7 +44,7 @@ rules:
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery-update-info:
status: exempt
comment: Discovery flow is not applicable for this integration
@@ -63,7 +63,7 @@ rules:
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
reconfiguration-flow: done
repair-issues: todo
stale-devices: todo
# Platinum
@@ -19,6 +19,16 @@
"data_description": {
"npsso": "[%key:component::playstation_network::config::step::user::data_description::npsso%]"
}
},
"reconfigure": {
"title": "Update PlayStation Network configuration",
"description": "[%key:component::playstation_network::config::step::user::description%]",
"data": {
"npsso": "[%key:component::playstation_network::config::step::user::data::npsso%]"
},
"data_description": {
"npsso": "[%key:component::playstation_network::config::step::user::data_description::npsso%]"
}
}
},
"error": {
@@ -30,7 +40,8 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"unique_id_mismatch": "The provided NPSSO token corresponds to the account {wrong_account}. Please re-authenticate with the account **{name}**"
"unique_id_mismatch": "The provided NPSSO token corresponds to the account {wrong_account}. Please re-authenticate with the account **{name}**",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
},
"exceptions": {
+2 -2
View File
@@ -11,7 +11,7 @@ import xmltodict
from homeassistant.core import HomeAssistant
from homeassistant.helpers import template
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.json import json_dumps
from homeassistant.util.ssl import SSLCipherList
@@ -94,7 +94,7 @@ class RestData:
async def async_update(self, log_errors: bool = True) -> None:
"""Get the latest data from REST service with provided method."""
if not self._session:
self._session = async_get_clientsession(
self._session = async_create_clientsession(
self._hass,
verify_ssl=self._verify_ssl,
ssl_cipher=self._ssl_cipher_list,
+2 -2
View File
@@ -221,8 +221,8 @@ class DimmableRflinkLight(SwitchableRflinkDevice, LightEntity):
elif command in ["off", "alloff"]:
self._state = False
# dimmable device accept 'set_level=(0-15)' commands
elif re.search("^set_level=(0?[0-9]|1[0-5])$", command, re.IGNORECASE):
self._brightness = rflink_to_brightness(int(command.split("=")[1]))
elif match := re.search("^set_level=(0?[0-9]|1[0-5])$", command, re.IGNORECASE):
self._brightness = rflink_to_brightness(int(match.group(1)))
self._state = True
@property
+6 -2
View File
@@ -192,8 +192,12 @@ def async_setup_rpc_attribute_entities(
if description.removal_condition and description.removal_condition(
coordinator.device.config, coordinator.device.status, key
):
domain = sensor_class.__module__.split(".")[-1]
unique_id = f"{coordinator.mac}-{key}-{sensor_id}"
entity_class = get_entity_class(sensor_class, description)
domain = entity_class.__module__.split(".")[-1]
unique_id = entity_class(
coordinator, key, sensor_id, description
).unique_id
LOGGER.debug("Removing Shelly entity with unique_id: %s", unique_id)
async_remove_shelly_entity(hass, domain, unique_id)
elif description.use_polling_coordinator:
if not sleep_period:
+2 -2
View File
@@ -5,7 +5,7 @@ from pysmarlaapi import Connection, Federwiege
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.exceptions import ConfigEntryError
from .const import HOST, PLATFORMS
@@ -18,7 +18,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FederwiegeConfigEntry) -
# Check if token still has access
if not await connection.refresh_token():
raise ConfigEntryAuthFailed("Invalid authentication")
raise ConfigEntryError("Invalid authentication")
federwiege = Federwiege(hass.loop, connection)
federwiege.register()
+65 -3
View File
@@ -24,6 +24,20 @@ from .speaker import SonosSpeaker
_LOGGER = logging.getLogger(__name__)
SONOS_POWER_SOURCE_BATTERY = "BATTERY"
SONOS_POWER_SOURCE_CHARGING_RING = "SONOS_CHARGING_RING"
SONOS_POWER_SOURCE_USB = "USB_POWER"
HA_POWER_SOURCE_BATTERY = "battery"
HA_POWER_SOURCE_CHARGING_BASE = "charging_base"
HA_POWER_SOURCE_USB = "usb"
power_source_map = {
SONOS_POWER_SOURCE_BATTERY: HA_POWER_SOURCE_BATTERY,
SONOS_POWER_SOURCE_CHARGING_RING: HA_POWER_SOURCE_CHARGING_BASE,
SONOS_POWER_SOURCE_USB: HA_POWER_SOURCE_USB,
}
async def async_setup_entry(
hass: HomeAssistant,
@@ -42,9 +56,15 @@ async def async_setup_entry(
@callback
def _async_create_battery_sensor(speaker: SonosSpeaker) -> None:
_LOGGER.debug("Creating battery level sensor on %s", speaker.zone_name)
entity = SonosBatteryEntity(speaker, config_entry)
async_add_entities([entity])
_LOGGER.debug(
"Creating battery level and power source sensor on %s", speaker.zone_name
)
async_add_entities(
[
SonosBatteryEntity(speaker, config_entry),
SonosPowerSourceEntity(speaker, config_entry),
]
)
@callback
def _async_create_favorites_sensor(favorites: SonosFavorites) -> None:
@@ -101,6 +121,48 @@ class SonosBatteryEntity(SonosEntity, SensorEntity):
return self.speaker.available and self.speaker.power_source is not None
class SonosPowerSourceEntity(SonosEntity, SensorEntity):
"""Representation of a Sonos Power Source entity."""
_attr_device_class = SensorDeviceClass.ENUM
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_entity_registry_enabled_default = False
_attr_options = [
HA_POWER_SOURCE_BATTERY,
HA_POWER_SOURCE_CHARGING_BASE,
HA_POWER_SOURCE_USB,
]
_attr_translation_key = "power_source"
def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> None:
"""Initialize the power source sensor."""
super().__init__(speaker, config_entry)
self._attr_unique_id = f"{self.soco.uid}-power_source"
async def _async_fallback_poll(self) -> None:
"""Poll the device for the current state."""
await self.speaker.async_poll_battery()
@property
def native_value(self) -> str | None:
"""Return the state of the sensor."""
if not (power_source := self.speaker.power_source):
return None
if not (value := power_source_map.get(power_source)):
_LOGGER.warning(
"Unknown power source '%s' for speaker %s",
power_source,
self.speaker.zone_name,
)
return None
return value
@property
def available(self) -> bool:
"""Return whether this entity is available."""
return self.speaker.available and self.speaker.power_source is not None
class SonosAudioInputFormatSensorEntity(SonosPollingEntity, SensorEntity):
"""Representation of a Sonos audio import format sensor entity."""
@@ -53,6 +53,14 @@
"sensor": {
"audio_input_format": {
"name": "Audio input format"
},
"power_source": {
"name": "Power source",
"state": {
"battery": "Battery",
"charging_base": "Charging base",
"usb": "USB"
}
}
},
"switch": {
+2 -2
View File
@@ -102,11 +102,11 @@
"services": {
"unlock_specific_door": {
"name": "Unlock specific door",
"description": "Unlocks specific door(s).",
"description": "Unlocks the driver door, all doors, or the tailgate.",
"fields": {
"door": {
"name": "Door",
"description": "Which door(s) to open."
"description": "The specific door(s) to unlock."
}
}
}
@@ -153,7 +153,12 @@ async def make_device_data(
)
devices_data.vacuums.append((device, coordinator))
if isinstance(device, Device) and device.device_type.startswith("Smart Lock"):
if isinstance(device, Device) and device.device_type in [
"Smart Lock",
"Smart Lock Lite",
"Smart Lock Pro",
"Smart Lock Ultra",
]:
coordinator = await coordinator_for_device(
hass, entry, api, device, coordinators_by_id
)
@@ -48,10 +48,18 @@ BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = {
CALIBRATION_DESCRIPTION,
DOOR_OPEN_DESCRIPTION,
),
"Smart Lock Lite": (
CALIBRATION_DESCRIPTION,
DOOR_OPEN_DESCRIPTION,
),
"Smart Lock Pro": (
CALIBRATION_DESCRIPTION,
DOOR_OPEN_DESCRIPTION,
),
"Smart Lock Ultra": (
CALIBRATION_DESCRIPTION,
DOOR_OPEN_DESCRIPTION,
),
}
@@ -69,7 +77,6 @@ async def async_setup_entry(
for description in BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES[
device.device_type
]
if device.device_type in BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES
)
@@ -134,8 +134,10 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = {
BATTERY_DESCRIPTION,
CO2_DESCRIPTION,
),
"Smart Lock Pro": (BATTERY_DESCRIPTION,),
"Smart Lock": (BATTERY_DESCRIPTION,),
"Smart Lock Lite": (BATTERY_DESCRIPTION,),
"Smart Lock Pro": (BATTERY_DESCRIPTION,),
"Smart Lock Ultra": (BATTERY_DESCRIPTION,),
}
@@ -151,7 +153,6 @@ async def async_setup_entry(
SwitchBotCloudSensor(data.api, device, coordinator, description)
for device, coordinator in data.devices.sensors
for description in SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES[device.device_type]
if device.device_type in SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES
)
@@ -231,7 +231,7 @@ async def handle_info(
"Error fetching system info for %s - %s",
domain,
key,
exc_info=(type(exception), exception, exception.__traceback__), # noqa: LOG014
exc_info=(type(exception), exception, exception.__traceback__),
)
event_msg["success"] = False
event_msg["error"] = {"type": "failed", "error": "unknown"}
@@ -29,6 +29,7 @@ from homeassistant.core import (
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
ServiceValidationError,
)
from homeassistant.helpers import config_validation as cv
@@ -390,9 +391,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
elif msgtype == SERVICE_DELETE_MESSAGE:
await notify_service.delete_message(context=service.context, **kwargs)
elif msgtype == SERVICE_LEAVE_CHAT:
messages = await notify_service.leave_chat(
context=service.context, **kwargs
)
await notify_service.leave_chat(context=service.context, **kwargs)
elif msgtype == SERVICE_SET_MESSAGE_REACTION:
await notify_service.set_message_reaction(context=service.context, **kwargs)
else:
@@ -400,12 +399,29 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
msgtype, context=service.context, **kwargs
)
if service.return_response and messages:
if service.return_response and messages is not None:
target: list[int] | None = service.data.get(ATTR_TARGET)
if not target:
target = notify_service.get_target_chat_ids(None)
failed_chat_ids = [chat_id for chat_id in target if chat_id not in messages]
if failed_chat_ids:
raise HomeAssistantError(
f"Failed targets: {failed_chat_ids}",
translation_domain=DOMAIN,
translation_key="failed_chat_ids",
translation_placeholders={
"chat_ids": ", ".join([str(i) for i in failed_chat_ids]),
"bot_name": config_entry.title,
},
)
return {
"chats": [
{"chat_id": cid, "message_id": mid} for cid, mid in messages.items()
]
}
return None
# Register notification services
+33 -29
View File
@@ -287,24 +287,32 @@ class TelegramNotificationService:
inline_message_id = msg_data["inline_message_id"]
return message_id, inline_message_id
def _get_target_chat_ids(self, target: Any) -> list[int]:
def get_target_chat_ids(self, target: int | list[int] | None) -> list[int]:
"""Validate chat_id targets or return default target (first).
:param target: optional list of integers ([12234, -12345])
:return list of chat_id targets (integers)
"""
allowed_chat_ids: list[int] = self._get_allowed_chat_ids()
default_user: int = allowed_chat_ids[0]
if target is not None:
if isinstance(target, int):
target = [target]
chat_ids = [t for t in target if t in allowed_chat_ids]
if chat_ids:
return chat_ids
_LOGGER.warning(
"Disallowed targets: %s, using default: %s", target, default_user
if target is None:
return [allowed_chat_ids[0]]
chat_ids = [target] if isinstance(target, int) else target
valid_chat_ids = [
chat_id for chat_id in chat_ids if chat_id in allowed_chat_ids
]
if not valid_chat_ids:
raise ServiceValidationError(
"Invalid chat IDs",
translation_domain=DOMAIN,
translation_key="invalid_chat_ids",
translation_placeholders={
"chat_ids": ", ".join(str(chat_id) for chat_id in chat_ids),
"bot_name": self.config.title,
},
)
return [default_user]
return valid_chat_ids
def _get_msg_kwargs(self, data: dict[str, Any]) -> dict[str, Any]:
"""Get parameters in message data kwargs."""
@@ -414,9 +422,9 @@ class TelegramNotificationService:
"""Send one message."""
try:
out = await func_send(*args_msg, **kwargs_msg)
if not isinstance(out, bool) and hasattr(out, ATTR_MESSAGEID):
if isinstance(out, Message):
chat_id = out.chat_id
message_id = out[ATTR_MESSAGEID]
message_id = out.message_id
self._last_message_id[chat_id] = message_id
_LOGGER.debug(
"Last message ID: %s (from chat_id %s)",
@@ -424,7 +432,7 @@ class TelegramNotificationService:
chat_id,
)
event_data = {
event_data: dict[str, Any] = {
ATTR_CHAT_ID: chat_id,
ATTR_MESSAGEID: message_id,
}
@@ -437,10 +445,6 @@ class TelegramNotificationService:
self.hass.bus.async_fire(
EVENT_TELEGRAM_SENT, event_data, context=context
)
elif not isinstance(out, bool):
_LOGGER.warning(
"Update last message: out_type:%s, out=%s", type(out), out
)
except TelegramError as exc:
_LOGGER.error(
"%s: %s. Args: %s, kwargs: %s", msg_error, exc, args_msg, kwargs_msg
@@ -460,7 +464,7 @@ class TelegramNotificationService:
text = f"{title}\n{message}" if title else message
params = self._get_msg_kwargs(kwargs)
msg_ids = {}
for chat_id in self._get_target_chat_ids(target):
for chat_id in self.get_target_chat_ids(target):
_LOGGER.debug("Send message in chat ID %s with params: %s", chat_id, params)
msg = await self._send_msg(
self.bot.send_message,
@@ -488,7 +492,7 @@ class TelegramNotificationService:
**kwargs: dict[str, Any],
) -> bool:
"""Delete a previously sent message."""
chat_id = self._get_target_chat_ids(chat_id)[0]
chat_id = self.get_target_chat_ids(chat_id)[0]
message_id, _ = self._get_msg_ids(kwargs, chat_id)
_LOGGER.debug("Delete message %s in chat ID %s", message_id, chat_id)
deleted: bool = await self._send_msg(
@@ -513,7 +517,7 @@ class TelegramNotificationService:
**kwargs: dict[str, Any],
) -> Any:
"""Edit a previously sent message."""
chat_id = self._get_target_chat_ids(chat_id)[0]
chat_id = self.get_target_chat_ids(chat_id)[0]
message_id, inline_message_id = self._get_msg_ids(kwargs, chat_id)
params = self._get_msg_kwargs(kwargs)
_LOGGER.debug(
@@ -620,7 +624,7 @@ class TelegramNotificationService:
msg_ids = {}
if file_content:
for chat_id in self._get_target_chat_ids(target):
for chat_id in self.get_target_chat_ids(target):
_LOGGER.debug("Sending file to chat ID %s", chat_id)
if file_type == SERVICE_SEND_PHOTO:
@@ -738,7 +742,7 @@ class TelegramNotificationService:
msg_ids = {}
if stickerid:
for chat_id in self._get_target_chat_ids(target):
for chat_id in self.get_target_chat_ids(target):
msg = await self._send_msg(
self.bot.send_sticker,
"Error sending sticker",
@@ -769,7 +773,7 @@ class TelegramNotificationService:
longitude = float(longitude)
params = self._get_msg_kwargs(kwargs)
msg_ids = {}
for chat_id in self._get_target_chat_ids(target):
for chat_id in self.get_target_chat_ids(target):
_LOGGER.debug(
"Send location %s/%s to chat ID %s", latitude, longitude, chat_id
)
@@ -803,7 +807,7 @@ class TelegramNotificationService:
params = self._get_msg_kwargs(kwargs)
openperiod = kwargs.get(ATTR_OPEN_PERIOD)
msg_ids = {}
for chat_id in self._get_target_chat_ids(target):
for chat_id in self.get_target_chat_ids(target):
_LOGGER.debug("Send poll '%s' to chat ID %s", question, chat_id)
msg = await self._send_msg(
self.bot.send_poll,
@@ -826,12 +830,12 @@ class TelegramNotificationService:
async def leave_chat(
self,
chat_id: Any = None,
chat_id: int | None = None,
context: Context | None = None,
**kwargs: dict[str, Any],
) -> Any:
"""Remove bot from chat."""
chat_id = self._get_target_chat_ids(chat_id)[0]
chat_id = self.get_target_chat_ids(chat_id)[0]
_LOGGER.debug("Leave from chat ID %s", chat_id)
return await self._send_msg(
self.bot.leave_chat, "Error leaving chat", None, chat_id, context=context
@@ -839,14 +843,14 @@ class TelegramNotificationService:
async def set_message_reaction(
self,
chat_id: int,
reaction: str,
chat_id: int | None = None,
is_big: bool = False,
context: Context | None = None,
**kwargs: dict[str, Any],
) -> None:
"""Set the bot's reaction for a given message."""
chat_id = self._get_target_chat_ids(chat_id)[0]
chat_id = self.get_target_chat_ids(chat_id)[0]
message_id, _ = self._get_msg_ids(kwargs, chat_id)
params = self._get_msg_kwargs(kwargs)
@@ -22,7 +22,7 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_API_KEY, CONF_PLATFORM, CONF_URL
from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.data_entry_flow import AbortFlow, section
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.network import NoURLAvailableError, get_url
@@ -58,6 +58,7 @@ from .const import (
PLATFORM_BROADCAST,
PLATFORM_POLLING,
PLATFORM_WEBHOOKS,
SECTION_ADVANCED_SETTINGS,
SUBENTRY_TYPE_ALLOWED_CHAT_IDS,
)
@@ -81,8 +82,15 @@ STEP_USER_DATA_SCHEMA: vol.Schema = vol.Schema(
autocomplete="current-password",
)
),
vol.Optional(CONF_PROXY_URL): TextSelector(
config=TextSelectorConfig(type=TextSelectorType.URL)
vol.Required(SECTION_ADVANCED_SETTINGS): section(
vol.Schema(
{
vol.Optional(CONF_PROXY_URL): TextSelector(
config=TextSelectorConfig(type=TextSelectorType.URL)
),
},
),
{"collapsed": True},
),
}
)
@@ -98,8 +106,15 @@ STEP_RECONFIGURE_USER_DATA_SCHEMA: vol.Schema = vol.Schema(
translation_key="platforms",
)
),
vol.Optional(CONF_PROXY_URL): TextSelector(
config=TextSelectorConfig(type=TextSelectorType.URL)
vol.Required(SECTION_ADVANCED_SETTINGS): section(
vol.Schema(
{
vol.Optional(CONF_PROXY_URL): TextSelector(
config=TextSelectorConfig(type=TextSelectorType.URL)
),
},
),
{"collapsed": True},
),
}
)
@@ -197,6 +212,9 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
import_data[CONF_TRUSTED_NETWORKS] = ",".join(
import_data[CONF_TRUSTED_NETWORKS]
)
import_data[SECTION_ADVANCED_SETTINGS] = {
CONF_PROXY_URL: import_data.get(CONF_PROXY_URL)
}
try:
config_flow_result: ConfigFlowResult = await self.async_step_user(
import_data
@@ -293,10 +311,15 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle a flow to create a new config entry for a Telegram bot."""
description_placeholders: dict[str, str] = {
"botfather_username": "@BotFather",
"botfather_url": "https://t.me/botfather",
}
if not user_input:
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
description_placeholders=description_placeholders,
)
# prevent duplicates
@@ -305,7 +328,6 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
# validate connection to Telegram API
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {}
bot_name = await self._validate_bot(
user_input, errors, description_placeholders
)
@@ -328,7 +350,7 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
data={
CONF_PLATFORM: user_input[CONF_PLATFORM],
CONF_API_KEY: user_input[CONF_API_KEY],
CONF_PROXY_URL: user_input.get(CONF_PROXY_URL),
CONF_PROXY_URL: user_input["advanced_settings"].get(CONF_PROXY_URL),
},
options={
# this value may come from yaml import
@@ -390,12 +412,20 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle config flow for webhook Telegram bot."""
if not user_input:
default_trusted_networks = ",".join(
[str(network) for network in DEFAULT_TRUSTED_NETWORKS]
)
if self.source == SOURCE_RECONFIGURE:
suggested_values = dict(self._get_reconfigure_entry().data)
if CONF_TRUSTED_NETWORKS not in self._get_reconfigure_entry().data:
suggested_values[CONF_TRUSTED_NETWORKS] = default_trusted_networks
return self.async_show_form(
step_id="webhooks",
data_schema=self.add_suggested_values_to_schema(
STEP_WEBHOOKS_DATA_SCHEMA,
self._get_reconfigure_entry().data,
suggested_values,
),
)
@@ -404,9 +434,7 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=self.add_suggested_values_to_schema(
STEP_WEBHOOKS_DATA_SCHEMA,
{
CONF_TRUSTED_NETWORKS: ",".join(
[str(network) for network in DEFAULT_TRUSTED_NETWORKS]
),
CONF_TRUSTED_NETWORKS: default_trusted_networks,
},
),
)
@@ -440,7 +468,9 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
data={
CONF_PLATFORM: self._step_user_data[CONF_PLATFORM],
CONF_API_KEY: self._step_user_data[CONF_API_KEY],
CONF_PROXY_URL: self._step_user_data.get(CONF_PROXY_URL),
CONF_PROXY_URL: self._step_user_data[SECTION_ADVANCED_SETTINGS].get(
CONF_PROXY_URL
),
CONF_URL: user_input.get(CONF_URL),
CONF_TRUSTED_NETWORKS: user_input[CONF_TRUSTED_NETWORKS],
},
@@ -455,12 +485,8 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders: dict[str, str],
) -> None:
# validate URL
if CONF_URL in user_input and not user_input[CONF_URL].startswith("https"):
errors["base"] = "invalid_url"
description_placeholders[ERROR_FIELD] = "URL"
description_placeholders[ERROR_MESSAGE] = "URL must start with https"
return
if CONF_URL not in user_input:
url: str | None = user_input.get(CONF_URL)
if url is None:
try:
get_url(self.hass, require_ssl=True, allow_internal=False)
except NoURLAvailableError:
@@ -470,6 +496,11 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
"URL is required since you have not configured an external URL in Home Assistant"
)
return
elif not url.startswith("https"):
errors["base"] = "invalid_url"
description_placeholders[ERROR_FIELD] = "URL"
description_placeholders[ERROR_MESSAGE] = "URL must start with https"
return
# validate trusted networks
csv_trusted_networks: list[str] = []
@@ -505,9 +536,19 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
STEP_RECONFIGURE_USER_DATA_SCHEMA,
self._get_reconfigure_entry().data,
{
**self._get_reconfigure_entry().data,
SECTION_ADVANCED_SETTINGS: {
CONF_PROXY_URL: self._get_reconfigure_entry().data.get(
CONF_PROXY_URL
),
},
},
),
)
user_input[CONF_PROXY_URL] = user_input[SECTION_ADVANCED_SETTINGS].get(
CONF_PROXY_URL
)
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {}
@@ -523,7 +564,12 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
STEP_RECONFIGURE_USER_DATA_SCHEMA,
user_input,
{
**user_input,
SECTION_ADVANCED_SETTINGS: {
CONF_PROXY_URL: user_input.get(CONF_PROXY_URL),
},
},
),
errors=errors,
description_placeholders=description_placeholders,
@@ -7,7 +7,7 @@ DOMAIN = "telegram_bot"
PLATFORM_BROADCAST = "broadcast"
PLATFORM_POLLING = "polling"
PLATFORM_WEBHOOKS = "webhooks"
SECTION_ADVANCED_SETTINGS = "advanced_settings"
SUBENTRY_TYPE_ALLOWED_CHAT_IDS = "allowed_chat_ids"
CONF_BOT_COUNT = "bot_count"
@@ -1,7 +1,7 @@
{
"domain": "telegram_bot",
"name": "Telegram bot",
"codeowners": [],
"codeowners": ["@hanwg"],
"config_flow": true,
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/telegram_bot",
@@ -2,17 +2,25 @@
"config": {
"step": {
"user": {
"title": "Telegram bot setup",
"description": "Create a new Telegram bot",
"description": "To create a Telegram bot, follow these steps:\n\n1. Open Telegram and start a chat with [{botfather_username}]({botfather_url}).\n1. Send the command `/newbot`.\n1. Follow the instructions to create your bot and get your API token.",
"data": {
"platform": "Platform",
"api_key": "[%key:common::config_flow::data::api_key%]",
"proxy_url": "Proxy URL"
"api_key": "[%key:common::config_flow::data::api_token%]"
},
"data_description": {
"platform": "Telegram bot implementation",
"api_key": "The API token of your bot.",
"proxy_url": "Proxy URL if working behind one, optionally including username and password.\n(socks5://username:password@proxy_ip:proxy_port)"
"api_key": "The API token of your bot."
},
"sections": {
"advanced_settings": {
"name": "Advanced settings",
"data": {
"proxy_url": "Proxy URL"
},
"data_description": {
"proxy_url": "Proxy URL if working behind one, optionally including username and password.\n(socks5://username:password@proxy_ip:proxy_port)"
}
}
}
},
"webhooks": {
@@ -30,12 +38,21 @@
"title": "Telegram bot setup",
"description": "Reconfigure Telegram bot",
"data": {
"platform": "[%key:component::telegram_bot::config::step::user::data::platform%]",
"proxy_url": "[%key:component::telegram_bot::config::step::user::data::proxy_url%]"
"platform": "[%key:component::telegram_bot::config::step::user::data::platform%]"
},
"data_description": {
"platform": "[%key:component::telegram_bot::config::step::user::data_description::platform%]",
"proxy_url": "[%key:component::telegram_bot::config::step::user::data_description::proxy_url%]"
"platform": "[%key:component::telegram_bot::config::step::user::data_description::platform%]"
},
"sections": {
"advanced_settings": {
"name": "[%key:component::telegram_bot::config::step::user::sections::advanced_settings::name%]",
"data": {
"proxy_url": "[%key:component::telegram_bot::config::step::user::sections::advanced_settings::data::proxy_url%]"
},
"data_description": {
"proxy_url": "[%key:component::telegram_bot::config::step::user::sections::advanced_settings::data_description::proxy_url%]"
}
}
}
},
"reauth_confirm": {
@@ -895,6 +912,12 @@
"missing_allowed_chat_ids": {
"message": "No allowed chat IDs found. Please add allowed chat IDs for {bot_name}."
},
"invalid_chat_ids": {
"message": "Invalid chat IDs: {chat_ids}. Please configure the chat IDs for {bot_name}."
},
"failed_chat_ids": {
"message": "Failed targets: {chat_ids}. Please verify that the chat IDs for {bot_name} have been configured."
},
"missing_input": {
"message": "{field} is required."
},
@@ -194,14 +194,14 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]):
except TeslaFleetError as e:
raise UpdateFailed(e.message) from e
if not data or not isinstance(data.get("time_series"), list):
raise UpdateFailed("Received invalid data")
# Add all time periods together
output = dict.fromkeys(ENERGY_HISTORY_FIELDS, None)
for period in data.get("time_series", []):
output = dict.fromkeys(ENERGY_HISTORY_FIELDS, 0)
for period in data["time_series"]:
for key in ENERGY_HISTORY_FIELDS:
if key in period:
if output[key] is None:
output[key] = period[key]
else:
output[key] += period[key]
output[key] += period[key]
return output
@@ -21,7 +21,7 @@
"entity": {
"button": {
"set_datetime": {
"name": "Set Date&Time"
"name": "Set date & time"
}
}
}
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/tibber",
"iot_class": "cloud_polling",
"loggers": ["tibber"],
"requirements": ["pyTibber==0.31.2"]
"requirements": ["pyTibber==0.31.6"]
}
+1 -1
View File
@@ -280,7 +280,7 @@ async def async_setup_entry(
except TimeoutError as err:
_LOGGER.error("Timeout connecting to Tibber home: %s ", err)
raise PlatformNotReady from err
except aiohttp.ClientError as err:
except (tibber.RetryableHttpExceptionError, aiohttp.ClientError) as err:
_LOGGER.error("Error connecting to Tibber home: %s ", err)
raise PlatformNotReady from err
+15
View File
@@ -1185,6 +1185,21 @@ class TextToSpeechView(HomeAssistantView):
"""Initialize a tts view."""
self.manager = manager
async def head(self, request: web.Request, token: str) -> web.StreamResponse:
"""Start a HEAD request.
This is sent by some DLNA renderers, like Samsung ones, prior to sending
the GET request.
Check whether the token (file) exists and return its content type.
"""
stream = self.manager.token_to_stream.get(token)
if stream is None:
return web.Response(status=HTTPStatus.NOT_FOUND)
return web.Response(content_type=stream.content_type)
async def get(self, request: web.Request, token: str) -> web.StreamResponse:
"""Start a get request."""
stream = self.manager.token_to_stream.get(token)
@@ -8,7 +8,6 @@ import logging
from aiohttp.client_exceptions import ServerDisconnectedError
from uiprotect.api import DEVICE_UPDATE_INTERVAL
from uiprotect.data import Bootstrap
from uiprotect.data.types import FirmwareReleaseChannel
from uiprotect.exceptions import ClientError, NotAuthorized
# Import the test_util.anonymize module from the uiprotect package
@@ -16,6 +15,7 @@ from uiprotect.exceptions import ClientError, NotAuthorized
# diagnostics module will not be imported in the executor.
from uiprotect.test_util.anonymize import anonymize_data # noqa: F401
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
@@ -58,10 +58,6 @@ SCAN_INTERVAL = timedelta(seconds=DEVICE_UPDATE_INTERVAL)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
EARLY_ACCESS_URL = (
"https://www.home-assistant.io/integrations/unifiprotect#software-support"
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the UniFi Protect."""
@@ -123,47 +119,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool:
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, data_service.async_stop)
)
if not entry.options.get(CONF_ALLOW_EA, False) and (
await nvr_info.get_is_prerelease()
or nvr_info.release_channel != FirmwareReleaseChannel.RELEASE
):
ir.async_create_issue(
hass,
DOMAIN,
"ea_channel_warning",
is_fixable=True,
is_persistent=False,
learn_more_url=EARLY_ACCESS_URL,
severity=IssueSeverity.WARNING,
translation_key="ea_channel_warning",
translation_placeholders={"version": str(nvr_info.version)},
data={"entry_id": entry.entry_id},
)
try:
await _async_setup_entry(hass, entry, data_service, bootstrap)
except Exception as err:
if await nvr_info.get_is_prerelease():
# If they are running a pre-release, its quite common for setup
# to fail so we want to create a repair issue for them so its
# obvious what the problem is.
ir.async_create_issue(
hass,
DOMAIN,
f"ea_setup_failed_{nvr_info.version}",
is_fixable=False,
is_persistent=False,
learn_more_url="https://www.home-assistant.io/integrations/unifiprotect#about-unifi-early-access",
severity=IssueSeverity.ERROR,
translation_key="ea_setup_failed",
translation_placeholders={
"error": str(err),
"version": str(nvr_info.version),
},
)
ir.async_delete_issue(hass, DOMAIN, "ea_channel_warning")
_LOGGER.exception("Error setting up UniFi Protect integration")
raise
await _async_setup_entry(hass, entry, data_service, bootstrap)
return True
@@ -211,3 +167,23 @@ async def async_remove_config_entry_device(
if device.is_adopted_by_us and device.mac in unifi_macs:
return False
return True
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate entry."""
_LOGGER.debug("Migrating configuration from version %s", entry.version)
if entry.version > 1:
return False
if entry.version == 1:
options = dict(entry.options)
if CONF_ALLOW_EA in options:
options.pop(CONF_ALLOW_EA)
hass.config_entries.async_update_entry(
entry, unique_id=str(entry.unique_id), version=2, options=options
)
_LOGGER.debug("Migration to configuration version %s successful", entry.version)
return True
@@ -44,7 +44,6 @@ from homeassistant.util.network import is_ip_address
from .const import (
CONF_ALL_UPDATES,
CONF_ALLOW_EA,
CONF_DISABLE_RTSP,
CONF_MAX_MEDIA,
CONF_OVERRIDE_CHOST,
@@ -238,7 +237,6 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_ALL_UPDATES: False,
CONF_OVERRIDE_CHOST: False,
CONF_MAX_MEDIA: DEFAULT_MAX_MEDIA,
CONF_ALLOW_EA: False,
},
)
@@ -408,10 +406,6 @@ class OptionsFlowHandler(OptionsFlow):
CONF_MAX_MEDIA, DEFAULT_MAX_MEDIA
),
): vol.All(vol.Coerce(int), vol.Range(min=100, max=10000)),
vol.Optional(
CONF_ALLOW_EA,
default=self.config_entry.options.get(CONF_ALLOW_EA, False),
): bool,
}
),
)
@@ -6,7 +6,6 @@ from typing import cast
from uiprotect import ProtectApiClient
from uiprotect.data import Bootstrap, Camera, ModelType
from uiprotect.data.types import FirmwareReleaseChannel
import voluptuous as vol
from homeassistant import data_entry_flow
@@ -15,7 +14,6 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import issue_registry as ir
from .const import CONF_ALLOW_EA
from .data import UFPConfigEntry, async_get_data_for_entry_id
from .utils import async_create_api_client
@@ -45,52 +43,6 @@ class ProtectRepair(RepairsFlow):
return description_placeholders
class EAConfirmRepair(ProtectRepair):
"""Handler for an issue fixing flow."""
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_start()
async def async_step_start(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the confirm step of a fix flow."""
if user_input is None:
placeholders = self._async_get_placeholders()
return self.async_show_form(
step_id="start",
data_schema=vol.Schema({}),
description_placeholders=placeholders,
)
nvr = await self._api.get_nvr()
if nvr.release_channel != FirmwareReleaseChannel.RELEASE:
return await self.async_step_confirm()
await self.hass.config_entries.async_reload(self._entry.entry_id)
return self.async_create_entry(data={})
async def async_step_confirm(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the confirm step of a fix flow."""
if user_input is not None:
options = dict(self._entry.options)
options[CONF_ALLOW_EA] = True
self.hass.config_entries.async_update_entry(self._entry, options=options)
return self.async_create_entry(data={})
placeholders = self._async_get_placeholders()
return self.async_show_form(
step_id="confirm",
data_schema=vol.Schema({}),
description_placeholders=placeholders,
)
class CloudAccountRepair(ProtectRepair):
"""Handler for an issue fixing flow."""
@@ -242,8 +194,6 @@ async def async_create_fix_flow(
and (entry := hass.config_entries.async_get_entry(cast(str, data["entry_id"])))
):
api = _async_get_or_create_api_client(hass, entry)
if issue_id == "ea_channel_warning":
return EAConfirmRepair(api=api, entry=entry)
if issue_id == "cloud_user":
return CloudAccountRepair(api=api, entry=entry)
if issue_id.startswith("rtsp_disabled_"):
@@ -55,32 +55,12 @@
"disable_rtsp": "Disable the RTSP stream",
"all_updates": "Realtime metrics (WARNING: Greatly increases CPU usage)",
"override_connection_host": "Override connection host",
"max_media": "Max number of event to load for Media Browser (increases RAM usage)",
"allow_ea_channel": "Allow Early Access versions of Protect (WARNING: Will mark your integration as unsupported)"
"max_media": "Max number of event to load for Media Browser (increases RAM usage)"
}
}
}
},
"issues": {
"ea_channel_warning": {
"title": "UniFi Protect Early Access enabled",
"fix_flow": {
"step": {
"start": {
"title": "UniFi Protect Early Access enabled",
"description": "You are either running an Early Access version of UniFi Protect (v{version}) or opt-ed into a release channel that is not the official release channel.\n\nAs these Early Access releases may not be tested yet, using it may cause the UniFi Protect integration to behave unexpectedly. [Read more about Early Access and Home Assistant]({learn_more}).\n\nSubmit to dismiss this message."
},
"confirm": {
"title": "[%key:component::unifiprotect::issues::ea_channel_warning::fix_flow::step::start::title%]",
"description": "Are you sure you want to run unsupported versions of UniFi Protect? This may cause your Home Assistant integration to break."
}
}
}
},
"ea_setup_failed": {
"title": "Setup error using Early Access version",
"description": "You are using v{version} of UniFi Protect which is an Early Access version. An unrecoverable error occurred while trying to load the integration. Please restore a backup of a stable release of UniFi Protect to continue using the integration.\n\nError: {error}"
},
"cloud_user": {
"title": "Ubiquiti Cloud Users are not Supported",
"fix_flow": {

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