Compare commits

...

83 Commits

Author SHA1 Message Date
Paulus Schoutsen 84e4a0f22e Allow finding relevant blueprints 2025-11-15 14:01:37 -05: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
238 changed files with 19301 additions and 3270 deletions
+2 -2
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"
+1
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.*
Generated
+4
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
+5 -5
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/.*
+1
View File
@@ -15,6 +15,7 @@
"google_tasks",
"google_translate",
"google_travel_time",
"google_weather",
"google_wifi",
"google",
"nest",
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["adguardhome"],
"requirements": ["adguardhome==0.7.0"]
"requirements": ["adguardhome==0.8.0"]
}
@@ -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,
@@ -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] = {}
@@ -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
+65 -1
View File
@@ -1,7 +1,13 @@
"""The blueprint integration."""
from typing import Any
import voluptuous as vol
from homeassistant.const import CONF_NAME, CONF_SELECTOR
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.selector import selector as create_selector
from homeassistant.helpers.typing import ConfigType
from . import websocket_api
@@ -29,3 +35,61 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the blueprint integration."""
websocket_api.async_setup(hass)
return True
async def async_find_relevant_blueprints(
hass: HomeAssistant, device_id: str
) -> dict[str, list[dict[str, Any]]]:
"""Find all blueprints relevant to a specific device."""
results = {}
entities = [
entry
for entry in er.async_entries_for_device(er.async_get(hass), device_id)
if not entry.entity_category
]
async def all_blueprints_generator(hass: HomeAssistant):
"""Yield all blueprints from all domains."""
blueprint_domains: dict[str, DomainBlueprints] = hass.data[DOMAIN]
for blueprint_domain in blueprint_domains.values():
blueprints = await blueprint_domain.async_get_blueprints()
for blueprint in blueprints.values():
yield blueprint
async for blueprint in all_blueprints_generator(hass):
blueprint_input_matches: dict[str, list[str]] = {}
for info in blueprint.inputs.values():
if (
not info
or not (selector_conf := info.get(CONF_SELECTOR))
or "entity" not in selector_conf
):
continue
selector = create_selector(selector_conf)
matched = []
for entity in entities:
try:
entity.entity_id, selector(entity.entity_id)
except vol.Invalid:
continue
matched.append(entity.entity_id)
if matched:
blueprint_input_matches[info[CONF_NAME]] = matched
if not blueprint_input_matches:
continue
results.setdefault(blueprint.domain, []).append(
{
"blueprint": blueprint,
"matched_input": blueprint_input_matches,
}
)
return results
+5 -2
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
@@ -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*",
@@ -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(
+1
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"
@@ -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",
@@ -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",
@@ -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
+272 -218
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(
@@ -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] = {}
+178 -329
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",
+ [
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,
on_phase=on_phase,
translation_placeholders={"phase_name": f"l{phase + 1}"},
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]
+4 -1
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
@@ -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]),
}
)
+54 -14
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
+29 -15
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
@@ -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"),
)
@@ -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)
@@ -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
@@ -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"
@@ -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,
)
@@ -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,
)
@@ -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"]
}
@@ -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
@@ -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%]"
}
}
}
}
}
}
@@ -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
@@ -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)
@@ -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):
@@ -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",
@@ -37,5 +37,5 @@
"iot_class": "cloud_push",
"loggers": ["pylamarzocco"],
"quality_scale": "platinum",
"requirements": ["pylamarzocco==2.1.2"]
"requirements": ["pylamarzocco==2.1.3"]
}
@@ -154,6 +154,7 @@ class LocalTodoListEntity(TodoListEntity):
),
due=due,
description=item.description,
completed=item.completed,
)
)
self._attr_todo_items = todo_items
+1 -1
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"
+2 -2
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
]
+4 -3
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
+3 -2
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",
@@ -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 = {}
+5 -2
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
+3 -2
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",
+4 -4
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",
+5 -3
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",
+3 -2
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."""
@@ -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]
@@ -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"]
}
@@ -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"]
}
@@ -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,
)
@@ -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 = [
@@ -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"]
}
@@ -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": {
+12 -5
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,
@@ -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"]
}
+5 -2
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:
@@ -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()
@@ -1,6 +1,6 @@
{
"domain": "ruuvitag_ble",
"name": "RuuviTag BLE",
"name": "Ruuvi BLE",
"bluetooth": [
{
"connectable": false,
@@ -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:
@@ -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": [
{
@@ -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)
@@ -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."""
@@ -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)}
)
@@ -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
@@ -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
+107
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()
@@ -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,
)
+16
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)
@@ -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
+27
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",
)
@@ -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"]
}
@@ -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: todo
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: todo
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
@@ -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"
}
}
}
}
+42 -3
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
+26 -6
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()
@@ -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)
+17 -1
View File
@@ -1,6 +1,7 @@
{
"config": {
"abort": {
"account_mismatch": "The used account does not match the original account",
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
@@ -9,7 +10,9 @@
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]"
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
@@ -23,12 +26,25 @@
"implementation": "[%key:common::config_flow::description::implementation%]"
},
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
},
"reauth_confirm": {
"description": "The SENZ integration needs to re-authenticate your account",
"title": "[%key:common::config_flow::title::reauth%]"
}
}
},
"exceptions": {
"config_entry_auth_failed": {
"message": "Authentication failed. Please log in again."
},
"config_entry_not_ready": {
"message": "Error while loading the integration."
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"set_attribute_error": {
"message": "Failed to set {attribute} on the device."
}
}
}
@@ -81,4 +81,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: SFRConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: SFRConfigEntry) -> bool:
"""Unload a config entry."""
if entry.data.get(CONF_USERNAME) and entry.data.get(CONF_PASSWORD):
return await hass.config_entries.async_unload_platforms(
entry, PLATFORMS_WITH_AUTH
)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+26 -2
View File
@@ -30,7 +30,10 @@ from .entity import ShellyBlockEntity, get_entity_rpc_device_info
from .utils import (
async_remove_orphaned_entities,
async_remove_shelly_entity,
get_block_channel,
get_block_custom_name,
get_device_entry_gen,
get_rpc_component_name,
get_rpc_entity_name,
get_rpc_key_instances,
is_block_momentary_input,
@@ -74,7 +77,6 @@ RPC_EVENT: Final = ShellyRpcEventDescription(
SCRIPT_EVENT: Final = ShellyRpcEventDescription(
key="script",
translation_key="script",
device_class=None,
entity_registry_enabled_default=False,
)
@@ -195,6 +197,17 @@ class ShellyBlockEvent(ShellyBlockEntity, EventEntity):
self._attr_event_types = list(BASIC_INPUTS_EVENTS_TYPES)
self.entity_description = description
if (
hasattr(self, "_attr_name")
and self._attr_name
and not get_block_custom_name(coordinator.device, block)
):
self._attr_translation_placeholders = {
"input_number": get_block_channel(block)
}
delattr(self, "_attr_name")
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
@@ -227,9 +240,20 @@ class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity):
self.event_id = int(key.split(":")[-1])
self._attr_device_info = get_entity_rpc_device_info(coordinator, key)
self._attr_unique_id = f"{coordinator.mac}-{key}"
self._attr_name = get_rpc_entity_name(coordinator.device, key)
self.entity_description = description
if description.key == "input":
component = key.split(":")[0]
component_id = key.split(":")[-1]
if not get_rpc_component_name(coordinator.device, key) and (
component.lower() == "input" and component_id.isnumeric()
):
self._attr_translation_placeholders = {"input_number": component_id}
else:
self._attr_name = get_rpc_entity_name(coordinator.device, key)
elif description.key == "script":
self._attr_name = get_rpc_entity_name(coordinator.device, key)
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
+14 -4
View File
@@ -77,16 +77,26 @@
"on": "mdi:home-export-outline"
}
},
"cury_boost": {
"default": "mdi:rocket-launch"
},
"cury_slot": {
"left_slot": {
"default": "mdi:scent",
"state": {
"off": "mdi:scent-off",
"on": "mdi:scent"
}
},
"left_slot_boost": {
"default": "mdi:rocket-launch"
},
"right_slot": {
"default": "mdi:scent",
"state": {
"off": "mdi:scent-off",
"on": "mdi:scent"
}
},
"right_slot_boost": {
"default": "mdi:rocket-launch"
},
"valve_switch": {
"default": "mdi:valve",
"state": {
+1 -19
View File
@@ -63,6 +63,7 @@ from .utils import (
get_blu_trv_device_info,
get_device_entry_gen,
get_device_uptime,
get_entity_translation_attributes,
get_rpc_channel_name,
get_shelly_air_lamp_life,
get_virtual_component_unit,
@@ -73,25 +74,6 @@ from .utils import (
PARALLEL_UPDATES = 0
def get_entity_translation_attributes(
channel_name: str | None,
translation_key: str | None,
device_class: str | None,
default_to_device_class_name: bool,
) -> tuple[dict[str, str] | None, str | None]:
"""Translation attributes for entity with channel name."""
if channel_name is None:
return None, None
key = translation_key
if key is None and default_to_device_class_name:
key = device_class
final_translation_key = f"{key}_with_channel_name" if key else None
return {"channel_name": channel_name}, final_translation_key
@dataclass(frozen=True, kw_only=True)
class BlockSensorDescription(BlockEntityDescription, SensorEntityDescription):
"""Class to describe a BLOCK sensor."""
@@ -168,6 +168,7 @@
},
"event": {
"input": {
"name": "Input {input_number}",
"state_attributes": {
"event_type": {
"state": {
@@ -463,6 +464,41 @@
"name": "Water temperature"
}
},
"switch": {
"charging": {
"name": "Charging"
},
"child_lock": {
"name": "Child lock"
},
"frost_protection": {
"name": "[%key:component::shelly::entity::climate::thermostat::state_attributes::preset_mode::state::frost_protection%]"
},
"left_slot": {
"name": "Left slot"
},
"left_slot_boost": {
"name": "Left slot boost"
},
"motion_detection": {
"name": "Motion detection"
},
"right_slot": {
"name": "Right slot"
},
"right_slot_boost": {
"name": "Right slot boost"
},
"thermostat_enabled": {
"name": "Thermostat enabled"
},
"valve_opened": {
"name": "Valve opened"
},
"zone_with_number": {
"name": "Zone {zone_number}"
}
},
"update": {
"beta_firmware": {
"name": "Beta firmware"
+44 -13
View File
@@ -67,7 +67,7 @@ BLOCK_RELAY_SWITCHES = {
BLOCK_SLEEPING_MOTION_SWITCH = {
("sensor", "motionActive"): BlockSwitchDescription(
key="sensor|motionActive",
name="Motion detection",
translation_key="motion_detection",
entity_category=EntityCategory.CONFIG,
)
}
@@ -99,7 +99,7 @@ RPC_SWITCHES = {
"boolean_generic": RpcSwitchDescription(
key="boolean",
sub_key="value",
removal_condition=lambda config, _status, key: not is_view_for_platform(
removal_condition=lambda config, _, key: not is_view_for_platform(
config, key, SWITCH_PLATFORM
),
is_on=lambda status: bool(status["value"]),
@@ -111,6 +111,7 @@ RPC_SWITCHES = {
"boolean_anti_freeze": RpcSwitchDescription(
key="boolean",
sub_key="value",
translation_key="frost_protection",
entity_registry_enabled_default=False,
is_on=lambda status: bool(status["value"]),
method_on="boolean_set",
@@ -122,6 +123,7 @@ RPC_SWITCHES = {
"boolean_child_lock": RpcSwitchDescription(
key="boolean",
sub_key="value",
translation_key="child_lock",
is_on=lambda status: bool(status["value"]),
method_on="boolean_set",
method_off="boolean_set",
@@ -132,6 +134,7 @@ RPC_SWITCHES = {
"boolean_enable": RpcSwitchDescription(
key="boolean",
sub_key="value",
translation_key="thermostat_enabled",
entity_registry_enabled_default=False,
is_on=lambda status: bool(status["value"]),
method_on="boolean_set",
@@ -143,7 +146,7 @@ RPC_SWITCHES = {
"boolean_start_charging": RpcSwitchDescription(
key="boolean",
sub_key="value",
name="Charging",
translation_key="charging",
is_on=lambda status: bool(status["value"]),
method_on="boolean_set",
method_off="boolean_set",
@@ -154,6 +157,7 @@ RPC_SWITCHES = {
"boolean_state": RpcSwitchDescription(
key="boolean",
sub_key="value",
translation_key="valve_opened",
entity_registry_enabled_default=False,
is_on=lambda status: bool(status["value"]),
method_on="boolean_set",
@@ -165,6 +169,8 @@ RPC_SWITCHES = {
"boolean_zone0": RpcSwitchDescription(
key="boolean",
sub_key="value",
translation_key="zone_with_number",
translation_placeholders={"zone_number": "1"},
entity_registry_enabled_default=False,
is_on=lambda status: bool(status["value"]),
method_on="boolean_set",
@@ -176,6 +182,8 @@ RPC_SWITCHES = {
"boolean_zone1": RpcSwitchDescription(
key="boolean",
sub_key="value",
translation_key="zone_with_number",
translation_placeholders={"zone_number": "2"},
entity_registry_enabled_default=False,
is_on=lambda status: bool(status["value"]),
method_on="boolean_set",
@@ -187,6 +195,8 @@ RPC_SWITCHES = {
"boolean_zone2": RpcSwitchDescription(
key="boolean",
sub_key="value",
translation_key="zone_with_number",
translation_placeholders={"zone_number": "3"},
entity_registry_enabled_default=False,
is_on=lambda status: bool(status["value"]),
method_on="boolean_set",
@@ -198,6 +208,8 @@ RPC_SWITCHES = {
"boolean_zone3": RpcSwitchDescription(
key="boolean",
sub_key="value",
translation_key="zone_with_number",
translation_placeholders={"zone_number": "4"},
entity_registry_enabled_default=False,
is_on=lambda status: bool(status["value"]),
method_on="boolean_set",
@@ -209,6 +221,8 @@ RPC_SWITCHES = {
"boolean_zone4": RpcSwitchDescription(
key="boolean",
sub_key="value",
translation_key="zone_with_number",
translation_placeholders={"zone_number": "5"},
entity_registry_enabled_default=False,
is_on=lambda status: bool(status["value"]),
method_on="boolean_set",
@@ -220,6 +234,8 @@ RPC_SWITCHES = {
"boolean_zone5": RpcSwitchDescription(
key="boolean",
sub_key="value",
translation_key="zone_with_number",
translation_placeholders={"zone_number": "6"},
entity_registry_enabled_default=False,
is_on=lambda status: bool(status["value"]),
method_on="boolean_set",
@@ -241,8 +257,7 @@ RPC_SWITCHES = {
"cury_left": RpcSwitchDescription(
key="cury",
sub_key="slots",
name="Left slot",
translation_key="cury_slot",
translation_key="left_slot",
is_on=lambda status: bool(status["slots"]["left"]["on"]),
method_on="cury_set",
method_off="cury_set",
@@ -254,8 +269,7 @@ RPC_SWITCHES = {
"cury_left_boost": RpcSwitchDescription(
key="cury",
sub_key="slots",
name="Left slot boost",
translation_key="cury_boost",
translation_key="left_slot_boost",
is_on=lambda status: status["slots"]["left"]["boost"] is not None,
method_on="cury_boost",
method_off="cury_stop_boost",
@@ -267,8 +281,7 @@ RPC_SWITCHES = {
"cury_right": RpcSwitchDescription(
key="cury",
sub_key="slots",
name="Right slot",
translation_key="cury_slot",
translation_key="right_slot",
is_on=lambda status: bool(status["slots"]["right"]["on"]),
method_on="cury_set",
method_off="cury_set",
@@ -280,8 +293,7 @@ RPC_SWITCHES = {
"cury_right_boost": RpcSwitchDescription(
key="cury",
sub_key="slots",
name="Right slot boost",
translation_key="cury_boost",
translation_key="right_slot_boost",
is_on=lambda status: status["slots"]["right"]["boost"] is not None,
method_on="cury_boost",
method_off="cury_stop_boost",
@@ -399,7 +411,6 @@ class BlockSleepingMotionSwitch(
"""Entity that controls Motion Sensor on Block based Shelly devices."""
entity_description: BlockSwitchDescription
_attr_translation_key = "motion_switch"
def __init__(
self,
@@ -413,6 +424,9 @@ class BlockSleepingMotionSwitch(
super().__init__(coordinator, block, attribute, description, entry)
self.last_state: State | None = None
if hasattr(self, "_attr_name"):
delattr(self, "_attr_name")
@property
def is_on(self) -> bool | None:
"""If motion is active."""
@@ -488,6 +502,23 @@ class RpcSwitch(ShellyRpcAttributeEntity, SwitchEntity):
entity_description: RpcSwitchDescription
def __init__(
self,
coordinator: ShellyRpcCoordinator,
key: str,
attribute: str,
description: RpcSwitchDescription,
) -> None:
"""Initialize select."""
super().__init__(coordinator, key, attribute, description)
if (
hasattr(self, "_attr_name")
and description.role != ROLE_GENERIC
and description.key not in ("switch", "script")
):
delattr(self, "_attr_name")
@property
def is_on(self) -> bool:
"""If switch is on."""
@@ -524,7 +555,7 @@ class RpcRelaySwitch(RpcSwitch):
coordinator: ShellyRpcCoordinator,
key: str,
attribute: str,
description: RpcEntityDescription,
description: RpcSwitchDescription,
) -> None:
"""Initialize the switch."""
super().__init__(coordinator, key, attribute, description)
+61 -30
View File
@@ -120,17 +120,35 @@ def get_number_of_channels(device: BlockDevice, block: Block) -> int:
def get_block_entity_name(
device: BlockDevice,
block: Block | None,
description: str | UndefinedType | None = None,
name: str | UndefinedType | None = None,
) -> str | None:
"""Naming for block based switch and sensors."""
channel_name = get_block_channel_name(device, block)
if description is not UNDEFINED and description:
return f"{channel_name} {description.lower()}" if channel_name else description
if name is not UNDEFINED and name:
return f"{channel_name} {name.lower()}" if channel_name else name
return channel_name
def get_block_custom_name(device: BlockDevice, block: Block | None) -> str | None:
"""Get custom name from device settings."""
if block and (key := cast(str, block.type) + "s") and key in device.settings:
assert block.channel
if name := device.settings[key][int(block.channel)].get("name"):
return cast(str, name)
return None
def get_block_channel(block: Block | None, base: str = "1") -> str:
"""Get block channel."""
assert block and block.channel
return chr(int(block.channel) + ord(base))
def get_block_channel_name(device: BlockDevice, block: Block | None) -> str | None:
"""Get name based on device and channel name."""
if (
@@ -140,19 +158,10 @@ def get_block_channel_name(device: BlockDevice, block: Block | None) -> str | No
):
return None
assert block.channel
if custom_name := get_block_custom_name(device, block):
return custom_name
channel_name: str | None = None
mode = cast(str, block.type) + "s"
if mode in device.settings:
channel_name = device.settings[mode][int(block.channel)].get("name")
if channel_name:
return channel_name
base = ord("1")
return f"Channel {chr(int(block.channel) + base)}"
return f"Channel {get_block_channel(block)}"
def get_block_sub_device_name(device: BlockDevice, block: Block) -> str:
@@ -160,18 +169,13 @@ def get_block_sub_device_name(device: BlockDevice, block: Block) -> str:
if TYPE_CHECKING:
assert block.channel
mode = cast(str, block.type) + "s"
if mode in device.settings:
if channel_name := device.settings[mode][int(block.channel)].get("name"):
return cast(str, channel_name)
if custom_name := get_block_custom_name(device, block):
return custom_name
if device.settings["device"]["type"] == MODEL_EM3:
base = ord("A")
return f"{device.name} Phase {chr(int(block.channel) + base)}"
return f"{device.name} Phase {get_block_channel(block, 'A')}"
base = ord("1")
return f"{device.name} Channel {chr(int(block.channel) + base)}"
return f"{device.name} Channel {get_block_channel(block)}"
def is_block_momentary_input(
@@ -387,6 +391,18 @@ def get_shelly_model_name(
return cast(str, MODEL_NAMES.get(model))
def get_rpc_component_name(device: RpcDevice, key: str) -> str | None:
"""Get component name from device config."""
if (
key in device.config
and key != "em:0" # workaround for Pro 3EM, we don't want to get name for em:0
and (name := device.config[key].get("name"))
):
return cast(str, name)
return None
def get_rpc_channel_name(device: RpcDevice, key: str) -> str | None:
"""Get name based on device and channel name."""
if BLU_TRV_IDENTIFIER in key:
@@ -398,13 +414,11 @@ def get_rpc_channel_name(device: RpcDevice, key: str) -> str | None:
component = key.split(":")[0]
component_id = key.split(":")[-1]
if key in device.config and key != "em:0":
# workaround for Pro 3EM, we don't want to get name for em:0
if component_name := device.config[key].get("name"):
if component in (*VIRTUAL_COMPONENTS, "input", "presencezone", "script"):
return cast(str, component_name)
if component_name := get_rpc_component_name(device, key):
if component in (*VIRTUAL_COMPONENTS, "input", "presencezone", "script"):
return component_name
return cast(str, component_name) if instances == 1 else None
return component_name if instances == 1 else None
if component in (*VIRTUAL_COMPONENTS, "input"):
return f"{component.title()} {component_id}"
@@ -459,6 +473,23 @@ def get_rpc_entity_name(
return channel_name
def get_entity_translation_attributes(
channel_name: str | None,
translation_key: str | None,
device_class: str | None,
default_to_device_class_name: bool,
) -> tuple[dict[str, str] | None, str | None]:
"""Translation attributes for entity with channel name."""
if channel_name is None:
return None, None
key = translation_key
if key is None and default_to_device_class_name:
key = device_class
return {"channel_name": channel_name}, f"{key}_with_channel_name" if key else None
def get_device_entry_gen(entry: ConfigEntry) -> int:
"""Return the device generation from config entry."""
return entry.data.get(CONF_GEN, 1) # type: ignore[no-any-return]
@@ -30,5 +30,5 @@
"iot_class": "cloud_push",
"loggers": ["pysmartthings"],
"quality_scale": "bronze",
"requirements": ["pysmartthings==3.3.2"]
"requirements": ["pysmartthings==3.3.3"]
}
+1 -1
View File
@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["async_upnp_client"],
"quality_scale": "internal",
"requirements": ["async-upnp-client==0.45.0"]
"requirements": ["async-upnp-client==0.46.0"]
}
@@ -7,6 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/telegram_bot",
"iot_class": "cloud_push",
"loggers": ["telegram"],
"quality_scale": "bronze",
"quality_scale": "silver",
"requirements": ["python-telegram-bot[socks]==22.1"]
}
@@ -26,7 +26,7 @@ rules:
unique-config-entry: done
# Silver
action-exceptions: todo
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
@@ -219,7 +219,6 @@ class AbstractTemplateAlarmControlPanel(
self._attr_code_arm_required: bool = config[CONF_CODE_ARM_REQUIRED]
self._attr_code_format = config[CONF_CODE_FORMAT].value
self._state: AlarmControlPanelState | None = None
self._attr_supported_features: AlarmControlPanelEntityFeature = (
AlarmControlPanelEntityFeature(0)
)
@@ -244,11 +243,6 @@ class AbstractTemplateAlarmControlPanel(
if (action_config := config.get(action_id)) is not None:
yield (action_id, action_config, supported_feature)
@property
def alarm_state(self) -> AlarmControlPanelState | None:
"""Return the state of the device."""
return self._state
async def _async_handle_restored_state(self) -> None:
if (
(last_state := await self.async_get_last_state()) is not None
@@ -256,14 +250,14 @@ class AbstractTemplateAlarmControlPanel(
and last_state.state in _VALID_STATES
# The trigger might have fired already while we waited for stored data,
# then we should not restore state
and self._state is None
and self._attr_alarm_state is None
):
self._state = AlarmControlPanelState(last_state.state)
self._attr_alarm_state = AlarmControlPanelState(last_state.state)
def _handle_state(self, result: Any) -> None:
# Validate state
if result in _VALID_STATES:
self._state = result
self._attr_alarm_state = result
_LOGGER.debug("Valid state - %s", result)
return
@@ -273,7 +267,7 @@ class AbstractTemplateAlarmControlPanel(
self.entity_id,
", ".join(_VALID_STATES),
)
self._state = None
self._attr_alarm_state = None
async def _async_alarm_arm(self, state: Any, script: Script | None, code: Any):
"""Arm the panel to specified state with supplied script."""
@@ -284,7 +278,7 @@ class AbstractTemplateAlarmControlPanel(
)
if self._attr_assumed_state:
self._state = state
self._attr_alarm_state = state
self.async_write_ha_state()
async def async_alarm_arm_away(self, code: str | None = None) -> None:
@@ -376,7 +370,7 @@ class StateAlarmControlPanelEntity(TemplateEntity, AbstractTemplateAlarmControlP
@callback
def _update_state(self, result):
if isinstance(result, TemplateError):
self._state = None
self._attr_alarm_state = None
return
self._handle_state(result)
@@ -386,7 +380,7 @@ class StateAlarmControlPanelEntity(TemplateEntity, AbstractTemplateAlarmControlP
"""Set up templates."""
if self._template:
self.add_template_attribute(
"_state", self._template, None, self._update_state
"_attr_alarm_state", self._template, None, self._update_state
)
super()._async_setup_templates()
@@ -227,6 +227,9 @@ class TodoItem:
description: str | None = None
"""A more complete description than that provided by the summary."""
completed: datetime.datetime | None = None
"""The date and time that a to-do item was marked completed."""
CACHED_PROPERTIES_WITH_ATTR_ = {
"todo_items",
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/tplink_omada",
"integration_type": "hub",
"iot_class": "local_polling",
"requirements": ["tplink-omada-client==1.4.4"]
"requirements": ["tplink-omada-client==1.5.3"]
}
@@ -7,7 +7,12 @@ from dataclasses import dataclass
from functools import partial
from typing import Any, Generic, TypeVar
from tplink_omada_client import OmadaSiteClient, SwitchPortOverrides
from tplink_omada_client import (
GatewayPortSettings,
OmadaSiteClient,
PortProfileOverrides,
SwitchPortSettings,
)
from tplink_omada_client.definitions import GatewayPortMode, PoEMode, PortType
from tplink_omada_client.devices import (
OmadaDevice,
@@ -17,7 +22,6 @@ from tplink_omada_client.devices import (
OmadaSwitch,
OmadaSwitchPortDetails,
)
from tplink_omada_client.omadasiteclient import GatewayPortSettings
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
@@ -184,7 +188,12 @@ SWITCH_PORT_DETAILS_SWITCHES: list[OmadaSwitchPortSwitchEntityDescription] = [
),
set_func=(
lambda client, device, port, enable: client.update_switch_port(
device, port, overrides=SwitchPortOverrides(enable_poe=enable)
device,
port,
settings=SwitchPortSettings(
profile_override_enabled=True,
profile_overrides=PortProfileOverrides(enable_poe=enable),
),
)
),
update_func=lambda p: p.poe_mode != PoEMode.DISABLED,
@@ -3,8 +3,7 @@
from __future__ import annotations
from base64 import b64decode
from dataclasses import dataclass
from enum import StrEnum
from typing import Any
from tuya_sharing import CustomerDevice, Manager
@@ -21,54 +20,89 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
from .entity import TuyaEntity
from .models import DPCodeEnumWrapper
from .util import get_dpcode
from .models import DPCodeBase64Wrapper, DPCodeEnumWrapper
@dataclass(frozen=True)
class TuyaAlarmControlPanelEntityDescription(AlarmControlPanelEntityDescription):
"""Describe a Tuya Alarm Control Panel entity."""
master_state: DPCode | None = None
alarm_msg: DPCode | None = None
class Mode(StrEnum):
"""Alarm modes."""
ARM = "arm"
DISARMED = "disarmed"
HOME = "home"
SOS = "sos"
class State(StrEnum):
"""Alarm states."""
NORMAL = "normal"
ALARM = "alarm"
STATE_MAPPING: dict[str, AlarmControlPanelState] = {
Mode.DISARMED: AlarmControlPanelState.DISARMED,
Mode.ARM: AlarmControlPanelState.ARMED_AWAY,
Mode.HOME: AlarmControlPanelState.ARMED_HOME,
Mode.SOS: AlarmControlPanelState.TRIGGERED,
}
ALARM: dict[DeviceCategory, tuple[TuyaAlarmControlPanelEntityDescription, ...]] = {
ALARM: dict[DeviceCategory, tuple[AlarmControlPanelEntityDescription, ...]] = {
DeviceCategory.MAL: (
TuyaAlarmControlPanelEntityDescription(
AlarmControlPanelEntityDescription(
key=DPCode.MASTER_MODE,
master_state=DPCode.MASTER_STATE,
alarm_msg=DPCode.ALARM_MSG,
name="Alarm",
),
)
}
class _AlarmChangedByWrapper(DPCodeBase64Wrapper):
"""Wrapper for changed_by.
Decode base64 to utf-16be string, but only if alarm has been triggered.
"""
def read_device_status(self, device: CustomerDevice) -> str | None:
"""Read the device status."""
if (
device.status.get(DPCode.MASTER_STATE) != "alarm"
or (data := self.read_bytes(device)) is None
):
return None
return data.decode("utf-16be")
class _AlarmModeWrapper(DPCodeEnumWrapper):
"""Wrapper for the alarm mode of a device.
Handles alarm mode enum values and determines the alarm state,
including logic for detecting when the alarm is triggered and
distinguishing triggered state from battery warnings.
"""
_ACTION_MAPPINGS = {
# Home Assistant action => Tuya device mode
"arm_home": "home",
"arm_away": "arm",
"disarm": "disarmed",
"trigger": "sos",
}
_STATE_MAPPINGS = {
# Tuya device mode => Home Assistant panel state
"disarmed": AlarmControlPanelState.DISARMED,
"arm": AlarmControlPanelState.ARMED_AWAY,
"home": AlarmControlPanelState.ARMED_HOME,
"sos": AlarmControlPanelState.TRIGGERED,
}
def read_panel_state(self, device: CustomerDevice) -> AlarmControlPanelState | None:
"""Read the device status."""
# When the alarm is triggered, only its 'state' is changing. From 'normal' to 'alarm'.
# The 'mode' doesn't change, and stays as 'arm' or 'home'.
if device.status.get(DPCode.MASTER_STATE) == "alarm":
# Only report as triggered if NOT a battery warning
if not (
(encoded_msg := device.status.get(DPCode.ALARM_MSG))
and (decoded_message := b64decode(encoded_msg).decode("utf-16be"))
and "Sensor Low Battery" in decoded_message
):
return AlarmControlPanelState.TRIGGERED
if (status := self.read_device_status(device)) is None:
return None
return self._STATE_MAPPINGS.get(status)
def supports_action(self, action: str) -> bool:
"""Return if action is supported."""
return (
mapped_value := self._ACTION_MAPPINGS.get(action)
) is not None and mapped_value in self.type_information.range
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
"""Convert value to raw value."""
if (
mapped_value := self._ACTION_MAPPINGS.get(value)
) is not None and mapped_value in self.type_information.range:
return mapped_value
raise ValueError(f"Unsupported value {value} for {self.dpcode}")
async def async_setup_entry(
hass: HomeAssistant,
entry: TuyaConfigEntry,
@@ -89,15 +123,15 @@ async def async_setup_entry(
device,
manager,
description,
action_dpcode_wrapper=action_dpcode_wrapper,
state_dpcode_wrapper=DPCodeEnumWrapper.find_dpcode(
device, description.master_state
mode_wrapper=mode_wrapper,
changed_by_wrapper=_AlarmChangedByWrapper.find_dpcode(
device, DPCode.ALARM_MSG
),
)
for description in descriptions
if (
action_dpcode_wrapper := DPCodeEnumWrapper.find_dpcode(
device, description.key, prefer_function=True
mode_wrapper := _AlarmModeWrapper.find_dpcode(
device, DPCode.MASTER_MODE, prefer_function=True
)
)
)
@@ -115,79 +149,55 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
_attr_name = None
_attr_code_arm_required = False
_alarm_msg_dpcode: DPCode | None = None
def __init__(
self,
device: CustomerDevice,
device_manager: Manager,
description: TuyaAlarmControlPanelEntityDescription,
description: AlarmControlPanelEntityDescription,
*,
action_dpcode_wrapper: DPCodeEnumWrapper,
state_dpcode_wrapper: DPCodeEnumWrapper | None,
mode_wrapper: _AlarmModeWrapper,
changed_by_wrapper: _AlarmChangedByWrapper | None,
) -> None:
"""Init Tuya Alarm."""
super().__init__(device, device_manager)
self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}"
self._action_dpcode_wrapper = action_dpcode_wrapper
self._state_dpcode_wrapper = state_dpcode_wrapper
self._mode_wrapper = mode_wrapper
self._changed_by_wrapper = changed_by_wrapper
# Determine supported modes
if Mode.HOME in action_dpcode_wrapper.type_information.range:
# Determine supported modes
if mode_wrapper.supports_action("arm_home"):
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME
if Mode.ARM in action_dpcode_wrapper.type_information.range:
if mode_wrapper.supports_action("arm_away"):
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY
if Mode.SOS in action_dpcode_wrapper.type_information.range:
if mode_wrapper.supports_action("trigger"):
self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER
# Determine alarm message
if dp_code := get_dpcode(self.device, description.alarm_msg):
self._alarm_msg_dpcode = dp_code
@property
def alarm_state(self) -> AlarmControlPanelState | None:
"""Return the state of the device."""
# When the alarm is triggered, only its 'state' is changing. From 'normal' to 'alarm'.
# The 'mode' doesn't change, and stays as 'arm' or 'home'.
if (
self._state_dpcode_wrapper is not None
and self.device.status.get(self._state_dpcode_wrapper.dpcode) == State.ALARM
):
# Only report as triggered if NOT a battery warning
if (
changed_by := self.changed_by
) is None or "Sensor Low Battery" not in changed_by:
return AlarmControlPanelState.TRIGGERED
if not (status := self.device.status.get(self.entity_description.key)):
return None
return STATE_MAPPING.get(status)
return self._mode_wrapper.read_panel_state(self.device)
@property
def changed_by(self) -> str | None:
"""Last change triggered by."""
if (
self._state_dpcode_wrapper is not None
and self._alarm_msg_dpcode is not None
and self.device.status.get(self._state_dpcode_wrapper.dpcode) == State.ALARM
and (encoded_msg := self.device.status.get(self._alarm_msg_dpcode))
):
return b64decode(encoded_msg).decode("utf-16be")
return None
if self._changed_by_wrapper is None:
return None
return self._changed_by_wrapper.read_device_status(self.device)
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send Disarm command."""
await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.DISARMED)
await self._async_send_dpcode_update(self._mode_wrapper, "disarm")
async def async_alarm_arm_home(self, code: str | None = None) -> None:
"""Send Home command."""
await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.HOME)
await self._async_send_dpcode_update(self._mode_wrapper, "arm_home")
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send Arm command."""
await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.ARM)
await self._async_send_dpcode_update(self._mode_wrapper, "arm_away")
async def async_alarm_trigger(self, code: str | None = None) -> None:
"""Send SOS command."""
await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.SOS)
await self._async_send_dpcode_update(self._mode_wrapper, "trigger")
+28 -7
View File
@@ -13,6 +13,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
from .entity import TuyaEntity
from .models import DPCodeBooleanWrapper
CAMERAS: tuple[DeviceCategory, ...] = (
DeviceCategory.DGHSXJ,
@@ -35,7 +36,18 @@ async def async_setup_entry(
for device_id in device_ids:
device = manager.device_map[device_id]
if device.category in CAMERAS:
entities.append(TuyaCameraEntity(device, manager))
entities.append(
TuyaCameraEntity(
device,
manager,
motion_detection_switch=DPCodeBooleanWrapper.find_dpcode(
device, DPCode.MOTION_SWITCH, prefer_function=True
),
recording_status=DPCodeBooleanWrapper.find_dpcode(
device, DPCode.RECORD_SWITCH
),
)
)
async_add_entities(entities)
@@ -57,21 +69,30 @@ class TuyaCameraEntity(TuyaEntity, CameraEntity):
self,
device: CustomerDevice,
device_manager: Manager,
*,
motion_detection_switch: DPCodeBooleanWrapper | None = None,
recording_status: DPCodeBooleanWrapper | None = None,
) -> None:
"""Init Tuya Camera."""
super().__init__(device, device_manager)
CameraEntity.__init__(self)
self._attr_model = device.product_name
self._motion_detection_switch = motion_detection_switch
self._recording_status = recording_status
@property
def is_recording(self) -> bool:
"""Return true if the device is recording."""
return self.device.status.get(DPCode.RECORD_SWITCH, False)
if (status := self._read_wrapper(self._recording_status)) is not None:
return status
return False
@property
def motion_detection_enabled(self) -> bool:
"""Return the camera motion detection status."""
return self.device.status.get(DPCode.MOTION_SWITCH, False)
if (status := self._read_wrapper(self._motion_detection_switch)) is not None:
return status
return False
async def stream_source(self) -> str | None:
"""Return the source of the stream."""
@@ -95,10 +116,10 @@ class TuyaCameraEntity(TuyaEntity, CameraEntity):
height=height,
)
def enable_motion_detection(self) -> None:
async def async_enable_motion_detection(self) -> None:
"""Enable motion detection in the camera."""
self._send_command([{"code": DPCode.MOTION_SWITCH, "value": True}])
await self._async_send_dpcode_update(self._motion_detection_switch, True)
def disable_motion_detection(self) -> None:
async def async_disable_motion_detection(self) -> None:
"""Disable motion detection in camera."""
self._send_command([{"code": DPCode.MOTION_SWITCH, "value": False}])
await self._async_send_dpcode_update(self._motion_detection_switch, False)
+33 -50
View File
@@ -26,7 +26,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
from .models import IntegerTypeData, find_dpcode
from .models import DPCodeIntegerWrapper, IntegerTypeData, find_dpcode
from .util import get_dpcode
TUYA_HVAC_TO_HA = {
@@ -41,6 +41,16 @@ TUYA_HVAC_TO_HA = {
}
class _RoundedIntegerWrapper(DPCodeIntegerWrapper):
"""An integer that always rounds its value."""
def read_device_status(self, device: CustomerDevice) -> int | None:
"""Read and round the device status."""
if (value := super().read_device_status(device)) is None:
return None
return round(value)
@dataclass(frozen=True, kw_only=True)
class TuyaClimateEntityDescription(ClimateEntityDescription):
"""Describe an Tuya climate entity."""
@@ -97,6 +107,12 @@ async def async_setup_entry(
manager,
CLIMATE_DESCRIPTIONS[device.category],
hass.config.units.temperature_unit,
current_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode(
device, DPCode.HUMIDITY_CURRENT
),
target_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode(
device, DPCode.HUMIDITY_SET, prefer_function=True
),
)
)
async_add_entities(entities)
@@ -111,10 +127,8 @@ async def async_setup_entry(
class TuyaClimateEntity(TuyaEntity, ClimateEntity):
"""Tuya Climate Device."""
_current_humidity: IntegerTypeData | None = None
_current_temperature: IntegerTypeData | None = None
_hvac_to_tuya: dict[str, str]
_set_humidity: IntegerTypeData | None = None
_set_temperature: IntegerTypeData | None = None
entity_description: TuyaClimateEntityDescription
_attr_name = None
@@ -125,12 +139,17 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
device_manager: Manager,
description: TuyaClimateEntityDescription,
system_temperature_unit: UnitOfTemperature,
*,
current_humidity_wrapper: _RoundedIntegerWrapper | None = None,
target_humidity_wrapper: _RoundedIntegerWrapper | None = None,
) -> None:
"""Determine which values to use."""
self._attr_target_temperature_step = 1.0
self.entity_description = description
super().__init__(device, device_manager)
self._current_humidity_wrapper = current_humidity_wrapper
self._target_humidity_wrapper = target_humidity_wrapper
# If both temperature values for celsius and fahrenheit are present,
# use whatever the device is set to, with a fallback to celsius.
@@ -227,21 +246,14 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
]
# Determine dpcode to use for setting the humidity
if int_type := find_dpcode(
self.device,
DPCode.HUMIDITY_SET,
dptype=DPType.INTEGER,
prefer_function=True,
):
if target_humidity_wrapper:
self._attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY
self._set_humidity = int_type
self._attr_min_humidity = int(int_type.min_scaled)
self._attr_max_humidity = int(int_type.max_scaled)
# Determine dpcode to use for getting the current humidity
self._current_humidity = find_dpcode(
self.device, DPCode.HUMIDITY_CURRENT, dptype=DPType.INTEGER
)
self._attr_min_humidity = round(
target_humidity_wrapper.type_information.min_scaled
)
self._attr_max_humidity = round(
target_humidity_wrapper.type_information.max_scaled
)
# Determine fan modes
self._fan_mode_dp_code: str | None = None
@@ -281,10 +293,6 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
)
async def async_added_to_hass(self) -> None:
"""Call when entity is added to hass."""
await super().async_added_to_hass()
def set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
commands = [{"code": DPCode.SWITCH, "value": hvac_mode != HVACMode.OFF}]
@@ -307,20 +315,9 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
self._send_command([{"code": self._fan_mode_dp_code, "value": fan_mode}])
def set_humidity(self, humidity: int) -> None:
async def async_set_humidity(self, humidity: int) -> None:
"""Set new target humidity."""
if TYPE_CHECKING:
# guarded by ClimateEntityFeature.TARGET_HUMIDITY
assert self._set_humidity is not None
self._send_command(
[
{
"code": self._set_humidity.dpcode,
"value": self._set_humidity.scale_value_back(humidity),
}
]
)
await self._async_send_dpcode_update(self._target_humidity_wrapper, humidity)
def set_swing_mode(self, swing_mode: str) -> None:
"""Set new target swing operation."""
@@ -386,14 +383,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
@property
def current_humidity(self) -> int | None:
"""Return the current humidity."""
if self._current_humidity is None:
return None
humidity = self.device.status.get(self._current_humidity.dpcode)
if humidity is None:
return None
return round(self._current_humidity.scale_value(humidity))
return self._read_wrapper(self._current_humidity_wrapper)
@property
def target_temperature(self) -> float | None:
@@ -410,14 +400,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
@property
def target_humidity(self) -> int | None:
"""Return the humidity currently set to be reached."""
if self._set_humidity is None:
return None
humidity = self.device.status.get(self._set_humidity.dpcode)
if humidity is None:
return None
return round(self._set_humidity.scale_value(humidity))
return self._read_wrapper(self._target_humidity_wrapper)
@property
def hvac_mode(self) -> HVACMode:
+1
View File
@@ -709,6 +709,7 @@ class DPCode(StrEnum):
DEW_POINT_TEMP = "dew_point_temp"
DISINFECTION = "disinfection"
DO_NOT_DISTURB = "do_not_disturb"
DOORBELL_PIC = "doorbell_pic"
DOORCONTACT_STATE = "doorcontact_state" # Status of door window sensor
DOORCONTACT_STATE_2 = "doorcontact_state_2"
DOORCONTACT_STATE_3 = "doorcontact_state_3"
+89 -136
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from typing import Any
from tuya_sharing import CustomerDevice, Manager
@@ -22,10 +22,57 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
from .models import EnumTypeData, IntegerTypeData, find_dpcode
from .models import DPCodeIntegerWrapper, find_dpcode
from .util import get_dpcode
class _DPCodePercentageMappingWrapper(DPCodeIntegerWrapper):
"""Wrapper for DPCode position values mapping to 0-100 range."""
def _position_reversed(self, device: CustomerDevice) -> bool:
"""Check if the position and direction should be reversed."""
return False
def read_device_status(self, device: CustomerDevice) -> float | None:
if (value := self._read_device_status_raw(device)) is None:
return None
return round(
self.type_information.remap_value_to(
value,
0,
100,
self._position_reversed(device),
)
)
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
return round(
self.type_information.remap_value_from(
value,
0,
100,
self._position_reversed(device),
)
)
class _InvertedPercentageMappingWrapper(_DPCodePercentageMappingWrapper):
"""Wrapper for DPCode position values mapping to 0-100 range."""
def _position_reversed(self, device: CustomerDevice) -> bool:
"""Check if the position and direction should be reversed."""
return True
class _ControlBackModePercentageMappingWrapper(_DPCodePercentageMappingWrapper):
"""Wrapper for DPCode position values with control_back_mode support."""
def _position_reversed(self, device: CustomerDevice) -> bool:
"""Check if the position and direction should be reversed."""
return device.status.get(DPCode.CONTROL_BACK_MODE) != "back"
@dataclass(frozen=True)
class TuyaCoverEntityDescription(CoverEntityDescription):
"""Describe an Tuya cover entity."""
@@ -33,11 +80,13 @@ class TuyaCoverEntityDescription(CoverEntityDescription):
current_state: DPCode | tuple[DPCode, ...] | None = None
current_state_inverse: bool = False
current_position: DPCode | tuple[DPCode, ...] | None = None
position_wrapper: type[_DPCodePercentageMappingWrapper] = (
_InvertedPercentageMappingWrapper
)
set_position: DPCode | None = None
open_instruction_value: str = "open"
close_instruction_value: str = "close"
stop_instruction_value: str = "stop"
motor_reverse_mode: DPCode | None = None
COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
@@ -117,8 +166,8 @@ COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
key=DPCode.CONTROL,
translation_key="curtain",
current_position=DPCode.PERCENT_CONTROL,
position_wrapper=_ControlBackModePercentageMappingWrapper,
set_position=DPCode.PERCENT_CONTROL,
motor_reverse_mode=DPCode.CONTROL_BACK_MODE,
device_class=CoverDeviceClass.CURTAIN,
),
TuyaCoverEntityDescription(
@@ -126,8 +175,8 @@ COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
translation_key="indexed_curtain",
translation_placeholders={"index": "2"},
current_position=DPCode.PERCENT_CONTROL_2,
position_wrapper=_ControlBackModePercentageMappingWrapper,
set_position=DPCode.PERCENT_CONTROL_2,
motor_reverse_mode=DPCode.CONTROL_BACK_MODE,
device_class=CoverDeviceClass.CURTAIN,
),
),
@@ -159,7 +208,22 @@ async def async_setup_entry(
device = manager.device_map[device_id]
if descriptions := COVERS.get(device.category):
entities.extend(
TuyaCoverEntity(device, manager, description)
TuyaCoverEntity(
device,
manager,
description,
current_position=description.position_wrapper.find_dpcode(
device, description.current_position
),
set_position=description.position_wrapper.find_dpcode(
device, description.set_position, prefer_function=True
),
tilt_position=description.position_wrapper.find_dpcode(
device,
(DPCode.ANGLE_HORIZONTAL, DPCode.ANGLE_VERTICAL),
prefer_function=True,
),
)
for description in descriptions
if (
description.key in device.function
@@ -179,11 +243,7 @@ async def async_setup_entry(
class TuyaCoverEntity(TuyaEntity, CoverEntity):
"""Tuya Cover Device."""
_current_position: IntegerTypeData | None = None
_current_state: DPCode | None = None
_set_position: IntegerTypeData | None = None
_tilt: IntegerTypeData | None = None
_motor_reverse_mode_enum: EnumTypeData | None = None
entity_description: TuyaCoverEntityDescription
def __init__(
@@ -191,6 +251,10 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
device: CustomerDevice,
device_manager: Manager,
description: TuyaCoverEntityDescription,
*,
current_position: _DPCodePercentageMappingWrapper | None = None,
set_position: _DPCodePercentageMappingWrapper | None = None,
tilt_position: _DPCodePercentageMappingWrapper | None = None,
) -> None:
"""Init Tuya Cover."""
super().__init__(device, device_manager)
@@ -198,6 +262,10 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
self._attr_unique_id = f"{super().unique_id}{description.key}"
self._attr_supported_features = CoverEntityFeature(0)
self._current_position = current_position or set_position
self._set_position = set_position
self._tilt_position = tilt_position
# Check if this cover is based on a switch or has controls
if get_dpcode(self.device, description.key):
if device.function[description.key].type == "Boolean":
@@ -216,72 +284,15 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
self._current_state = get_dpcode(self.device, description.current_state)
# Determine type to use for setting the position
if int_type := find_dpcode(
self.device,
description.set_position,
dptype=DPType.INTEGER,
prefer_function=True,
):
if set_position:
self._attr_supported_features |= CoverEntityFeature.SET_POSITION
self._set_position = int_type
# Set as default, unless overwritten below
self._current_position = int_type
# Determine type for getting the position
if int_type := find_dpcode(
self.device,
description.current_position,
dptype=DPType.INTEGER,
prefer_function=True,
):
self._current_position = int_type
# Determine type to use for setting the tilt
if int_type := find_dpcode(
self.device,
(DPCode.ANGLE_HORIZONTAL, DPCode.ANGLE_VERTICAL),
dptype=DPType.INTEGER,
prefer_function=True,
):
if tilt_position:
self._attr_supported_features |= CoverEntityFeature.SET_TILT_POSITION
self._tilt = int_type
# Determine type to use for checking motor reverse mode
if (motor_mode := description.motor_reverse_mode) and (
enum_type := find_dpcode(
self.device,
motor_mode,
dptype=DPType.ENUM,
prefer_function=True,
)
):
self._motor_reverse_mode_enum = enum_type
@property
def _is_position_reversed(self) -> bool:
"""Check if the cover position and direction should be reversed."""
# The default is True
# Having motor_reverse_mode == "back" cancels the inversion
return not (
self._motor_reverse_mode_enum
and self.device.status.get(self._motor_reverse_mode_enum.dpcode) == "back"
)
@property
def current_cover_position(self) -> int | None:
"""Return cover current position."""
if self._current_position is None:
return None
if (position := self.device.status.get(self._current_position.dpcode)) is None:
return None
return round(
self._current_position.remap_value_to(
position, 0, 100, reverse=self._is_position_reversed
)
)
return self._read_wrapper(self._current_position)
@property
def current_cover_tilt_position(self) -> int | None:
@@ -289,13 +300,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
None is unknown, 0 is closed, 100 is fully open.
"""
if self._tilt is None:
return None
if (angle := self.device.status.get(self._tilt.dpcode)) is None:
return None
return round(self._tilt.remap_value_to(angle, 0, 100))
return self._read_wrapper(self._tilt_position)
@property
def is_closed(self) -> bool | None:
@@ -332,16 +337,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
]
if self._set_position is not None:
commands.append(
{
"code": self._set_position.dpcode,
"value": round(
self._set_position.remap_value_from(
100, 0, 100, reverse=self._is_position_reversed
),
),
}
)
commands.append(self._set_position.get_update_command(self.device, 100))
self._send_command(commands)
@@ -361,40 +357,13 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
]
if self._set_position is not None:
commands.append(
{
"code": self._set_position.dpcode,
"value": round(
self._set_position.remap_value_from(
0, 0, 100, reverse=self._is_position_reversed
),
),
}
)
commands.append(self._set_position.get_update_command(self.device, 0))
self._send_command(commands)
def set_cover_position(self, **kwargs: Any) -> None:
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
if TYPE_CHECKING:
# guarded by CoverEntityFeature.SET_POSITION
assert self._set_position is not None
self._send_command(
[
{
"code": self._set_position.dpcode,
"value": round(
self._set_position.remap_value_from(
kwargs[ATTR_POSITION],
0,
100,
reverse=self._is_position_reversed,
)
),
}
]
)
await self._async_send_dpcode_update(self._set_position, kwargs[ATTR_POSITION])
def stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
@@ -407,24 +376,8 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
]
)
def set_cover_tilt_position(self, **kwargs: Any) -> None:
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move the cover tilt to a specific position."""
if TYPE_CHECKING:
# guarded by CoverEntityFeature.SET_TILT_POSITION
assert self._tilt is not None
self._send_command(
[
{
"code": self._tilt.dpcode,
"value": round(
self._tilt.remap_value_from(
kwargs[ATTR_TILT_POSITION],
0,
100,
reverse=self._is_position_reversed,
)
),
}
]
await self._async_send_dpcode_update(
self._tilt_position, kwargs[ATTR_TILT_POSITION]
)

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