Compare commits

...

90 Commits

Author SHA1 Message Date
Franck Nijhof
fc8f8b39b4 2025.11.3 (#157006) 2025-11-21 18:02:26 +01:00
Franck Nijhof
ec0918027e Bump version to 2025.11.3 2025-11-21 16:27:45 +00:00
Joost Lekkerkerker
8a54f8d4e2 Throttle Decora wifi updates (#156994) 2025-11-21 16:26:49 +00:00
Bram Kragten
5c27126b6d Update frontend to 20251105.1 (#156992) 2025-11-21 16:26:47 +00:00
Robert Resch
e069aff0e2 Bump go2rtc to 1.9.12 and go2rtc-client to 0.3.0 (#156948) 2025-11-21 16:26:46 +00:00
Timothy
733526fae3 Rework CloudhookURL setup for mobile app (#156940) 2025-11-21 16:26:45 +00:00
Sebastian Schneider
1ef001f8e9 Bump aiounifi to 88 (#156867) 2025-11-21 16:26:43 +00:00
Josef Zweck
7732377fde Bump onedrive-personal-sdk to 0.0.17 (#156865) 2025-11-21 16:26:42 +00:00
puddly
b7786e589b Bump universal-silabs-flasher to 0.1.2 (#156849) 2025-11-21 16:26:41 +00:00
Joost Lekkerkerker
4f60970a91 Bump pySmartThings to 3.3.4 (#156830) 2025-11-21 16:26:40 +00:00
Thomas55555
1c1286dd57 Bump aioautomower to 2.7.1 (#156826) 2025-11-21 16:26:39 +00:00
Copilot
41c9f08f60 Fix hvv_departures to pass config_entry explicitly to DataUpdateCoordinator (#156794)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: joostlek <7083755+joostlek@users.noreply.github.com>
2025-11-21 16:26:37 +00:00
Josef Zweck
fc4bfab0f7 Lamarzocco fix websocket reconnect issue (#156786)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2025-11-21 16:26:36 +00:00
epenet
769a12f74e Fix blocking call in cync (#156782) 2025-11-21 16:26:35 +00:00
Dan Raper
dabaa2bc5e Bump ohmepy and remove advanced_settings_coordinator (#156764) 2025-11-21 16:26:34 +00:00
Jan Bouwhuis
b674828a91 Fix missing temperature_delta device class translations (#156685) 2025-11-21 16:26:32 +00:00
Jan Bouwhuis
761da66658 Fix missing description placeholders in MQTT subentry flow (#156684) 2025-11-21 16:26:31 +00:00
MarkGodwin
c8aba62301 Bump tplink-omada-api to 1.5.3 (#156645) 2025-11-21 16:26:30 +00:00
Robert Resch
07ab2e6805 Bump async-upnp-client to 0.46.0 (#156622) 2025-11-21 16:26:28 +00:00
Fredrik Mårtensson
f62e0c8c08 Fix is_matching in samsungtv config flow (#156594)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2025-11-21 16:26:27 +00:00
PaulCavill
6ca00f9dbb Bump pyiCloud to 2.2.0 (#156485) 2025-11-21 16:26:25 +00:00
Jamin
0fba80e30f Reset state on error during VOIP announcement (#156384) 2025-11-21 16:26:24 +00:00
puddly
7073c40385 Bump universal-silabs-flasher to v0.1.0 (#156291)
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2025-11-21 16:26:23 +00:00
Charlie Rusbridger
8fb9d92daf Fix wrong BrowseError module in Kode (#155971) 2025-11-21 16:26:22 +00:00
cdnninja
2d81665f99 update methods to non deprecated methods in vesync (#155887) 2025-11-21 16:26:20 +00:00
Tom Monck JR
b398935539 Fix args passed to check_config script (#155885) 2025-11-21 16:26:19 +00:00
averybiteydinosaur
95f588aae1 Bump version of python_awair to 0.2.5 (#155798)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-21 16:26:18 +00:00
Hessel
ffe524d95a Cache token info in Wallbox (#154147)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-21 16:26:17 +00:00
Franck Nijhof
ee05adfca1 2025.11.2 (#156620) 2025-11-14 23:09:51 +01:00
Franck Nijhof
168c915b5f Update snapshots 2025-11-14 21:43:53 +00:00
Franck Nijhof
6c80be52af Bump version to 2025.11.2 2025-11-14 21:15:12 +00:00
Simone Chemelli
ead92cdf82 Add debounce to Alexa Devices coordinator (#156609) 2025-11-14 21:14:11 +00:00
Thomas55555
c0f0cfef59 Fix model_id in Husqvarna Automower (#156608) 2025-11-14 21:14:09 +00:00
epenet
cefc0ba96e Fix sfr_box entry reload (#156593) 2025-11-14 21:14:08 +00:00
TheJulianJES
ad091b1062 Bump ZHA to 0.0.79 (#156571) 2025-11-14 21:14:07 +00:00
TheJulianJES
876bc6d8c4 Bump ZHA to 0.0.78 (#155937) 2025-11-14 21:14:05 +00:00
Joost Lekkerkerker
9f206d4363 Bump python-open-router to 0.3.3 (#156563) 2025-11-14 21:12:17 +00:00
starkillerOG
a2d11e6d98 Bump reolink-aio to 0.16.5 (#156553) 2025-11-14 21:12:16 +00:00
Willem-Jan van Rootselaar
3b38af3984 Update bsblan to python-bsblan version 3.1.1 (#156536)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-11-14 21:12:14 +00:00
Joost Lekkerkerker
3875f91bb9 Bump pySmartThings to 3.3.3 (#156528) 2025-11-14 21:12:13 +00:00
Jan Čermák
c813776b0c Update Home Assistant base image to 2025.11.0 (#156517) 2025-11-14 21:12:12 +00:00
Foscam-wangzhengyu
3afb421cba URL-encode the RTSP URL in the Foscam integration (#156488)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-14 21:12:10 +00:00
puddly
c16633568b Add firmware flashing debug loggers to hardware integrations (#156480)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2025-11-14 21:12:09 +00:00
Josef Zweck
87f8ff2bb4 Fix lamarzocco update status (#156442) 2025-11-14 21:12:08 +00:00
cdnninja
b423303f1e Bump pyvesync to 3.2.2 (#156423)
Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-11-14 21:12:06 +00:00
Brett Adams
f6ff222679 Fix update progress in Teslemetry (#156422) 2025-11-14 21:12:05 +00:00
Manu
0152fa0c03 Prevent sensor updates caused by fluctuating “last seen” timestamps in Xbox integration (#156419) 2025-11-14 21:12:03 +00:00
Daniel Hjelseth Høyer
37ebbe83bc Update pyMill to 0.14.1 (#156396) 2025-11-14 21:12:02 +00:00
antoniocifu
63e036d39e Fix support for Hyperion 2.1.1 (#156343)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-14 21:12:01 +00:00
Erik Montnemery
f0cbf34a78 Check collation of statistics_meta DB table (#156327) 2025-11-14 21:11:59 +00:00
Teemu R.
596bc89ee6 tplink: handle repeated, unknown thermostat modes gracefully (#156310) 2025-11-14 21:11:58 +00:00
Assaf Inbal
b8c877e1d2 Ituran: Don't cache properties (#156281) 2025-11-14 21:11:56 +00:00
Åke Strandberg
197d9781cb Improve logging of failing miele action commands (#156275) 2025-11-14 21:11:55 +00:00
Erik Montnemery
f3f323637e Correct migration to recorder schema 51 (#156267)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-11-14 21:11:54 +00:00
Joost Lekkerkerker
9748abc103 Bump pySmartThings to 3.3.2 (#156250) 2025-11-14 21:11:52 +00:00
dotvav
596f049971 Bump pypalazzetti lib from 0.1.19 to 0.1.20 (#156249) 2025-11-14 21:11:51 +00:00
Foscam-wangzhengyu
dee80cb6f5 Foscam Integration with Legacy Model Compatibility (#156226)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-14 21:11:50 +00:00
Michael
b4ab73468b Fix Climate state reproduction when target temperature is None (#156220) 2025-11-14 21:11:48 +00:00
cdnninja
a300199a97 Bump pyvesync to 3.2.1 (#156195) 2025-11-14 21:11:47 +00:00
Simone Chemelli
09dd765583 Fix config flow reconfigure for Comelit (#156193) 2025-11-14 21:11:46 +00:00
starkillerOG
0c8b765415 Fix set_absolute_position angle (#156185) 2025-11-14 21:11:44 +00:00
Paul Annekov
0824ec502f Forbid to choose state in Ukraine Alarm integration (#156183) 2025-11-14 21:11:43 +00:00
Matthias Alphart
9e0e353a5f Update xknx to 3.10.1 (#156177) 2025-11-14 21:11:42 +00:00
Abílio Costa
e934b006e2 Fix MFA Notify setup flow schema (#156158) 2025-11-14 21:11:40 +00:00
Jan Rieger
05479bb8fd Bump aio-ownet to 0.0.5 (#156157) 2025-11-14 21:11:39 +00:00
TheJulianJES
d07247566d Log HomeAssistantErrors in ZHA config flow (#156075) 2025-11-14 21:11:38 +00:00
Erwin Douna
19e6097df6 Bump pyportainter 1.0.14 (#156072) 2025-11-14 21:11:36 +00:00
Erwin Douna
2cff3cf29c Bump pyportainer 1.0.13 (#155783) 2025-11-14 21:11:35 +00:00
Timothy
5cac9b8e5e Make sure to clean register callbacks when mobile_app reloads (#156028) 2025-11-14 21:09:04 +00:00
Erik Montnemery
c2a516ea32 Fix progress step bugs (#155923) 2025-11-14 21:09:03 +00:00
Nojus
192b38d3e2 Remove arbitrary forecast limit for meteo_lt (#155877) 2025-11-14 21:09:01 +00:00
puddly
bb018e3546 Avoid firing discovery events when flows immediately create a config entry (#155753) 2025-11-14 21:09:00 +00:00
Diogo Gomes
4919d73cc5 Bump cronsim to 2.7 (#155648) 2025-11-14 21:08:58 +00:00
Franck Nijhof
f3ddffb5ff 2025.11.1 (#156076) 2025-11-07 13:29:37 -08:00
Franck Nijhof
9bdfa77fa0 Merge branch 'master' into rc 2025-11-07 12:41:56 -08:00
Franck Nijhof
c65003009f Bump version to 2025.11.1 2025-11-07 20:36:12 +00:00
Michael Hansen
0f722109b7 Bump intents to 2025.11.7 (#156063) 2025-11-07 20:35:56 +00:00
Foscam-wangzhengyu
f7d86dec3c Fix the exception caused by the missing Foscam integration key (#156022) 2025-11-07 20:35:55 +00:00
Josef Zweck
6b49c8a70c Bump onedrive-personal-sdk to 0.0.16 (#156021) 2025-11-07 20:35:54 +00:00
epenet
ab9a8f3e53 Bump tuya-device-sharing-sdk to 0.2.5 (#156014) 2025-11-07 20:35:53 +00:00
johanzander
4e12628266 Fix Growatt integration authentication error for legacy config entries (#155993)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2025-11-07 20:35:51 +00:00
Simone Chemelli
e6d8d4de42 Bump aioamazondevices to 8.0.1 (#155989) 2025-11-07 20:35:50 +00:00
tronikos
6620b90eb4 Fix SolarEdge unload failing when there are no sensors (#155979) 2025-11-07 20:35:49 +00:00
tronikos
6fd3af8891 Handle empty fields in SolarEdge config flow (#155978) 2025-11-07 20:35:48 +00:00
Åke Strandberg
46979b8418 Fix for corrupt restored state in miele consumption sensors (#155966) 2025-11-07 20:35:47 +00:00
Marc Mueller
1718a11de2 Truncate password before sending it to bcrypt (#155950) 2025-11-07 20:35:45 +00:00
Matthias Alphart
2016b1d8c7 Fix KNX Climate humidity DPT (#155942) 2025-11-07 20:35:44 +00:00
puddly
4b72e45fc2 Remove @progress_step decorator from ZHA and Hardware integration (#155867)
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2025-11-07 20:35:43 +00:00
Ståle Storø Hauknes
ead5ce905b Improve scan interval for Airthings Corentium Home 2 (#155694)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-11-07 20:35:42 +00:00
Franck Nijhof
f233f2da3f Bump version to 2025.11.0 2025-11-05 19:21:40 +00:00
209 changed files with 5124 additions and 923 deletions

2
Dockerfile generated
View File

@@ -25,7 +25,7 @@ RUN \
"armv7") go2rtc_suffix='arm' ;; \ "armv7") go2rtc_suffix='arm' ;; \
*) go2rtc_suffix=${BUILD_ARCH} ;; \ *) go2rtc_suffix=${BUILD_ARCH} ;; \
esac \ esac \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.11/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \ && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.12/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& chmod +x /bin/go2rtc \ && chmod +x /bin/go2rtc \
# Verify go2rtc can be executed # Verify go2rtc can be executed
&& go2rtc --version && go2rtc --version

View File

@@ -1,10 +1,10 @@
image: ghcr.io/home-assistant/{arch}-homeassistant image: ghcr.io/home-assistant/{arch}-homeassistant
build_from: build_from:
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.10.1 aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.11.0
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.10.1 armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.11.0
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.10.1 armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.11.0
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.10.1 amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.11.0
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.10.1 i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.11.0
cosign: cosign:
base_identity: https://github.com/home-assistant/docker/.* base_identity: https://github.com/home-assistant/docker/.*
identity: https://github.com/home-assistant/core/.* identity: https://github.com/home-assistant/core/.*

View File

@@ -6,7 +6,6 @@ Sending HOTP through notify service
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections import OrderedDict
import logging import logging
from typing import Any, cast from typing import Any, cast
@@ -304,14 +303,15 @@ class NotifySetupFlow(SetupFlow[NotifyAuthModule]):
if not self._available_notify_services: if not self._available_notify_services:
return self.async_abort(reason="no_available_service") return self.async_abort(reason="no_available_service")
schema: dict[str, Any] = OrderedDict() schema = vol.Schema(
schema["notify_service"] = vol.In(self._available_notify_services) {
schema["target"] = vol.Optional(str) vol.Required("notify_service"): vol.In(self._available_notify_services),
vol.Optional("target"): str,
return self.async_show_form( }
step_id="init", data_schema=vol.Schema(schema), errors=errors
) )
return self.async_show_form(step_id="init", data_schema=schema, errors=errors)
async def async_step_setup( async def async_step_setup(
self, user_input: dict[str, str] | None = None self, user_input: dict[str, str] | None = None
) -> FlowResult: ) -> FlowResult:

View File

@@ -179,12 +179,18 @@ class Data:
user_hash = base64.b64decode(found["password"]) user_hash = base64.b64decode(found["password"])
# bcrypt.checkpw is timing-safe # bcrypt.checkpw is timing-safe
if not bcrypt.checkpw(password.encode(), user_hash): # With bcrypt 5.0 passing a password longer than 72 bytes raises a ValueError.
# Previously the password was silently truncated.
# https://github.com/pyca/bcrypt/pull/1000
if not bcrypt.checkpw(password.encode()[:72], user_hash):
raise InvalidAuth raise InvalidAuth
def hash_password(self, password: str, for_storage: bool = False) -> bytes: def hash_password(self, password: str, for_storage: bool = False) -> bytes:
"""Encode a password.""" """Encode a password."""
hashed: bytes = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)) # With bcrypt 5.0 passing a password longer than 72 bytes raises a ValueError.
# Previously the password was silently truncated.
# https://github.com/pyca/bcrypt/pull/1000
hashed: bytes = bcrypt.hashpw(password.encode()[:72], bcrypt.gensalt(rounds=12))
if for_storage: if for_storage:
hashed = base64.b64encode(hashed) hashed = base64.b64encode(hashed)

View File

@@ -23,7 +23,7 @@ from homeassistant.components.bluetooth import (
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS from homeassistant.const import CONF_ADDRESS
from .const import DOMAIN, MFCT_ID from .const import DEVICE_MODEL, DOMAIN, MFCT_ID
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -128,15 +128,15 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Confirm discovery.""" """Confirm discovery."""
assert self._discovered_device is not None
if user_input is not None: if user_input is not None:
if ( if self._discovered_device.device.firmware.need_firmware_upgrade:
self._discovered_device is not None
and self._discovered_device.device.firmware.need_firmware_upgrade
):
return self.async_abort(reason="firmware_upgrade_required") return self.async_abort(reason="firmware_upgrade_required")
return self.async_create_entry( return self.async_create_entry(
title=self.context["title_placeholders"]["name"], data={} title=self.context["title_placeholders"]["name"],
data={DEVICE_MODEL: self._discovered_device.device.model.value},
) )
self._set_confirm_only() self._set_confirm_only()
@@ -164,7 +164,10 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovered_device = discovery self._discovered_device = discovery
return self.async_create_entry(title=discovery.name, data={}) return self.async_create_entry(
title=discovery.name,
data={DEVICE_MODEL: discovery.device.model.value},
)
current_addresses = self._async_current_ids(include_ignore=False) current_addresses = self._async_current_ids(include_ignore=False)
devices: list[BluetoothServiceInfoBleak] = [] devices: list[BluetoothServiceInfoBleak] = []

View File

@@ -1,11 +1,16 @@
"""Constants for Airthings BLE.""" """Constants for Airthings BLE."""
from airthings_ble import AirthingsDeviceType
DOMAIN = "airthings_ble" DOMAIN = "airthings_ble"
MFCT_ID = 820 MFCT_ID = 820
VOLUME_BECQUEREL = "Bq/m³" VOLUME_BECQUEREL = "Bq/m³"
VOLUME_PICOCURIE = "pCi/L" VOLUME_PICOCURIE = "pCi/L"
DEVICE_MODEL = "device_model"
DEFAULT_SCAN_INTERVAL = 300 DEFAULT_SCAN_INTERVAL = 300
DEVICE_SPECIFIC_SCAN_INTERVAL = {AirthingsDeviceType.CORENTIUM_HOME_2.value: 1800}
MAX_RETRIES_AFTER_STARTUP = 5 MAX_RETRIES_AFTER_STARTUP = 5

View File

@@ -16,7 +16,12 @@ from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.unit_system import METRIC_SYSTEM from homeassistant.util.unit_system import METRIC_SYSTEM
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN from .const import (
DEFAULT_SCAN_INTERVAL,
DEVICE_MODEL,
DEVICE_SPECIFIC_SCAN_INTERVAL,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -34,12 +39,18 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]):
self.airthings = AirthingsBluetoothDeviceData( self.airthings = AirthingsBluetoothDeviceData(
_LOGGER, hass.config.units is METRIC_SYSTEM _LOGGER, hass.config.units is METRIC_SYSTEM
) )
device_model = entry.data.get(DEVICE_MODEL)
interval = DEVICE_SPECIFIC_SCAN_INTERVAL.get(
device_model, DEFAULT_SCAN_INTERVAL
)
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
config_entry=entry, config_entry=entry,
name=DOMAIN, name=DOMAIN,
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), update_interval=timedelta(seconds=interval),
) )
async def _async_setup(self) -> None: async def _async_setup(self) -> None:
@@ -58,11 +69,29 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]):
) )
self.ble_device = ble_device self.ble_device = ble_device
if DEVICE_MODEL not in self.config_entry.data:
_LOGGER.debug("Fetching device info for migration")
try:
data = await self.airthings.update_device(self.ble_device)
except Exception as err:
raise UpdateFailed(
f"Unable to fetch data for migration: {err}"
) from err
self.hass.config_entries.async_update_entry(
self.config_entry,
data={**self.config_entry.data, DEVICE_MODEL: data.model.value},
)
self.update_interval = timedelta(
seconds=DEVICE_SPECIFIC_SCAN_INTERVAL.get(
data.model.value, DEFAULT_SCAN_INTERVAL
)
)
async def _async_update_data(self) -> AirthingsDevice: async def _async_update_data(self) -> AirthingsDevice:
"""Get data from Airthings BLE.""" """Get data from Airthings BLE."""
try: try:
data = await self.airthings.update_device(self.ble_device) data = await self.airthings.update_device(self.ble_device)
except Exception as err: except Exception as err:
raise UpdateFailed(f"Unable to fetch data: {err}") from err raise UpdateFailed(f"Unable to fetch data: {err}") from err
return data return data

View File

@@ -6,8 +6,8 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Final from typing import Final
from aioamazondevices.api import AmazonDevice from aioamazondevices.const.metadata import SENSOR_STATE_OFF
from aioamazondevices.const import SENSOR_STATE_OFF from aioamazondevices.structures import AmazonDevice
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN, DOMAIN as BINARY_SENSOR_DOMAIN,

View File

@@ -2,12 +2,13 @@
from datetime import timedelta from datetime import timedelta
from aioamazondevices.api import AmazonDevice, AmazonEchoApi from aioamazondevices.api import AmazonEchoApi
from aioamazondevices.exceptions import ( from aioamazondevices.exceptions import (
CannotAuthenticate, CannotAuthenticate,
CannotConnect, CannotConnect,
CannotRetrieveData, CannotRetrieveData,
) )
from aioamazondevices.structures import AmazonDevice
from aiohttp import ClientSession from aiohttp import ClientSession
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@@ -15,6 +16,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN
@@ -42,6 +44,9 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
name=entry.title, name=entry.title,
config_entry=entry, config_entry=entry,
update_interval=timedelta(seconds=SCAN_INTERVAL), update_interval=timedelta(seconds=SCAN_INTERVAL),
request_refresh_debouncer=Debouncer(
hass, _LOGGER, cooldown=30, immediate=False
),
) )
self.api = AmazonEchoApi( self.api = AmazonEchoApi(
session, session,

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from aioamazondevices.api import AmazonDevice from aioamazondevices.structures import AmazonDevice
from homeassistant.components.diagnostics import async_redact_data from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME

View File

@@ -1,7 +1,7 @@
"""Defines a base Alexa Devices entity.""" """Defines a base Alexa Devices entity."""
from aioamazondevices.api import AmazonDevice from aioamazondevices.const.devices import SPEAKER_GROUP_MODEL
from aioamazondevices.const import SPEAKER_GROUP_MODEL from aioamazondevices.structures import AmazonDevice
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity import EntityDescription

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["aioamazondevices"], "loggers": ["aioamazondevices"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["aioamazondevices==6.5.6"] "requirements": ["aioamazondevices==8.0.1"]
} }

View File

@@ -6,8 +6,9 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Final from typing import Any, Final
from aioamazondevices.api import AmazonDevice, AmazonEchoApi from aioamazondevices.api import AmazonEchoApi
from aioamazondevices.const import SPEAKER_GROUP_FAMILY from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY
from aioamazondevices.structures import AmazonDevice
from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant

View File

@@ -7,12 +7,12 @@ from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Final from typing import Final
from aioamazondevices.api import AmazonDevice from aioamazondevices.const.schedules import (
from aioamazondevices.const import (
NOTIFICATION_ALARM, NOTIFICATION_ALARM,
NOTIFICATION_REMINDER, NOTIFICATION_REMINDER,
NOTIFICATION_TIMER, NOTIFICATION_TIMER,
) )
from aioamazondevices.structures import AmazonDevice
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,

View File

@@ -1,6 +1,6 @@
"""Support for services.""" """Support for services."""
from aioamazondevices.sounds import SOUNDS_LIST from aioamazondevices.const.sounds import SOUNDS_LIST
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState

View File

@@ -6,7 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Final from typing import TYPE_CHECKING, Any, Final
from aioamazondevices.api import AmazonDevice from aioamazondevices.structures import AmazonDevice
from homeassistant.components.switch import ( from homeassistant.components.switch import (
DOMAIN as SWITCH_DOMAIN, DOMAIN as SWITCH_DOMAIN,

View File

@@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps from functools import wraps
from typing import Any, Concatenate from typing import Any, Concatenate
from aioamazondevices.const import SPEAKER_GROUP_FAMILY from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN

View File

@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/awair", "documentation": "https://www.home-assistant.io/integrations/awair",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["python_awair"], "loggers": ["python_awair"],
"requirements": ["python-awair==0.2.4"], "requirements": ["python-awair==0.2.5"],
"zeroconf": [ "zeroconf": [
{ {
"name": "awair*", "name": "awair*",

View File

@@ -8,6 +8,6 @@
"integration_type": "service", "integration_type": "service",
"iot_class": "calculated", "iot_class": "calculated",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["cronsim==2.6", "securetar==2025.2.1"], "requirements": ["cronsim==2.7", "securetar==2025.2.1"],
"single_config_entry": true "single_config_entry": true
} }

View File

@@ -74,8 +74,11 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
super().__init__(data.fast_coordinator, data) super().__init__(data.fast_coordinator, data)
self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate" self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate"
self._attr_min_temp = data.static.min_temp.value # Set temperature range if available, otherwise use Home Assistant defaults
self._attr_max_temp = data.static.max_temp.value if data.static.min_temp is not None and data.static.min_temp.value is not None:
self._attr_min_temp = data.static.min_temp.value
if data.static.max_temp is not None and data.static.max_temp.value is not None:
self._attr_max_temp = data.static.max_temp.value
self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit
@property @property

View File

@@ -7,7 +7,7 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["bsblan"], "loggers": ["bsblan"],
"requirements": ["python-bsblan==3.1.0"], "requirements": ["python-bsblan==3.1.1"],
"zeroconf": [ "zeroconf": [
{ {
"name": "bsb-lan*", "name": "bsb-lan*",

View File

@@ -57,9 +57,9 @@ async def _async_reproduce_states(
await call_service(SERVICE_SET_HVAC_MODE, [], {ATTR_HVAC_MODE: state.state}) await call_service(SERVICE_SET_HVAC_MODE, [], {ATTR_HVAC_MODE: state.state})
if ( if (
(ATTR_TEMPERATURE in state.attributes) (state.attributes.get(ATTR_TEMPERATURE) is not None)
or (ATTR_TARGET_TEMP_HIGH in state.attributes) or (state.attributes.get(ATTR_TARGET_TEMP_HIGH) is not None)
or (ATTR_TARGET_TEMP_LOW in state.attributes) or (state.attributes.get(ATTR_TARGET_TEMP_LOW) is not None)
): ):
await call_service( await call_service(
SERVICE_SET_TEMPERATURE, SERVICE_SET_TEMPERATURE,

View File

@@ -7,7 +7,7 @@ from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta from datetime import datetime, timedelta
from enum import Enum from enum import Enum
import logging import logging
from typing import cast from typing import Any, cast
from hass_nabucasa import Cloud from hass_nabucasa import Cloud
import voluptuous as vol import voluptuous as vol
@@ -85,6 +85,10 @@ SIGNAL_CLOUD_CONNECTION_STATE: SignalType[CloudConnectionState] = SignalType(
"CLOUD_CONNECTION_STATE" "CLOUD_CONNECTION_STATE"
) )
_SIGNAL_CLOUDHOOKS_UPDATED: SignalType[dict[str, Any]] = SignalType(
"CLOUDHOOKS_UPDATED"
)
STARTUP_REPAIR_DELAY = 1 # 1 hour STARTUP_REPAIR_DELAY = 1 # 1 hour
ALEXA_ENTITY_SCHEMA = vol.Schema( ALEXA_ENTITY_SCHEMA = vol.Schema(
@@ -240,6 +244,24 @@ async def async_delete_cloudhook(hass: HomeAssistant, webhook_id: str) -> None:
await hass.data[DATA_CLOUD].cloudhooks.async_delete(webhook_id) await hass.data[DATA_CLOUD].cloudhooks.async_delete(webhook_id)
@callback
def async_listen_cloudhook_change(
hass: HomeAssistant,
webhook_id: str,
on_change: Callable[[dict[str, Any] | None], None],
) -> Callable[[], None]:
"""Listen for cloudhook changes for the given webhook and notify when modified or deleted."""
@callback
def _handle_cloudhooks_updated(cloudhooks: dict[str, Any]) -> None:
"""Handle cloudhooks updated signal."""
on_change(cloudhooks.get(webhook_id))
return async_dispatcher_connect(
hass, _SIGNAL_CLOUDHOOKS_UPDATED, _handle_cloudhooks_updated
)
@bind_hass @bind_hass
@callback @callback
def async_remote_ui_url(hass: HomeAssistant) -> str: def async_remote_ui_url(hass: HomeAssistant) -> str:
@@ -287,7 +309,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
_remote_handle_prefs_updated(cloud) _handle_prefs_updated(hass, cloud)
_setup_services(hass, prefs) _setup_services(hass, prefs)
async def async_startup_repairs(_: datetime) -> None: async def async_startup_repairs(_: datetime) -> None:
@@ -371,26 +393,32 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@callback @callback
def _remote_handle_prefs_updated(cloud: Cloud[CloudClient]) -> None: def _handle_prefs_updated(hass: HomeAssistant, cloud: Cloud[CloudClient]) -> None:
"""Handle remote preferences updated.""" """Register handler for cloud preferences updates."""
cur_pref = cloud.client.prefs.remote_enabled cur_remote_enabled = cloud.client.prefs.remote_enabled
cur_cloudhooks = cloud.client.prefs.cloudhooks
lock = asyncio.Lock() lock = asyncio.Lock()
# Sync remote connection with prefs async def on_prefs_updated(prefs: CloudPreferences) -> None:
async def remote_prefs_updated(prefs: CloudPreferences) -> None: """Handle cloud preferences updates."""
"""Update remote status.""" nonlocal cur_remote_enabled
nonlocal cur_pref nonlocal cur_cloudhooks
# Lock protects cur_ state variables from concurrent updates
async with lock: async with lock:
if prefs.remote_enabled == cur_pref: if cur_cloudhooks != prefs.cloudhooks:
cur_cloudhooks = prefs.cloudhooks
async_dispatcher_send(hass, _SIGNAL_CLOUDHOOKS_UPDATED, cur_cloudhooks)
if prefs.remote_enabled == cur_remote_enabled:
return return
if cur_pref := prefs.remote_enabled: if cur_remote_enabled := prefs.remote_enabled:
await cloud.remote.connect() await cloud.remote.connect()
else: else:
await cloud.remote.disconnect() await cloud.remote.disconnect()
cloud.client.prefs.async_listen_updates(remote_prefs_updated) cloud.client.prefs.async_listen_updates(on_prefs_updated)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

View File

@@ -37,13 +37,6 @@ USER_SCHEMA = vol.Schema(
} }
) )
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.string}) STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.string})
STEP_RECONFIGURE = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_PORT): cv.port,
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.string,
}
)
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]: async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]:
@@ -175,36 +168,55 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle reconfiguration of the device.""" """Handle reconfiguration of the device."""
reconfigure_entry = self._get_reconfigure_entry() reconfigure_entry = self._get_reconfigure_entry()
if not user_input:
return self.async_show_form(
step_id="reconfigure", data_schema=STEP_RECONFIGURE
)
updated_host = user_input[CONF_HOST]
self._async_abort_entries_match({CONF_HOST: updated_host})
errors: dict[str, str] = {} errors: dict[str, str] = {}
try: if user_input is not None:
await validate_input(self.hass, user_input) updated_host = user_input[CONF_HOST]
except CannotConnect:
errors["base"] = "cannot_connect" self._async_abort_entries_match({CONF_HOST: updated_host})
except InvalidAuth:
errors["base"] = "invalid_auth" try:
except InvalidPin: data_to_validate = {
errors["base"] = "invalid_pin" CONF_HOST: updated_host,
except Exception: # noqa: BLE001 CONF_PORT: user_input[CONF_PORT],
_LOGGER.exception("Unexpected exception") CONF_PIN: user_input[CONF_PIN],
errors["base"] = "unknown" CONF_TYPE: reconfigure_entry.data.get(CONF_TYPE, BRIDGE),
else: }
return self.async_update_reload_and_abort( await validate_input(self.hass, data_to_validate)
reconfigure_entry, data_updates={CONF_HOST: updated_host} except CannotConnect:
) errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except InvalidPin:
errors["base"] = "invalid_pin"
except Exception: # noqa: BLE001
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
data_updates = {
CONF_HOST: updated_host,
CONF_PORT: user_input[CONF_PORT],
CONF_PIN: user_input[CONF_PIN],
}
return self.async_update_reload_and_abort(
reconfigure_entry, data_updates=data_updates
)
schema = vol.Schema(
{
vol.Required(
CONF_HOST, default=reconfigure_entry.data[CONF_HOST]
): cv.string,
vol.Required(
CONF_PORT, default=reconfigure_entry.data[CONF_PORT]
): cv.port,
vol.Optional(CONF_PIN): cv.string,
}
)
return self.async_show_form( return self.async_show_form(
step_id="reconfigure", step_id="reconfigure",
data_schema=STEP_RECONFIGURE, data_schema=schema,
errors=errors, errors=errors,
) )

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation", "documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity", "integration_type": "entity",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["hassil==3.4.0", "home-assistant-intents==2025.10.28"] "requirements": ["hassil==3.4.0", "home-assistant-intents==2025.11.7"]
} }

View File

@@ -9,6 +9,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util.ssl import get_default_context
from .const import ( from .const import (
CONF_AUTHORIZE_STRING, CONF_AUTHORIZE_STRING,
@@ -31,9 +32,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: CyncConfigEntry) -> bool
expires_at=entry.data[CONF_EXPIRES_AT], expires_at=entry.data[CONF_EXPIRES_AT],
) )
cync_auth = Auth(async_get_clientsession(hass), user=user_info) cync_auth = Auth(async_get_clientsession(hass), user=user_info)
ssl_context = get_default_context()
try: try:
cync = await Cync.create(cync_auth) cync = await Cync.create(
auth=cync_auth,
ssl_context=ssl_context,
)
except AuthFailedError as ex: except AuthFailedError as ex:
raise ConfigEntryAuthFailed("User token invalid") from ex raise ConfigEntryAuthFailed("User token invalid") from ex
except CyncError as ex: except CyncError as ex:

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta
import logging import logging
from typing import Any from typing import Any
@@ -25,6 +26,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -167,6 +169,7 @@ class DecoraWifiLight(LightEntity):
except ValueError: except ValueError:
_LOGGER.error("Failed to turn off myLeviton switch") _LOGGER.error("Failed to turn off myLeviton switch")
@Throttle(timedelta(seconds=30))
def update(self) -> None: def update(self) -> None:
"""Fetch new state data for this switch.""" """Fetch new state data for this switch."""
try: try:

View File

@@ -8,7 +8,7 @@
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["async_upnp_client"], "loggers": ["async_upnp_client"],
"requirements": ["async-upnp-client==0.45.0", "getmac==0.9.5"], "requirements": ["async-upnp-client==0.46.0", "getmac==0.9.5"],
"ssdp": [ "ssdp": [
{ {
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",

View File

@@ -7,7 +7,7 @@
"dependencies": ["ssdp"], "dependencies": ["ssdp"],
"documentation": "https://www.home-assistant.io/integrations/dlna_dms", "documentation": "https://www.home-assistant.io/integrations/dlna_dms",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["async-upnp-client==0.45.0"], "requirements": ["async-upnp-client==0.46.0"],
"ssdp": [ "ssdp": [
{ {
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1", "deviceType": "urn:schemas-upnp-org:device:MediaServer:1",

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from urllib.parse import quote
import voluptuous as vol import voluptuous as vol
@@ -152,7 +153,9 @@ class HassFoscamCamera(FoscamEntity, Camera):
async def stream_source(self) -> str | None: async def stream_source(self) -> str | None:
"""Return the stream source.""" """Return the stream source."""
if self._rtsp_port: if self._rtsp_port:
return f"rtsp://{self._username}:{self._password}@{self._foscam_session.host}:{self._rtsp_port}/video{self._stream}" _username = quote(self._username)
_password = quote(self._password)
return f"rtsp://{_username}:{_password}@{self._foscam_session.host}:{self._rtsp_port}/video{self._stream}"
return None return None

View File

@@ -37,6 +37,7 @@ class FoscamDeviceInfo:
supports_speak_volume_adjustment: bool supports_speak_volume_adjustment: bool
supports_pet_adjustment: bool supports_pet_adjustment: bool
supports_car_adjustment: bool supports_car_adjustment: bool
supports_human_adjustment: bool
supports_wdr_adjustment: bool supports_wdr_adjustment: bool
supports_hdr_adjustment: bool supports_hdr_adjustment: bool
@@ -115,20 +116,28 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
is_open_wdr = None is_open_wdr = None
is_open_hdr = None is_open_hdr = None
reserve3 = product_info.get("reserve4") reserve3 = product_info.get("reserve4")
reserve3_int = int(reserve3) if reserve3 is not None else 0 model = product_info.get("model")
supports_wdr_adjustment_val = bool(int(reserve3_int & 256)) model_int = int(model) if model is not None else 7002
supports_hdr_adjustment_val = bool(int(reserve3_int & 128)) if model_int > 7001:
if supports_wdr_adjustment_val: reserve3_int = int(reserve3) if reserve3 is not None else 0
ret_wdr, is_open_wdr_data = self.session.getWdrMode() supports_wdr_adjustment_val = bool(int(reserve3_int & 256))
mode = is_open_wdr_data["mode"] if ret_wdr == 0 and is_open_wdr_data else 0 supports_hdr_adjustment_val = bool(int(reserve3_int & 128))
is_open_wdr = bool(int(mode)) if supports_wdr_adjustment_val:
elif supports_hdr_adjustment_val: ret_wdr, is_open_wdr_data = self.session.getWdrMode()
ret_hdr, is_open_hdr_data = self.session.getHdrMode() mode = (
mode = is_open_hdr_data["mode"] if ret_hdr == 0 and is_open_hdr_data else 0 is_open_wdr_data["mode"] if ret_wdr == 0 and is_open_wdr_data else 0
is_open_hdr = bool(int(mode)) )
is_open_wdr = bool(int(mode))
elif supports_hdr_adjustment_val:
ret_hdr, is_open_hdr_data = self.session.getHdrMode()
mode = (
is_open_hdr_data["mode"] if ret_hdr == 0 and is_open_hdr_data else 0
)
is_open_hdr = bool(int(mode))
else:
supports_wdr_adjustment_val = False
supports_hdr_adjustment_val = False
ret_sw, software_capabilities = self.session.getSWCapabilities() ret_sw, software_capabilities = self.session.getSWCapabilities()
supports_speak_volume_adjustment_val = ( supports_speak_volume_adjustment_val = (
bool(int(software_capabilities.get("swCapabilities1")) & 32) bool(int(software_capabilities.get("swCapabilities1")) & 32)
if ret_sw == 0 if ret_sw == 0
@@ -144,24 +153,32 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
if ret_sw == 0 if ret_sw == 0
else False else False
) )
ret_md, mothion_config_val = self.session.get_motion_detect_config() human_adjustment_val = (
bool(int(software_capabilities.get("swCapabilities2")) & 128)
if ret_sw == 0
else False
)
ret_md, motion_config_val = self.session.get_motion_detect_config()
if pet_adjustment_val: if pet_adjustment_val:
is_pet_detection_on_val = ( is_pet_detection_on_val = (
mothion_config_val["petEnable"] == "1" if ret_md == 0 else False motion_config_val.get("petEnable") == "1" if ret_md == 0 else False
) )
else: else:
is_pet_detection_on_val = False is_pet_detection_on_val = False
if car_adjustment_val: if car_adjustment_val:
is_car_detection_on_val = ( is_car_detection_on_val = (
mothion_config_val["carEnable"] == "1" if ret_md == 0 else False motion_config_val.get("carEnable") == "1" if ret_md == 0 else False
) )
else: else:
is_car_detection_on_val = False is_car_detection_on_val = False
is_human_detection_on_val = ( if human_adjustment_val:
mothion_config_val["humanEnable"] == "1" if ret_md == 0 else False is_human_detection_on_val = (
) motion_config_val.get("humanEnable") == "1" if ret_md == 0 else False
)
else:
is_human_detection_on_val = False
return FoscamDeviceInfo( return FoscamDeviceInfo(
dev_info=dev_info, dev_info=dev_info,
@@ -179,6 +196,7 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
supports_speak_volume_adjustment=supports_speak_volume_adjustment_val, supports_speak_volume_adjustment=supports_speak_volume_adjustment_val,
supports_pet_adjustment=pet_adjustment_val, supports_pet_adjustment=pet_adjustment_val,
supports_car_adjustment=car_adjustment_val, supports_car_adjustment=car_adjustment_val,
supports_human_adjustment=human_adjustment_val,
supports_hdr_adjustment=supports_hdr_adjustment_val, supports_hdr_adjustment=supports_hdr_adjustment_val,
supports_wdr_adjustment=supports_wdr_adjustment_val, supports_wdr_adjustment=supports_wdr_adjustment_val,
is_open_wdr=is_open_wdr, is_open_wdr=is_open_wdr,

View File

@@ -143,6 +143,7 @@ SWITCH_DESCRIPTIONS: list[FoscamSwitchEntityDescription] = [
native_value_fn=lambda data: data.is_human_detection_on, native_value_fn=lambda data: data.is_human_detection_on,
turn_off_fn=lambda session: set_motion_detection(session, "humanEnable", False), turn_off_fn=lambda session: set_motion_detection(session, "humanEnable", False),
turn_on_fn=lambda session: set_motion_detection(session, "humanEnable", True), turn_on_fn=lambda session: set_motion_detection(session, "humanEnable", True),
exists_fn=lambda coordinator: coordinator.data.supports_human_adjustment,
), ),
] ]

View File

@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend", "documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system", "integration_type": "system",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["home-assistant-frontend==20251105.0"] "requirements": ["home-assistant-frontend==20251105.1"]
} }

View File

@@ -60,35 +60,6 @@ from .server import Server
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_FFMPEG = "ffmpeg" _FFMPEG = "ffmpeg"
_SUPPORTED_STREAMS = frozenset(
(
"bubble",
"dvrip",
"expr",
_FFMPEG,
"gopro",
"homekit",
"http",
"https",
"httpx",
"isapi",
"ivideon",
"kasa",
"nest",
"onvif",
"roborock",
"rtmp",
"rtmps",
"rtmpx",
"rtsp",
"rtsps",
"rtspx",
"tapo",
"tcp",
"webrtc",
"webtorrent",
)
)
CONFIG_SCHEMA = vol.Schema( CONFIG_SCHEMA = vol.Schema(
{ {
@@ -197,6 +168,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: Go2RtcConfigEntry) -> bo
return False return False
provider = entry.runtime_data = WebRTCProvider(hass, url, session, client) provider = entry.runtime_data = WebRTCProvider(hass, url, session, client)
await provider.initialize()
entry.async_on_unload(async_register_webrtc_provider(hass, provider)) entry.async_on_unload(async_register_webrtc_provider(hass, provider))
return True return True
@@ -228,16 +200,21 @@ class WebRTCProvider(CameraWebRTCProvider):
self._session = session self._session = session
self._rest_client = rest_client self._rest_client = rest_client
self._sessions: dict[str, Go2RtcWsClient] = {} self._sessions: dict[str, Go2RtcWsClient] = {}
self._supported_schemes: set[str] = set()
@property @property
def domain(self) -> str: def domain(self) -> str:
"""Return the integration domain of the provider.""" """Return the integration domain of the provider."""
return DOMAIN return DOMAIN
async def initialize(self) -> None:
"""Initialize the provider."""
self._supported_schemes = await self._rest_client.schemes.list()
@callback @callback
def async_is_supported(self, stream_source: str) -> bool: def async_is_supported(self, stream_source: str) -> bool:
"""Return if this provider is supports the Camera as source.""" """Return if this provider is supports the Camera as source."""
return stream_source.partition(":")[0] in _SUPPORTED_STREAMS return stream_source.partition(":")[0] in self._supported_schemes
async def async_handle_async_webrtc_offer( async def async_handle_async_webrtc_offer(
self, self,

View File

@@ -6,4 +6,4 @@ CONF_DEBUG_UI = "debug_ui"
DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time."
HA_MANAGED_API_PORT = 11984 HA_MANAGED_API_PORT = 11984
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/" HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
RECOMMENDED_VERSION = "1.9.11" RECOMMENDED_VERSION = "1.9.12"

View File

@@ -8,6 +8,6 @@
"integration_type": "system", "integration_type": "system",
"iot_class": "local_polling", "iot_class": "local_polling",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["go2rtc-client==0.2.1"], "requirements": ["go2rtc-client==0.3.0"],
"single_config_entry": true "single_config_entry": true
} }

View File

@@ -29,8 +29,18 @@ _RESPAWN_COOLDOWN = 1
_GO2RTC_CONFIG_FORMAT = r"""# This file is managed by Home Assistant _GO2RTC_CONFIG_FORMAT = r"""# This file is managed by Home Assistant
# Do not edit it manually # Do not edit it manually
app:
modules: {app_modules}
api: api:
listen: "{api_ip}:{api_port}" listen: "{api_ip}:{api_port}"
allow_paths: {api_allow_paths}
# ffmpeg needs the exec module
# Restrict execution to only ffmpeg binary
exec:
allow_paths:
- ffmpeg
rtsp: rtsp:
listen: "127.0.0.1:18554" listen: "127.0.0.1:18554"
@@ -40,6 +50,43 @@ webrtc:
ice_servers: [] ice_servers: []
""" """
_APP_MODULES = (
"api",
"exec", # Execution module for ffmpeg
"ffmpeg",
"http",
"mjpeg",
"onvif",
"rtmp",
"rtsp",
"srtp",
"webrtc",
"ws",
)
_API_ALLOW_PATHS = (
"/", # UI static page and version control
"/api", # Main API path
"/api/frame.jpeg", # Snapshot functionality
"/api/schemes", # Supported stream schemes
"/api/streams", # Stream management
"/api/webrtc", # Webrtc functionality
"/api/ws", # Websocket functionality (e.g. webrtc candidates)
)
# Additional modules when UI is enabled
_UI_APP_MODULES = (
*_APP_MODULES,
"debug",
)
# Additional api paths when UI is enabled
_UI_API_ALLOW_PATHS = (
*_API_ALLOW_PATHS,
"/api/config", # UI config view
"/api/log", # UI log view
"/api/streams.dot", # UI network view
)
_LOG_LEVEL_MAP = { _LOG_LEVEL_MAP = {
"TRC": logging.DEBUG, "TRC": logging.DEBUG,
"DBG": logging.DEBUG, "DBG": logging.DEBUG,
@@ -61,14 +108,34 @@ class Go2RTCWatchdogError(HomeAssistantError):
"""Raised on watchdog error.""" """Raised on watchdog error."""
def _create_temp_file(api_ip: str) -> str: def _format_list_for_yaml(items: tuple[str, ...]) -> str:
"""Format a list of strings for yaml config."""
if not items:
return "[]"
formatted_items = ",".join(f'"{item}"' for item in items)
return f"[{formatted_items}]"
def _create_temp_file(enable_ui: bool) -> str:
"""Create temporary config file.""" """Create temporary config file."""
app_modules: tuple[str, ...] = _APP_MODULES
api_paths: tuple[str, ...] = _API_ALLOW_PATHS
api_ip = _LOCALHOST_IP
if enable_ui:
app_modules = _UI_APP_MODULES
api_paths = _UI_API_ALLOW_PATHS
# Listen on all interfaces for allowing access from all ips
api_ip = ""
# Set delete=False to prevent the file from being deleted when the file is closed # Set delete=False to prevent the file from being deleted when the file is closed
# Linux is clearing tmp folder on reboot, so no need to delete it manually # Linux is clearing tmp folder on reboot, so no need to delete it manually
with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file: with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file:
file.write( file.write(
_GO2RTC_CONFIG_FORMAT.format( _GO2RTC_CONFIG_FORMAT.format(
api_ip=api_ip, api_port=HA_MANAGED_API_PORT api_ip=api_ip,
api_port=HA_MANAGED_API_PORT,
app_modules=_format_list_for_yaml(app_modules),
api_allow_paths=_format_list_for_yaml(api_paths),
).encode() ).encode()
) )
return file.name return file.name
@@ -86,10 +153,7 @@ class Server:
self._log_buffer: deque[str] = deque(maxlen=_LOG_BUFFER_SIZE) self._log_buffer: deque[str] = deque(maxlen=_LOG_BUFFER_SIZE)
self._process: asyncio.subprocess.Process | None = None self._process: asyncio.subprocess.Process | None = None
self._startup_complete = asyncio.Event() self._startup_complete = asyncio.Event()
self._api_ip = _LOCALHOST_IP self._enable_ui = enable_ui
if enable_ui:
# Listen on all interfaces for allowing access from all ips
self._api_ip = ""
self._watchdog_task: asyncio.Task | None = None self._watchdog_task: asyncio.Task | None = None
self._watchdog_tasks: list[asyncio.Task] = [] self._watchdog_tasks: list[asyncio.Task] = []
@@ -104,7 +168,7 @@ class Server:
"""Start the server.""" """Start the server."""
_LOGGER.debug("Starting go2rtc server") _LOGGER.debug("Starting go2rtc server")
config_file = await self._hass.async_add_executor_job( config_file = await self._hass.async_add_executor_job(
_create_temp_file, self._api_ip _create_temp_file, self._enable_ui
) )
self._startup_complete.clear() self._startup_complete.clear()

View File

@@ -136,6 +136,21 @@ async def async_setup_entry(
new_data[CONF_URL] = url new_data[CONF_URL] = url
hass.config_entries.async_update_entry(config_entry, data=new_data) hass.config_entries.async_update_entry(config_entry, data=new_data)
# Migrate legacy config entries without auth_type field
if CONF_AUTH_TYPE not in config:
new_data = dict(config_entry.data)
# Detect auth type based on which fields are present
if CONF_TOKEN in config:
new_data[CONF_AUTH_TYPE] = AUTH_API_TOKEN
elif CONF_USERNAME in config:
new_data[CONF_AUTH_TYPE] = AUTH_PASSWORD
else:
raise ConfigEntryError(
"Unable to determine authentication type from config entry."
)
hass.config_entries.async_update_entry(config_entry, data=new_data)
config = config_entry.data
# Determine API version # Determine API version
if config.get(CONF_AUTH_TYPE) == AUTH_API_TOKEN: if config.get(CONF_AUTH_TYPE) == AUTH_API_TOKEN:
api_version = "v1" api_version = "v1"

View File

@@ -76,9 +76,18 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
"""Mixin for Home Assistant Connect ZBT-2 firmware methods.""" """Mixin for Home Assistant Connect ZBT-2 firmware methods."""
context: ConfigFlowContext context: ConfigFlowContext
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR]
ZIGBEE_BAUDRATE = 460800 ZIGBEE_BAUDRATE = 460800
# Early ZBT-2 samples used RTS/DTR to trigger the bootloader, later ones use the
# baudrate method. Since the two are mutually exclusive we just use both.
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR, ResetTarget.BAUDRATE]
APPLICATION_PROBE_METHODS = [
(ApplicationType.GECKO_BOOTLOADER, 115200),
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
(ApplicationType.SPINEL, 460800),
]
async def async_step_install_zigbee_firmware( async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:

View File

@@ -6,6 +6,12 @@
"dependencies": ["hardware", "usb", "homeassistant_hardware"], "dependencies": ["hardware", "usb", "homeassistant_hardware"],
"documentation": "https://www.home-assistant.io/integrations/homeassistant_connect_zbt2", "documentation": "https://www.home-assistant.io/integrations/homeassistant_connect_zbt2",
"integration_type": "hardware", "integration_type": "hardware",
"loggers": [
"bellows",
"universal_silabs_flasher",
"zigpy.serial",
"serial_asyncio_fast"
],
"quality_scale": "bronze", "quality_scale": "bronze",
"usb": [ "usb": [
{ {

View File

@@ -14,7 +14,6 @@ from homeassistant.components.homeassistant_hardware.update import (
from homeassistant.components.homeassistant_hardware.util import ( from homeassistant.components.homeassistant_hardware.util import (
ApplicationType, ApplicationType,
FirmwareInfo, FirmwareInfo,
ResetTarget,
) )
from homeassistant.components.update import UpdateDeviceClass from homeassistant.components.update import UpdateDeviceClass
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
@@ -24,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeAssistantConnectZBT2ConfigEntry from . import HomeAssistantConnectZBT2ConfigEntry
from .config_flow import ZBT2FirmwareMixin
from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, HARDWARE_NAME, SERIAL_NUMBER from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, HARDWARE_NAME, SERIAL_NUMBER
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -134,7 +134,8 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""Connect ZBT-2 firmware update entity.""" """Connect ZBT-2 firmware update entity."""
bootloader_reset_methods = [ResetTarget.RTS_DTR] BOOTLOADER_RESET_METHODS = ZBT2FirmwareMixin.BOOTLOADER_RESET_METHODS
APPLICATION_PROBE_METHODS = ZBT2FirmwareMixin.APPLICATION_PROBE_METHODS
def __init__( def __init__(
self, self,

View File

@@ -28,7 +28,7 @@ from homeassistant.config_entries import (
OptionsFlow, OptionsFlow,
) )
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow, progress_step from homeassistant.data_entry_flow import AbortFlow
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.hassio import is_hassio
@@ -81,6 +81,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
BOOTLOADER_RESET_METHODS: list[ResetTarget] = [] # Default, subclasses may override BOOTLOADER_RESET_METHODS: list[ResetTarget] = [] # Default, subclasses may override
APPLICATION_PROBE_METHODS: list[tuple[ApplicationType, int]] = []
_picked_firmware_type: PickedFirmwareType _picked_firmware_type: PickedFirmwareType
_zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED _zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED
@@ -97,6 +98,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
self.addon_uninstall_task: asyncio.Task | None = None self.addon_uninstall_task: asyncio.Task | None = None
self.firmware_install_task: asyncio.Task[None] | None = None self.firmware_install_task: asyncio.Task[None] | None = None
self.installing_firmware_name: str | None = None self.installing_firmware_name: str | None = None
self._install_otbr_addon_task: asyncio.Task[None] | None = None
self._start_otbr_addon_task: asyncio.Task[None] | None = None
# Progress flow steps cannot abort so we need to store the abort reason and then
# re-raise it in a dedicated step
self._progress_error: AbortFlow | None = None
def _get_translation_placeholders(self) -> dict[str, str]: def _get_translation_placeholders(self) -> dict[str, str]:
"""Shared translation placeholders.""" """Shared translation placeholders."""
@@ -106,6 +113,11 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
if self._probed_firmware_info is not None if self._probed_firmware_info is not None
else "unknown" else "unknown"
), ),
"firmware_name": (
self.installing_firmware_name
if self.installing_firmware_name is not None
else "unknown"
),
"model": self._hardware_name, "model": self._hardware_name,
} }
@@ -182,22 +194,22 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
return self.async_show_progress( return self.async_show_progress(
step_id=step_id, step_id=step_id,
progress_action="install_firmware", progress_action="install_firmware",
description_placeholders={ description_placeholders=self._get_translation_placeholders(),
**self._get_translation_placeholders(),
"firmware_name": firmware_name,
},
progress_task=self.firmware_install_task, progress_task=self.firmware_install_task,
) )
try: try:
await self.firmware_install_task await self.firmware_install_task
except AbortFlow as err: except AbortFlow as err:
return self.async_show_progress_done( self._progress_error = err
next_step_id=err.reason, return self.async_show_progress_done(next_step_id="progress_failed")
)
except HomeAssistantError: except HomeAssistantError:
_LOGGER.exception("Failed to flash firmware") _LOGGER.exception("Failed to flash firmware")
return self.async_show_progress_done(next_step_id="firmware_install_failed") self._progress_error = AbortFlow(
reason="fw_install_failed",
description_placeholders=self._get_translation_placeholders(),
)
return self.async_show_progress_done(next_step_id="progress_failed")
finally: finally:
self.firmware_install_task = None self.firmware_install_task = None
@@ -219,7 +231,11 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
# Installing new firmware is only truly required if the wrong type is # Installing new firmware is only truly required if the wrong type is
# installed: upgrading to the latest release of the current firmware type # installed: upgrading to the latest release of the current firmware type
# isn't strictly necessary for functionality. # isn't strictly necessary for functionality.
self._probed_firmware_info = await probe_silabs_firmware_info(self._device) self._probed_firmware_info = await probe_silabs_firmware_info(
self._device,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
)
firmware_install_required = self._probed_firmware_info is None or ( firmware_install_required = self._probed_firmware_info is None or (
self._probed_firmware_info.firmware_type != expected_installed_firmware_type self._probed_firmware_info.firmware_type != expected_installed_firmware_type
@@ -241,7 +257,10 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
_LOGGER.debug("Skipping firmware upgrade due to index download failure") _LOGGER.debug("Skipping firmware upgrade due to index download failure")
return return
raise AbortFlow(reason="firmware_download_failed") from err raise AbortFlow(
reason="fw_download_failed",
description_placeholders=self._get_translation_placeholders(),
) from err
if not firmware_install_required: if not firmware_install_required:
assert self._probed_firmware_info is not None assert self._probed_firmware_info is not None
@@ -270,7 +289,10 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
return return
# Otherwise, fail # Otherwise, fail
raise AbortFlow(reason="firmware_download_failed") from err raise AbortFlow(
reason="fw_download_failed",
description_placeholders=self._get_translation_placeholders(),
) from err
self._probed_firmware_info = await async_flash_silabs_firmware( self._probed_firmware_info = await async_flash_silabs_firmware(
hass=self.hass, hass=self.hass,
@@ -278,6 +300,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
fw_data=fw_data, fw_data=fw_data,
expected_installed_firmware_type=expected_installed_firmware_type, expected_installed_firmware_type=expected_installed_firmware_type,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS, bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
progress_callback=lambda offset, total: self.async_update_progress( progress_callback=lambda offset, total: self.async_update_progress(
offset / total offset / total
), ),
@@ -313,41 +336,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
await otbr_manager.async_start_addon_waiting() await otbr_manager.async_start_addon_waiting()
async def async_step_firmware_download_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort when firmware download failed."""
assert self.installing_firmware_name is not None
return self.async_abort(
reason="fw_download_failed",
description_placeholders={
**self._get_translation_placeholders(),
"firmware_name": self.installing_firmware_name,
},
)
async def async_step_firmware_install_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort when firmware install failed."""
assert self.installing_firmware_name is not None
return self.async_abort(
reason="fw_install_failed",
description_placeholders={
**self._get_translation_placeholders(),
"firmware_name": self.installing_firmware_name,
},
)
async def async_step_unsupported_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort when unsupported firmware is detected."""
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
async def async_step_zigbee_installation_type( async def async_step_zigbee_installation_type(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@@ -511,16 +499,15 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Install Thread firmware.""" """Install Thread firmware."""
raise NotImplementedError raise NotImplementedError
@progress_step( async def async_step_progress_failed(
description_placeholders=lambda self: {
**self._get_translation_placeholders(),
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
}
)
async def async_step_install_otbr_addon(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Show progress dialog for installing the OTBR addon.""" """Abort when progress step failed."""
assert self._progress_error is not None
raise self._progress_error
async def _async_install_otbr_addon(self) -> None:
"""Do the work of installing the OTBR addon."""
addon_manager = get_otbr_addon_manager(self.hass) addon_manager = get_otbr_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(addon_manager) addon_info = await self._async_get_addon_info(addon_manager)
@@ -538,18 +525,39 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
}, },
) from err ) from err
return await self.async_step_finish_thread_installation() async def async_step_install_otbr_addon(
@progress_step(
description_placeholders=lambda self: {
**self._get_translation_placeholders(),
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
}
)
async def async_step_start_otbr_addon(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Configure OTBR to point to the SkyConnect and run the addon.""" """Show progress dialog for installing the OTBR addon."""
if self._install_otbr_addon_task is None:
self._install_otbr_addon_task = self.hass.async_create_task(
self._async_install_otbr_addon(),
"Install OTBR addon",
)
if not self._install_otbr_addon_task.done():
return self.async_show_progress(
step_id="install_otbr_addon",
progress_action="install_otbr_addon",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
},
progress_task=self._install_otbr_addon_task,
)
try:
await self._install_otbr_addon_task
except AbortFlow as err:
self._progress_error = err
return self.async_show_progress_done(next_step_id="progress_failed")
finally:
self._install_otbr_addon_task = None
return self.async_show_progress_done(next_step_id="finish_thread_installation")
async def _async_start_otbr_addon(self) -> None:
"""Do the work of starting the OTBR addon."""
try: try:
await self._configure_and_start_otbr_addon() await self._configure_and_start_otbr_addon()
except AddonError as err: except AddonError as err:
@@ -562,7 +570,36 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
}, },
) from err ) from err
return await self.async_step_pre_confirm_otbr() async def async_step_start_otbr_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Configure OTBR to point to the SkyConnect and run the addon."""
if self._start_otbr_addon_task is None:
self._start_otbr_addon_task = self.hass.async_create_task(
self._async_start_otbr_addon(),
"Start OTBR addon",
)
if not self._start_otbr_addon_task.done():
return self.async_show_progress(
step_id="start_otbr_addon",
progress_action="start_otbr_addon",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
},
progress_task=self._start_otbr_addon_task,
)
try:
await self._start_otbr_addon_task
except AbortFlow as err:
self._progress_error = err
return self.async_show_progress_done(next_step_id="progress_failed")
finally:
self._start_otbr_addon_task = None
return self.async_show_progress_done(next_step_id="pre_confirm_otbr")
async def async_step_pre_confirm_otbr( async def async_step_pre_confirm_otbr(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None

View File

@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware", "documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
"integration_type": "system", "integration_type": "system",
"requirements": [ "requirements": [
"universal-silabs-flasher==0.0.37", "universal-silabs-flasher==0.1.2",
"ha-silabs-firmware-client==0.3.0" "ha-silabs-firmware-client==0.3.0"
] ]
} }

View File

@@ -86,7 +86,8 @@ class BaseFirmwareUpdateEntity(
# Subclasses provide the mapping between firmware types and entity descriptions # Subclasses provide the mapping between firmware types and entity descriptions
entity_description: FirmwareUpdateEntityDescription entity_description: FirmwareUpdateEntityDescription
bootloader_reset_methods: list[ResetTarget] = [] BOOTLOADER_RESET_METHODS: list[ResetTarget]
APPLICATION_PROBE_METHODS: list[tuple[ApplicationType, int]]
_attr_supported_features = ( _attr_supported_features = (
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
@@ -278,7 +279,8 @@ class BaseFirmwareUpdateEntity(
device=self._current_device, device=self._current_device,
fw_data=fw_data, fw_data=fw_data,
expected_installed_firmware_type=self.entity_description.expected_firmware_type, expected_installed_firmware_type=self.entity_description.expected_firmware_type,
bootloader_reset_methods=self.bootloader_reset_methods, bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
progress_callback=self._update_progress, progress_callback=self._update_progress,
domain=self._config_entry.domain, domain=self._config_entry.domain,
) )

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
from collections import defaultdict from collections import defaultdict
from collections.abc import AsyncIterator, Callable, Iterable, Sequence from collections.abc import AsyncIterator, Callable, Sequence
from contextlib import AsyncExitStack, asynccontextmanager from contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
@@ -309,15 +309,20 @@ async def guess_firmware_info(hass: HomeAssistant, device_path: str) -> Firmware
async def probe_silabs_firmware_info( async def probe_silabs_firmware_info(
device: str, *, probe_methods: Iterable[ApplicationType] | None = None device: str,
*,
bootloader_reset_methods: Sequence[ResetTarget],
application_probe_methods: Sequence[tuple[ApplicationType, int]],
) -> FirmwareInfo | None: ) -> FirmwareInfo | None:
"""Probe the running firmware on a SiLabs device.""" """Probe the running firmware on a SiLabs device."""
flasher = Flasher( flasher = Flasher(
device=device, device=device,
**( probe_methods=tuple(
{"probe_methods": [m.as_flasher_application_type() for m in probe_methods]} (m.as_flasher_application_type(), baudrate)
if probe_methods for m, baudrate in application_probe_methods
else {} ),
bootloader_reset=tuple(
m.as_flasher_reset_target() for m in bootloader_reset_methods
), ),
) )
@@ -343,11 +348,18 @@ async def probe_silabs_firmware_info(
async def probe_silabs_firmware_type( async def probe_silabs_firmware_type(
device: str, *, probe_methods: Iterable[ApplicationType] | None = None device: str,
*,
bootloader_reset_methods: Sequence[ResetTarget],
application_probe_methods: Sequence[tuple[ApplicationType, int]],
) -> ApplicationType | None: ) -> ApplicationType | None:
"""Probe the running firmware type on a SiLabs device.""" """Probe the running firmware type on a SiLabs device."""
fw_info = await probe_silabs_firmware_info(device, probe_methods=probe_methods) fw_info = await probe_silabs_firmware_info(
device,
bootloader_reset_methods=bootloader_reset_methods,
application_probe_methods=application_probe_methods,
)
if fw_info is None: if fw_info is None:
return None return None
@@ -359,12 +371,22 @@ async def async_flash_silabs_firmware(
device: str, device: str,
fw_data: bytes, fw_data: bytes,
expected_installed_firmware_type: ApplicationType, expected_installed_firmware_type: ApplicationType,
bootloader_reset_methods: Sequence[ResetTarget] = (), bootloader_reset_methods: Sequence[ResetTarget],
application_probe_methods: Sequence[tuple[ApplicationType, int]],
progress_callback: Callable[[int, int], None] | None = None, progress_callback: Callable[[int, int], None] | None = None,
*, *,
domain: str = DOMAIN, domain: str = DOMAIN,
) -> FirmwareInfo: ) -> FirmwareInfo:
"""Flash firmware to the SiLabs device.""" """Flash firmware to the SiLabs device."""
if not any(
method == expected_installed_firmware_type
for method, _ in application_probe_methods
):
raise ValueError(
f"Expected installed firmware type {expected_installed_firmware_type!r}"
f" not in application probe methods {application_probe_methods!r}"
)
async with async_firmware_update_context(hass, device, domain): async with async_firmware_update_context(hass, device, domain):
firmware_info = await guess_firmware_info(hass, device) firmware_info = await guess_firmware_info(hass, device)
_LOGGER.debug("Identified firmware info: %s", firmware_info) _LOGGER.debug("Identified firmware info: %s", firmware_info)
@@ -373,11 +395,9 @@ async def async_flash_silabs_firmware(
flasher = Flasher( flasher = Flasher(
device=device, device=device,
probe_methods=( probe_methods=tuple(
ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(), (m.as_flasher_application_type(), baudrate)
ApplicationType.EZSP.as_flasher_application_type(), for m, baudrate in application_probe_methods
ApplicationType.SPINEL.as_flasher_application_type(),
ApplicationType.CPC.as_flasher_application_type(),
), ),
bootloader_reset=tuple( bootloader_reset=tuple(
m.as_flasher_reset_target() for m in bootloader_reset_methods m.as_flasher_reset_target() for m in bootloader_reset_methods
@@ -401,7 +421,13 @@ async def async_flash_silabs_firmware(
probed_firmware_info = await probe_silabs_firmware_info( probed_firmware_info = await probe_silabs_firmware_info(
device, device,
probe_methods=(expected_installed_firmware_type,), bootloader_reset_methods=bootloader_reset_methods,
# Only probe for the expected installed firmware type
application_probe_methods=[
(method, baudrate)
for method, baudrate in application_probe_methods
if method == expected_installed_firmware_type
],
) )
if probed_firmware_info is None: if probed_firmware_info is None:

View File

@@ -16,6 +16,7 @@ from homeassistant.components.homeassistant_hardware.helpers import (
from homeassistant.components.homeassistant_hardware.util import ( from homeassistant.components.homeassistant_hardware.util import (
ApplicationType, ApplicationType,
FirmwareInfo, FirmwareInfo,
ResetTarget,
) )
from homeassistant.components.usb import ( from homeassistant.components.usb import (
usb_service_info_from_device, usb_service_info_from_device,
@@ -79,6 +80,20 @@ class SkyConnectFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
context: ConfigFlowContext context: ConfigFlowContext
ZIGBEE_BAUDRATE = 115200
# There is no hardware bootloader trigger
BOOTLOADER_RESET_METHODS: list[ResetTarget] = []
APPLICATION_PROBE_METHODS = [
(ApplicationType.GECKO_BOOTLOADER, 115200),
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
(ApplicationType.SPINEL, 460800),
# CPC baudrates can be removed once multiprotocol is removed
(ApplicationType.CPC, 115200),
(ApplicationType.CPC, 230400),
(ApplicationType.CPC, 460800),
(ApplicationType.ROUTER, 115200),
]
def _get_translation_placeholders(self) -> dict[str, str]: def _get_translation_placeholders(self) -> dict[str, str]:
"""Shared translation placeholders.""" """Shared translation placeholders."""
placeholders = { placeholders = {

View File

@@ -6,6 +6,12 @@
"dependencies": ["hardware", "usb", "homeassistant_hardware"], "dependencies": ["hardware", "usb", "homeassistant_hardware"],
"documentation": "https://www.home-assistant.io/integrations/homeassistant_sky_connect", "documentation": "https://www.home-assistant.io/integrations/homeassistant_sky_connect",
"integration_type": "hardware", "integration_type": "hardware",
"loggers": [
"bellows",
"universal_silabs_flasher",
"zigpy.serial",
"serial_asyncio_fast"
],
"usb": [ "usb": [
{ {
"description": "*skyconnect v1.0*", "description": "*skyconnect v1.0*",

View File

@@ -23,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeAssistantSkyConnectConfigEntry from . import HomeAssistantSkyConnectConfigEntry
from .config_flow import SkyConnectFirmwareMixin
from .const import ( from .const import (
DOMAIN, DOMAIN,
FIRMWARE, FIRMWARE,
@@ -151,8 +152,8 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""SkyConnect firmware update entity.""" """SkyConnect firmware update entity."""
# The ZBT-1 does not have a hardware bootloader trigger BOOTLOADER_RESET_METHODS = SkyConnectFirmwareMixin.BOOTLOADER_RESET_METHODS
bootloader_reset_methods = [] APPLICATION_PROBE_METHODS = SkyConnectFirmwareMixin.APPLICATION_PROBE_METHODS
def __init__( def __init__(
self, self,

View File

@@ -82,7 +82,18 @@ else:
class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol): class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
"""Mixin for Home Assistant Yellow firmware methods.""" """Mixin for Home Assistant Yellow firmware methods."""
ZIGBEE_BAUDRATE = 115200
BOOTLOADER_RESET_METHODS = [ResetTarget.YELLOW] BOOTLOADER_RESET_METHODS = [ResetTarget.YELLOW]
APPLICATION_PROBE_METHODS = [
(ApplicationType.GECKO_BOOTLOADER, 115200),
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
(ApplicationType.SPINEL, 460800),
# CPC baudrates can be removed once multiprotocol is removed
(ApplicationType.CPC, 115200),
(ApplicationType.CPC, 230400),
(ApplicationType.CPC, 460800),
(ApplicationType.ROUTER, 115200),
]
async def async_step_install_zigbee_firmware( async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@@ -146,7 +157,11 @@ class HomeAssistantYellowConfigFlow(
assert self._device is not None assert self._device is not None
# We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this # We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this
self._probed_firmware_info = await probe_silabs_firmware_info(self._device) self._probed_firmware_info = await probe_silabs_firmware_info(
self._device,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
)
# Kick off ZHA hardware discovery automatically if Zigbee firmware is running # Kick off ZHA hardware discovery automatically if Zigbee firmware is running
if ( if (

View File

@@ -7,5 +7,11 @@
"dependencies": ["hardware", "homeassistant_hardware"], "dependencies": ["hardware", "homeassistant_hardware"],
"documentation": "https://www.home-assistant.io/integrations/homeassistant_yellow", "documentation": "https://www.home-assistant.io/integrations/homeassistant_yellow",
"integration_type": "hardware", "integration_type": "hardware",
"loggers": [
"bellows",
"universal_silabs_flasher",
"zigpy.serial",
"serial_asyncio_fast"
],
"single_config_entry": true "single_config_entry": true
} }

View File

@@ -14,7 +14,6 @@ from homeassistant.components.homeassistant_hardware.update import (
from homeassistant.components.homeassistant_hardware.util import ( from homeassistant.components.homeassistant_hardware.util import (
ApplicationType, ApplicationType,
FirmwareInfo, FirmwareInfo,
ResetTarget,
) )
from homeassistant.components.update import UpdateDeviceClass from homeassistant.components.update import UpdateDeviceClass
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
@@ -24,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeAssistantYellowConfigEntry from . import HomeAssistantYellowConfigEntry
from .config_flow import YellowFirmwareMixin
from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, MANUFACTURER, MODEL, RADIO_DEVICE from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, MANUFACTURER, MODEL, RADIO_DEVICE
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -150,7 +150,8 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""Yellow firmware update entity.""" """Yellow firmware update entity."""
bootloader_reset_methods = [ResetTarget.YELLOW] # Triggers a GPIO reset BOOTLOADER_RESET_METHODS = YellowFirmwareMixin.BOOTLOADER_RESET_METHODS
APPLICATION_PROBE_METHODS = YellowFirmwareMixin.APPLICATION_PROBE_METHODS
def __init__( def __init__(
self, self,

View File

@@ -121,12 +121,15 @@ class AutomowerBaseEntity(CoordinatorEntity[AutomowerDataUpdateCoordinator]):
"""Initialize AutomowerEntity.""" """Initialize AutomowerEntity."""
super().__init__(coordinator) super().__init__(coordinator)
self.mower_id = mower_id self.mower_id = mower_id
parts = self.mower_attributes.system.model.split(maxsplit=2) model_witout_manufacturer = self.mower_attributes.system.model.removeprefix(
"Husqvarna "
).removeprefix("HUSQVARNA ")
parts = model_witout_manufacturer.split(maxsplit=1)
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, mower_id)}, identifiers={(DOMAIN, mower_id)},
manufacturer=parts[0], manufacturer="Husqvarna",
model=parts[1], model=parts[0].capitalize().removesuffix("®"),
model_id=parts[2], model_id=parts[1],
name=self.mower_attributes.system.name, name=self.mower_attributes.system.name,
serial_number=self.mower_attributes.system.serial_number, serial_number=self.mower_attributes.system.serial_number,
suggested_area="Garden", suggested_area="Garden",

View File

@@ -9,5 +9,5 @@
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["aioautomower"], "loggers": ["aioautomower"],
"quality_scale": "silver", "quality_scale": "silver",
"requirements": ["aioautomower==2.7.0"] "requirements": ["aioautomower==2.7.1"]
} }

View File

@@ -112,6 +112,7 @@ async def async_setup_entry(
update_method=async_update_data, update_method=async_update_data,
# Polling interval. Will only be polled if there are subscribers. # Polling interval. Will only be polled if there are subscribers.
update_interval=timedelta(hours=1), update_interval=timedelta(hours=1),
config_entry=entry,
) )
# Fetch initial data so we have data when entities subscribe # Fetch initial data so we have data when entities subscribe

View File

@@ -13,6 +13,7 @@ from typing import Any
from aiohttp import web from aiohttp import web
from hyperion import client from hyperion import client
from hyperion.const import ( from hyperion.const import (
KEY_DATA,
KEY_IMAGE, KEY_IMAGE,
KEY_IMAGE_STREAM, KEY_IMAGE_STREAM,
KEY_LEDCOLORS, KEY_LEDCOLORS,
@@ -155,7 +156,8 @@ class HyperionCamera(Camera):
"""Update Hyperion components.""" """Update Hyperion components."""
if not img: if not img:
return return
img_data = img.get(KEY_RESULT, {}).get(KEY_IMAGE) # Prefer KEY_DATA (Hyperion server >= 2.1.1); fall back to KEY_RESULT for older server versions
img_data = img.get(KEY_DATA, img.get(KEY_RESULT, {})).get(KEY_IMAGE)
if not img_data or not img_data.startswith(IMAGE_STREAM_JPG_SENTINEL): if not img_data or not img_data.startswith(IMAGE_STREAM_JPG_SENTINEL):
return return
async with self._image_cond: async with self._image_cond:

View File

@@ -12,6 +12,7 @@ from pyicloud.exceptions import (
PyiCloudFailedLoginException, PyiCloudFailedLoginException,
PyiCloudNoDevicesException, PyiCloudNoDevicesException,
PyiCloudServiceNotActivatedException, PyiCloudServiceNotActivatedException,
PyiCloudServiceUnavailable,
) )
from pyicloud.services.findmyiphone import AppleDevice from pyicloud.services.findmyiphone import AppleDevice
@@ -130,15 +131,21 @@ class IcloudAccount:
except ( except (
PyiCloudServiceNotActivatedException, PyiCloudServiceNotActivatedException,
PyiCloudNoDevicesException, PyiCloudNoDevicesException,
PyiCloudServiceUnavailable,
) as err: ) as err:
_LOGGER.error("No iCloud device found") _LOGGER.error("No iCloud device found")
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err
self._owner_fullname = f"{user_info['firstName']} {user_info['lastName']}" if user_info is None:
raise ConfigEntryNotReady("No user info found in iCloud devices response")
self._owner_fullname = (
f"{user_info.get('firstName')} {user_info.get('lastName')}"
)
self._family_members_fullname = {} self._family_members_fullname = {}
if user_info.get("membersInfo") is not None: if user_info.get("membersInfo") is not None:
for prs_id, member in user_info["membersInfo"].items(): for prs_id, member in user_info.get("membersInfo").items():
self._family_members_fullname[prs_id] = ( self._family_members_fullname[prs_id] = (
f"{member['firstName']} {member['lastName']}" f"{member['firstName']} {member['lastName']}"
) )

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/icloud", "documentation": "https://www.home-assistant.io/integrations/icloud",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["keyrings.alt", "pyicloud"], "loggers": ["keyrings.alt", "pyicloud"],
"requirements": ["pyicloud==2.1.0"] "requirements": ["pyicloud==2.2.0"]
} }

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from propcache.api import cached_property
from pyituran import Vehicle from pyituran import Vehicle
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
@@ -69,7 +68,7 @@ class IturanBinarySensor(IturanBaseEntity, BinarySensorEntity):
super().__init__(coordinator, license_plate, description.key) super().__init__(coordinator, license_plate, description.key)
self.entity_description = description self.entity_description = description
@cached_property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return true if the binary sensor is on.""" """Return true if the binary sensor is on."""
return self.entity_description.value_fn(self.vehicle) return self.entity_description.value_fn(self.vehicle)

View File

@@ -2,8 +2,6 @@
from __future__ import annotations from __future__ import annotations
from propcache.api import cached_property
from homeassistant.components.device_tracker import TrackerEntity from homeassistant.components.device_tracker import TrackerEntity
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -40,12 +38,12 @@ class IturanDeviceTracker(IturanBaseEntity, TrackerEntity):
"""Initialize the device tracker.""" """Initialize the device tracker."""
super().__init__(coordinator, license_plate, "device_tracker") super().__init__(coordinator, license_plate, "device_tracker")
@cached_property @property
def latitude(self) -> float | None: def latitude(self) -> float | None:
"""Return latitude value of the device.""" """Return latitude value of the device."""
return self.vehicle.gps_coordinates[0] return self.vehicle.gps_coordinates[0]
@cached_property @property
def longitude(self) -> float | None: def longitude(self) -> float | None:
"""Return longitude value of the device.""" """Return longitude value of the device."""
return self.vehicle.gps_coordinates[1] return self.vehicle.gps_coordinates[1]

View File

@@ -6,7 +6,6 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from propcache.api import cached_property
from pyituran import Vehicle from pyituran import Vehicle
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
@@ -133,7 +132,7 @@ class IturanSensor(IturanBaseEntity, SensorEntity):
super().__init__(coordinator, license_plate, description.key) super().__init__(coordinator, license_plate, description.key)
self.entity_description = description self.entity_description = description
@cached_property @property
def native_value(self) -> StateType | datetime: def native_value(self) -> StateType | datetime:
"""Return the state of the device.""" """Return the state of the device."""
return self.entity_description.value_fn(self.vehicle) return self.entity_description.value_fn(self.vehicle)

View File

@@ -11,7 +11,7 @@
"loggers": ["xknx", "xknxproject"], "loggers": ["xknx", "xknxproject"],
"quality_scale": "silver", "quality_scale": "silver",
"requirements": [ "requirements": [
"xknx==3.10.0", "xknx==3.10.1",
"xknxproject==3.8.2", "xknxproject==3.8.2",
"knx-frontend==2025.10.31.195356" "knx-frontend==2025.10.31.195356"
], ],

View File

@@ -359,7 +359,7 @@ CLIMATE_KNX_SCHEMA = vol.Schema(
write=False, state_required=True, valid_dpt="9.001" write=False, state_required=True, valid_dpt="9.001"
), ),
vol.Optional(CONF_GA_HUMIDITY_CURRENT): GASelector( vol.Optional(CONF_GA_HUMIDITY_CURRENT): GASelector(
write=False, valid_dpt="9.002" write=False, valid_dpt="9.007"
), ),
vol.Required(CONF_TARGET_TEMPERATURE): GroupSelect( vol.Required(CONF_TARGET_TEMPERATURE): GroupSelect(
GroupSelectOption( GroupSelectOption(

View File

@@ -221,7 +221,7 @@ async def library_payload(hass):
for child in library_info.children: for child in library_info.children:
child.thumbnail = "https://brands.home-assistant.io/_/kodi/logo.png" child.thumbnail = "https://brands.home-assistant.io/_/kodi/logo.png"
with contextlib.suppress(media_source.BrowseError): with contextlib.suppress(BrowseError):
item = await media_source.async_browse_media( item = await media_source.async_browse_media(
hass, None, content_filter=media_source_content_filter hass, None, content_filter=media_source_content_filter
) )

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from abc import abstractmethod from abc import abstractmethod
from asyncio import Task
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
import logging import logging
@@ -44,7 +45,7 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
_default_update_interval = SCAN_INTERVAL _default_update_interval = SCAN_INTERVAL
config_entry: LaMarzoccoConfigEntry config_entry: LaMarzoccoConfigEntry
websocket_terminated = True _websocket_task: Task | None = None
def __init__( def __init__(
self, self,
@@ -64,6 +65,13 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
self.device = device self.device = device
self.cloud_client = cloud_client self.cloud_client = cloud_client
@property
def websocket_terminated(self) -> bool:
"""Return True if the websocket task is terminated or not running."""
if self._websocket_task is None:
return True
return self._websocket_task.done()
async def _async_update_data(self) -> None: async def _async_update_data(self) -> None:
"""Do the data update.""" """Do the data update."""
try: try:
@@ -95,13 +103,14 @@ class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
# ensure token stays valid; does nothing if token is still valid # ensure token stays valid; does nothing if token is still valid
await self.cloud_client.async_get_access_token() await self.cloud_client.async_get_access_token()
if self.device.websocket.connected: # Only skip websocket reconnection if it's currently connected and the task is still running
if self.device.websocket.connected and not self.websocket_terminated:
return return
await self.device.get_dashboard() await self.device.get_dashboard()
_LOGGER.debug("Current status: %s", self.device.dashboard.to_dict()) _LOGGER.debug("Current status: %s", self.device.dashboard.to_dict())
self.config_entry.async_create_background_task( self._websocket_task = self.config_entry.async_create_background_task(
hass=self.hass, hass=self.hass,
target=self.connect_websocket(), target=self.connect_websocket(),
name="lm_websocket_task", name="lm_websocket_task",
@@ -120,7 +129,6 @@ class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
_LOGGER.debug("Init WebSocket in background task") _LOGGER.debug("Init WebSocket in background task")
self.websocket_terminated = False
self.async_update_listeners() self.async_update_listeners()
await self.device.connect_dashboard_websocket( await self.device.connect_dashboard_websocket(
@@ -129,7 +137,6 @@ class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
disconnect_callback=self.async_update_listeners, disconnect_callback=self.async_update_listeners,
) )
self.websocket_terminated = True
self.async_update_listeners() self.async_update_listeners()

View File

@@ -125,7 +125,7 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity):
await self.coordinator.device.update_firmware() await self.coordinator.device.update_firmware()
while ( while (
update_progress := await self.coordinator.device.get_firmware() update_progress := await self.coordinator.device.get_firmware()
).command_status is UpdateStatus.IN_PROGRESS: ).command_status is not UpdateStatus.UPDATED:
if counter >= MAX_UPDATE_WAIT: if counter >= MAX_UPDATE_WAIT:
_raise_timeout_error() _raise_timeout_error()
self._attr_update_percentage = update_progress.progress_percentage self._attr_update_percentage = update_progress.progress_percentage

View File

@@ -139,7 +139,7 @@ class MeteoLtWeatherEntity(CoordinatorEntity[MeteoLtUpdateCoordinator], WeatherE
forecasts_by_date[date].append(timestamp) forecasts_by_date[date].append(timestamp)
daily_forecasts = [] daily_forecasts = []
for date in sorted(forecasts_by_date.keys())[:5]: for date in sorted(forecasts_by_date.keys()):
day_forecasts = forecasts_by_date[date] day_forecasts = forecasts_by_date[date]
if not day_forecasts: if not day_forecasts:
continue continue
@@ -186,5 +186,5 @@ class MeteoLtWeatherEntity(CoordinatorEntity[MeteoLtUpdateCoordinator], WeatherE
return None return None
return [ return [
self._convert_forecast_data(forecast_data) self._convert_forecast_data(forecast_data)
for forecast_data in self.coordinator.data.forecast_timestamps[:24] for forecast_data in self.coordinator.data.forecast_timestamps
] ]

View File

@@ -6,7 +6,7 @@ from dataclasses import dataclass
import logging import logging
from typing import Final from typing import Final
import aiohttp from aiohttp import ClientResponseError
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@@ -153,11 +153,12 @@ class MieleButton(MieleEntity, ButtonEntity):
self._device_id, self._device_id,
{PROCESS_ACTION: self.entity_description.press_data}, {PROCESS_ACTION: self.entity_description.press_data},
) )
except aiohttp.ClientResponseError as ex: except ClientResponseError as err:
_LOGGER.debug("Error setting button state for %s: %s", self.entity_id, err)
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="set_state_error", translation_key="set_state_error",
translation_placeholders={ translation_placeholders={
"entity": self.entity_id, "entity": self.entity_id,
}, },
) from ex ) from err

View File

@@ -7,7 +7,7 @@ from dataclasses import dataclass
import logging import logging
from typing import Any, Final, cast from typing import Any, Final, cast
import aiohttp from aiohttp import ClientResponseError
from pymiele import MieleDevice, MieleTemperature from pymiele import MieleDevice, MieleTemperature
from homeassistant.components.climate import ( from homeassistant.components.climate import (
@@ -250,7 +250,8 @@ class MieleClimate(MieleEntity, ClimateEntity):
cast(float, kwargs.get(ATTR_TEMPERATURE)), cast(float, kwargs.get(ATTR_TEMPERATURE)),
self.entity_description.zone, self.entity_description.zone,
) )
except aiohttp.ClientError as err: except ClientResponseError as err:
_LOGGER.debug("Error setting climate state for %s: %s", self.entity_id, err)
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="set_state_error", translation_key="set_state_error",

View File

@@ -73,7 +73,7 @@ class MieleDataUpdateCoordinator(DataUpdateCoordinator[MieleCoordinatorData]):
_LOGGER.debug( _LOGGER.debug(
"Error fetching actions for device %s: Status: %s, Message: %s", "Error fetching actions for device %s: Status: %s, Message: %s",
device_id, device_id,
err.status, str(err.status),
err.message, err.message,
) )
actions_json = {} actions_json = {}

View File

@@ -142,14 +142,15 @@ class MieleFan(MieleEntity, FanEntity):
await self.api.send_action( await self.api.send_action(
self._device_id, {VENTILATION_STEP: ventilation_step} self._device_id, {VENTILATION_STEP: ventilation_step}
) )
except ClientResponseError as ex: except ClientResponseError as err:
_LOGGER.debug("Error setting fan state for %s: %s", self.entity_id, err)
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="set_state_error", translation_key="set_state_error",
translation_placeholders={ translation_placeholders={
"entity": self.entity_id, "entity": self.entity_id,
}, },
) from ex ) from err
self.device.state_ventilation_step = ventilation_step self.device.state_ventilation_step = ventilation_step
self.async_write_ha_state() self.async_write_ha_state()
@@ -171,6 +172,7 @@ class MieleFan(MieleEntity, FanEntity):
translation_key="set_state_error", translation_key="set_state_error",
translation_placeholders={ translation_placeholders={
"entity": self.entity_id, "entity": self.entity_id,
"err_status": str(ex.status),
}, },
) from ex ) from ex
@@ -188,6 +190,7 @@ class MieleFan(MieleEntity, FanEntity):
translation_key="set_state_error", translation_key="set_state_error",
translation_placeholders={ translation_placeholders={
"entity": self.entity_id, "entity": self.entity_id,
"err_status": str(ex.status),
}, },
) from ex ) from ex

View File

@@ -7,7 +7,7 @@ from dataclasses import dataclass
import logging import logging
from typing import Any, Final from typing import Any, Final
import aiohttp from aiohttp import ClientResponseError
from homeassistant.components.light import ( from homeassistant.components.light import (
ColorMode, ColorMode,
@@ -131,7 +131,8 @@ class MieleLight(MieleEntity, LightEntity):
await self.api.send_action( await self.api.send_action(
self._device_id, {self.entity_description.light_type: mode} self._device_id, {self.entity_description.light_type: mode}
) )
except aiohttp.ClientError as err: except ClientResponseError as err:
_LOGGER.debug("Error setting light state for %s: %s", self.entity_id, err)
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="set_state_error", translation_key="set_state_error",

View File

@@ -943,13 +943,19 @@ class MieleConsumptionSensor(MieleRestorableSensor):
"""Update the last value of the sensor.""" """Update the last value of the sensor."""
current_value = self.entity_description.value_fn(self.device) current_value = self.entity_description.value_fn(self.device)
current_status = StateStatus(self.device.state_status) current_status = StateStatus(self.device.state_status)
# Guard for corrupt restored value
restored_value = (
self._attr_native_value
if isinstance(self._attr_native_value, (int, float))
else 0
)
last_value = ( last_value = (
float(cast(str, self._attr_native_value)) float(cast(str, restored_value))
if self._attr_native_value is not None if self._attr_native_value is not None
else 0 else 0
) )
# force unknown when appliance is not able to report consumption # Force unknown when appliance is not able to report consumption
if current_status in ( if current_status in (
StateStatus.ON, StateStatus.ON,
StateStatus.OFF, StateStatus.OFF,

View File

@@ -4,7 +4,7 @@ from datetime import timedelta
import logging import logging
from typing import cast from typing import cast
import aiohttp from aiohttp import ClientResponseError
import voluptuous as vol import voluptuous as vol
from homeassistant.const import ATTR_DEVICE_ID, ATTR_TEMPERATURE from homeassistant.const import ATTR_DEVICE_ID, ATTR_TEMPERATURE
@@ -107,7 +107,7 @@ async def set_program(call: ServiceCall) -> None:
data = {"programId": call.data[ATTR_PROGRAM_ID]} data = {"programId": call.data[ATTR_PROGRAM_ID]}
try: try:
await api.set_program(serial_number, data) await api.set_program(serial_number, data)
except aiohttp.ClientResponseError as ex: except ClientResponseError as ex:
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="set_program_error", translation_key="set_program_error",
@@ -137,7 +137,7 @@ async def set_program_oven(call: ServiceCall) -> None:
data["temperature"] = call.data[ATTR_TEMPERATURE] data["temperature"] = call.data[ATTR_TEMPERATURE]
try: try:
await api.set_program(serial_number, data) await api.set_program(serial_number, data)
except aiohttp.ClientResponseError as ex: except ClientResponseError as ex:
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="set_program_oven_error", translation_key="set_program_oven_error",
@@ -157,7 +157,7 @@ async def get_programs(call: ServiceCall) -> ServiceResponse:
try: try:
programs = await api.get_programs(serial_number) programs = await api.get_programs(serial_number)
except aiohttp.ClientResponseError as ex: except ClientResponseError as ex:
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="get_programs_error", translation_key="get_programs_error",

View File

@@ -7,7 +7,7 @@ from dataclasses import dataclass
import logging import logging
from typing import Any, Final, cast from typing import Any, Final, cast
import aiohttp from aiohttp import ClientResponseError
from pymiele import MieleDevice from pymiele import MieleDevice
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
@@ -165,7 +165,8 @@ class MieleSwitch(MieleEntity, SwitchEntity):
"""Set switch to mode.""" """Set switch to mode."""
try: try:
await self.api.send_action(self._device_id, mode) await self.api.send_action(self._device_id, mode)
except aiohttp.ClientError as err: except ClientResponseError as err:
_LOGGER.debug("Error setting switch state for %s: %s", self.entity_id, err)
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="set_state_error", translation_key="set_state_error",
@@ -197,7 +198,8 @@ class MielePowerSwitch(MieleSwitch):
"""Set switch to mode.""" """Set switch to mode."""
try: try:
await self.api.send_action(self._device_id, mode) await self.api.send_action(self._device_id, mode)
except aiohttp.ClientError as err: except ClientResponseError as err:
_LOGGER.debug("Error setting switch state for %s: %s", self.entity_id, err)
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="set_state_error", translation_key="set_state_error",

View File

@@ -189,14 +189,15 @@ class MieleVacuum(MieleEntity, StateVacuumEntity):
"""Send action to the device.""" """Send action to the device."""
try: try:
await self.api.send_action(device_id, action) await self.api.send_action(device_id, action)
except ClientResponseError as ex: except ClientResponseError as err:
_LOGGER.debug("Error setting vacuum state for %s: %s", self.entity_id, err)
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="set_state_error", translation_key="set_state_error",
translation_placeholders={ translation_placeholders={
"entity": self.entity_id, "entity": self.entity_id,
}, },
) from ex ) from err
async def async_clean_spot(self, **kwargs: Any) -> None: async def async_clean_spot(self, **kwargs: Any) -> None:
"""Clean spot.""" """Clean spot."""

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/mill", "documentation": "https://www.home-assistant.io/integrations/mill",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["mill", "mill_local"], "loggers": ["mill", "mill_local"],
"requirements": ["millheater==0.14.0", "mill-local==0.3.0"] "requirements": ["millheater==0.14.1", "mill-local==0.3.0"]
} }

View File

@@ -128,12 +128,41 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
registration_name = f"Mobile App: {registration[ATTR_DEVICE_NAME]}" registration_name = f"Mobile App: {registration[ATTR_DEVICE_NAME]}"
webhook_register(hass, DOMAIN, registration_name, webhook_id, handle_webhook) webhook_register(hass, DOMAIN, registration_name, webhook_id, handle_webhook)
def clean_cloudhook() -> None:
"""Clean up cloudhook from config entry."""
if CONF_CLOUDHOOK_URL in entry.data:
data = dict(entry.data)
data.pop(CONF_CLOUDHOOK_URL)
hass.config_entries.async_update_entry(entry, data=data)
def on_cloudhook_change(cloudhook: dict[str, Any] | None) -> None:
"""Handle cloudhook changes."""
if cloudhook:
if entry.data.get(CONF_CLOUDHOOK_URL) == cloudhook[CONF_CLOUDHOOK_URL]:
return
hass.config_entries.async_update_entry(
entry,
data={**entry.data, CONF_CLOUDHOOK_URL: cloudhook[CONF_CLOUDHOOK_URL]},
)
else:
clean_cloudhook()
async def manage_cloudhook(state: cloud.CloudConnectionState) -> None: async def manage_cloudhook(state: cloud.CloudConnectionState) -> None:
if ( if (
state is cloud.CloudConnectionState.CLOUD_CONNECTED state is cloud.CloudConnectionState.CLOUD_CONNECTED
and CONF_CLOUDHOOK_URL not in entry.data and CONF_CLOUDHOOK_URL not in entry.data
): ):
await async_create_cloud_hook(hass, webhook_id, entry) await async_create_cloud_hook(hass, webhook_id, entry)
elif (
state is cloud.CloudConnectionState.CLOUD_DISCONNECTED
and not cloud.async_is_logged_in(hass)
):
clean_cloudhook()
entry.async_on_unload(
cloud.async_listen_cloudhook_change(hass, webhook_id, on_cloudhook_change)
)
if cloud.async_is_logged_in(hass): if cloud.async_is_logged_in(hass):
if ( if (
@@ -144,9 +173,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await async_create_cloud_hook(hass, webhook_id, entry) await async_create_cloud_hook(hass, webhook_id, entry)
elif CONF_CLOUDHOOK_URL in entry.data: elif CONF_CLOUDHOOK_URL in entry.data:
# If we have a cloudhook but no longer logged in to the cloud, remove it from the entry # If we have a cloudhook but no longer logged in to the cloud, remove it from the entry
data = dict(entry.data) clean_cloudhook()
data.pop(CONF_CLOUDHOOK_URL)
hass.config_entries.async_update_entry(entry, data=data)
entry.async_on_unload(cloud.async_listen_connection_change(hass, manage_cloudhook)) entry.async_on_unload(cloud.async_listen_connection_change(hass, manage_cloudhook))

View File

@@ -61,10 +61,12 @@ async def async_setup_entry(
async_add_entities([MobileAppBinarySensor(data, config_entry)]) async_add_entities([MobileAppBinarySensor(data, config_entry)])
async_dispatcher_connect( config_entry.async_on_unload(
hass, async_dispatcher_connect(
f"{DOMAIN}_{ENTITY_TYPE}_register", hass,
handle_sensor_registration, f"{DOMAIN}_{ENTITY_TYPE}_register",
handle_sensor_registration,
)
) )

View File

@@ -72,10 +72,12 @@ async def async_setup_entry(
async_add_entities([MobileAppSensor(data, config_entry)]) async_add_entities([MobileAppSensor(data, config_entry)])
async_dispatcher_connect( config_entry.async_on_unload(
hass, async_dispatcher_connect(
f"{DOMAIN}_{ENTITY_TYPE}_register", hass,
handle_sensor_registration, f"{DOMAIN}_{ENTITY_TYPE}_register",
handle_sensor_registration,
)
) )

View File

@@ -738,10 +738,9 @@ async def webhook_get_config(
"theme_color": MANIFEST_JSON["theme_color"], "theme_color": MANIFEST_JSON["theme_color"],
} }
if CONF_CLOUDHOOK_URL in config_entry.data:
resp[CONF_CLOUDHOOK_URL] = config_entry.data[CONF_CLOUDHOOK_URL]
if cloud.async_active_subscription(hass): if cloud.async_active_subscription(hass):
if CONF_CLOUDHOOK_URL in config_entry.data:
resp[CONF_CLOUDHOOK_URL] = config_entry.data[CONF_CLOUDHOOK_URL]
with suppress(cloud.CloudNotAvailable): with suppress(cloud.CloudNotAvailable):
resp[CONF_REMOTE_UI_URL] = cloud.async_remote_ui_url(hass) resp[CONF_REMOTE_UI_URL] = cloud.async_remote_ui_url(hass)

View File

@@ -239,6 +239,7 @@ class MotionBaseDevice(MotionCoordinatorEntity, CoverEntity):
angle = kwargs.get(ATTR_TILT_POSITION) angle = kwargs.get(ATTR_TILT_POSITION)
if angle is not None: if angle is not None:
angle = angle * 180 / 100 angle = angle * 180 / 100
angle = 180 - angle
async with self._api_lock: async with self._api_lock:
await self.hass.async_add_executor_job( await self.hass.async_add_executor_job(
self._blind.Set_position, self._blind.Set_position,

View File

@@ -4237,7 +4237,8 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
return self.async_show_form( return self.async_show_form(
step_id="entity", step_id="entity",
data_schema=data_schema, data_schema=data_schema,
description_placeholders={ description_placeholders=TRANSLATION_DESCRIPTION_PLACEHOLDERS
| {
"mqtt_device": device_name, "mqtt_device": device_name,
"entity_name_label": entity_name_label, "entity_name_label": entity_name_label,
"platform_label": platform_label, "platform_label": platform_label,

View File

@@ -1312,6 +1312,7 @@
"speed": "[%key:component::sensor::entity_component::speed::name%]", "speed": "[%key:component::sensor::entity_component::speed::name%]",
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]", "sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
"temperature": "[%key:component::sensor::entity_component::temperature::name%]", "temperature": "[%key:component::sensor::entity_component::temperature::name%]",
"temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]",
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]", "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",

View File

@@ -11,7 +11,6 @@ from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, PLATFORMS from .const import DOMAIN, PLATFORMS
from .coordinator import ( from .coordinator import (
OhmeAdvancedSettingsCoordinator,
OhmeChargeSessionCoordinator, OhmeChargeSessionCoordinator,
OhmeConfigEntry, OhmeConfigEntry,
OhmeDeviceInfoCoordinator, OhmeDeviceInfoCoordinator,
@@ -56,7 +55,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OhmeConfigEntry) -> bool
coordinators = ( coordinators = (
OhmeChargeSessionCoordinator(hass, entry, client), OhmeChargeSessionCoordinator(hass, entry, client),
OhmeAdvancedSettingsCoordinator(hass, entry, client),
OhmeDeviceInfoCoordinator(hass, entry, client), OhmeDeviceInfoCoordinator(hass, entry, client),
) )

View File

@@ -10,7 +10,7 @@ import logging
from ohme import ApiException, OhmeApiClient from ohme import ApiException, OhmeApiClient
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN from .const import DOMAIN
@@ -23,7 +23,6 @@ class OhmeRuntimeData:
"""Dataclass to hold ohme coordinators.""" """Dataclass to hold ohme coordinators."""
charge_session_coordinator: OhmeChargeSessionCoordinator charge_session_coordinator: OhmeChargeSessionCoordinator
advanced_settings_coordinator: OhmeAdvancedSettingsCoordinator
device_info_coordinator: OhmeDeviceInfoCoordinator device_info_coordinator: OhmeDeviceInfoCoordinator
@@ -78,31 +77,6 @@ class OhmeChargeSessionCoordinator(OhmeBaseCoordinator):
await self.client.async_get_charge_session() await self.client.async_get_charge_session()
class OhmeAdvancedSettingsCoordinator(OhmeBaseCoordinator):
"""Coordinator to pull settings and charger state from the API."""
coordinator_name = "Advanced Settings"
def __init__(
self, hass: HomeAssistant, config_entry: OhmeConfigEntry, client: OhmeApiClient
) -> None:
"""Initialise coordinator."""
super().__init__(hass, config_entry, client)
@callback
def _dummy_listener() -> None:
pass
# This coordinator is used by the API library to determine whether the
# charger is online and available. It is therefore required even if no
# entities are using it.
self.async_add_listener(_dummy_listener)
async def _internal_update_data(self) -> None:
"""Fetch data from API endpoint."""
await self.client.async_get_advanced_settings()
class OhmeDeviceInfoCoordinator(OhmeBaseCoordinator): class OhmeDeviceInfoCoordinator(OhmeBaseCoordinator):
"""Coordinator to pull device info and charger settings from the API.""" """Coordinator to pull device info and charger settings from the API."""

View File

@@ -7,5 +7,5 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["ohme==1.5.2"] "requirements": ["ohme==1.6.0"]
} }

View File

@@ -37,7 +37,7 @@ class OhmeSensorDescription(OhmeEntityDescription, SensorEntityDescription):
value_fn: Callable[[OhmeApiClient], str | int | float | None] value_fn: Callable[[OhmeApiClient], str | int | float | None]
SENSOR_CHARGE_SESSION = [ SENSORS = [
OhmeSensorDescription( OhmeSensorDescription(
key="status", key="status",
translation_key="status", translation_key="status",
@@ -91,18 +91,6 @@ SENSOR_CHARGE_SESSION = [
), ),
] ]
SENSOR_ADVANCED_SETTINGS = [
OhmeSensorDescription(
key="ct_current",
translation_key="ct_current",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
value_fn=lambda client: client.power.ct_amps,
is_supported_fn=lambda client: client.ct_connected,
entity_registry_enabled_default=False,
),
]
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@@ -110,16 +98,11 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up sensors.""" """Set up sensors."""
coordinators = config_entry.runtime_data coordinator = config_entry.runtime_data.charge_session_coordinator
coordinator_map = [
(SENSOR_CHARGE_SESSION, coordinators.charge_session_coordinator),
(SENSOR_ADVANCED_SETTINGS, coordinators.advanced_settings_coordinator),
]
async_add_entities( async_add_entities(
OhmeSensor(coordinator, description) OhmeSensor(coordinator, description)
for entities, coordinator in coordinator_map for description in SENSORS
for description in entities
if description.is_supported_fn(coordinator.client) if description.is_supported_fn(coordinator.client)
) )

View File

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

View File

@@ -8,6 +8,6 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["aio_ownet"], "loggers": ["aio_ownet"],
"quality_scale": "silver", "quality_scale": "silver",
"requirements": ["aio-ownet==0.0.4"], "requirements": ["aio-ownet==0.0.5"],
"zeroconf": ["_owserver._tcp.local."] "zeroconf": ["_owserver._tcp.local."]
} }

View File

@@ -9,5 +9,5 @@
"integration_type": "service", "integration_type": "service",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["openai==2.2.0", "python-open-router==0.3.2"] "requirements": ["openai==2.2.0", "python-open-router==0.3.3"]
} }

View File

@@ -15,5 +15,5 @@
"documentation": "https://www.home-assistant.io/integrations/palazzetti", "documentation": "https://www.home-assistant.io/integrations/palazzetti",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["pypalazzetti==0.1.19"] "requirements": ["pypalazzetti==0.1.20"]
} }

View File

@@ -7,5 +7,5 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["pyportainer==1.0.12"] "requirements": ["pyportainer==1.0.14"]
} }

View File

@@ -128,6 +128,7 @@
"speed": "[%key:component::sensor::entity_component::speed::name%]", "speed": "[%key:component::sensor::entity_component::speed::name%]",
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]", "sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
"temperature": "[%key:component::sensor::entity_component::temperature::name%]", "temperature": "[%key:component::sensor::entity_component::temperature::name%]",
"temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]",
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]", "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",

View File

@@ -26,6 +26,9 @@ def validate_db_schema(instance: Recorder) -> set[str]:
schema_errors |= validate_table_schema_supports_utf8( schema_errors |= validate_table_schema_supports_utf8(
instance, StatisticsMeta, (StatisticsMeta.statistic_id,) instance, StatisticsMeta, (StatisticsMeta.statistic_id,)
) )
schema_errors |= validate_table_schema_has_correct_collation(
instance, StatisticsMeta
)
for table in (Statistics, StatisticsShortTerm): for table in (Statistics, StatisticsShortTerm):
schema_errors |= validate_db_schema_precision(instance, table) schema_errors |= validate_db_schema_precision(instance, table)
schema_errors |= validate_table_schema_has_correct_collation(instance, table) schema_errors |= validate_table_schema_has_correct_collation(instance, table)

View File

@@ -54,7 +54,7 @@ CONTEXT_ID_AS_BINARY_SCHEMA_VERSION = 36
EVENT_TYPE_IDS_SCHEMA_VERSION = 37 EVENT_TYPE_IDS_SCHEMA_VERSION = 37
STATES_META_SCHEMA_VERSION = 38 STATES_META_SCHEMA_VERSION = 38
CIRCULAR_MEAN_SCHEMA_VERSION = 49 CIRCULAR_MEAN_SCHEMA_VERSION = 49
UNIT_CLASS_SCHEMA_VERSION = 51 UNIT_CLASS_SCHEMA_VERSION = 52
LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28 LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28
LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION = 43 LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION = 43

View File

@@ -71,7 +71,7 @@ class LegacyBase(DeclarativeBase):
"""Base class for tables, used for schema migration.""" """Base class for tables, used for schema migration."""
SCHEMA_VERSION = 51 SCHEMA_VERSION = 52
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)

View File

@@ -13,7 +13,15 @@ from typing import TYPE_CHECKING, Any, TypedDict, cast, final
from uuid import UUID from uuid import UUID
import sqlalchemy import sqlalchemy
from sqlalchemy import ForeignKeyConstraint, MetaData, Table, func, text, update from sqlalchemy import (
ForeignKeyConstraint,
MetaData,
Table,
cast as cast_,
func,
text,
update,
)
from sqlalchemy.engine import CursorResult, Engine from sqlalchemy.engine import CursorResult, Engine
from sqlalchemy.exc import ( from sqlalchemy.exc import (
DatabaseError, DatabaseError,
@@ -26,8 +34,9 @@ from sqlalchemy.exc import (
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from sqlalchemy.schema import AddConstraint, CreateTable, DropConstraint from sqlalchemy.schema import AddConstraint, CreateTable, DropConstraint
from sqlalchemy.sql.expression import true from sqlalchemy.sql.expression import and_, true
from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.lambdas import StatementLambdaElement
from sqlalchemy.types import BINARY
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.util.enum import try_parse_enum from homeassistant.util.enum import try_parse_enum
@@ -2044,14 +2053,74 @@ class _SchemaVersion50Migrator(_SchemaVersionMigrator, target_version=50):
class _SchemaVersion51Migrator(_SchemaVersionMigrator, target_version=51): class _SchemaVersion51Migrator(_SchemaVersionMigrator, target_version=51):
def _apply_update(self) -> None: def _apply_update(self) -> None:
"""Version specific update method.""" """Version specific update method."""
# Add unit class column to StatisticsMeta # Replaced with version 52 which corrects issues with MySQL string comparisons.
class _SchemaVersion52Migrator(_SchemaVersionMigrator, target_version=52):
def _apply_update(self) -> None:
"""Version specific update method."""
if self.engine.dialect.name == SupportedDialect.MYSQL:
self._apply_update_mysql()
else:
self._apply_update_postgresql_sqlite()
def _apply_update_mysql(self) -> None:
"""Version specific update method for mysql."""
_add_columns(self.session_maker, "statistics_meta", ["unit_class VARCHAR(255)"]) _add_columns(self.session_maker, "statistics_meta", ["unit_class VARCHAR(255)"])
with session_scope(session=self.session_maker()) as session: with session_scope(session=self.session_maker()) as session:
connection = session.connection() connection = session.connection()
for conv in _PRIMARY_UNIT_CONVERTERS: for conv in _PRIMARY_UNIT_CONVERTERS:
case_sensitive_units = {
u.encode("utf-8") if u else u for u in conv.VALID_UNITS
}
# Reset unit_class to None for entries that do not match
# the valid units (case sensitive) but matched before due to
# case insensitive comparisons.
connection.execute( connection.execute(
update(StatisticsMeta) update(StatisticsMeta)
.where(StatisticsMeta.unit_of_measurement.in_(conv.VALID_UNITS)) .where(
and_(
StatisticsMeta.unit_of_measurement.in_(conv.VALID_UNITS),
cast_(StatisticsMeta.unit_of_measurement, BINARY).not_in(
case_sensitive_units
),
)
)
.values(unit_class=None)
)
# Do an explicitly case sensitive match (actually binary) to set the
# correct unit_class. This is needed because we use the case sensitive
# utf8mb4_unicode_ci collation.
connection.execute(
update(StatisticsMeta)
.where(
and_(
cast_(StatisticsMeta.unit_of_measurement, BINARY).in_(
case_sensitive_units
),
StatisticsMeta.unit_class.is_(None),
)
)
.values(unit_class=conv.UNIT_CLASS)
)
def _apply_update_postgresql_sqlite(self) -> None:
"""Version specific update method for postgresql and sqlite."""
_add_columns(self.session_maker, "statistics_meta", ["unit_class VARCHAR(255)"])
with session_scope(session=self.session_maker()) as session:
connection = session.connection()
for conv in _PRIMARY_UNIT_CONVERTERS:
# Set the correct unit_class. Unlike MySQL, Postgres and SQLite
# have case sensitive string comparisons by default, so we
# can directly match on the valid units.
connection.execute(
update(StatisticsMeta)
.where(
and_(
StatisticsMeta.unit_of_measurement.in_(conv.VALID_UNITS),
StatisticsMeta.unit_class.is_(None),
)
)
.values(unit_class=conv.UNIT_CLASS) .values(unit_class=conv.UNIT_CLASS)
) )

View File

@@ -19,5 +19,5 @@
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["reolink_aio"], "loggers": ["reolink_aio"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["reolink-aio==0.16.4"] "requirements": ["reolink-aio==0.16.5"]
} }

View File

@@ -441,7 +441,7 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN):
def is_matching(self, other_flow: Self) -> bool: def is_matching(self, other_flow: Self) -> bool:
"""Return True if other_flow is matching this flow.""" """Return True if other_flow is matching this flow."""
return other_flow._host == self._host # noqa: SLF001 return getattr(other_flow, "_host", None) == self._host
@callback @callback
def _abort_if_manufacturer_is_not_samsung(self) -> None: def _abort_if_manufacturer_is_not_samsung(self) -> None:

View File

@@ -40,7 +40,7 @@
"samsungctl[websocket]==0.7.1", "samsungctl[websocket]==0.7.1",
"samsungtvws[async,encrypted]==2.7.2", "samsungtvws[async,encrypted]==2.7.2",
"wakeonlan==3.1.0", "wakeonlan==3.1.0",
"async-upnp-client==0.45.0" "async-upnp-client==0.46.0"
], ],
"ssdp": [ "ssdp": [
{ {

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