Compare commits

...

111 Commits

Author SHA1 Message Date
epenet
4bb08e4abe Migrate Tuya climate (fan_mode) to use wrapper class 2025-11-17 07:24:03 +00:00
Allen Porter
4874610ad6 Bump google-nest-sdm to 9.0.1 (#156707) 2025-11-16 23:13:40 +01:00
Michael
9180282fc6 Add update entity to AdGUard Home (#156682)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-16 22:52:38 +01:00
J. Nick Koston
118f30f32e Bump dbus to 3.0.0 (#156704) 2025-11-16 22:34:31 +01:00
Bouwe Westerdijk
bd10da126f Revisit diagnostic-category assignments for Plugwise (#156279) 2025-11-16 21:29:24 +01:00
starkillerOG
b73a7928ca Enable Reolink RTSP and ONVIF port when supported (#156700) 2025-11-16 21:24:25 +01:00
Jan Bouwhuis
3e20c2ea93 Fix missing description placeholders in MQTT subentry flow (#156684) 2025-11-16 21:21:22 +01:00
andreipoenaru
60130d3d68 Add support for encoded URLs to RESTful Command (#154957)
Co-authored-by: Jan-Philipp Benecke <jan-philipp@bnck.me>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-11-16 21:04:18 +01:00
Denis Shulyaka
c45ede2e5d Bump anthropic to 0.73.0 (#156692) 2025-11-16 21:02:55 +01:00
J. Nick Koston
e167061f53 Bump dbus-fast to 2.46.4 (#156703) 2025-11-16 20:52:05 +01:00
Kamil Breguła
5560fb6c9e Refactor tests in GIOS (#155756)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
2025-11-16 20:21:46 +01:00
J. Nick Koston
9808b6c961 Bump dbus-fast to 2.46.1 (#156695) 2025-11-16 19:34:29 +01:00
J. Nick Koston
e8cfde579e Bump dbus-fast to 2.46.0 (#156693) 2025-11-16 09:46:53 -06:00
J. Nick Koston
f695fb4d51 Bump dbus-fast to 2.45.1 (#156691) 2025-11-16 09:12:44 -06:00
Jan Bouwhuis
a0e0549d90 Fix missing temperature_delta device class translations (#156685) 2025-11-16 15:00:51 +01:00
epenet
ba034c6c8c Add alarm_state to Tuya siren alarm (#151221) 2025-11-16 12:26:33 +01:00
Åke Strandberg
008bb85c59 Mock arguments in senz tests (#156677) 2025-11-16 12:25:28 +01:00
Michael
cf1c1294d3 Bump adguardhome to 0.8.1 (#156679) 2025-11-16 12:16:17 +01:00
Åke Strandberg
11d5d314cc Fix type hints in miele tests (#156657) 2025-11-16 12:12:45 +01:00
Joost Lekkerkerker
6f0de3071a Add fixture for dual washing machine to SmartThings (#156646) 2025-11-16 11:43:25 +01:00
mettolen
87d2597292 Add diagnostics to Saunum integration (#156623) 2025-11-16 11:40:49 +01:00
Manu
437bc04fe8 Remove Live-TV support from Xbox integration (#156669) 2025-11-16 11:30:04 +01:00
Åke Strandberg
67a0d6a187 Mock arguments to ClientResponseError() in miele tests (#156676) 2025-11-16 10:03:32 +01:00
Lukas
abb52bca81 Add more sensors to Pooldose (#156002)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-15 23:02:27 +01:00
J. Nick Koston
d2d6889278 Bump thermopro-ble to 1.1.2 (#156652) 2025-11-15 14:10:59 -06:00
David Rapan
bdca592219 Add Shelly event translation (#156162)
Signed-off-by: David Rapan <david@rapan.cz>
2025-11-15 20:52:13 +02:00
Michael
5c0c7b9ec3 Bump adguardhome to 0.8.0 (#156651) 2025-11-15 16:26:05 +01:00
TheDK
9717599fb9 Use SensorDeviceClass.PRESSURE in Withings (#156648)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-11-15 16:08:34 +01:00
MarkGodwin
4d7de2f814 Bump tplink-omada-api to 1.5.3 (#156645) 2025-11-15 15:39:56 +01:00
epenet
779590ce1c Migrate Tuya climate (humidity) to use wrapper class (#156575) 2025-11-15 15:05:52 +01:00
epenet
f3a185ff9c Migrate Tuya cover to use wrapper class (#156558)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-15 15:04:54 +01:00
epenet
5a5a106984 Migrate Tuya humidifier to use wrapper class (#156572)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-15 15:03:30 +01:00
epenet
796b421d99 Migrate Tuya vacuum to use wrapper class (#156569) 2025-11-15 15:02:49 +01:00
epenet
0c03e8dbe9 Migrate Tuya light (color_mode) to use wrapper class (#156582) 2025-11-15 15:02:40 +01:00
epenet
47cf4e3ffe Ensure Tuya scale and step are integers (#156555) 2025-11-15 14:30:44 +02:00
epenet
0ea0fc151d Use parametrize in tuya climate tests (#156577) 2025-11-15 14:28:57 +02:00
Åke Strandberg
b7e5afec9f Fix typing in miele tests (#156637) 2025-11-15 12:19:34 +01:00
cdnninja
7a2bb67e82 Refactor vesync test (#156625) 2025-11-15 09:24:48 +01:00
Manu
e0612bec07 Bump pythonkuma to v0.3.2 (#156626) 2025-11-15 03:43:16 +01:00
Denis Shulyaka
a06f4b6776 Anthropic model selection from list (#156261) 2025-11-14 21:16:52 -05:00
Denis Shulyaka
275670a526 Add support for gpt-5.1 (#156612) 2025-11-14 18:39:05 -05:00
Robert Resch
d0d62526dd Bump async-upnp-client to 0.46.0 (#156622) 2025-11-14 18:30:18 -05:00
Franck Nijhof
aefdf412b0 Extract device template functions into a devices Jinja2 extension (#156619) 2025-11-15 00:23:38 +01:00
Manu
56ab6b2512 Prevent sensor updates caused by fluctuating “last seen” timestamps in Xbox integration (#156419) 2025-11-14 22:07:13 +01:00
mettolen
d1dea85cf5 Add Saunum integration (#155099)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-11-14 19:55:55 +01:00
wollew
84b0d39763 clean up velux test fixtures (#156554) 2025-11-14 19:53:17 +01:00
tronikos
3aff225bc3 Add Google Weather integration (#147015)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-14 19:46:56 +01:00
Ludovic BOUÉ
04458e01be Fix spelling of 'Auto-relock time' in Matter integration strings (#156607) 2025-11-14 19:10:57 +01:00
Thomas55555
ae51cfb8c0 Fix model_id in Husqvarna Automower (#156608) 2025-11-14 19:10:16 +01:00
Simone Chemelli
c116a9c037 Add debounce to Alexa Devices coordinator (#156609) 2025-11-14 19:09:19 +01:00
Allen Porter
fb58758684 Add completed timestamp support in Google tasks (#156564) 2025-11-14 12:07:23 -05:00
Franck Nijhof
25fbcbc68c Extract floor template functions into a floors Jinja2 extension (#156589)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-14 12:06:55 -05:00
Denis Shulyaka
a670286b45 Bump openai to 2.8.0 (#156602) 2025-11-14 12:06:21 -05:00
ElectricSteve
52ba55b17f Bump youtubeaio to 2.1.0 (#156595) 2025-11-14 16:01:00 +01:00
epenet
ff0fc98c36 Fix sfr_box entry reload (#156593) 2025-11-14 15:03:43 +01:00
David Rapan
9f78a2263d Move Shelly sensor get_entity_translation_attributes to utils (#156590)
Signed-off-by: David Rapan <david@rapan.cz>
2025-11-14 15:44:48 +02:00
Kamil Breguła
9b4696a80b Add quality_scale to mvglive manifest (#155474) 2025-11-14 13:20:10 +01:00
wollew
70fe8cae39 Fix velux scenes (naming and unique ids) (#156436) 2025-11-14 13:18:08 +01:00
wollew
95eb45ab08 cleanup registered callbacks before removing velux config entry (#156525) 2025-11-14 13:06:45 +01:00
Erwin Douna
84f8e57141 Add retry_after to UpdateFailed in update coordinator (#153550)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-14 12:19:02 +01:00
Franck Nijhof
f484b6df0d Extract label template functions into a label Jinja2 extension (#156439)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-14 11:04:31 +01:00
J. Diego Rodríguez Royo
34c1d45ee0 Ensure that Home Connect program update value event is a string when updating options (#156416)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-11-14 10:51:52 +01:00
epenet
09a105d9ad Migrate Tuya light (switch) to use wrapper class (#156580) 2025-11-14 10:33:58 +01:00
epenet
6bd1787d0a Improve parametrize in tuya light tests (#156581) 2025-11-14 10:19:48 +01:00
David Rapan
37040f5064 Add Shelly switch translation (#156146)
Signed-off-by: David Rapan <david@rapan.cz>
Co-authored-by: Shay Levy <levyshay1@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-14 11:16:04 +02:00
puddly
531397ec07 Avoid firing discovery events when flows immediately create a config entry (#155753) 2025-11-14 09:40:46 +01:00
epenet
d6cc0f81de Remove pointless super.async_added_to_hass in Tuya climate (#156573) 2025-11-14 09:31:03 +01:00
TheJulianJES
f8ef8a466a Bump ZHA to 0.0.79 (#156571) 2025-11-14 09:30:38 +01:00
Åke Strandberg
713015e26a Improve logging of failing miele action commands (#156275) 2025-11-14 09:10:24 +01:00
Åke Strandberg
f9c1e81c5e Improve error handling and add tests to senz climate (#156544) 2025-11-14 09:06:56 +01:00
Joost Lekkerkerker
0549d113e6 Bump python-open-router to 0.3.3 (#156563) 2025-11-14 08:48:47 +01:00
dependabot[bot]
0d842978ec Bump github/codeql-action from 4.31.2 to 4.31.3 (#156565) 2025-11-13 23:05:52 -08:00
Nojus
55476ef6ea Remove arbitrary forecast limit for meteo_lt (#155877) 2025-11-14 00:14:48 +01:00
starkillerOG
0e130d8fdd Bump reolink-aio to 0.16.5 (#156553) 2025-11-14 00:12:03 +01:00
slickm0nty
20bcb84956 Set suggested display precision in modbus integration (#155467) 2025-11-13 22:47:40 +00:00
jlanchares
bbb1d57081 Goodwe port502ftp support with PORT stored on config data. (#148628)
Co-authored-by: starkillerOG <starkiller.og@gmail.com>
2025-11-13 21:12:47 +01:00
hanwg
121406569b Upgrade Telegram bot quality scale to Silver (#155352) 2025-11-13 21:08:35 +01:00
Joost Lekkerkerker
4866c775ce Fix CI (#156549) 2025-11-13 21:08:08 +01:00
Willem-Jan van Rootselaar
7c5ab12270 Update bsblan to python-bsblan version 3.1.1 (#156536)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-11-13 20:44:12 +01:00
Erik Montnemery
099edfac20 Fix flux_led tests opening sockets (#156458) 2025-11-13 20:39:56 +01:00
epenet
aa31df0fd5 Migrate Tuya camera to use wrapper class (#156542) 2025-11-13 20:38:44 +01:00
Bram Kragten
13fbeb6cdb Add support for trigger and condition category icons (#156533) 2025-11-13 14:36:34 -05:00
karwosts
8d557447df Add completed timestamp to TodoItem (#156547)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2025-11-13 14:35:33 -05:00
Joakim Sørensen
e6e3f2455f Add discovery_service_actions configuration option (#156537) 2025-11-13 14:35:22 -05:00
epenet
c9c518ee84 Improve IntegerTypeData scaling in Tuya (#156507) 2025-11-13 20:29:07 +01:00
Magnus
214731e964 Component asuswrt: Type check is redundant for this value (#154535) 2025-11-13 20:24:24 +01:00
Jan Čermák
c4b09c9a0a Update Home Assistant base image to 2025.11.0 (#156517) 2025-11-13 20:01:22 +01:00
epenet
f5b5b2fb70 Remove unused/absent property from Tuya (#156508) 2025-11-13 19:32:08 +01:00
Manu
bb3cdd382b Add media_content_id to media player in Xbox integration (#156519) 2025-11-13 19:25:43 +01:00
starkillerOG
8d09b5c273 Relax Reolink update interval and timeout for big installs (#156509) 2025-11-13 19:25:03 +01:00
epenet
d92fa7fa72 Move more logic from entity to wrapper in Tuya alarm (#156450) 2025-11-13 18:37:47 +01:00
Åke Strandberg
0c45b7f615 Add reconfiguration flow to senz (#156539) 2025-11-13 16:49:16 +01:00
Alexandre CUER
bfa1116115 Add quality scale to Emoncms (#149727)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-13 16:10:29 +01:00
Arie Catsman
4984237987 Add alternative ct meter source to enphase_envoy diagnostics (#154468) 2025-11-13 15:37:36 +01:00
Joost Lekkerkerker
3839573151 Bump pySmartThings to 3.3.3 (#156528) 2025-11-13 15:31:03 +01:00
Åke Strandberg
e02dc53df3 Add reauthentication flow and tests to senz (#156534) 2025-11-13 15:28:45 +01:00
Arie Catsman
bedae1e12c Optimize Enphase_Envoy CT sensor entity code (#153859) 2025-11-13 14:59:24 +01:00
epenet
b4eb73be98 Improve tests for Tuya alarm control panel (#156481) 2025-11-13 14:44:38 +01:00
wollew
0ac3f776fa set shorthand atrributes for supported_features in velux cover (#156524) 2025-11-13 14:18:20 +01:00
Petar Petrov
8e8a4fff11 Extract grid, gas, and water source validation into separate functions (#156515) 2025-11-13 13:28:25 +01:00
Åke Strandberg
579ffcc64d Add unique_id to senz config_entry (#156472) 2025-11-13 12:26:33 +01:00
Foscam-wangzhengyu
81943fb31d URL-encode the RTSP URL in the Foscam integration (#156488)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-13 12:23:28 +01:00
Petro31
70dd0bf12e Modernize template alarm control panel (#156476) 2025-11-13 12:21:03 +01:00
Tom Matheussen
c2d462c1e7 Refactor Satel Integra platforms to use shared base entity (#156499) 2025-11-13 12:20:32 +01:00
epenet
49e050cc60 Redact more DP codes in tuya diagnostics (#156497) 2025-11-13 12:18:43 +01:00
Josef Zweck
f6d829a2f3 Bump pylamarzocco to 2.1.3 (#156501) 2025-11-13 11:54:15 +01:00
Aarni Koskela
e44e3b6f25 Rename RuuviTag BLE to Ruuvi BLE (#156504) 2025-11-13 11:36:50 +01:00
Christopher Fenner
af603661c0 Fix spelling in ViCare integration (#156500) 2025-11-13 10:54:55 +01:00
puddly
35c6113777 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-13 09:25:00 +01:00
TheJulianJES
3c2f729ddc Fix Z-Wave generating name before setting entity description (#156494) 2025-11-13 08:18:22 +01:00
Erik Montnemery
0d63cb765f Fix lg_netcast tests opening sockets (#156459)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-13 07:44:56 +01:00
309 changed files with 21344 additions and 3973 deletions

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Initialize CodeQL
uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
with:
category: "/language:python"

View File

@@ -231,6 +231,7 @@ homeassistant.components.google_cloud.*
homeassistant.components.google_drive.*
homeassistant.components.google_photos.*
homeassistant.components.google_sheets.*
homeassistant.components.google_weather.*
homeassistant.components.govee_ble.*
homeassistant.components.gpsd.*
homeassistant.components.greeneye_monitor.*

4
CODEOWNERS generated
View File

@@ -607,6 +607,8 @@ build.json @home-assistant/supervisor
/tests/components/google_tasks/ @allenporter
/homeassistant/components/google_travel_time/ @eifinger
/tests/components/google_travel_time/ @eifinger
/homeassistant/components/google_weather/ @tronikos
/tests/components/google_weather/ @tronikos
/homeassistant/components/govee_ble/ @bdraco
/tests/components/govee_ble/ @bdraco
/homeassistant/components/govee_light_local/ @Galorhallen
@@ -1374,6 +1376,8 @@ build.json @home-assistant/supervisor
/tests/components/sanix/ @tomaszsluszniak
/homeassistant/components/satel_integra/ @Tommatheussen
/tests/components/satel_integra/ @Tommatheussen
/homeassistant/components/saunum/ @mettolen
/tests/components/saunum/ @mettolen
/homeassistant/components/scene/ @home-assistant/core
/tests/components/scene/ @home-assistant/core
/homeassistant/components/schedule/ @home-assistant/core

View File

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

View File

@@ -15,6 +15,7 @@
"google_tasks",
"google_translate",
"google_travel_time",
"google_weather",
"google_wifi",
"google",
"nest",

View File

@@ -45,7 +45,7 @@ SERVICE_REFRESH_SCHEMA = vol.Schema(
{vol.Optional(CONF_FORCE, default=False): cv.boolean}
)
PLATFORMS = [Platform.SENSOR, Platform.SWITCH]
PLATFORMS = [Platform.SENSOR, Platform.SWITCH, Platform.UPDATE]
type AdGuardConfigEntry = ConfigEntry[AdGuardData]

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["adguardhome"],
"requirements": ["adguardhome==0.7.0"]
"requirements": ["adguardhome==0.8.1"]
}

View File

@@ -0,0 +1,71 @@
"""AdGuard Home Update platform."""
from __future__ import annotations
from datetime import timedelta
from typing import Any
from adguardhome import AdGuardHomeError
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdGuardConfigEntry, AdGuardData
from .const import DOMAIN
from .entity import AdGuardHomeEntity
SCAN_INTERVAL = timedelta(seconds=300)
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: AdGuardConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AdGuard Home update entity based on a config entry."""
data = entry.runtime_data
if (await data.client.update.update_available()).disabled:
return
async_add_entities([AdGuardHomeUpdate(data, entry)], True)
class AdGuardHomeUpdate(AdGuardHomeEntity, UpdateEntity):
"""Defines an AdGuard Home update."""
_attr_supported_features = UpdateEntityFeature.INSTALL
_attr_name = None
def __init__(
self,
data: AdGuardData,
entry: AdGuardConfigEntry,
) -> None:
"""Initialize AdGuard Home update."""
super().__init__(data, entry)
self._attr_unique_id = "_".join(
[DOMAIN, self.adguard.host, str(self.adguard.port), "update"]
)
async def _adguard_update(self) -> None:
"""Update AdGuard Home entity."""
value = await self.adguard.update.update_available()
self._attr_installed_version = self.data.version
self._attr_latest_version = value.new_version
self._attr_release_summary = value.announcement
self._attr_release_url = value.announcement_url
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
"""Install latest update."""
try:
await self.adguard.update.begin_update()
except AdGuardHomeError as err:
raise HomeAssistantError(f"Failed to install update: {err}") from err
self.hass.config_entries.async_schedule_reload(self._entry.entry_id)

View File

@@ -16,6 +16,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN
@@ -43,6 +44,9 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
name=entry.title,
config_entry=entry,
update_interval=timedelta(seconds=SCAN_INTERVAL),
request_refresh_debouncer=Debouncer(
hass, _LOGGER, cooldown=30, immediate=False
),
)
self.api = AmazonEchoApi(
session,

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from functools import partial
import json
import logging
import re
from typing import Any
import anthropic
@@ -283,7 +284,11 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
vol.Optional(
CONF_CHAT_MODEL,
default=RECOMMENDED_CHAT_MODEL,
): str,
): SelectSelector(
SelectSelectorConfig(
options=await self._get_model_list(), custom_value=True
)
),
vol.Optional(
CONF_MAX_TOKENS,
default=RECOMMENDED_MAX_TOKENS,
@@ -394,6 +399,39 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
last_step=True,
)
async def _get_model_list(self) -> list[SelectOptionDict]:
"""Get list of available models."""
try:
client = await self.hass.async_add_executor_job(
partial(
anthropic.AsyncAnthropic,
api_key=self._get_entry().data[CONF_API_KEY],
)
)
models = (await client.models.list()).data
except anthropic.AnthropicError:
models = []
_LOGGER.debug("Available models: %s", models)
model_options: list[SelectOptionDict] = []
short_form = re.compile(r"[^\d]-\d$")
for model_info in models:
# Resolve alias from versioned model name:
model_alias = (
model_info.id[:-9]
if model_info.id
not in ("claude-3-haiku-20240307", "claude-3-opus-20240229")
else model_info.id
)
if short_form.search(model_alias):
model_alias += "-0"
model_options.append(
SelectOptionDict(
label=model_info.display_name,
value=model_alias,
)
)
return model_options
async def _get_location_data(self) -> dict[str, str]:
"""Get approximate location data of the user."""
location_data: dict[str, str] = {}

View File

@@ -392,7 +392,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
type="tool_use",
id=response.content_block.id,
name=response.content_block.name,
input="",
input={},
)
current_tool_args = ""
if response.content_block.name == output_tool:
@@ -459,7 +459,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
type="server_tool_use",
id=response.content_block.id,
name=response.content_block.name,
input="",
input={},
)
current_tool_args = ""
elif isinstance(response.content_block, WebSearchToolResultBlock):

View File

@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/anthropic",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["anthropic==0.69.0"]
"requirements": ["anthropic==0.73.0"]
}

View File

@@ -111,8 +111,6 @@ def handle_errors_and_zip[_AsusWrtBridgeT: AsusWrtBridge](
if isinstance(data, dict):
return dict(zip(keys, list(data.values()), strict=False))
if not isinstance(data, (list, tuple)):
raise UpdateFailed("Received invalid data type")
return dict(zip(keys, data, strict=False))
return _wrapper

View File

@@ -20,7 +20,7 @@
"bluetooth-adapters==2.1.0",
"bluetooth-auto-recovery==1.5.3",
"bluetooth-data-tools==1.28.4",
"dbus-fast==2.45.0",
"dbus-fast==3.0.0",
"habluetooth==5.7.0"
]
}

View File

@@ -74,8 +74,11 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
super().__init__(data.fast_coordinator, data)
self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate"
self._attr_min_temp = data.static.min_temp.value
self._attr_max_temp = data.static.max_temp.value
# Set temperature range if available, otherwise use Home Assistant defaults
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
@property

View File

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

View File

@@ -55,6 +55,7 @@ from .const import (
CONF_ALIASES,
CONF_API_SERVER,
CONF_COGNITO_CLIENT_ID,
CONF_DISCOVERY_SERVICE_ACTIONS,
CONF_ENTITY_CONFIG,
CONF_FILTER,
CONF_GOOGLE_ACTIONS,
@@ -139,6 +140,7 @@ CONFIG_SCHEMA = vol.Schema(
{
vol.Required(CONF_MODE): vol.In([MODE_DEV]),
vol.Required(CONF_API_SERVER): str,
vol.Optional(CONF_DISCOVERY_SERVICE_ACTIONS): {str: cv.url},
}
),
_BASE_CONFIG_SCHEMA.extend(

View File

@@ -79,6 +79,7 @@ CONF_ACCOUNT_LINK_SERVER = "account_link_server"
CONF_ACCOUNTS_SERVER = "accounts_server"
CONF_ACME_SERVER = "acme_server"
CONF_API_SERVER = "api_server"
CONF_DISCOVERY_SERVICE_ACTIONS = "discovery_service_actions"
CONF_RELAYER_SERVER = "relayer_server"
CONF_REMOTESTATE_SERVER = "remotestate_server"
CONF_SERVICEHANDLERS_SERVER = "servicehandlers_server"

View File

@@ -8,7 +8,7 @@
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
"iot_class": "local_push",
"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": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",

View File

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

View File

@@ -0,0 +1,84 @@
rules:
# todo : add get_feed_list to the library
# todo : see if we can drop some extra attributes
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage:
status: todo
comment: |
test_reconfigure_api_error should use a mock config entry fixture
test_user_flow_failure should use a mock config entry fixture
move test_user_flow_* to the top of the file
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
No events are explicitly registered by the integration.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: todo
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow: todo
test-coverage:
status: todo
comment: |
test the entry state in test_failure
# Gold
devices: todo
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: done
docs-examples:
status: exempt
comment: |
This integration does not provide any automation
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class:
status: todo
comment: change device_class=SensorDeviceClass.SIGNAL_STRENGTH to SOUND_PRESSURE
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: done
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -386,11 +386,260 @@ def _async_validate_auto_generated_cost_entity(
issues.add_issue(hass, "recorder_untracked", cost_entity_id)
def _validate_grid_source(
hass: HomeAssistant,
source: data.GridSourceType,
statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
wanted_statistics_metadata: set[str],
source_result: ValidationIssues,
validate_calls: list[functools.partial[None]],
) -> None:
"""Validate grid energy source."""
flow_from: data.FlowFromGridSourceType
for flow_from in source["flow_from"]:
wanted_statistics_metadata.add(flow_from["stat_energy_from"])
validate_calls.append(
functools.partial(
_async_validate_usage_stat,
hass,
statistics_metadata,
flow_from["stat_energy_from"],
ENERGY_USAGE_DEVICE_CLASSES,
ENERGY_USAGE_UNITS,
ENERGY_UNIT_ERROR,
source_result,
)
)
if (stat_cost := flow_from.get("stat_cost")) is not None:
wanted_statistics_metadata.add(stat_cost)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_cost,
source_result,
)
)
elif (entity_energy_price := flow_from.get("entity_energy_price")) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
ENERGY_PRICE_UNITS,
ENERGY_PRICE_UNIT_ERROR,
)
)
if (
flow_from.get("entity_energy_price") is not None
or flow_from.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
flow_from["stat_energy_from"],
source_result,
)
)
flow_to: data.FlowToGridSourceType
for flow_to in source["flow_to"]:
wanted_statistics_metadata.add(flow_to["stat_energy_to"])
validate_calls.append(
functools.partial(
_async_validate_usage_stat,
hass,
statistics_metadata,
flow_to["stat_energy_to"],
ENERGY_USAGE_DEVICE_CLASSES,
ENERGY_USAGE_UNITS,
ENERGY_UNIT_ERROR,
source_result,
)
)
if (stat_compensation := flow_to.get("stat_compensation")) is not None:
wanted_statistics_metadata.add(stat_compensation)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_compensation,
source_result,
)
)
elif (entity_energy_price := flow_to.get("entity_energy_price")) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
ENERGY_PRICE_UNITS,
ENERGY_PRICE_UNIT_ERROR,
)
)
if (
flow_to.get("entity_energy_price") is not None
or flow_to.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
flow_to["stat_energy_to"],
source_result,
)
)
for power_stat in source.get("power", []):
wanted_statistics_metadata.add(power_stat["stat_rate"])
validate_calls.append(
functools.partial(
_async_validate_power_stat,
hass,
statistics_metadata,
power_stat["stat_rate"],
POWER_USAGE_DEVICE_CLASSES,
POWER_USAGE_UNITS,
POWER_UNIT_ERROR,
source_result,
)
)
def _validate_gas_source(
hass: HomeAssistant,
source: data.GasSourceType,
statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
wanted_statistics_metadata: set[str],
source_result: ValidationIssues,
validate_calls: list[functools.partial[None]],
) -> None:
"""Validate gas energy source."""
wanted_statistics_metadata.add(source["stat_energy_from"])
validate_calls.append(
functools.partial(
_async_validate_usage_stat,
hass,
statistics_metadata,
source["stat_energy_from"],
GAS_USAGE_DEVICE_CLASSES,
GAS_USAGE_UNITS,
GAS_UNIT_ERROR,
source_result,
)
)
if (stat_cost := source.get("stat_cost")) is not None:
wanted_statistics_metadata.add(stat_cost)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_cost,
source_result,
)
)
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
GAS_PRICE_UNITS,
GAS_PRICE_UNIT_ERROR,
)
)
if (
source.get("entity_energy_price") is not None
or source.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
source["stat_energy_from"],
source_result,
)
)
def _validate_water_source(
hass: HomeAssistant,
source: data.WaterSourceType,
statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
wanted_statistics_metadata: set[str],
source_result: ValidationIssues,
validate_calls: list[functools.partial[None]],
) -> None:
"""Validate water energy source."""
wanted_statistics_metadata.add(source["stat_energy_from"])
validate_calls.append(
functools.partial(
_async_validate_usage_stat,
hass,
statistics_metadata,
source["stat_energy_from"],
WATER_USAGE_DEVICE_CLASSES,
WATER_USAGE_UNITS,
WATER_UNIT_ERROR,
source_result,
)
)
if (stat_cost := source.get("stat_cost")) is not None:
wanted_statistics_metadata.add(stat_cost)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_cost,
source_result,
)
)
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
WATER_PRICE_UNITS,
WATER_PRICE_UNIT_ERROR,
)
)
if (
source.get("entity_energy_price") is not None
or source.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
source["stat_energy_from"],
source_result,
)
)
async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
"""Validate the energy configuration."""
manager: data.EnergyManager = await data.async_get_manager(hass)
statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]] = {}
validate_calls = []
validate_calls: list[functools.partial[None]] = []
wanted_statistics_metadata: set[str] = set()
result = EnergyPreferencesValidation()
@@ -404,230 +653,35 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
result.energy_sources.append(source_result)
if source["type"] == "grid":
flow: data.FlowFromGridSourceType | data.FlowToGridSourceType
for flow in source["flow_from"]:
wanted_statistics_metadata.add(flow["stat_energy_from"])
validate_calls.append(
functools.partial(
_async_validate_usage_stat,
hass,
statistics_metadata,
flow["stat_energy_from"],
ENERGY_USAGE_DEVICE_CLASSES,
ENERGY_USAGE_UNITS,
ENERGY_UNIT_ERROR,
source_result,
)
)
if (stat_cost := flow.get("stat_cost")) is not None:
wanted_statistics_metadata.add(stat_cost)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_cost,
source_result,
)
)
elif (
entity_energy_price := flow.get("entity_energy_price")
) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
ENERGY_PRICE_UNITS,
ENERGY_PRICE_UNIT_ERROR,
)
)
if (
flow.get("entity_energy_price") is not None
or flow.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
flow["stat_energy_from"],
source_result,
)
)
for flow in source["flow_to"]:
wanted_statistics_metadata.add(flow["stat_energy_to"])
validate_calls.append(
functools.partial(
_async_validate_usage_stat,
hass,
statistics_metadata,
flow["stat_energy_to"],
ENERGY_USAGE_DEVICE_CLASSES,
ENERGY_USAGE_UNITS,
ENERGY_UNIT_ERROR,
source_result,
)
)
if (stat_compensation := flow.get("stat_compensation")) is not None:
wanted_statistics_metadata.add(stat_compensation)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_compensation,
source_result,
)
)
elif (
entity_energy_price := flow.get("entity_energy_price")
) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
ENERGY_PRICE_UNITS,
ENERGY_PRICE_UNIT_ERROR,
)
)
if (
flow.get("entity_energy_price") is not None
or flow.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
flow["stat_energy_to"],
source_result,
)
)
for power_stat in source.get("power", []):
wanted_statistics_metadata.add(power_stat["stat_rate"])
validate_calls.append(
functools.partial(
_async_validate_power_stat,
hass,
statistics_metadata,
power_stat["stat_rate"],
POWER_USAGE_DEVICE_CLASSES,
POWER_USAGE_UNITS,
POWER_UNIT_ERROR,
source_result,
)
)
_validate_grid_source(
hass,
source,
statistics_metadata,
wanted_statistics_metadata,
source_result,
validate_calls,
)
elif source["type"] == "gas":
wanted_statistics_metadata.add(source["stat_energy_from"])
validate_calls.append(
functools.partial(
_async_validate_usage_stat,
hass,
statistics_metadata,
source["stat_energy_from"],
GAS_USAGE_DEVICE_CLASSES,
GAS_USAGE_UNITS,
GAS_UNIT_ERROR,
source_result,
)
_validate_gas_source(
hass,
source,
statistics_metadata,
wanted_statistics_metadata,
source_result,
validate_calls,
)
if (stat_cost := source.get("stat_cost")) is not None:
wanted_statistics_metadata.add(stat_cost)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_cost,
source_result,
)
)
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
GAS_PRICE_UNITS,
GAS_PRICE_UNIT_ERROR,
)
)
if (
source.get("entity_energy_price") is not None
or source.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
source["stat_energy_from"],
source_result,
)
)
elif source["type"] == "water":
wanted_statistics_metadata.add(source["stat_energy_from"])
validate_calls.append(
functools.partial(
_async_validate_usage_stat,
hass,
statistics_metadata,
source["stat_energy_from"],
WATER_USAGE_DEVICE_CLASSES,
WATER_USAGE_UNITS,
WATER_UNIT_ERROR,
source_result,
)
_validate_water_source(
hass,
source,
statistics_metadata,
wanted_statistics_metadata,
source_result,
validate_calls,
)
if (stat_cost := source.get("stat_cost")) is not None:
wanted_statistics_metadata.add(stat_cost)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_cost,
source_result,
)
)
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
WATER_PRICE_UNITS,
WATER_PRICE_UNIT_ERROR,
)
)
if (
source.get("entity_energy_price") is not None
or source.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
source["stat_energy_from"],
source_result,
)
)
elif source["type"] == "solar":
wanted_statistics_metadata.add(source["stat_energy_from"])
validate_calls.append(

View File

@@ -147,6 +147,8 @@ async def async_get_config_entry_diagnostics(
"ctmeter_production_phases": envoy_data.ctmeter_production_phases,
"ctmeter_consumption_phases": envoy_data.ctmeter_consumption_phases,
"ctmeter_storage_phases": envoy_data.ctmeter_storage_phases,
"ctmeters": envoy_data.ctmeters,
"ctmeters_phases": envoy_data.ctmeters_phases,
"dry_contact_status": envoy_data.dry_contact_status,
"dry_contact_settings": envoy_data.dry_contact_settings,
"inverters": envoy_data.inverters,
@@ -179,6 +181,7 @@ async def async_get_config_entry_diagnostics(
"ct_consumption_meter": envoy.consumption_meter_type,
"ct_production_meter": envoy.production_meter_type,
"ct_storage_meter": envoy.storage_meter_type,
"ct_meters": list(envoy_data.ctmeters.keys()),
}
fixture_data: dict[str, Any] = {}

View File

@@ -399,330 +399,189 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription):
cttype: str | None = None
CT_NET_CONSUMPTION_SENSORS = (
EnvoyCTSensorEntityDescription(
key="lifetime_net_consumption",
translation_key="lifetime_net_consumption",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.ENERGY,
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
suggested_display_precision=3,
value_fn=attrgetter("energy_delivered"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="lifetime_net_production",
translation_key="lifetime_net_production",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.ENERGY,
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
suggested_display_precision=3,
value_fn=attrgetter("energy_received"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="net_consumption",
translation_key="net_consumption",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
suggested_display_precision=3,
value_fn=attrgetter("active_power"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="frequency",
translation_key="net_ct_frequency",
native_unit_of_measurement=UnitOfFrequency.HERTZ,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.FREQUENCY,
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=attrgetter("frequency"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="voltage",
translation_key="net_ct_voltage",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLTAGE,
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=attrgetter("voltage"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="net_ct_current",
translation_key="net_ct_current",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.CURRENT,
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=attrgetter("current"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="net_ct_powerfactor",
translation_key="net_ct_powerfactor",
device_class=SensorDeviceClass.POWER_FACTOR,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=2,
entity_registry_enabled_default=False,
value_fn=attrgetter("power_factor"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="net_consumption_ct_metering_status",
translation_key="net_ct_metering_status",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
options=list(CtMeterStatus),
entity_registry_enabled_default=False,
value_fn=attrgetter("metering_status"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="net_consumption_ct_status_flags",
translation_key="net_ct_status_flags",
state_class=None,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
)
CT_NET_CONSUMPTION_PHASE_SENSORS = {
(on_phase := PHASENAMES[phase]): [
replace(
sensor,
key=f"{sensor.key}_l{phase + 1}",
translation_key=f"{sensor.translation_key}_phase",
entity_registry_enabled_default=False,
on_phase=on_phase,
translation_placeholders={"phase_name": f"l{phase + 1}"},
# All ct types unified in common setup
CT_SENSORS = (
[
EnvoyCTSensorEntityDescription(
key=key,
translation_key=key,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.ENERGY,
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
suggested_display_precision=3,
value_fn=attrgetter("energy_delivered"),
on_phase=None,
cttype=cttype,
)
for cttype, key in (
(CtType.NET_CONSUMPTION, "lifetime_net_consumption"),
# Production CT energy_delivered is not used
(CtType.STORAGE, "lifetime_battery_discharged"),
)
for sensor in list(CT_NET_CONSUMPTION_SENSORS)
]
for phase in range(3)
}
CT_PRODUCTION_SENSORS = (
EnvoyCTSensorEntityDescription(
key="production_ct_frequency",
translation_key="production_ct_frequency",
native_unit_of_measurement=UnitOfFrequency.HERTZ,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.FREQUENCY,
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=attrgetter("frequency"),
on_phase=None,
cttype=CtType.PRODUCTION,
),
EnvoyCTSensorEntityDescription(
key="production_ct_voltage",
translation_key="production_ct_voltage",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLTAGE,
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=attrgetter("voltage"),
on_phase=None,
cttype=CtType.PRODUCTION,
),
EnvoyCTSensorEntityDescription(
key="production_ct_current",
translation_key="production_ct_current",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.CURRENT,
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=attrgetter("current"),
on_phase=None,
cttype=CtType.PRODUCTION,
),
EnvoyCTSensorEntityDescription(
key="production_ct_powerfactor",
translation_key="production_ct_powerfactor",
device_class=SensorDeviceClass.POWER_FACTOR,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=2,
entity_registry_enabled_default=False,
value_fn=attrgetter("power_factor"),
on_phase=None,
cttype=CtType.PRODUCTION,
),
EnvoyCTSensorEntityDescription(
key="production_ct_metering_status",
translation_key="production_ct_metering_status",
device_class=SensorDeviceClass.ENUM,
options=list(CtMeterStatus),
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=attrgetter("metering_status"),
on_phase=None,
cttype=CtType.PRODUCTION,
),
EnvoyCTSensorEntityDescription(
key="production_ct_status_flags",
translation_key="production_ct_status_flags",
state_class=None,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
on_phase=None,
cttype=CtType.PRODUCTION,
),
)
CT_PRODUCTION_PHASE_SENSORS = {
(on_phase := PHASENAMES[phase]): [
replace(
sensor,
key=f"{sensor.key}_l{phase + 1}",
translation_key=f"{sensor.translation_key}_phase",
entity_registry_enabled_default=False,
on_phase=on_phase,
translation_placeholders={"phase_name": f"l{phase + 1}"},
+ [
EnvoyCTSensorEntityDescription(
key=key,
translation_key=key,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.ENERGY,
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
suggested_display_precision=3,
value_fn=attrgetter("energy_received"),
on_phase=None,
cttype=cttype,
)
for cttype, key in (
(CtType.NET_CONSUMPTION, "lifetime_net_production"),
# Production CT energy_received is not used
(CtType.STORAGE, "lifetime_battery_charged"),
)
]
+ [
EnvoyCTSensorEntityDescription(
key=key,
translation_key=key,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
suggested_display_precision=3,
value_fn=attrgetter("active_power"),
on_phase=None,
cttype=cttype,
)
for cttype, key in (
(CtType.NET_CONSUMPTION, "net_consumption"),
# Production CT active_power is not used
(CtType.STORAGE, "battery_discharge"),
)
]
+ [
EnvoyCTSensorEntityDescription(
key=key,
translation_key=(translation_key if translation_key != "" else key),
native_unit_of_measurement=UnitOfFrequency.HERTZ,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.FREQUENCY,
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=attrgetter("frequency"),
on_phase=None,
cttype=cttype,
)
for cttype, key, translation_key in (
(CtType.NET_CONSUMPTION, "frequency", "net_ct_frequency"),
(CtType.PRODUCTION, "production_ct_frequency", ""),
(CtType.STORAGE, "storage_ct_frequency", ""),
)
]
+ [
EnvoyCTSensorEntityDescription(
key=key,
translation_key=(translation_key if translation_key != "" else key),
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLTAGE,
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=attrgetter("voltage"),
on_phase=None,
cttype=cttype,
)
for cttype, key, translation_key in (
(CtType.NET_CONSUMPTION, "voltage", "net_ct_voltage"),
(CtType.PRODUCTION, "production_ct_voltage", ""),
(CtType.STORAGE, "storage_voltage", "storage_ct_voltage"),
)
]
+ [
EnvoyCTSensorEntityDescription(
key=key,
translation_key=key,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.CURRENT,
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=attrgetter("current"),
on_phase=None,
cttype=cttype,
)
for cttype, key in (
(CtType.NET_CONSUMPTION, "net_ct_current"),
(CtType.PRODUCTION, "production_ct_current"),
(CtType.STORAGE, "storage_ct_current"),
)
]
+ [
EnvoyCTSensorEntityDescription(
key=key,
translation_key=key,
device_class=SensorDeviceClass.POWER_FACTOR,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=2,
entity_registry_enabled_default=False,
value_fn=attrgetter("power_factor"),
on_phase=None,
cttype=cttype,
)
for cttype, key in (
(CtType.NET_CONSUMPTION, "net_ct_powerfactor"),
(CtType.PRODUCTION, "production_ct_powerfactor"),
(CtType.STORAGE, "storage_ct_powerfactor"),
)
]
+ [
EnvoyCTSensorEntityDescription(
key=key,
translation_key=(translation_key if translation_key != "" else key),
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
options=list(CtMeterStatus),
entity_registry_enabled_default=False,
value_fn=attrgetter("metering_status"),
on_phase=None,
cttype=cttype,
)
for cttype, key, translation_key in (
(
CtType.NET_CONSUMPTION,
"net_consumption_ct_metering_status",
"net_ct_metering_status",
),
(CtType.PRODUCTION, "production_ct_metering_status", ""),
(CtType.STORAGE, "storage_ct_metering_status", ""),
)
]
+ [
EnvoyCTSensorEntityDescription(
key=key,
translation_key=(translation_key if translation_key != "" else key),
state_class=None,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
on_phase=None,
cttype=cttype,
)
for cttype, key, translation_key in (
(
CtType.NET_CONSUMPTION,
"net_consumption_ct_status_flags",
"net_ct_status_flags",
),
(CtType.PRODUCTION, "production_ct_status_flags", ""),
(CtType.STORAGE, "storage_ct_status_flags", ""),
)
for sensor in list(CT_PRODUCTION_SENSORS)
]
for phase in range(3)
}
CT_STORAGE_SENSORS = (
EnvoyCTSensorEntityDescription(
key="lifetime_battery_discharged",
translation_key="lifetime_battery_discharged",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.ENERGY,
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
suggested_display_precision=3,
value_fn=attrgetter("energy_delivered"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="lifetime_battery_charged",
translation_key="lifetime_battery_charged",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.ENERGY,
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
suggested_display_precision=3,
value_fn=attrgetter("energy_received"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="battery_discharge",
translation_key="battery_discharge",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
suggested_display_precision=3,
value_fn=attrgetter("active_power"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="storage_ct_frequency",
translation_key="storage_ct_frequency",
native_unit_of_measurement=UnitOfFrequency.HERTZ,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.FREQUENCY,
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=attrgetter("frequency"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="storage_voltage",
translation_key="storage_ct_voltage",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLTAGE,
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=attrgetter("voltage"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="storage_ct_current",
translation_key="storage_ct_current",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.CURRENT,
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=attrgetter("current"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="storage_ct_powerfactor",
translation_key="storage_ct_powerfactor",
device_class=SensorDeviceClass.POWER_FACTOR,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=2,
entity_registry_enabled_default=False,
value_fn=attrgetter("power_factor"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="storage_ct_metering_status",
translation_key="storage_ct_metering_status",
device_class=SensorDeviceClass.ENUM,
options=list(CtMeterStatus),
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=attrgetter("metering_status"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="storage_ct_status_flags",
translation_key="storage_ct_status_flags",
state_class=None,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
on_phase=None,
cttype=CtType.STORAGE,
),
)
CT_STORAGE_PHASE_SENSORS = {
CT_PHASE_SENSORS = {
(on_phase := PHASENAMES[phase]): [
replace(
sensor,
@@ -732,7 +591,7 @@ CT_STORAGE_PHASE_SENSORS = {
on_phase=on_phase,
translation_placeholders={"phase_name": f"l{phase + 1}"},
)
for sensor in list(CT_STORAGE_SENSORS)
for sensor in list(CT_SENSORS)
]
for phase in range(3)
}
@@ -1060,24 +919,14 @@ async def async_setup_entry(
if envoy_data.ctmeters:
entities.extend(
EnvoyCTEntity(coordinator, description)
for sensors in (
CT_NET_CONSUMPTION_SENSORS,
CT_PRODUCTION_SENSORS,
CT_STORAGE_SENSORS,
)
for description in sensors
for description in CT_SENSORS
if description.cttype in envoy_data.ctmeters
)
# Add Current Transformer phase entities
if ctmeters_phases := envoy_data.ctmeters_phases:
entities.extend(
EnvoyCTPhaseEntity(coordinator, description)
for sensors in (
CT_NET_CONSUMPTION_PHASE_SENSORS,
CT_PRODUCTION_PHASE_SENSORS,
CT_STORAGE_PHASE_SENSORS,
)
for phase, descriptions in sensors.items()
for phase, descriptions in CT_PHASE_SENSORS.items()
for description in descriptions
if (cttype := description.cttype) in ctmeters_phases
and phase in ctmeters_phases[cttype]

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
from urllib.parse import quote
import voluptuous as vol
@@ -152,7 +153,9 @@ class HassFoscamCamera(FoscamEntity, Camera):
async def stream_source(self) -> str | None:
"""Return the stream source."""
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

View File

@@ -777,7 +777,9 @@ class ManifestJSONView(HomeAssistantView):
@websocket_api.websocket_command(
{
"type": "frontend/get_icons",
vol.Required("category"): vol.In({"entity", "entity_component", "services"}),
vol.Required("category"): vol.In(
{"entity", "entity_component", "services", "triggers", "conditions"}
),
vol.Optional("integration"): vol.All(cv.ensure_list, [str]),
}
)

View File

@@ -1,13 +1,14 @@
"""The Goodwe inverter component."""
from goodwe import InverterError, connect
from goodwe.const import GOODWE_TCP_PORT, GOODWE_UDP_PORT
from goodwe import Inverter, InverterError, connect
from goodwe.const import GOODWE_UDP_PORT
from homeassistant.const import CONF_HOST
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.device_registry import DeviceInfo
from .config_flow import GoodweFlowHandler
from .const import CONF_MODEL_FAMILY, DOMAIN, PLATFORMS
from .coordinator import GoodweConfigEntry, GoodweRuntimeData, GoodweUpdateCoordinator
@@ -15,28 +16,22 @@ from .coordinator import GoodweConfigEntry, GoodweRuntimeData, GoodweUpdateCoord
async def async_setup_entry(hass: HomeAssistant, entry: GoodweConfigEntry) -> bool:
"""Set up the Goodwe components from a config entry."""
host = entry.data[CONF_HOST]
port = entry.data.get(CONF_PORT, GOODWE_UDP_PORT)
model_family = entry.data[CONF_MODEL_FAMILY]
# Connect to Goodwe inverter
try:
inverter = await connect(
host=host,
port=GOODWE_UDP_PORT,
port=port,
family=model_family,
retries=10,
)
except InverterError as err_udp:
# First try with UDP failed, trying with the TCP port
except InverterError as err:
try:
inverter = await connect(
host=host,
port=GOODWE_TCP_PORT,
family=model_family,
retries=10,
)
inverter = await async_check_port(hass, entry, host)
except InverterError:
# Both ports are unavailable
raise ConfigEntryNotReady from err_udp
raise ConfigEntryNotReady from err
device_info = DeviceInfo(
configuration_url="https://www.semsportal.com",
@@ -66,6 +61,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoodweConfigEntry) -> bo
return True
async def async_check_port(
hass: HomeAssistant, entry: GoodweConfigEntry, host: str
) -> Inverter:
"""Check the communication port of the inverter, it may have changed after a firmware update."""
inverter, port = await GoodweFlowHandler.async_detect_inverter_port(host=host)
family = type(inverter).__name__
hass.config_entries.async_update_entry(
entry,
data={
CONF_HOST: host,
CONF_PORT: port,
CONF_MODEL_FAMILY: family,
},
)
return inverter
async def async_unload_entry(
hass: HomeAssistant, config_entry: GoodweConfigEntry
) -> bool:
@@ -76,3 +88,31 @@ async def async_unload_entry(
async def update_listener(hass: HomeAssistant, config_entry: GoodweConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(config_entry.entry_id)
async def async_migrate_entry(
hass: HomeAssistant, config_entry: GoodweConfigEntry
) -> bool:
"""Migrate old config entries."""
if config_entry.version > 2:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1:
# Update from version 1 to version 2 adding the PROTOCOL to the config entry
host = config_entry.data[CONF_HOST]
try:
inverter, port = await GoodweFlowHandler.async_detect_inverter_port(
host=host
)
except InverterError as err:
raise ConfigEntryNotReady from err
new_data = {
CONF_HOST: host,
CONF_PORT: port,
CONF_MODEL_FAMILY: type(inverter).__name__,
}
hass.config_entries.async_update_entry(config_entry, data=new_data, version=2)
return True

View File

@@ -5,12 +5,12 @@ from __future__ import annotations
import logging
from typing import Any
from goodwe import InverterError, connect
from goodwe import Inverter, InverterError, connect
from goodwe.const import GOODWE_TCP_PORT, GOODWE_UDP_PORT
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.const import CONF_HOST, CONF_PORT
from .const import CONF_MODEL_FAMILY, DEFAULT_NAME, DOMAIN
@@ -26,9 +26,15 @@ _LOGGER = logging.getLogger(__name__)
class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a Goodwe config flow."""
VERSION = 1
MINOR_VERSION = 2
async def _handle_successful_connection(self, inverter, host):
async def async_handle_successful_connection(
self,
inverter: Inverter,
host: str,
port: int,
) -> ConfigFlowResult:
"""Handle a successful connection storing it's values on the entry data."""
await self.async_set_unique_id(inverter.serial_number)
self._abort_if_unique_id_configured()
@@ -36,6 +42,7 @@ class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
title=DEFAULT_NAME,
data={
CONF_HOST: host,
CONF_PORT: port,
CONF_MODEL_FAMILY: type(inverter).__name__,
},
)
@@ -48,19 +55,26 @@ class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is not None:
host = user_input[CONF_HOST]
try:
inverter = await connect(host=host, port=GOODWE_UDP_PORT, retries=10)
inverter, port = await self.async_detect_inverter_port(host=host)
except InverterError:
try:
inverter = await connect(
host=host, port=GOODWE_TCP_PORT, retries=10
)
except InverterError:
errors[CONF_HOST] = "connection_error"
else:
return await self._handle_successful_connection(inverter, host)
errors[CONF_HOST] = "connection_error"
else:
return await self._handle_successful_connection(inverter, host)
return await self.async_handle_successful_connection(
inverter, host, port
)
return self.async_show_form(
step_id="user", data_schema=CONFIG_SCHEMA, errors=errors
)
@staticmethod
async def async_detect_inverter_port(
host: str,
) -> tuple[Inverter, int]:
"""Detects the port of the Inverter."""
port = GOODWE_UDP_PORT
try:
inverter = await connect(host=host, port=port, retries=10)
except InverterError:
port = GOODWE_TCP_PORT
inverter = await connect(host=host, port=port, retries=10)
return inverter, port

View File

@@ -53,6 +53,9 @@ def _convert_api_item(item: dict[str, str]) -> TodoItem:
# Due dates are returned always in UTC so we only need to
# parse the date portion which will be interpreted as a a local date.
due = datetime.fromisoformat(due_str).date()
completed: datetime | None = None
if (completed_str := item.get("completed")) is not None:
completed = datetime.fromisoformat(completed_str)
return TodoItem(
summary=item["title"],
uid=item["id"],
@@ -61,6 +64,7 @@ def _convert_api_item(item: dict[str, str]) -> TodoItem:
TodoItemStatus.NEEDS_ACTION,
),
due=due,
completed=completed,
description=item.get("notes"),
)

View File

@@ -0,0 +1,84 @@
"""The Google Weather integration."""
from __future__ import annotations
import asyncio
from google_weather_api import GoogleWeatherApi
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_REFERRER
from .coordinator import (
GoogleWeatherConfigEntry,
GoogleWeatherCurrentConditionsCoordinator,
GoogleWeatherDailyForecastCoordinator,
GoogleWeatherHourlyForecastCoordinator,
GoogleWeatherRuntimeData,
GoogleWeatherSubEntryRuntimeData,
)
_PLATFORMS: list[Platform] = [Platform.WEATHER]
async def async_setup_entry(
hass: HomeAssistant, entry: GoogleWeatherConfigEntry
) -> bool:
"""Set up Google Weather from a config entry."""
api = GoogleWeatherApi(
session=async_get_clientsession(hass),
api_key=entry.data[CONF_API_KEY],
referrer=entry.data.get(CONF_REFERRER),
language_code=hass.config.language,
)
subentries_runtime_data: dict[str, GoogleWeatherSubEntryRuntimeData] = {}
for subentry in entry.subentries.values():
subentry_runtime_data = GoogleWeatherSubEntryRuntimeData(
coordinator_observation=GoogleWeatherCurrentConditionsCoordinator(
hass, entry, subentry, api
),
coordinator_daily_forecast=GoogleWeatherDailyForecastCoordinator(
hass, entry, subentry, api
),
coordinator_hourly_forecast=GoogleWeatherHourlyForecastCoordinator(
hass, entry, subentry, api
),
)
subentries_runtime_data[subentry.subentry_id] = subentry_runtime_data
tasks = [
coro
for subentry_runtime_data in subentries_runtime_data.values()
for coro in (
subentry_runtime_data.coordinator_observation.async_config_entry_first_refresh(),
subentry_runtime_data.coordinator_daily_forecast.async_config_entry_first_refresh(),
subentry_runtime_data.coordinator_hourly_forecast.async_config_entry_first_refresh(),
)
]
await asyncio.gather(*tasks)
entry.runtime_data = GoogleWeatherRuntimeData(
api=api,
subentries_runtime_data=subentries_runtime_data,
)
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
entry.async_on_unload(entry.add_update_listener(async_update_options))
return True
async def async_unload_entry(
hass: HomeAssistant, entry: GoogleWeatherConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
async def async_update_options(
hass: HomeAssistant, entry: GoogleWeatherConfigEntry
) -> None:
"""Update options."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -0,0 +1,198 @@
"""Config flow for the Google Weather integration."""
from __future__ import annotations
import logging
from typing import Any
from google_weather_api import GoogleWeatherApi, GoogleWeatherApiError
import voluptuous as vol
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
ConfigSubentryFlow,
SubentryFlowResult,
)
from homeassistant.const import (
CONF_API_KEY,
CONF_LATITUDE,
CONF_LOCATION,
CONF_LONGITUDE,
CONF_NAME,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import section
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import LocationSelector, LocationSelectorConfig
from .const import CONF_REFERRER, DOMAIN, SECTION_API_KEY_OPTIONS
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_KEY): str,
vol.Optional(SECTION_API_KEY_OPTIONS): section(
vol.Schema({vol.Optional(CONF_REFERRER): str}), {"collapsed": True}
),
}
)
async def _validate_input(
user_input: dict[str, Any],
api: GoogleWeatherApi,
errors: dict[str, str],
description_placeholders: dict[str, str],
) -> bool:
try:
await api.async_get_current_conditions(
latitude=user_input[CONF_LOCATION][CONF_LATITUDE],
longitude=user_input[CONF_LOCATION][CONF_LONGITUDE],
)
except GoogleWeatherApiError as err:
errors["base"] = "cannot_connect"
description_placeholders["error_message"] = str(err)
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return True
return False
def _get_location_schema(hass: HomeAssistant) -> vol.Schema:
"""Return the schema for a location with default values from the hass config."""
return vol.Schema(
{
vol.Required(CONF_NAME, default=hass.config.location_name): str,
vol.Required(
CONF_LOCATION,
default={
CONF_LATITUDE: hass.config.latitude,
CONF_LONGITUDE: hass.config.longitude,
},
): LocationSelector(LocationSelectorConfig(radius=False)),
}
)
def _is_location_already_configured(
hass: HomeAssistant, new_data: dict[str, float], epsilon: float = 1e-4
) -> bool:
"""Check if the location is already configured."""
for entry in hass.config_entries.async_entries(DOMAIN):
for subentry in entry.subentries.values():
# A more accurate way is to use the haversine formula, but for simplicity
# we use a simple distance check. The epsilon value is small anyway.
# This is mostly to capture cases where the user has slightly moved the location pin.
if (
abs(subentry.data[CONF_LATITUDE] - new_data[CONF_LATITUDE]) <= epsilon
and abs(subentry.data[CONF_LONGITUDE] - new_data[CONF_LONGITUDE])
<= epsilon
):
return True
return False
class GoogleWeatherConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Google Weather."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {
"api_key_url": "https://developers.google.com/maps/documentation/weather/get-api-key",
"restricting_api_keys_url": "https://developers.google.com/maps/api-security-best-practices#restricting-api-keys",
}
if user_input is not None:
api_key = user_input[CONF_API_KEY]
referrer = user_input.get(SECTION_API_KEY_OPTIONS, {}).get(CONF_REFERRER)
self._async_abort_entries_match({CONF_API_KEY: api_key})
if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]):
return self.async_abort(reason="already_configured")
api = GoogleWeatherApi(
session=async_get_clientsession(self.hass),
api_key=api_key,
referrer=referrer,
language_code=self.hass.config.language,
)
if await _validate_input(user_input, api, errors, description_placeholders):
return self.async_create_entry(
title="Google Weather",
data={
CONF_API_KEY: api_key,
CONF_REFERRER: referrer,
},
subentries=[
{
"subentry_type": "location",
"data": user_input[CONF_LOCATION],
"title": user_input[CONF_NAME],
"unique_id": None,
},
],
)
else:
user_input = {}
schema = STEP_USER_DATA_SCHEMA.schema.copy()
schema.update(_get_location_schema(self.hass).schema)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(schema), user_input
),
errors=errors,
description_placeholders=description_placeholders,
)
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this integration."""
return {"location": LocationSubentryFlowHandler}
class LocationSubentryFlowHandler(ConfigSubentryFlow):
"""Handle a subentry flow for location."""
async def async_step_location(
self,
user_input: dict[str, Any] | None = None,
) -> SubentryFlowResult:
"""Handle the location step."""
if self._get_entry().state != ConfigEntryState.LOADED:
return self.async_abort(reason="entry_not_loaded")
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {}
if user_input is not None:
if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]):
return self.async_abort(reason="already_configured")
api: GoogleWeatherApi = self._get_entry().runtime_data.api
if await _validate_input(user_input, api, errors, description_placeholders):
return self.async_create_entry(
title=user_input[CONF_NAME],
data=user_input[CONF_LOCATION],
)
else:
user_input = {}
return self.async_show_form(
step_id="location",
data_schema=self.add_suggested_values_to_schema(
_get_location_schema(self.hass), user_input
),
errors=errors,
description_placeholders=description_placeholders,
)
async_step_user = async_step_location

View File

@@ -0,0 +1,8 @@
"""Constants for the Google Weather integration."""
from typing import Final
DOMAIN = "google_weather"
SECTION_API_KEY_OPTIONS: Final = "api_key_options"
CONF_REFERRER: Final = "referrer"

View File

@@ -0,0 +1,169 @@
"""The Google Weather coordinator."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import TypeVar
from google_weather_api import (
CurrentConditionsResponse,
DailyForecastResponse,
GoogleWeatherApi,
GoogleWeatherApiError,
HourlyForecastResponse,
)
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import (
TimestampDataUpdateCoordinator,
UpdateFailed,
)
_LOGGER = logging.getLogger(__name__)
T = TypeVar(
"T",
bound=(
CurrentConditionsResponse
| DailyForecastResponse
| HourlyForecastResponse
| None
),
)
@dataclass
class GoogleWeatherSubEntryRuntimeData:
"""Runtime data for a Google Weather sub-entry."""
coordinator_observation: GoogleWeatherCurrentConditionsCoordinator
coordinator_daily_forecast: GoogleWeatherDailyForecastCoordinator
coordinator_hourly_forecast: GoogleWeatherHourlyForecastCoordinator
@dataclass
class GoogleWeatherRuntimeData:
"""Runtime data for the Google Weather integration."""
api: GoogleWeatherApi
subentries_runtime_data: dict[str, GoogleWeatherSubEntryRuntimeData]
type GoogleWeatherConfigEntry = ConfigEntry[GoogleWeatherRuntimeData]
class GoogleWeatherBaseCoordinator(TimestampDataUpdateCoordinator[T]):
"""Base class for Google Weather coordinators."""
config_entry: GoogleWeatherConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: GoogleWeatherConfigEntry,
subentry: ConfigSubentry,
data_type_name: str,
update_interval: timedelta,
api_method: Callable[..., Awaitable[T]],
) -> None:
"""Initialize the data updater."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=f"Google Weather {data_type_name} coordinator for {subentry.title}",
update_interval=update_interval,
)
self.subentry = subentry
self._data_type_name = data_type_name
self._api_method = api_method
async def _async_update_data(self) -> T:
"""Fetch data from API and handle errors."""
try:
return await self._api_method(
self.subentry.data[CONF_LATITUDE],
self.subentry.data[CONF_LONGITUDE],
)
except GoogleWeatherApiError as err:
_LOGGER.error(
"Error fetching %s for %s: %s",
self._data_type_name,
self.subentry.title,
err,
)
raise UpdateFailed(f"Error fetching {self._data_type_name}") from err
class GoogleWeatherCurrentConditionsCoordinator(
GoogleWeatherBaseCoordinator[CurrentConditionsResponse]
):
"""Handle fetching current weather conditions."""
def __init__(
self,
hass: HomeAssistant,
config_entry: GoogleWeatherConfigEntry,
subentry: ConfigSubentry,
api: GoogleWeatherApi,
) -> None:
"""Initialize the data updater."""
super().__init__(
hass,
config_entry,
subentry,
"current weather conditions",
timedelta(minutes=15),
api.async_get_current_conditions,
)
class GoogleWeatherDailyForecastCoordinator(
GoogleWeatherBaseCoordinator[DailyForecastResponse]
):
"""Handle fetching daily weather forecast."""
def __init__(
self,
hass: HomeAssistant,
config_entry: GoogleWeatherConfigEntry,
subentry: ConfigSubentry,
api: GoogleWeatherApi,
) -> None:
"""Initialize the data updater."""
super().__init__(
hass,
config_entry,
subentry,
"daily weather forecast",
timedelta(hours=1),
api.async_get_daily_forecast,
)
class GoogleWeatherHourlyForecastCoordinator(
GoogleWeatherBaseCoordinator[HourlyForecastResponse]
):
"""Handle fetching hourly weather forecast."""
def __init__(
self,
hass: HomeAssistant,
config_entry: GoogleWeatherConfigEntry,
subentry: ConfigSubentry,
api: GoogleWeatherApi,
) -> None:
"""Initialize the data updater."""
super().__init__(
hass,
config_entry,
subentry,
"hourly weather forecast",
timedelta(hours=1),
api.async_get_hourly_forecast,
)

View File

@@ -0,0 +1,28 @@
"""Base entity for Google Weather."""
from __future__ import annotations
from homeassistant.config_entries import ConfigSubentry
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import DOMAIN
from .coordinator import GoogleWeatherConfigEntry
class GoogleWeatherBaseEntity(Entity):
"""Base entity for all Google Weather entities."""
_attr_has_entity_name = True
def __init__(
self, config_entry: GoogleWeatherConfigEntry, subentry: ConfigSubentry
) -> None:
"""Initialize base entity."""
self._attr_unique_id = subentry.subentry_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, subentry.subentry_id)},
name=subentry.title,
manufacturer="Google",
entry_type=DeviceEntryType.SERVICE,
)

View File

@@ -0,0 +1,12 @@
{
"domain": "google_weather",
"name": "Google Weather",
"codeowners": ["@tronikos"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/google_weather",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["google_weather_api"],
"quality_scale": "bronze",
"requirements": ["python-google-weather-api==0.0.4"]
}

View File

@@ -0,0 +1,82 @@
rules:
# Bronze
action-setup:
status: exempt
comment: No actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: No actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: No events subscribed.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: No actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: No configuration options.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: No discovery.
discovery:
status: exempt
comment: No discovery.
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices:
status: exempt
comment: No physical devices.
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
comment: N/A
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: No repairs.
stale-devices:
status: exempt
comment: N/A
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done

View File

@@ -0,0 +1,65 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"error": {
"cannot_connect": "Unable to connect to the Google Weather API:\n\n{error_message}",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"location": "[%key:common::config_flow::data::location%]",
"name": "[%key:common::config_flow::data::name%]"
},
"data_description": {
"api_key": "A unique alphanumeric string that associates your Google billing account with Google Weather API",
"location": "Location coordinates",
"name": "Location name"
},
"description": "Get your API key from [here]({api_key_url}).",
"sections": {
"api_key_options": {
"data": {
"referrer": "HTTP referrer"
},
"data_description": {
"referrer": "Specify this only if the API key has a [website application restriction]({restricting_api_keys_url})"
},
"name": "Optional API key options"
}
}
}
}
},
"config_subentries": {
"location": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
"entry_not_loaded": "Cannot add things while the configuration is disabled."
},
"entry_type": "Location",
"error": {
"cannot_connect": "[%key:component::google_weather::config::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"initiate_flow": {
"user": "Add location"
},
"step": {
"location": {
"data": {
"location": "[%key:common::config_flow::data::location%]",
"name": "[%key:common::config_flow::data::name%]"
},
"data_description": {
"location": "[%key:component::google_weather::config::step::user::data_description::location%]",
"name": "[%key:component::google_weather::config::step::user::data_description::name%]"
}
}
}
}
}
}

View File

@@ -0,0 +1,366 @@
"""Weather entity."""
from __future__ import annotations
from google_weather_api import (
DailyForecastResponse,
HourlyForecastResponse,
WeatherCondition,
)
from homeassistant.components.weather import (
ATTR_CONDITION_CLEAR_NIGHT,
ATTR_CONDITION_CLOUDY,
ATTR_CONDITION_HAIL,
ATTR_CONDITION_LIGHTNING_RAINY,
ATTR_CONDITION_PARTLYCLOUDY,
ATTR_CONDITION_POURING,
ATTR_CONDITION_RAINY,
ATTR_CONDITION_SNOWY,
ATTR_CONDITION_SNOWY_RAINY,
ATTR_CONDITION_SUNNY,
ATTR_CONDITION_WINDY,
ATTR_FORECAST_CLOUD_COVERAGE,
ATTR_FORECAST_CONDITION,
ATTR_FORECAST_HUMIDITY,
ATTR_FORECAST_IS_DAYTIME,
ATTR_FORECAST_NATIVE_APPARENT_TEMP,
ATTR_FORECAST_NATIVE_DEW_POINT,
ATTR_FORECAST_NATIVE_PRECIPITATION,
ATTR_FORECAST_NATIVE_PRESSURE,
ATTR_FORECAST_NATIVE_TEMP,
ATTR_FORECAST_NATIVE_TEMP_LOW,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED,
ATTR_FORECAST_NATIVE_WIND_SPEED,
ATTR_FORECAST_PRECIPITATION_PROBABILITY,
ATTR_FORECAST_TIME,
ATTR_FORECAST_UV_INDEX,
ATTR_FORECAST_WIND_BEARING,
CoordinatorWeatherEntity,
Forecast,
WeatherEntityFeature,
)
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import (
UnitOfLength,
UnitOfPrecipitationDepth,
UnitOfPressure,
UnitOfSpeed,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import (
GoogleWeatherConfigEntry,
GoogleWeatherCurrentConditionsCoordinator,
GoogleWeatherDailyForecastCoordinator,
GoogleWeatherHourlyForecastCoordinator,
)
from .entity import GoogleWeatherBaseEntity
PARALLEL_UPDATES = 0
# Maps https://developers.google.com/maps/documentation/weather/weather-condition-icons
# to https://developers.home-assistant.io/docs/core/entity/weather/#recommended-values-for-state-and-condition
_CONDITION_MAP: dict[WeatherCondition.Type, str | None] = {
WeatherCondition.Type.TYPE_UNSPECIFIED: None,
WeatherCondition.Type.CLEAR: ATTR_CONDITION_SUNNY,
WeatherCondition.Type.MOSTLY_CLEAR: ATTR_CONDITION_PARTLYCLOUDY,
WeatherCondition.Type.PARTLY_CLOUDY: ATTR_CONDITION_PARTLYCLOUDY,
WeatherCondition.Type.MOSTLY_CLOUDY: ATTR_CONDITION_CLOUDY,
WeatherCondition.Type.CLOUDY: ATTR_CONDITION_CLOUDY,
WeatherCondition.Type.WINDY: ATTR_CONDITION_WINDY,
WeatherCondition.Type.WIND_AND_RAIN: ATTR_CONDITION_RAINY,
WeatherCondition.Type.LIGHT_RAIN_SHOWERS: ATTR_CONDITION_RAINY,
WeatherCondition.Type.CHANCE_OF_SHOWERS: ATTR_CONDITION_RAINY,
WeatherCondition.Type.SCATTERED_SHOWERS: ATTR_CONDITION_RAINY,
WeatherCondition.Type.RAIN_SHOWERS: ATTR_CONDITION_RAINY,
WeatherCondition.Type.HEAVY_RAIN_SHOWERS: ATTR_CONDITION_POURING,
WeatherCondition.Type.LIGHT_TO_MODERATE_RAIN: ATTR_CONDITION_RAINY,
WeatherCondition.Type.MODERATE_TO_HEAVY_RAIN: ATTR_CONDITION_POURING,
WeatherCondition.Type.RAIN: ATTR_CONDITION_RAINY,
WeatherCondition.Type.LIGHT_RAIN: ATTR_CONDITION_RAINY,
WeatherCondition.Type.HEAVY_RAIN: ATTR_CONDITION_POURING,
WeatherCondition.Type.RAIN_PERIODICALLY_HEAVY: ATTR_CONDITION_POURING,
WeatherCondition.Type.LIGHT_SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.CHANCE_OF_SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.SCATTERED_SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.HEAVY_SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.LIGHT_TO_MODERATE_SNOW: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.MODERATE_TO_HEAVY_SNOW: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.SNOW: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.LIGHT_SNOW: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.HEAVY_SNOW: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.SNOWSTORM: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.SNOW_PERIODICALLY_HEAVY: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.HEAVY_SNOW_STORM: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.BLOWING_SNOW: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.RAIN_AND_SNOW: ATTR_CONDITION_SNOWY_RAINY,
WeatherCondition.Type.HAIL: ATTR_CONDITION_HAIL,
WeatherCondition.Type.HAIL_SHOWERS: ATTR_CONDITION_HAIL,
WeatherCondition.Type.THUNDERSTORM: ATTR_CONDITION_LIGHTNING_RAINY,
WeatherCondition.Type.THUNDERSHOWER: ATTR_CONDITION_LIGHTNING_RAINY,
WeatherCondition.Type.LIGHT_THUNDERSTORM_RAIN: ATTR_CONDITION_LIGHTNING_RAINY,
WeatherCondition.Type.SCATTERED_THUNDERSTORMS: ATTR_CONDITION_LIGHTNING_RAINY,
WeatherCondition.Type.HEAVY_THUNDERSTORM: ATTR_CONDITION_LIGHTNING_RAINY,
}
def _get_condition(
api_condition: WeatherCondition.Type, is_daytime: bool
) -> str | None:
"""Map Google Weather condition to Home Assistant condition."""
cond = _CONDITION_MAP[api_condition]
if cond == ATTR_CONDITION_SUNNY and not is_daytime:
return ATTR_CONDITION_CLEAR_NIGHT
return cond
async def async_setup_entry(
hass: HomeAssistant,
entry: GoogleWeatherConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add a weather entity from a config_entry."""
for subentry in entry.subentries.values():
async_add_entities(
[GoogleWeatherEntity(entry, subentry)],
config_subentry_id=subentry.subentry_id,
)
class GoogleWeatherEntity(
CoordinatorWeatherEntity[
GoogleWeatherCurrentConditionsCoordinator,
GoogleWeatherDailyForecastCoordinator,
GoogleWeatherHourlyForecastCoordinator,
GoogleWeatherDailyForecastCoordinator,
],
GoogleWeatherBaseEntity,
):
"""Representation of a Google Weather entity."""
_attr_attribution = "Data from Google Weather"
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
_attr_native_pressure_unit = UnitOfPressure.MBAR
_attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR
_attr_native_visibility_unit = UnitOfLength.KILOMETERS
_attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS
_attr_name = None
_attr_supported_features = (
WeatherEntityFeature.FORECAST_DAILY
| WeatherEntityFeature.FORECAST_HOURLY
| WeatherEntityFeature.FORECAST_TWICE_DAILY
)
def __init__(
self,
entry: GoogleWeatherConfigEntry,
subentry: ConfigSubentry,
) -> None:
"""Initialize the weather entity."""
subentry_runtime_data = entry.runtime_data.subentries_runtime_data[
subentry.subentry_id
]
super().__init__(
observation_coordinator=subentry_runtime_data.coordinator_observation,
daily_coordinator=subentry_runtime_data.coordinator_daily_forecast,
hourly_coordinator=subentry_runtime_data.coordinator_hourly_forecast,
twice_daily_coordinator=subentry_runtime_data.coordinator_daily_forecast,
)
GoogleWeatherBaseEntity.__init__(self, entry, subentry)
@property
def condition(self) -> str | None:
"""Return the current condition."""
return _get_condition(
self.coordinator.data.weather_condition.type,
self.coordinator.data.is_daytime,
)
@property
def native_temperature(self) -> float:
"""Return the temperature."""
return self.coordinator.data.temperature.degrees
@property
def native_apparent_temperature(self) -> float:
"""Return the apparent temperature."""
return self.coordinator.data.feels_like_temperature.degrees
@property
def native_dew_point(self) -> float:
"""Return the dew point."""
return self.coordinator.data.dew_point.degrees
@property
def humidity(self) -> int:
"""Return the humidity."""
return self.coordinator.data.relative_humidity
@property
def uv_index(self) -> float:
"""Return the UV index."""
return float(self.coordinator.data.uv_index)
@property
def native_pressure(self) -> float:
"""Return the pressure."""
return self.coordinator.data.air_pressure.mean_sea_level_millibars
@property
def native_wind_gust_speed(self) -> float:
"""Return the wind gust speed."""
return self.coordinator.data.wind.gust.value
@property
def native_wind_speed(self) -> float:
"""Return the wind speed."""
return self.coordinator.data.wind.speed.value
@property
def wind_bearing(self) -> int:
"""Return the wind bearing."""
return self.coordinator.data.wind.direction.degrees
@property
def native_visibility(self) -> float:
"""Return the visibility."""
return self.coordinator.data.visibility.distance
@property
def cloud_coverage(self) -> float:
"""Return the Cloud coverage in %."""
return float(self.coordinator.data.cloud_cover)
@callback
def _async_forecast_daily(self) -> list[Forecast] | None:
"""Return the daily forecast in native units."""
coordinator = self.forecast_coordinators["daily"]
assert coordinator
daily_data = coordinator.data
assert isinstance(daily_data, DailyForecastResponse)
return [
{
ATTR_FORECAST_CONDITION: _get_condition(
item.daytime_forecast.weather_condition.type, is_daytime=True
),
ATTR_FORECAST_TIME: item.interval.start_time,
ATTR_FORECAST_HUMIDITY: item.daytime_forecast.relative_humidity,
ATTR_FORECAST_PRECIPITATION_PROBABILITY: max(
item.daytime_forecast.precipitation.probability.percent,
item.nighttime_forecast.precipitation.probability.percent,
),
ATTR_FORECAST_CLOUD_COVERAGE: item.daytime_forecast.cloud_cover,
ATTR_FORECAST_NATIVE_PRECIPITATION: (
item.daytime_forecast.precipitation.qpf.quantity
+ item.nighttime_forecast.precipitation.qpf.quantity
),
ATTR_FORECAST_NATIVE_TEMP: item.max_temperature.degrees,
ATTR_FORECAST_NATIVE_TEMP_LOW: item.min_temperature.degrees,
ATTR_FORECAST_NATIVE_APPARENT_TEMP: (
item.feels_like_max_temperature.degrees
),
ATTR_FORECAST_WIND_BEARING: item.daytime_forecast.wind.direction.degrees,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: max(
item.daytime_forecast.wind.gust.value,
item.nighttime_forecast.wind.gust.value,
),
ATTR_FORECAST_NATIVE_WIND_SPEED: max(
item.daytime_forecast.wind.speed.value,
item.nighttime_forecast.wind.speed.value,
),
ATTR_FORECAST_UV_INDEX: item.daytime_forecast.uv_index,
}
for item in daily_data.forecast_days
]
@callback
def _async_forecast_hourly(self) -> list[Forecast] | None:
"""Return the hourly forecast in native units."""
coordinator = self.forecast_coordinators["hourly"]
assert coordinator
hourly_data = coordinator.data
assert isinstance(hourly_data, HourlyForecastResponse)
return [
{
ATTR_FORECAST_CONDITION: _get_condition(
item.weather_condition.type, item.is_daytime
),
ATTR_FORECAST_TIME: item.interval.start_time,
ATTR_FORECAST_HUMIDITY: item.relative_humidity,
ATTR_FORECAST_PRECIPITATION_PROBABILITY: item.precipitation.probability.percent,
ATTR_FORECAST_CLOUD_COVERAGE: item.cloud_cover,
ATTR_FORECAST_NATIVE_PRECIPITATION: item.precipitation.qpf.quantity,
ATTR_FORECAST_NATIVE_PRESSURE: item.air_pressure.mean_sea_level_millibars,
ATTR_FORECAST_NATIVE_TEMP: item.temperature.degrees,
ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_temperature.degrees,
ATTR_FORECAST_WIND_BEARING: item.wind.direction.degrees,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: item.wind.gust.value,
ATTR_FORECAST_NATIVE_WIND_SPEED: item.wind.speed.value,
ATTR_FORECAST_NATIVE_DEW_POINT: item.dew_point.degrees,
ATTR_FORECAST_UV_INDEX: item.uv_index,
ATTR_FORECAST_IS_DAYTIME: item.is_daytime,
}
for item in hourly_data.forecast_hours
]
@callback
def _async_forecast_twice_daily(self) -> list[Forecast] | None:
"""Return the twice daily forecast in native units."""
coordinator = self.forecast_coordinators["twice_daily"]
assert coordinator
daily_data = coordinator.data
assert isinstance(daily_data, DailyForecastResponse)
forecasts: list[Forecast] = []
for item in daily_data.forecast_days:
# Process daytime forecast
day_forecast = item.daytime_forecast
forecasts.append(
{
ATTR_FORECAST_CONDITION: _get_condition(
day_forecast.weather_condition.type, is_daytime=True
),
ATTR_FORECAST_TIME: day_forecast.interval.start_time,
ATTR_FORECAST_HUMIDITY: day_forecast.relative_humidity,
ATTR_FORECAST_PRECIPITATION_PROBABILITY: day_forecast.precipitation.probability.percent,
ATTR_FORECAST_CLOUD_COVERAGE: day_forecast.cloud_cover,
ATTR_FORECAST_NATIVE_PRECIPITATION: day_forecast.precipitation.qpf.quantity,
ATTR_FORECAST_NATIVE_TEMP: item.max_temperature.degrees,
ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_max_temperature.degrees,
ATTR_FORECAST_WIND_BEARING: day_forecast.wind.direction.degrees,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: day_forecast.wind.gust.value,
ATTR_FORECAST_NATIVE_WIND_SPEED: day_forecast.wind.speed.value,
ATTR_FORECAST_UV_INDEX: day_forecast.uv_index,
ATTR_FORECAST_IS_DAYTIME: True,
}
)
# Process nighttime forecast
night_forecast = item.nighttime_forecast
forecasts.append(
{
ATTR_FORECAST_CONDITION: _get_condition(
night_forecast.weather_condition.type, is_daytime=False
),
ATTR_FORECAST_TIME: night_forecast.interval.start_time,
ATTR_FORECAST_HUMIDITY: night_forecast.relative_humidity,
ATTR_FORECAST_PRECIPITATION_PROBABILITY: night_forecast.precipitation.probability.percent,
ATTR_FORECAST_CLOUD_COVERAGE: night_forecast.cloud_cover,
ATTR_FORECAST_NATIVE_PRECIPITATION: night_forecast.precipitation.qpf.quantity,
ATTR_FORECAST_NATIVE_TEMP: item.min_temperature.degrees,
ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_min_temperature.degrees,
ATTR_FORECAST_WIND_BEARING: night_forecast.wind.direction.degrees,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: night_forecast.wind.gust.value,
ATTR_FORECAST_NATIVE_WIND_SPEED: night_forecast.wind.speed.value,
ATTR_FORECAST_UV_INDEX: night_forecast.uv_index,
ATTR_FORECAST_IS_DAYTIME: False,
}
)
return forecasts

View File

@@ -7,7 +7,7 @@ from collections import defaultdict
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import Any, cast
from typing import Any
from aiohomeconnect.client import Client as HomeConnectClient
from aiohomeconnect.model import (
@@ -247,14 +247,15 @@ class HomeConnectCoordinator(
value=event.value,
)
else:
event_value = event.value
if event_key in (
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
):
) and isinstance(event_value, str):
await self.update_options(
event_message_ha_id,
event_key,
ProgramKey(cast(str, event.value)),
ProgramKey(event_value),
)
events[event_key] = event
self._call_event_listener(event_message)

View File

@@ -14,7 +14,6 @@ from aiohomeconnect.model.error import (
TooManyRequestsError,
)
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
@@ -62,10 +61,8 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self.update_native_value()
available = self._attr_available = self.appliance.info.connected
self.async_write_ha_state()
state = STATE_UNAVAILABLE if not available else self.state
_LOGGER.debug("Updated %s, new state: %s", self.entity_id, state)
_LOGGER.debug("Updated %s", self)
@property
def bsh_key(self) -> str:
@@ -80,7 +77,7 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
as event updates should take precedence over the coordinator
refresh.
"""
return self._attr_available
return self.appliance.info.connected and self._attr_available
class HomeConnectOptionEntity(HomeConnectEntity):

View File

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

View File

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

View File

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

View File

@@ -121,12 +121,15 @@ class AutomowerBaseEntity(CoordinatorEntity[AutomowerDataUpdateCoordinator]):
"""Initialize AutomowerEntity."""
super().__init__(coordinator)
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(
identifiers={(DOMAIN, mower_id)},
manufacturer=parts[0],
model=parts[1],
model_id=parts[2],
manufacturer="Husqvarna",
model=parts[0].capitalize().removesuffix("®"),
model_id=parts[1],
name=self.mower_attributes.system.name,
serial_number=self.mower_attributes.system.serial_number,
suggested_area="Garden",

View File

@@ -37,5 +37,5 @@
"iot_class": "cloud_push",
"loggers": ["pylamarzocco"],
"quality_scale": "platinum",
"requirements": ["pylamarzocco==2.1.2"]
"requirements": ["pylamarzocco==2.1.3"]
}

View File

@@ -154,6 +154,7 @@ class LocalTodoListEntity(TodoListEntity):
),
due=due,
description=item.description,
completed=item.completed,
)
)
self._attr_todo_items = todo_items

View File

@@ -194,7 +194,7 @@
"name": "Altitude above sea level"
},
"auto_relock_timer": {
"name": "Autorelock time"
"name": "Auto-relock time"
},
"cook_time": {
"name": "Cooking time"

View File

@@ -139,7 +139,7 @@ class MeteoLtWeatherEntity(CoordinatorEntity[MeteoLtUpdateCoordinator], WeatherE
forecasts_by_date[date].append(timestamp)
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]
if not day_forecasts:
continue
@@ -186,5 +186,5 @@ class MeteoLtWeatherEntity(CoordinatorEntity[MeteoLtUpdateCoordinator], WeatherE
return None
return [
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
from typing import Final
import aiohttp
from aiohttp import ClientResponseError
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
@@ -153,11 +153,12 @@ class MieleButton(MieleEntity, ButtonEntity):
self._device_id,
{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(
translation_domain=DOMAIN,
translation_key="set_state_error",
translation_placeholders={
"entity": self.entity_id,
},
) from ex
) from err

View File

@@ -7,7 +7,7 @@ from dataclasses import dataclass
import logging
from typing import Any, Final, cast
import aiohttp
from aiohttp import ClientResponseError
from pymiele import MieleDevice, MieleTemperature
from homeassistant.components.climate import (
@@ -250,7 +250,8 @@ class MieleClimate(MieleEntity, ClimateEntity):
cast(float, kwargs.get(ATTR_TEMPERATURE)),
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(
translation_domain=DOMAIN,
translation_key="set_state_error",

View File

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

View File

@@ -142,14 +142,15 @@ class MieleFan(MieleEntity, FanEntity):
await self.api.send_action(
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(
translation_domain=DOMAIN,
translation_key="set_state_error",
translation_placeholders={
"entity": self.entity_id,
},
) from ex
) from err
self.device.state_ventilation_step = ventilation_step
self.async_write_ha_state()
@@ -171,6 +172,7 @@ class MieleFan(MieleEntity, FanEntity):
translation_key="set_state_error",
translation_placeholders={
"entity": self.entity_id,
"err_status": str(ex.status),
},
) from ex
@@ -188,6 +190,7 @@ class MieleFan(MieleEntity, FanEntity):
translation_key="set_state_error",
translation_placeholders={
"entity": self.entity_id,
"err_status": str(ex.status),
},
) from ex

View File

@@ -7,7 +7,7 @@ from dataclasses import dataclass
import logging
from typing import Any, Final
import aiohttp
from aiohttp import ClientResponseError
from homeassistant.components.light import (
ColorMode,
@@ -131,7 +131,8 @@ class MieleLight(MieleEntity, LightEntity):
await self.api.send_action(
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(
translation_domain=DOMAIN,
translation_key="set_state_error",

View File

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

View File

@@ -7,7 +7,7 @@ from dataclasses import dataclass
import logging
from typing import Any, Final, cast
import aiohttp
from aiohttp import ClientResponseError
from pymiele import MieleDevice
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
@@ -165,7 +165,8 @@ class MieleSwitch(MieleEntity, SwitchEntity):
"""Set switch to mode."""
try:
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(
translation_domain=DOMAIN,
translation_key="set_state_error",
@@ -197,7 +198,8 @@ class MielePowerSwitch(MieleSwitch):
"""Set switch to mode."""
try:
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(
translation_domain=DOMAIN,
translation_key="set_state_error",

View File

@@ -189,14 +189,15 @@ class MieleVacuum(MieleEntity, StateVacuumEntity):
"""Send action to the device."""
try:
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(
translation_domain=DOMAIN,
translation_key="set_state_error",
translation_placeholders={
"entity": self.entity_id,
},
) from ex
) from err
async def async_clean_spot(self, **kwargs: Any) -> None:
"""Clean spot."""

View File

@@ -88,6 +88,8 @@ class ModbusRegisterSensor(ModbusStructEntity, RestoreSensor, SensorEntity):
self._attr_device_class = entry.get(CONF_DEVICE_CLASS)
if self._precision > 0 or self._scale != int(self._scale):
self._value_is_int = False
if self._precision > 0 and self._data_type not in ["string", "custom"]:
self._attr_suggested_display_precision = self._precision
async def async_setup_slaves(
self, hass: HomeAssistant, slave_count: int, entry: dict[str, Any]

View File

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

View File

@@ -1312,6 +1312,7 @@
"speed": "[%key:component::sensor::entity_component::speed::name%]",
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::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%]",
"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%]",

View File

@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/mvglive",
"iot_class": "cloud_polling",
"loggers": ["MVG"],
"quality_scale": "legacy",
"requirements": ["mvg==1.4.0"]
}

View File

@@ -19,5 +19,5 @@
"documentation": "https://www.home-assistant.io/integrations/nest",
"iot_class": "cloud_push",
"loggers": ["google_nest_sdm"],
"requirements": ["google-nest-sdm==7.1.4"]
"requirements": ["google-nest-sdm==9.0.1"]
}

View File

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

View File

@@ -338,6 +338,13 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
options.pop(CONF_CODE_INTERPRETER)
if model.startswith(("o", "gpt-5")) and not model.startswith("gpt-5-pro"):
if model.startswith("gpt-5.1"):
reasoning_options = ["none", "low", "medium", "high"]
elif model.startswith("gpt-5"):
reasoning_options = ["minimal", "low", "medium", "high"]
else:
reasoning_options = ["low", "medium", "high"]
step_schema.update(
{
vol.Optional(
@@ -345,9 +352,7 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
default=RECOMMENDED_REASONING_EFFORT,
): SelectSelector(
SelectSelectorConfig(
options=["low", "medium", "high"]
if model.startswith("o")
else ["minimal", "low", "medium", "high"],
options=reasoning_options,
translation_key=CONF_REASONING_EFFORT,
mode=SelectSelectorMode.DROPDOWN,
)

View File

@@ -510,6 +510,9 @@ class OpenAIBaseLLMEntity(Entity):
"verbosity": options.get(CONF_VERBOSITY, RECOMMENDED_VERBOSITY)
}
if model_args["model"].startswith("gpt-5.1"):
model_args["prompt_cache_retention"] = "24h"
tools: list[ToolParam] = []
if chat_log.llm_api:
tools = [

View File

@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/openai_conversation",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["openai==2.2.0"]
"requirements": ["openai==2.8.0"]
}

View File

@@ -146,7 +146,8 @@
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"medium": "[%key:common::state::medium%]",
"minimal": "Minimal"
"minimal": "Minimal",
"none": "None"
}
},
"search_context_size": {

View File

@@ -37,6 +37,7 @@ SELECT_TYPES = (
PlugwiseSelectEntityDescription(
key=SELECT_SCHEDULE,
translation_key=SELECT_SCHEDULE,
entity_category=EntityCategory.CONFIG,
options_key="available_schedules",
),
PlugwiseSelectEntityDescription(

View File

@@ -48,7 +48,6 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
PlugwiseSensorEntityDescription(
key="setpoint_high",
@@ -56,7 +55,6 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
PlugwiseSensorEntityDescription(
key="setpoint_low",
@@ -64,13 +62,11 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
PlugwiseSensorEntityDescription(
key="temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
PlugwiseSensorEntityDescription(
@@ -94,6 +90,7 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = (
translation_key="outdoor_temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
PlugwiseSensorEntityDescription(
@@ -352,8 +349,8 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = (
key="illuminance",
native_unit_of_measurement=LIGHT_LUX,
device_class=SensorDeviceClass.ILLUMINANCE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
PlugwiseSensorEntityDescription(
key="modulation_level",
@@ -365,8 +362,8 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = (
PlugwiseSensorEntityDescription(
key="valve_position",
translation_key="valve_position",
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
PlugwiseSensorEntityDescription(

View File

@@ -2,5 +2,17 @@
from __future__ import annotations
from homeassistant.const import UnitOfTemperature, UnitOfVolumeFlowRate
DOMAIN = "pooldose"
MANUFACTURER = "SEKO"
# Mapping of device units to Home Assistant units
UNIT_MAPPING: dict[str, str] = {
# Temperature units
"°C": UnitOfTemperature.CELSIUS,
"°F": UnitOfTemperature.FAHRENHEIT,
# Volume flow rate units
"m3/h": UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
"L/s": UnitOfVolumeFlowRate.LITERS_PER_SECOND,
}

View File

@@ -1,6 +1,15 @@
{
"entity": {
"sensor": {
"cl": {
"default": "mdi:pool"
},
"cl_type_dosing": {
"default": "mdi:flask"
},
"flow_rate": {
"default": "mdi:pipe-valve"
},
"ofa_orp_time": {
"default": "mdi:clock"
},
@@ -22,6 +31,9 @@
"orp_type_dosing": {
"default": "mdi:flask"
},
"peristaltic_cl_dosing": {
"default": "mdi:pump"
},
"peristaltic_orp_dosing": {
"default": "mdi:pump"
},

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from dataclasses import dataclass
import logging
from typing import TYPE_CHECKING
@@ -10,36 +11,61 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
)
from homeassistant.const import EntityCategory, UnitOfElectricPotential, UnitOfTime
from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
EntityCategory,
UnitOfElectricPotential,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import PooldoseConfigEntry
from .const import UNIT_MAPPING
from .entity import PooldoseEntity
_LOGGER = logging.getLogger(__name__)
SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
@dataclass(frozen=True, kw_only=True)
class PooldoseSensorEntityDescription(SensorEntityDescription):
"""Describes PoolDose sensor entity."""
use_dynamic_unit: bool = False
SENSOR_DESCRIPTIONS: tuple[PooldoseSensorEntityDescription, ...] = (
PooldoseSensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
# Unit dynamically determined via API
use_dynamic_unit=True,
),
SensorEntityDescription(key="ph", device_class=SensorDeviceClass.PH),
SensorEntityDescription(
PooldoseSensorEntityDescription(key="ph", device_class=SensorDeviceClass.PH),
PooldoseSensorEntityDescription(
key="orp",
translation_key="orp",
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
),
SensorEntityDescription(
PooldoseSensorEntityDescription(
key="cl",
translation_key="cl",
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
),
PooldoseSensorEntityDescription(
key="flow_rate",
translation_key="flow_rate",
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
use_dynamic_unit=True,
),
PooldoseSensorEntityDescription(
key="ph_type_dosing",
translation_key="ph_type_dosing",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.ENUM,
options=["alcalyne", "acid"],
),
SensorEntityDescription(
PooldoseSensorEntityDescription(
key="peristaltic_ph_dosing",
translation_key="peristaltic_ph_dosing",
entity_category=EntityCategory.DIAGNOSTIC,
@@ -47,7 +73,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENUM,
options=["proportional", "on_off", "timed"],
),
SensorEntityDescription(
PooldoseSensorEntityDescription(
key="ofa_ph_time",
translation_key="ofa_ph_time",
entity_category=EntityCategory.DIAGNOSTIC,
@@ -55,7 +81,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfTime.MINUTES,
),
SensorEntityDescription(
PooldoseSensorEntityDescription(
key="orp_type_dosing",
translation_key="orp_type_dosing",
entity_category=EntityCategory.DIAGNOSTIC,
@@ -63,7 +89,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENUM,
options=["low", "high"],
),
SensorEntityDescription(
PooldoseSensorEntityDescription(
key="peristaltic_orp_dosing",
translation_key="peristaltic_orp_dosing",
entity_category=EntityCategory.DIAGNOSTIC,
@@ -71,7 +97,23 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENUM,
options=["off", "proportional", "on_off", "timed"],
),
SensorEntityDescription(
PooldoseSensorEntityDescription(
key="cl_type_dosing",
translation_key="cl_type_dosing",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
device_class=SensorDeviceClass.ENUM,
options=["low", "high"],
),
PooldoseSensorEntityDescription(
key="peristaltic_cl_dosing",
translation_key="peristaltic_cl_dosing",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
device_class=SensorDeviceClass.ENUM,
options=["off", "proportional", "on_off", "timed"],
),
PooldoseSensorEntityDescription(
key="ofa_orp_time",
translation_key="ofa_orp_time",
device_class=SensorDeviceClass.DURATION,
@@ -79,7 +121,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfTime.MINUTES,
),
SensorEntityDescription(
PooldoseSensorEntityDescription(
key="ph_calibration_type",
translation_key="ph_calibration_type",
entity_category=EntityCategory.DIAGNOSTIC,
@@ -87,7 +129,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENUM,
options=["off", "reference", "1_point", "2_points"],
),
SensorEntityDescription(
PooldoseSensorEntityDescription(
key="ph_calibration_offset",
translation_key="ph_calibration_offset",
entity_category=EntityCategory.DIAGNOSTIC,
@@ -96,7 +138,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
),
SensorEntityDescription(
PooldoseSensorEntityDescription(
key="ph_calibration_slope",
translation_key="ph_calibration_slope",
entity_category=EntityCategory.DIAGNOSTIC,
@@ -105,7 +147,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
),
SensorEntityDescription(
PooldoseSensorEntityDescription(
key="orp_calibration_type",
translation_key="orp_calibration_type",
entity_category=EntityCategory.DIAGNOSTIC,
@@ -113,7 +155,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENUM,
options=["off", "reference", "1_point"],
),
SensorEntityDescription(
PooldoseSensorEntityDescription(
key="orp_calibration_offset",
translation_key="orp_calibration_offset",
entity_category=EntityCategory.DIAGNOSTIC,
@@ -122,7 +164,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
),
SensorEntityDescription(
PooldoseSensorEntityDescription(
key="orp_calibration_slope",
translation_key="orp_calibration_slope",
entity_category=EntityCategory.DIAGNOSTIC,
@@ -163,6 +205,8 @@ async def async_setup_entry(
class PooldoseSensor(PooldoseEntity, SensorEntity):
"""Sensor entity for the Seko PoolDose Python API."""
entity_description: PooldoseSensorEntityDescription
@property
def native_value(self) -> float | int | str | None:
"""Return the current value of the sensor."""
@@ -175,9 +219,12 @@ class PooldoseSensor(PooldoseEntity, SensorEntity):
def native_unit_of_measurement(self) -> str | None:
"""Return the unit of measurement."""
if (
self.entity_description.key == "temperature"
self.entity_description.use_dynamic_unit
and (data := self.get_data()) is not None
and (device_unit := data.get("unit"))
):
return data["unit"] # °C or °F
# Map device unit to Home Assistant unit, return None if unknown
return UNIT_MAPPING.get(device_unit)
# Fall back to static unit from entity description
return super().native_unit_of_measurement

View File

@@ -34,6 +34,19 @@
},
"entity": {
"sensor": {
"cl": {
"name": "Chlorine"
},
"cl_type_dosing": {
"name": "Chlorine dosing type",
"state": {
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]"
}
},
"flow_rate": {
"name": "Flow rate"
},
"ofa_orp_time": {
"name": "ORP overfeed alert time"
},
@@ -64,6 +77,15 @@
"low": "[%key:common::state::low%]"
}
},
"peristaltic_cl_dosing": {
"name": "Chlorine peristaltic dosing",
"state": {
"off": "[%key:common::state::off%]",
"on_off": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::on_off%]",
"proportional": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::proportional%]",
"timed": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::timed%]"
}
},
"peristaltic_orp_dosing": {
"name": "ORP peristaltic dosing",
"state": {

View File

@@ -128,6 +128,7 @@
"speed": "[%key:component::sensor::entity_component::speed::name%]",
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::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%]",
"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%]",

View File

@@ -58,7 +58,8 @@ PLATFORMS = [
Platform.SWITCH,
Platform.UPDATE,
]
DEVICE_UPDATE_INTERVAL = timedelta(seconds=60)
DEVICE_UPDATE_INTERVAL_MIN = timedelta(seconds=60)
DEVICE_UPDATE_INTERVAL_PER_CAM = timedelta(seconds=10)
FIRMWARE_UPDATE_INTERVAL = timedelta(hours=24)
NUM_CRED_ERRORS = 3
@@ -137,9 +138,12 @@ async def async_setup_entry(
}
hass.config_entries.async_update_entry(config_entry, data=data)
min_timeout = host.api.timeout * (RETRY_ATTEMPTS + 2)
update_timeout = max(min_timeout, min_timeout * host.api.num_cameras / 10)
async def async_device_config_update() -> None:
"""Update the host state cache and renew the ONVIF-subscription."""
async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)):
async with asyncio.timeout(update_timeout):
try:
await host.update_states()
except CredentialsInvalidError as err:
@@ -156,7 +160,7 @@ async def async_setup_entry(
host.credential_errors = 0
async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)):
async with asyncio.timeout(min_timeout):
await host.renew()
if host.api.new_devices and config_entry.state == ConfigEntryState.LOADED:
@@ -171,7 +175,7 @@ async def async_setup_entry(
async def async_check_firmware_update() -> None:
"""Check for firmware updates."""
async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)):
async with asyncio.timeout(min_timeout):
try:
await host.api.check_new_firmware(host.firmware_ch_list)
except ReolinkError as err:
@@ -197,7 +201,10 @@ async def async_setup_entry(
config_entry=config_entry,
name=f"reolink.{host.api.nvr_name}",
update_method=async_device_config_update,
update_interval=DEVICE_UPDATE_INTERVAL,
update_interval=max(
DEVICE_UPDATE_INTERVAL_MIN,
DEVICE_UPDATE_INTERVAL_PER_CAM * host.api.num_cameras,
),
)
firmware_coordinator = DataUpdateCoordinator(
hass,

View File

@@ -222,17 +222,13 @@ class ReolinkHost:
enable_onvif = None
enable_rtmp = None
if not self._api.rtsp_enabled and not self._api.baichuan_only:
if not self._api.rtsp_enabled and self._api.supported(None, "RTSP"):
_LOGGER.debug(
"RTSP is disabled on %s, trying to enable it", self._api.nvr_name
)
enable_rtsp = True
if (
not self._api.onvif_enabled
and onvif_supported
and not self._api.baichuan_only
):
if not self._api.onvif_enabled and onvif_supported:
_LOGGER.debug(
"ONVIF is disabled on %s, trying to enable it", self._api.nvr_name
)

View File

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

View File

@@ -23,7 +23,7 @@ from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator,
)
from . import DEVICE_UPDATE_INTERVAL
from . import DEVICE_UPDATE_INTERVAL_MIN, DEVICE_UPDATE_INTERVAL_PER_CAM
from .const import DOMAIN
from .entity import (
ReolinkChannelCoordinatorEntity,
@@ -221,7 +221,10 @@ class ReolinkUpdateBaseEntity(
async def _resume_update_coordinator(self, *args: Any) -> None:
"""Resume updating the states using the data update coordinator (after reboots)."""
self._reolink_data.device_coordinator.update_interval = DEVICE_UPDATE_INTERVAL
self._reolink_data.device_coordinator.update_interval = max(
DEVICE_UPDATE_INTERVAL_MIN,
DEVICE_UPDATE_INTERVAL_PER_CAM * self._host.api.num_cameras,
)
try:
await self._reolink_data.device_coordinator.async_refresh()
finally:

View File

@@ -10,6 +10,7 @@ from typing import Any
import aiohttp
from aiohttp import hdrs
import voluptuous as vol
from yarl import URL
from homeassistant.const import (
CONF_AUTHENTICATION,
@@ -51,6 +52,7 @@ SUPPORT_REST_METHODS = ["get", "patch", "post", "put", "delete"]
CONF_CONTENT_TYPE = "content_type"
CONF_INSECURE_CIPHER = "insecure_cipher"
CONF_SKIP_URL_ENCODING = "skip_url_encoding"
COMMAND_SCHEMA = vol.Schema(
{
@@ -69,6 +71,7 @@ COMMAND_SCHEMA = vol.Schema(
vol.Optional(CONF_CONTENT_TYPE): cv.string,
vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean,
vol.Optional(CONF_INSECURE_CIPHER, default=False): cv.boolean,
vol.Optional(CONF_SKIP_URL_ENCODING, default=False): cv.boolean,
}
)
@@ -113,6 +116,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
method = command_config[CONF_METHOD]
template_url = command_config[CONF_URL]
skip_url_encoding = command_config[CONF_SKIP_URL_ENCODING]
auth = None
digest_middleware = None
@@ -179,7 +183,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
request_kwargs["middlewares"] = (digest_middleware,)
async with getattr(websession, method)(
request_url,
URL(request_url, encoded=skip_url_encoding),
**request_kwargs,
) as response:
if response.status < HTTPStatus.BAD_REQUEST:

View File

@@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Ruuvitag BLE device from a config entry."""
"""Set up Ruuvi BLE device from a config entry."""
address = entry.unique_id
assert address is not None
data = RuuvitagBluetoothDeviceData()

View File

@@ -1,6 +1,6 @@
{
"domain": "ruuvitag_ble",
"name": "RuuviTag BLE",
"name": "Ruuvi BLE",
"bluetooth": [
{
"connectable": false,

View File

@@ -191,7 +191,7 @@ async def async_setup_entry(
entry: config_entries.ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Ruuvitag BLE sensors."""
"""Set up the Ruuvi BLE sensors."""
coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
entry.entry_id
]
@@ -210,7 +210,7 @@ class RuuvitagBluetoothSensorEntity(
],
SensorEntity,
):
"""Representation of a Ruuvitag BLE sensor."""
"""Representation of a Ruuvi BLE sensor."""
@property
def native_value(self) -> int | float | None:

View File

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

View File

@@ -13,20 +13,19 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelState,
CodeFormat,
)
from homeassistant.const import CONF_NAME
from homeassistant.config_entries import ConfigSubentry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_ARM_HOME_MODE,
CONF_PARTITION_NUMBER,
DOMAIN,
SIGNAL_PANEL_MESSAGE,
SUBENTRY_TYPE_PARTITION,
SatelConfigEntry,
)
from .entity import SatelIntegraEntity
ALARM_STATE_MAP = {
AlarmState.TRIGGERED: AlarmControlPanelState.TRIGGERED,
@@ -59,54 +58,49 @@ async def async_setup_entry(
for subentry in partition_subentries:
partition_num: int = subentry.data[CONF_PARTITION_NUMBER]
zone_name: str = subentry.data[CONF_NAME]
arm_home_mode: int = subentry.data[CONF_ARM_HOME_MODE]
async_add_entities(
[
SatelIntegraAlarmPanel(
controller,
zone_name,
arm_home_mode,
partition_num,
config_entry.entry_id,
subentry,
partition_num,
arm_home_mode,
)
],
config_subentry_id=subentry.subentry_id,
)
class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
class SatelIntegraAlarmPanel(SatelIntegraEntity, AlarmControlPanelEntity):
"""Representation of an AlarmDecoder-based alarm panel."""
_attr_code_format = CodeFormat.NUMBER
_attr_should_poll = False
_attr_supported_features = (
AlarmControlPanelEntityFeature.ARM_HOME
| AlarmControlPanelEntityFeature.ARM_AWAY
)
_attr_has_entity_name = True
_attr_name = None
def __init__(
self,
controller: AsyncSatel,
device_name: str,
arm_home_mode: int,
partition_id: int,
config_entry_id: str,
subentry: ConfigSubentry,
device_number: int,
arm_home_mode: int,
) -> None:
"""Initialize the alarm panel."""
self._attr_unique_id = f"{config_entry_id}_alarm_panel_{partition_id}"
self._arm_home_mode = arm_home_mode
self._partition_id = partition_id
self._satel = controller
self._attr_device_info = DeviceInfo(
name=device_name, identifiers={(DOMAIN, self._attr_unique_id)}
super().__init__(
controller,
config_entry_id,
subentry,
device_number,
)
self._arm_home_mode = arm_home_mode
async def async_added_to_hass(self) -> None:
"""Update alarm status and register callbacks for future updates."""
self._attr_alarm_state = self._read_alarm_state()
@@ -136,7 +130,7 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
for satel_state, ha_state in ALARM_STATE_MAP.items():
if (
satel_state in self._satel.partition_states
and self._partition_id in self._satel.partition_states[satel_state]
and self._device_number in self._satel.partition_states[satel_state]
):
return ha_state
@@ -152,21 +146,21 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
self._attr_alarm_state == AlarmControlPanelState.TRIGGERED
)
await self._satel.disarm(code, [self._partition_id])
await self._satel.disarm(code, [self._device_number])
if clear_alarm_necessary:
# Wait 1s before clearing the alarm
await asyncio.sleep(1)
await self._satel.clear_alarm(code, [self._partition_id])
await self._satel.clear_alarm(code, [self._device_number])
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send arm away command."""
if code:
await self._satel.arm(code, [self._partition_id])
await self._satel.arm(code, [self._device_number])
async def async_alarm_arm_home(self, code: str | None = None) -> None:
"""Send arm home command."""
if code:
await self._satel.arm(code, [self._partition_id], self._arm_home_mode)
await self._satel.arm(code, [self._device_number], self._arm_home_mode)

View File

@@ -8,25 +8,22 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.const import CONF_NAME
from homeassistant.config_entries import ConfigSubentry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_OUTPUT_NUMBER,
CONF_OUTPUTS,
CONF_ZONE_NUMBER,
CONF_ZONE_TYPE,
CONF_ZONES,
DOMAIN,
SIGNAL_OUTPUTS_UPDATED,
SIGNAL_ZONES_UPDATED,
SUBENTRY_TYPE_OUTPUT,
SUBENTRY_TYPE_ZONE,
SatelConfigEntry,
)
from .entity import SatelIntegraEntity
async def async_setup_entry(
@@ -46,18 +43,16 @@ async def async_setup_entry(
for subentry in zone_subentries:
zone_num: int = subentry.data[CONF_ZONE_NUMBER]
zone_type: BinarySensorDeviceClass = subentry.data[CONF_ZONE_TYPE]
zone_name: str = subentry.data[CONF_NAME]
async_add_entities(
[
SatelIntegraBinarySensor(
controller,
zone_num,
zone_name,
zone_type,
CONF_ZONES,
SIGNAL_ZONES_UPDATED,
config_entry.entry_id,
subentry,
zone_num,
zone_type,
SIGNAL_ZONES_UPDATED,
)
],
config_subentry_id=subentry.subentry_id,
@@ -71,51 +66,44 @@ async def async_setup_entry(
for subentry in output_subentries:
output_num: int = subentry.data[CONF_OUTPUT_NUMBER]
ouput_type: BinarySensorDeviceClass = subentry.data[CONF_ZONE_TYPE]
output_name: str = subentry.data[CONF_NAME]
async_add_entities(
[
SatelIntegraBinarySensor(
controller,
output_num,
output_name,
ouput_type,
CONF_OUTPUTS,
SIGNAL_OUTPUTS_UPDATED,
config_entry.entry_id,
subentry,
output_num,
ouput_type,
SIGNAL_OUTPUTS_UPDATED,
)
],
config_subentry_id=subentry.subentry_id,
)
class SatelIntegraBinarySensor(BinarySensorEntity):
class SatelIntegraBinarySensor(SatelIntegraEntity, BinarySensorEntity):
"""Representation of an Satel Integra binary sensor."""
_attr_should_poll = False
_attr_has_entity_name = True
_attr_name = None
def __init__(
self,
controller: AsyncSatel,
device_number: int,
device_name: str,
device_class: BinarySensorDeviceClass,
sensor_type: str,
react_to_signal: str,
config_entry_id: str,
subentry: ConfigSubentry,
device_number: int,
device_class: BinarySensorDeviceClass,
react_to_signal: str,
) -> None:
"""Initialize the binary_sensor."""
self._device_number = device_number
self._attr_unique_id = f"{config_entry_id}_{sensor_type}_{device_number}"
self._react_to_signal = react_to_signal
self._satel = controller
super().__init__(
controller,
config_entry_id,
subentry,
device_number,
)
self._attr_device_class = device_class
self._attr_device_info = DeviceInfo(
name=device_name, identifiers={(DOMAIN, self._attr_unique_id)}
)
self._react_to_signal = react_to_signal
async def async_added_to_hass(self) -> None:
"""Register callbacks."""

View File

@@ -0,0 +1,58 @@
"""Satel Integra base entity."""
from __future__ import annotations
from typing import TYPE_CHECKING
from satel_integra.satel_integra import AsyncSatel
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import CONF_NAME
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import (
DOMAIN,
SUBENTRY_TYPE_OUTPUT,
SUBENTRY_TYPE_PARTITION,
SUBENTRY_TYPE_SWITCHABLE_OUTPUT,
SUBENTRY_TYPE_ZONE,
)
SubentryTypeToEntityType: dict[str, str] = {
SUBENTRY_TYPE_PARTITION: "alarm_panel",
SUBENTRY_TYPE_SWITCHABLE_OUTPUT: "switch",
SUBENTRY_TYPE_ZONE: "zones",
SUBENTRY_TYPE_OUTPUT: "outputs",
}
class SatelIntegraEntity(Entity):
"""Defines a base Satel Integra entity."""
_attr_should_poll = False
_attr_has_entity_name = True
_attr_name = None
def __init__(
self,
controller: AsyncSatel,
config_entry_id: str,
subentry: ConfigSubentry,
device_number: int,
) -> None:
"""Initialize the Satel Integra entity."""
self._satel = controller
self._device_number = device_number
entity_type = SubentryTypeToEntityType[subentry.subentry_type]
if TYPE_CHECKING:
assert entity_type is not None
self._attr_unique_id = f"{config_entry_id}_{entity_type}_{device_number}"
self._attr_device_info = DeviceInfo(
name=subentry.data[CONF_NAME], identifiers={(DOMAIN, self._attr_unique_id)}
)

View File

@@ -7,19 +7,19 @@ from typing import Any
from satel_integra.satel_integra import AsyncSatel
from homeassistant.components.switch import SwitchEntity
from homeassistant.const import CONF_CODE, CONF_NAME
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import CONF_CODE
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_SWITCHABLE_OUTPUT_NUMBER,
DOMAIN,
SIGNAL_OUTPUTS_UPDATED,
SUBENTRY_TYPE_SWITCHABLE_OUTPUT,
SatelConfigEntry,
)
from .entity import SatelIntegraEntity
async def async_setup_entry(
@@ -38,47 +38,42 @@ async def async_setup_entry(
for subentry in switchable_output_subentries:
switchable_output_num: int = subentry.data[CONF_SWITCHABLE_OUTPUT_NUMBER]
switchable_output_name: str = subentry.data[CONF_NAME]
async_add_entities(
[
SatelIntegraSwitch(
controller,
switchable_output_num,
switchable_output_name,
config_entry.options.get(CONF_CODE),
config_entry.entry_id,
subentry,
switchable_output_num,
config_entry.options.get(CONF_CODE),
),
],
config_subentry_id=subentry.subentry_id,
)
class SatelIntegraSwitch(SwitchEntity):
"""Representation of an Satel switch."""
_attr_should_poll = False
_attr_has_entity_name = True
_attr_name = None
class SatelIntegraSwitch(SatelIntegraEntity, SwitchEntity):
"""Representation of an Satel Integra switch."""
def __init__(
self,
controller: AsyncSatel,
device_number: int,
device_name: str,
code: str | None,
config_entry_id: str,
subentry: ConfigSubentry,
device_number: int,
code: str | None,
) -> None:
"""Initialize the switch."""
self._device_number = device_number
self._attr_unique_id = f"{config_entry_id}_switch_{device_number}"
self._code = code
self._satel = controller
self._attr_device_info = DeviceInfo(
name=device_name, identifiers={(DOMAIN, self._attr_unique_id)}
super().__init__(
controller,
config_entry_id,
subentry,
device_number,
)
self._code = code
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
self._attr_is_on = self._device_number in self._satel.violated_outputs

View File

@@ -0,0 +1,50 @@
"""The Saunum Leil Sauna Control Unit integration."""
from __future__ import annotations
import logging
from pysaunum import SaunumClient, SaunumConnectionError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import PLATFORMS
from .coordinator import LeilSaunaCoordinator
_LOGGER = logging.getLogger(__name__)
type LeilSaunaConfigEntry = ConfigEntry[LeilSaunaCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> bool:
"""Set up Saunum Leil Sauna from a config entry."""
host = entry.data[CONF_HOST]
client = SaunumClient(host=host)
# Test connection
try:
await client.connect()
except SaunumConnectionError as exc:
raise ConfigEntryNotReady(f"Error connecting to {host}: {exc}") from exc
coordinator = LeilSaunaCoordinator(hass, client, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
coordinator = entry.runtime_data
coordinator.client.close()
return unload_ok

View File

@@ -0,0 +1,107 @@
"""Climate platform for Saunum Leil Sauna Control Unit."""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from pysaunum import MAX_TEMPERATURE, MIN_TEMPERATURE, SaunumException
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import LeilSaunaConfigEntry
from .const import DELAYED_REFRESH_SECONDS
from .entity import LeilSaunaEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: LeilSaunaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Saunum Leil Sauna climate entity."""
coordinator = entry.runtime_data
async_add_entities([LeilSaunaClimate(coordinator)])
class LeilSaunaClimate(LeilSaunaEntity, ClimateEntity):
"""Representation of a Saunum Leil Sauna climate entity."""
_attr_name = None
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_min_temp = MIN_TEMPERATURE
_attr_max_temp = MAX_TEMPERATURE
@property
def current_temperature(self) -> float | None:
"""Return the current temperature in Celsius."""
return self.coordinator.data.current_temperature
@property
def target_temperature(self) -> float | None:
"""Return the target temperature in Celsius."""
return self.coordinator.data.target_temperature
@property
def hvac_mode(self) -> HVACMode:
"""Return current HVAC mode."""
session_active = self.coordinator.data.session_active
return HVACMode.HEAT if session_active else HVACMode.OFF
@property
def hvac_action(self) -> HVACAction | None:
"""Return current HVAC action."""
if not self.coordinator.data.session_active:
return HVACAction.OFF
heater_elements_active = self.coordinator.data.heater_elements_active
return (
HVACAction.HEATING
if heater_elements_active and heater_elements_active > 0
else HVACAction.IDLE
)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new HVAC mode."""
try:
if hvac_mode == HVACMode.HEAT:
await self.coordinator.client.async_start_session()
else:
await self.coordinator.client.async_stop_session()
except SaunumException as err:
raise HomeAssistantError(f"Failed to set HVAC mode to {hvac_mode}") from err
# The device takes 1-2 seconds to turn heater elements on/off and
# update heater_elements_active. Wait and refresh again to ensure
# the HVAC action state reflects the actual heater status.
await asyncio.sleep(DELAYED_REFRESH_SECONDS.total_seconds())
await self.coordinator.async_request_refresh()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
try:
await self.coordinator.client.async_set_target_temperature(
int(kwargs[ATTR_TEMPERATURE])
)
except SaunumException as err:
raise HomeAssistantError(
f"Failed to set temperature to {kwargs[ATTR_TEMPERATURE]}"
) from err
await self.coordinator.async_request_refresh()

View File

@@ -0,0 +1,76 @@
"""Config flow for Saunum Leil Sauna Control Unit integration."""
from __future__ import annotations
import logging
from typing import Any
from pysaunum import SaunumClient, SaunumException
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.helpers import config_validation as cv
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
}
)
async def validate_input(data: dict[str, Any]) -> None:
"""Validate the user input allows us to connect.
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
"""
host = data[CONF_HOST]
client = SaunumClient(host=host)
try:
await client.connect()
# Try to read data to verify communication
await client.async_get_data()
finally:
client.close()
class LeilSaunaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Saunum Leil Sauna Control Unit."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
# Check for duplicate configuration
self._async_abort_entries_match(user_input)
try:
await validate_input(user_input)
except SaunumException:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title="Saunum Leil Sauna",
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)

View File

@@ -0,0 +1,16 @@
"""Constants for the Saunum Leil Sauna Control Unit integration."""
from datetime import timedelta
from typing import Final
from homeassistant.const import Platform
DOMAIN: Final = "saunum"
# Platforms
PLATFORMS: list[Platform] = [
Platform.CLIMATE,
]
DEFAULT_SCAN_INTERVAL: Final = timedelta(seconds=60)
DELAYED_REFRESH_SECONDS: Final = timedelta(seconds=3)

View File

@@ -0,0 +1,47 @@
"""Coordinator for Saunum Leil Sauna Control Unit integration."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from pysaunum import SaunumClient, SaunumData, SaunumException
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
if TYPE_CHECKING:
from . import LeilSaunaConfigEntry
_LOGGER = logging.getLogger(__name__)
class LeilSaunaCoordinator(DataUpdateCoordinator[SaunumData]):
"""Coordinator for fetching Saunum Leil Sauna data."""
config_entry: LeilSaunaConfigEntry
def __init__(
self,
hass: HomeAssistant,
client: SaunumClient,
config_entry: LeilSaunaConfigEntry,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=DEFAULT_SCAN_INTERVAL,
config_entry=config_entry,
)
self.client = client
async def _async_update_data(self) -> SaunumData:
"""Fetch data from the sauna controller."""
try:
return await self.client.async_get_data()
except SaunumException as err:
raise UpdateFailed(f"Communication error: {err}") from err

View File

@@ -0,0 +1,49 @@
"""Diagnostics support for Saunum Leil Sauna Control Unit integration."""
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from . import LeilSaunaConfigEntry
REDACT_CONFIG = {CONF_HOST}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: LeilSaunaConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
# Build diagnostics data
diagnostics_data: dict[str, Any] = {
"config": async_redact_data(entry.data, REDACT_CONFIG),
"client_info": {"connected": coordinator.client.is_connected},
"coordinator_info": {
"last_update_success": coordinator.last_update_success,
"update_interval": str(coordinator.update_interval),
"last_exception": str(coordinator.last_exception)
if coordinator.last_exception
else None,
},
}
# Add coordinator data if available
if coordinator.data:
data_dict = asdict(coordinator.data)
diagnostics_data["coordinator_data"] = data_dict
# Add alarm summary
alarm_fields = [
key
for key, value in data_dict.items()
if key.startswith("alarm_") and value is True
]
diagnostics_data["active_alarms"] = alarm_fields
return diagnostics_data

View File

@@ -0,0 +1,27 @@
"""Base entity for Saunum Leil Sauna Control Unit integration."""
from __future__ import annotations
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import LeilSaunaCoordinator
class LeilSaunaEntity(CoordinatorEntity[LeilSaunaCoordinator]):
"""Base entity for Saunum Leil Sauna."""
_attr_has_entity_name = True
def __init__(self, coordinator: LeilSaunaCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
entry_id = coordinator.config_entry.entry_id
self._attr_unique_id = entry_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry_id)},
name="Saunum Leil",
manufacturer="Saunum",
model="Leil Touch Panel",
)

View File

@@ -0,0 +1,12 @@
{
"domain": "saunum",
"name": "Saunum Leil",
"codeowners": ["@mettolen"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/saunum",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pysaunum"],
"quality_scale": "silver",
"requirements": ["pysaunum==0.1.0"]
}

View File

@@ -0,0 +1,74 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not register custom actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Integration does not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver tier
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: Modbus TCP does not require authentication.
test-coverage: done
# Gold tier
devices: done
diagnostics: done
discovery:
status: exempt
comment: Device uses generic Espressif hardware with no unique identifying information (MAC OUI or hostname) that would distinguish it from other Espressif-based devices on the network.
discovery-update-info: todo
docs-data-update: done
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
comment: Integration controls a single device; no dynamic device discovery needed.
entity-category: done
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices:
status: exempt
comment: Integration controls a single device; no dynamic device discovery needed.
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo

View File

@@ -0,0 +1,22 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::ip%]"
},
"data_description": {
"host": "IP address of your Saunum Leil sauna control unit"
},
"description": "To find the IP address, navigate to Settings → Modbus Settings on your Leil touch panel"
}
}
}
}

View File

@@ -186,6 +186,7 @@
"speed": "[%key:component::sensor::entity_component::speed::name%]",
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::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%]",
"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%]",

View File

@@ -3,15 +3,17 @@
from __future__ import annotations
from datetime import timedelta
from http import HTTPStatus
import logging
from aiosenz import SENZAPI, Thermostat
from httpx import RequestError
from httpx import HTTPStatusError, RequestError
import jwt
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, httpx_client
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
@@ -58,8 +60,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: SENZConfigEntry) -> bool
try:
account = await senz_api.get_account()
except HTTPStatusError as err:
if err.response.status_code == HTTPStatus.UNAUTHORIZED:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="config_entry_auth_failed",
) from err
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="config_entry_not_ready",
) from err
except RequestError as err:
raise ConfigEntryNotReady from err
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="config_entry_not_ready",
) from err
coordinator: SENZDataUpdateCoordinator = DataUpdateCoordinator(
hass,
@@ -82,3 +97,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: SENZConfigEntry) -> bool
async def async_unload_entry(hass: HomeAssistant, entry: SENZConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(
hass: HomeAssistant, config_entry: SENZConfigEntry
) -> bool:
"""Migrate old entry."""
# Use sub(ject) from access_token as unique_id
if config_entry.version == 1 and config_entry.minor_version == 1:
token = jwt.decode(
config_entry.data["token"]["access_token"],
options={"verify_signature": False},
)
uid = token["sub"]
hass.config_entries.async_update_entry(
config_entry, unique_id=uid, minor_version=2
)
_LOGGER.info(
"Migration to version %s.%s successful",
config_entry.version,
config_entry.minor_version,
)
return True

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from typing import Any
from aiosenz import MODE_AUTO, Thermostat
from httpx import RequestError
from homeassistant.components.climate import (
ClimateEntity,
@@ -14,6 +15,7 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -81,7 +83,7 @@ class SENZClimate(CoordinatorEntity[SENZDataUpdateCoordinator], ClimateEntity):
@property
def available(self) -> bool:
"""Return True if the thermostat is available."""
return self._thermostat.online
return super().available and self._thermostat.online
@property
def hvac_mode(self) -> HVACMode:
@@ -97,14 +99,32 @@ class SENZClimate(CoordinatorEntity[SENZDataUpdateCoordinator], ClimateEntity):
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
if hvac_mode == HVACMode.AUTO:
await self._thermostat.auto()
else:
await self._thermostat.manual()
try:
if hvac_mode == HVACMode.AUTO:
await self._thermostat.auto()
else:
await self._thermostat.manual()
except RequestError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_attribute_error",
translation_placeholders={
"attribute": "hvac mode",
},
) from err
await self.coordinator.async_request_refresh()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
temp: float = kwargs[ATTR_TEMPERATURE]
await self._thermostat.manual(temp)
try:
await self._thermostat.manual(temp)
except RequestError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_attribute_error",
translation_placeholders={
"attribute": "target temperature",
},
) from err
await self.coordinator.async_request_refresh()

View File

@@ -1,7 +1,16 @@
"""Config flow for nVent RAYCHEM SENZ."""
from collections.abc import Mapping
import logging
from typing import Any
import jwt
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
ConfigFlowResult,
)
from homeassistant.helpers import config_entry_oauth2_flow
from .const import DOMAIN
@@ -12,6 +21,8 @@ class OAuth2FlowHandler(
):
"""Config flow to handle SENZ OAuth2 authentication."""
VERSION = 1
MINOR_VERSION = 2
DOMAIN = DOMAIN
@property
@@ -23,3 +34,49 @@ class OAuth2FlowHandler(
def extra_authorize_data(self) -> dict:
"""Extra data that needs to be appended to the authorize url."""
return {"scope": "restapi offline_access"}
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()
async def async_step_reconfigure(
self, user_input: Mapping[str, Any] | None = None
) -> ConfigFlowResult:
"""User initiated reconfiguration."""
return await self.async_step_user()
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Create or update the config entry."""
token = jwt.decode(
data["token"]["access_token"], options={"verify_signature": False}
)
uid = token["sub"]
await self.async_set_unique_id(uid)
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch(reason="account_mismatch")
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data=data
)
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch(reason="account_mismatch")
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(), data=data
)
self._abort_if_unique_id_configured()
return await super().async_oauth_create_entry(data)

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