Compare commits

...

72 Commits

Author SHA1 Message Date
Erik
45f1fc32e2 Address comments 2026-01-08 09:36:34 +01:00
Erik
1e95598c96 Add helper for creating entity condition tests 2026-01-07 14:58:50 +01:00
Norbert Rittel
e963adfdf0 Fix capitalization in openevse data_description string (#160423) 2026-01-07 14:53:19 +01:00
Simone Chemelli
fd7bbc68c6 Bump aioshelly to 13.23.1 (#160420) 2026-01-07 14:49:18 +01:00
Robert Resch
9281ab018c Constraint aiomqtt>=2.5.0 to fix blocking call (#160410)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-07 14:21:49 +01:00
Andres Ruiz
80baf86e23 Add codeowners and integration_type for waterfurnace (#160397) 2026-01-07 13:12:58 +01:00
Simone Chemelli
db497b23fe Small cleanup for Vodafone Station tests (#160415) 2026-01-07 12:50:12 +01:00
cdnninja
a2fb8f5a72 Add Vesync Air Fryer Sensors (#160170)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-01-07 12:41:34 +01:00
hanwg
6953bd4599 Fix schema validation error in Telegram (#160367) 2026-01-07 12:27:17 +01:00
Xiangxuan Qu
225be65f71 Fix IndexError in Israel Rail sensor when no departures available (#160351)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-07 12:22:39 +01:00
momala454
7b0463f763 Add additional lens modes 4 to 10 to JVC projector remote (#159657)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-07 12:22:19 +01:00
Luke Lashley
4d305b657a Bump python-roborock to 4.2.1 (#160398) 2026-01-07 11:23:40 +01:00
Paul Tarjan
d5a553c8c7 Fix Ring integration log flooding for accounts without subscription (#158012)
Co-authored-by: Robert Resch <robert@resch.dev>
2026-01-07 11:14:05 +01:00
Ivan Dlugos
9169b68254 Bump sentry-sdk to 2.48.0 (#159415)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 11:05:38 +01:00
Colin
fde9bd95d5 Replace openevse backend library (#160325) 2026-01-07 10:25:15 +01:00
Marc Mueller
e4db8ff86e Update guppy3 to 3.1.6 (#160356) 2026-01-07 10:11:01 +01:00
Erik Montnemery
a084e51345 Add test helpers for numerical state triggers (#160308)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-01-07 08:53:35 +01:00
Luke Lashley
00381e6dfd Remove q7 total cleaning time for Roborock (#160399) 2026-01-06 20:27:09 -08:00
Michael Hansen
b6d493696a Bump intents to 2026.1.6 (#160389) 2026-01-06 17:11:54 -06:00
Artem Draft
5f0500c3cd Add SSL support in Bravia TV (#160373)
Co-authored-by: Maciej Bieniek <bieniu@users.noreply.github.com>
2026-01-06 23:59:47 +01:00
dontinelli
c61a63cc6f Bump solarlog_cli to 0.7.0 (#160382) 2026-01-06 23:59:16 +01:00
Raphael Hehl
5445a4f40f Bump uiprotect to 8.0.0 (#160384)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-01-06 23:57:19 +01:00
Daniel Hjelseth Høyer
2888cacc3f Bump pyTibber to 0.34.1 (#160380)
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-06 23:56:26 +01:00
TheJulianJES
16f3e6d2c9 Bump ZHA to 0.0.83 (#160342) 2026-01-06 12:11:40 -05:00
Bram Kragten
7a872970fa Update frontend to 20251229.1 (#160372) 2026-01-06 17:53:56 +01:00
Bram Kragten
4f5ca986ce Fix number or entity choose schema (#160358) 2026-01-06 17:23:24 +01:00
Artem Draft
b58e058da5 Bump pybravia to 0.4.1 (#160368) 2026-01-06 16:42:58 +01:00
epenet
badebe0c7f Refactor Tuya event platform to use DeviceWrapper (#160366) 2026-01-06 16:09:13 +01:00
mettolen
7817ec1a52 Update Saunum integration to gold quality tier (#159783) 2026-01-06 16:07:28 +01:00
epenet
c773998946 Remove default in Tuya DeviceWrapper options (#160303) 2026-01-06 13:06:53 +01:00
Mika
2bc9397103 Fix missing state class to solaredge (#160336) 2026-01-06 12:36:49 +01:00
Daniel Hjelseth Høyer
685534b17c Add more Tibber sensors (#160354) 2026-01-06 11:18:35 +01:00
tronikos
c740f44bfa Bump opower to 0.16.0 (#160348) 2026-01-06 10:25:43 +01:00
mettolen
ce471d0222 Add button entity to Airobot integration (#160169) 2026-01-06 08:57:24 +01:00
Manu
53ed344fe0 Add action exceptions to Duck DNS (#160331) 2026-01-06 08:38:22 +01:00
Manu
5f8f3c961a Remove stale devices in Xbox integration (#160337) 2026-01-06 08:27:42 +01:00
Aidan Timson
9d0c5530f2 Bump systembridgeconnector to 5.3.1 (#160326) 2026-01-06 08:09:25 +01:00
abelyliu
d114fe4fbd Add report_type to Tuya diagnostic (#160311) 2026-01-06 07:37:48 +01:00
Daniel Hjelseth Høyer
f03d44d5b5 Bump pyTibber to 0.34.0 (#160333) 2026-01-06 00:55:28 +01:00
Frédéric
35f4464d4a Add Resideo X2S Smart Thermostat to Matter fan-only mode list (#160260) 2026-01-06 00:24:20 +01:00
DeerMaximum
fc2530e979 Use a fixture in NINA to mock async_setup_entry (#160323) 2026-01-05 21:51:10 +01:00
Manu
354fafda1a Refactor Xbox coordinators (#160174) 2026-01-05 21:31:57 +01:00
Sid
5b0dab479d Bump eheimdigital to 1.5.0 (#160312) 2026-01-05 21:30:05 +01:00
Daniel Hjelseth Høyer
1e1f414849 Fix unit for Tibber sensor (#160319) 2026-01-05 21:27:00 +01:00
Xidorn Quan
7c81df6c5c Fix rain count sensors' state class of Ecowitt (#158204) 2026-01-05 21:02:21 +01:00
J. Nick Koston
95d7c42e6a Require service_uuid and service_data_uuid to match hue ble (#160321) 2026-01-05 19:47:31 +01:00
Joakim Sørensen
19fd80035e Add connection check before registering cloudhook URL (#160284) 2026-01-05 09:35:49 -05:00
Martin Hjelmare
8e30787ae6 Test hassfest translations gen_strings_schema (#159464) 2026-01-05 15:27:03 +01:00
epenet
7133da928f Add max_value/min_value/value_step to Tuya DeviceWrapper (#160300) 2026-01-05 14:56:17 +01:00
Colin
3f9a41d393 Add openevse config flow (#158968)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-01-05 14:49:36 +01:00
epenet
f4caf36204 Use generic DeviceWrapper in Tuya cover (#160301) 2026-01-05 14:45:13 +01:00
Bram Kragten
079866e384 Fix humidifier trigger turned on icon (#160297) 2026-01-05 13:45:31 +01:00
epenet
dce0db78aa Use generic DeviceWrapper in Tuya sensor (#160299) 2026-01-05 13:45:08 +01:00
epenet
fffc18d28b Use generic DeviceWrapper in more Tuya platforms (#160298) 2026-01-05 13:44:45 +01:00
cdnninja
c7cbcbc32d Refactor entity unavailable handling in VeSync (#160274) 2026-01-05 13:17:35 +01:00
Samuel Xiao
aebcdd6e7a Switchbot Cloud: Add new supported light (#160282) 2026-01-05 13:12:02 +01:00
Paul Tarjan
3be92510f8 Fix Tesla update showing scheduled updates as installing (#158681) 2026-01-05 12:42:58 +01:00
Matrix
4d8448e82a Bump yolink api to 0.6.1 (#160293) 2026-01-05 12:37:26 +01:00
Daniel Hjelseth Høyer
625bc467d4 Move Tibber to OAuth (#156690)
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-05 12:36:41 +01:00
Steven Looman
47573b7f6a Bump async-upnp-client to 0.46.2 (#160188) 2026-01-05 12:33:40 +01:00
epenet
7c95c92525 Make Tuya DeviceWrapper a generic class (#159349) 2026-01-05 12:22:48 +01:00
dotlambda
1aed46e39e Bump google-genai to 1.56.0 (#160210) 2026-01-05 12:21:02 +01:00
epenet
6659166df0 Move Tuya vacuum entity logic to wrapper class (#159255) 2026-01-05 12:20:25 +01:00
Erik Montnemery
1e6b0ba9ec Allow passing trigger options to parametrize_trigger_states (#160119) 2026-01-05 10:44:31 +01:00
abelyliu
1f23098638 Bump tuya-device-sharing-sdk to 0.2.8 (#160288) 2026-01-05 10:28:13 +01:00
epenet
98ee0421b7 Fix Tuya light color data wrapper (#160280) 2026-01-05 10:27:57 +01:00
cdnninja
6aaa57f660 Set PARALLEL_UPDATES in VeSync (#160272) 2026-01-05 09:18:16 +01:00
Andres Ruiz
fad817853f Add state_class to waterfurnace sensors (#160277) 2026-01-05 09:17:41 +01:00
Michael
7ef7d3f570 Add entered and left home person triggers (#159320)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2026-01-05 09:09:59 +01:00
Thomas55555
14bca5a052 Make verify_ssl configurable in remote calendar (#160216) 2026-01-04 15:32:38 -08:00
Vincent Courcelle
18769730f0 Bump python-roborock to 4.2.0 (#160184) 2026-01-05 00:26:50 +01:00
Sab44
de6d117d9a Bump librehardwaremonitor-api to version 1.8.4 (#160249) 2026-01-04 21:35:28 +01:00
207 changed files with 6934 additions and 1806 deletions

3
CODEOWNERS generated
View File

@@ -1170,6 +1170,8 @@ build.json @home-assistant/supervisor
/tests/components/open_router/ @joostlek
/homeassistant/components/openerz/ @misialq
/tests/components/openerz/ @misialq
/homeassistant/components/openevse/ @c00w
/tests/components/openevse/ @c00w
/homeassistant/components/openexchangerates/ @MartinHjelmare
/tests/components/openexchangerates/ @MartinHjelmare
/homeassistant/components/opengarage/ @danielhiversen
@@ -1801,6 +1803,7 @@ build.json @home-assistant/supervisor
/tests/components/waqi/ @joostlek
/homeassistant/components/water_heater/ @home-assistant/core
/tests/components/water_heater/ @home-assistant/core
/homeassistant/components/waterfurnace/ @sdague @masterkoppa
/homeassistant/components/watergate/ @adam-the-hero
/tests/components/watergate/ @adam-the-hero
/homeassistant/components/watson_tts/ @rutkai

View File

@@ -7,7 +7,12 @@ from homeassistant.core import HomeAssistant
from .coordinator import AirobotConfigEntry, AirobotDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR]
PLATFORMS: list[Platform] = [
Platform.BUTTON,
Platform.CLIMATE,
Platform.NUMBER,
Platform.SENSOR,
]
async def async_setup_entry(hass: HomeAssistant, entry: AirobotConfigEntry) -> bool:

View File

@@ -0,0 +1,89 @@
"""Button platform for Airobot integration."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from pyairobotrest.exceptions import (
AirobotConnectionError,
AirobotError,
AirobotTimeoutError,
)
from homeassistant.components.button import (
ButtonDeviceClass,
ButtonEntity,
ButtonEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import AirobotConfigEntry, AirobotDataUpdateCoordinator
from .entity import AirobotEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AirobotButtonEntityDescription(ButtonEntityDescription):
"""Describes Airobot button entity."""
press_fn: Callable[[AirobotDataUpdateCoordinator], Coroutine[Any, Any, None]]
BUTTON_TYPES: tuple[AirobotButtonEntityDescription, ...] = (
AirobotButtonEntityDescription(
key="restart",
device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG,
press_fn=lambda coordinator: coordinator.client.reboot_thermostat(),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AirobotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Airobot button entities."""
coordinator = entry.runtime_data
async_add_entities(
AirobotButton(coordinator, description) for description in BUTTON_TYPES
)
class AirobotButton(AirobotEntity, ButtonEntity):
"""Representation of an Airobot button."""
entity_description: AirobotButtonEntityDescription
def __init__(
self,
coordinator: AirobotDataUpdateCoordinator,
description: AirobotButtonEntityDescription,
) -> None:
"""Initialize the button."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.status.device_id}_{description.key}"
async def async_press(self) -> None:
"""Handle the button press."""
try:
await self.entity_description.press_fn(self.coordinator)
except (AirobotConnectionError, AirobotTimeoutError):
# Connection errors during reboot are expected as device restarts
pass
except AirobotError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="button_press_failed",
translation_placeholders={"button": self.entity_description.key},
) from err

View File

@@ -86,6 +86,9 @@
"authentication_failed": {
"message": "Authentication failed, please reauthenticate."
},
"button_press_failed": {
"message": "Failed to press {button} button."
},
"connection_failed": {
"message": "Failed to communicate with device."
},

View File

@@ -136,6 +136,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"light",
"lock",
"media_player",
"person",
"scene",
"siren",
"switch",

View File

@@ -11,6 +11,7 @@ from homeassistant.const import CONF_HOST, CONF_MAC, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import CONF_USE_SSL
from .coordinator import BraviaTVConfigEntry, BraviaTVCoordinator
PLATFORMS: Final[list[Platform]] = [
@@ -26,11 +27,12 @@ async def async_setup_entry(
"""Set up a config entry."""
host = config_entry.data[CONF_HOST]
mac = config_entry.data[CONF_MAC]
ssl = config_entry.data.get(CONF_USE_SSL, False)
session = async_create_clientsession(
hass, cookie_jar=CookieJar(unsafe=True, quote_cookie=False)
)
client = BraviaClient(host, mac, session=session)
client = BraviaClient(host, mac, session=session, ssl=ssl)
coordinator = BraviaTVCoordinator(
hass=hass,
config_entry=config_entry,

View File

@@ -28,6 +28,7 @@ from .const import (
ATTR_MODEL,
CONF_NICKNAME,
CONF_USE_PSK,
CONF_USE_SSL,
DOMAIN,
NICKNAME_PREFIX,
)
@@ -46,11 +47,12 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN):
def create_client(self) -> None:
"""Create Bravia TV client from config."""
host = self.device_config[CONF_HOST]
ssl = self.device_config[CONF_USE_SSL]
session = async_create_clientsession(
self.hass,
cookie_jar=CookieJar(unsafe=True, quote_cookie=False),
)
self.client = BraviaClient(host=host, session=session)
self.client = BraviaClient(host=host, session=session, ssl=ssl)
async def gen_instance_ids(self) -> tuple[str, str]:
"""Generate client_id and nickname."""
@@ -123,10 +125,10 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle authorize step."""
self.create_client()
if user_input is not None:
self.device_config[CONF_USE_PSK] = user_input[CONF_USE_PSK]
self.device_config[CONF_USE_SSL] = user_input[CONF_USE_SSL]
self.create_client()
if user_input[CONF_USE_PSK]:
return await self.async_step_psk()
return await self.async_step_pin()
@@ -136,6 +138,7 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=vol.Schema(
{
vol.Required(CONF_USE_PSK, default=False): bool,
vol.Required(CONF_USE_SSL, default=False): bool,
}
),
)

View File

@@ -12,6 +12,7 @@ ATTR_MODEL: Final = "model"
CONF_NICKNAME: Final = "nickname"
CONF_USE_PSK: Final = "use_psk"
CONF_USE_SSL: Final = "use_ssl"
DOMAIN: Final = "braviatv"
LEGACY_CLIENT_ID: Final = "HomeAssistant"

View File

@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pybravia"],
"requirements": ["pybravia==0.3.4"],
"requirements": ["pybravia==0.4.1"],
"ssdp": [
{
"manufacturer": "Sony Corporation",

View File

@@ -15,9 +15,10 @@
"step": {
"authorize": {
"data": {
"use_psk": "Use PSK authentication"
"use_psk": "Use PSK authentication",
"use_ssl": "Use SSL connection"
},
"description": "Make sure that «Control remotely» is enabled on your TV, go to: \nSettings -> Network -> Remote device settings -> Control remotely. \n\nThere are two authorization methods: PIN code or PSK (Pre-Shared Key). \nAuthorization via PSK is recommended as more stable.",
"description": "Make sure that «Control remotely» is enabled on your TV. Go to: \nSettings -> Network -> Remote device settings -> Control remotely. \n\nThere are two authorization methods: PIN code or PSK (Pre-Shared Key). \nAuthorization via PSK is recommended, as it is more stable. \n\nUse an SSL connection only if your TV supports this connection type.",
"title": "Authorize Sony Bravia TV"
},
"confirm": {

View File

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

View File

@@ -9,7 +9,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["async_upnp_client"],
"requirements": ["async-upnp-client==0.46.1", "getmac==0.9.5"],
"requirements": ["async-upnp-client==0.46.2", "getmac==0.9.5"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",

View File

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

View File

@@ -1,4 +1,4 @@
"""Integrate with DuckDNS."""
"""Duck DNS integration."""
from __future__ import annotations

View File

@@ -2,11 +2,12 @@
from __future__ import annotations
from aiohttp import ClientError
import voluptuous as vol
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import ConfigEntrySelector
@@ -62,9 +63,25 @@ async def update_domain_service(call: ServiceCall) -> None:
session = async_get_clientsession(call.hass)
await update_duckdns(
session,
entry.data[CONF_DOMAIN],
entry.data[CONF_ACCESS_TOKEN],
txt=call.data.get(ATTR_TXT),
)
try:
if not await update_duckdns(
session,
entry.data[CONF_DOMAIN],
entry.data[CONF_ACCESS_TOKEN],
txt=call.data.get(ATTR_TXT),
):
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={
CONF_DOMAIN: entry.data[CONF_DOMAIN],
},
)
except ClientError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="connection_error",
translation_placeholders={
CONF_DOMAIN: entry.data[CONF_DOMAIN],
},
) from e

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import dataclasses
from datetime import datetime
import logging
from typing import Final
from aioecowitt import EcoWittSensor, EcoWittSensorTypes
@@ -39,6 +40,9 @@ from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM
from . import EcowittConfigEntry
from .entity import EcowittEntity
_LOGGER = logging.getLogger(__name__)
_METRIC: Final = (
EcoWittSensorTypes.TEMPERATURE_C,
EcoWittSensorTypes.RAIN_COUNT_MM,
@@ -57,6 +61,40 @@ _IMPERIAL: Final = (
)
_RAIN_COUNT_SENSORS_STATE_CLASS_MAPPING: Final = {
"eventrainin": SensorStateClass.TOTAL_INCREASING,
"hourlyrainin": None,
"totalrainin": SensorStateClass.TOTAL_INCREASING,
"dailyrainin": SensorStateClass.TOTAL_INCREASING,
"weeklyrainin": SensorStateClass.TOTAL_INCREASING,
"monthlyrainin": SensorStateClass.TOTAL_INCREASING,
"yearlyrainin": SensorStateClass.TOTAL_INCREASING,
"last24hrainin": None,
"eventrainmm": SensorStateClass.TOTAL_INCREASING,
"hourlyrainmm": None,
"totalrainmm": SensorStateClass.TOTAL_INCREASING,
"dailyrainmm": SensorStateClass.TOTAL_INCREASING,
"weeklyrainmm": SensorStateClass.TOTAL_INCREASING,
"monthlyrainmm": SensorStateClass.TOTAL_INCREASING,
"yearlyrainmm": SensorStateClass.TOTAL_INCREASING,
"last24hrainmm": None,
"erain_piezo": SensorStateClass.TOTAL_INCREASING,
"hrain_piezo": None,
"drain_piezo": SensorStateClass.TOTAL_INCREASING,
"wrain_piezo": SensorStateClass.TOTAL_INCREASING,
"mrain_piezo": SensorStateClass.TOTAL_INCREASING,
"yrain_piezo": SensorStateClass.TOTAL_INCREASING,
"last24hrain_piezo": None,
"erain_piezomm": SensorStateClass.TOTAL_INCREASING,
"hrain_piezomm": None,
"drain_piezomm": SensorStateClass.TOTAL_INCREASING,
"wrain_piezomm": SensorStateClass.TOTAL_INCREASING,
"mrain_piezomm": SensorStateClass.TOTAL_INCREASING,
"yrain_piezomm": SensorStateClass.TOTAL_INCREASING,
"last24hrain_piezomm": None,
}
ECOWITT_SENSORS_MAPPING: Final = {
EcoWittSensorTypes.HUMIDITY: SensorEntityDescription(
key="HUMIDITY",
@@ -285,15 +323,15 @@ async def async_setup_entry(
name=sensor.name,
)
# Only total rain needs state class for long-term statistics
if sensor.key in (
"totalrainin",
"totalrainmm",
if sensor.stype in (
EcoWittSensorTypes.RAIN_COUNT_INCHES,
EcoWittSensorTypes.RAIN_COUNT_MM,
):
description = dataclasses.replace(
description,
state_class=SensorStateClass.TOTAL_INCREASING,
)
if sensor.key not in _RAIN_COUNT_SENSORS_STATE_CLASS_MAPPING:
_LOGGER.warning("Unknown rain count sensor: %s", sensor.key)
return
state_class = _RAIN_COUNT_SENSORS_STATE_CLASS_MAPPING[sensor.key]
description = dataclasses.replace(description, state_class=state_class)
async_add_entities([EcowittSensorEntity(sensor, description)])

View File

@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["eheimdigital"],
"quality_scale": "platinum",
"requirements": ["eheimdigital==1.4.0"],
"requirements": ["eheimdigital==1.5.0"],
"zeroconf": [
{ "name": "eheimdigital._http._tcp.local.", "type": "_http._tcp.local." }
]

View File

@@ -23,5 +23,5 @@
"winter_mode": {}
},
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20251229.0"]
"requirements": ["home-assistant-frontend==20251229.1"]
}

View File

@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["google-genai==1.38.0"]
"requirements": ["google-genai==1.56.0"]
}

View File

@@ -4,7 +4,8 @@
"bluetooth": [
{
"connectable": true,
"service_data_uuid": "0000fe0f-0000-1000-8000-00805f9b34fb"
"service_data_uuid": "0000fe0f-0000-1000-8000-00805f9b34fb",
"service_uuid": "0000fe0f-0000-1000-8000-00805f9b34fb"
}
],
"codeowners": ["@flip-dots"],

View File

@@ -66,7 +66,7 @@
"trigger": "mdi:air-humidifier-off"
},
"turned_on": {
"trigger": "mdi:air-humidifier-on"
"trigger": "mdi:air-humidifier"
}
}
}

View File

@@ -116,6 +116,8 @@ class IsraelRailEntitySensor(
@property
def native_value(self) -> StateType | datetime:
"""Return the state of the sensor."""
if self.entity_description.index >= len(self.coordinator.data):
return None
return self.entity_description.value_fn(
self.coordinator.data[self.entity_description.index]
)

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["jvcprojector"],
"requirements": ["pyjvcprojector==1.1.2"]
"requirements": ["pyjvcprojector==1.1.3"]
}

View File

@@ -41,6 +41,13 @@ COMMANDS = {
"mode_1": const.REMOTE_MODE_1,
"mode_2": const.REMOTE_MODE_2,
"mode_3": const.REMOTE_MODE_3,
"mode_4": const.REMOTE_MODE_4,
"mode_5": const.REMOTE_MODE_5,
"mode_6": const.REMOTE_MODE_6,
"mode_7": const.REMOTE_MODE_7,
"mode_8": const.REMOTE_MODE_8,
"mode_9": const.REMOTE_MODE_9,
"mode_10": const.REMOTE_MODE_10,
"lens_ap": const.REMOTE_LENS_AP,
"gamma": const.REMOTE_GAMMA,
"color_temp": const.REMOTE_COLOR_TEMP,

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["librehardwaremonitor-api==1.7.2"]
"requirements": ["librehardwaremonitor-api==1.8.4"]
}

View File

@@ -154,6 +154,7 @@ SUPPORT_FAN_MODE_DEVICES: set[tuple[int, int]] = {
(0x1209, 0x8027),
(0x1209, 0x8028),
(0x1209, 0x8029),
(0x131A, 0x1000),
}
SystemModeEnum = clusters.Thermostat.Enums.SystemModeEnum

View File

@@ -69,7 +69,7 @@ class RegistrationsView(HomeAssistantView):
webhook_id = secrets.token_hex()
if cloud.async_active_subscription(hass):
if cloud.async_active_subscription(hass) and cloud.async_is_connected(hass):
data[CONF_CLOUDHOOK_URL] = await async_create_cloud_hook(
hass, webhook_id, None
)

View File

@@ -1 +1,30 @@
"""The openevse component."""
"""The OpenEVSE integration."""
from __future__ import annotations
from openevsehttp.__main__ import OpenEVSE
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
type OpenEVSEConfigEntry = ConfigEntry[OpenEVSE]
async def async_setup_entry(hass: HomeAssistant, entry: OpenEVSEConfigEntry) -> bool:
"""Set up openevse from a config entry."""
entry.runtime_data = OpenEVSE(entry.data[CONF_HOST])
try:
await entry.runtime_data.test_and_get()
except TimeoutError as ex:
raise ConfigEntryError("Unable to connect to charger") from ex
await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR])
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, [Platform.SENSOR])

View File

@@ -0,0 +1,64 @@
"""Config flow for OpenEVSE integration."""
from typing import Any
from openevsehttp.__main__ import OpenEVSE
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from .const import DOMAIN
class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN):
"""OpenEVSE config flow."""
VERSION = 1
MINOR_VERSION = 1
async def check_status(self, host: str) -> bool:
"""Check if we can connect to the OpenEVSE charger."""
charger = OpenEVSE(host)
try:
await charger.test_and_get()
except TimeoutError:
return False
else:
return True
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors = None
if user_input is not None:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
if await self.check_status(user_input[CONF_HOST]):
return self.async_create_entry(
title=f"OpenEVSE {user_input[CONF_HOST]}",
data=user_input,
)
errors = {CONF_HOST: "cannot_connect"}
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
errors=errors,
)
async def async_step_import(self, data: dict[str, str]) -> ConfigFlowResult:
"""Handle the initial step."""
self._async_abort_entries_match({CONF_HOST: data[CONF_HOST]})
if not await self.check_status(data[CONF_HOST]):
return self.async_abort(reason="unavailable_host")
return self.async_create_entry(
title=f"OpenEVSE {data[CONF_HOST]}",
data=data,
)

View File

@@ -0,0 +1,4 @@
"""Constants for the OpenEVSE integration."""
DOMAIN = "openevse"
INTEGRATION_TITLE = "OpenEVSE"

View File

@@ -1,10 +1,12 @@
{
"domain": "openevse",
"name": "OpenEVSE",
"codeowners": [],
"codeowners": ["@c00w"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/openevse",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["openevsewifi"],
"loggers": ["openevsehttp"],
"quality_scale": "legacy",
"requirements": ["openevsewifi==1.1.2"]
"requirements": ["python-openevse-http==0.2.1"]
}

View File

@@ -4,17 +4,18 @@ from __future__ import annotations
import logging
import openevsewifi
from requests import RequestException
from openevsehttp.__main__ import OpenEVSE
import voluptuous as vol
from homeassistant.components.sensor import (
DOMAIN as HOMEASSISTANT_DOMAIN,
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import (
CONF_HOST,
CONF_MONITORED_VARIABLES,
@@ -23,10 +24,17 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import ConfigEntry
from .const import DOMAIN, INTEGRATION_TITLE
_LOGGER = logging.getLogger(__name__)
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
@@ -54,6 +62,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
SensorEntityDescription(
key="rtc_temp",
@@ -61,6 +70,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
SensorEntityDescription(
key="usage_session",
@@ -90,54 +100,110 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
)
def setup_platform(
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the OpenEVSE sensor."""
host = config[CONF_HOST]
monitored_variables = config[CONF_MONITORED_VARIABLES]
"""Set up the openevse platform."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=config,
)
charger = openevsewifi.Charger(host)
if (
result.get("type") is FlowResultType.ABORT
and result.get("reason") != "already_configured"
):
ir.async_create_issue(
hass,
DOMAIN,
f"deprecated_yaml_import_issue_{result.get('reason')}",
breaks_in_ha_version="2026.6.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key=f"deprecated_yaml_import_issue_{result.get('reason')}",
translation_placeholders={
"domain": DOMAIN,
"integration_title": INTEGRATION_TITLE,
},
)
return
entities = [
OpenEVSESensor(charger, description)
for description in SENSOR_TYPES
if description.key in monitored_variables
]
ir.async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
"deprecated_yaml",
breaks_in_ha_version="2026.7.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": INTEGRATION_TITLE,
},
)
add_entities(entities, True)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add sensors for passed config_entry in HA."""
async_add_entities(
(
OpenEVSESensor(
config_entry.data[CONF_HOST],
config_entry.runtime_data,
description,
)
for description in SENSOR_TYPES
),
True,
)
class OpenEVSESensor(SensorEntity):
"""Implementation of an OpenEVSE sensor."""
def __init__(self, charger, description: SensorEntityDescription) -> None:
def __init__(
self,
host: str,
charger: OpenEVSE,
description: SensorEntityDescription,
) -> None:
"""Initialize the sensor."""
self.entity_description = description
self.host = host
self.charger = charger
def update(self) -> None:
async def async_update(self) -> None:
"""Get the monitored data from the charger."""
try:
sensor_type = self.entity_description.key
if sensor_type == "status":
self._attr_native_value = self.charger.getStatus()
elif sensor_type == "charge_time":
self._attr_native_value = self.charger.getChargeTimeElapsed() / 60
elif sensor_type == "ambient_temp":
self._attr_native_value = self.charger.getAmbientTemperature()
elif sensor_type == "ir_temp":
self._attr_native_value = self.charger.getIRTemperature()
elif sensor_type == "rtc_temp":
self._attr_native_value = self.charger.getRTCTemperature()
elif sensor_type == "usage_session":
self._attr_native_value = float(self.charger.getUsageSession()) / 1000
elif sensor_type == "usage_total":
self._attr_native_value = float(self.charger.getUsageTotal()) / 1000
else:
self._attr_native_value = "Unknown"
except (RequestException, ValueError, KeyError):
await self.charger.update()
except TimeoutError:
_LOGGER.warning("Could not update status for %s", self.name)
return
sensor_type = self.entity_description.key
if sensor_type == "status":
self._attr_native_value = self.charger.status
elif sensor_type == "charge_time":
self._attr_native_value = self.charger.charge_time_elapsed / 60
elif sensor_type == "ambient_temp":
self._attr_native_value = self.charger.ambient_temperature
elif sensor_type == "ir_temp":
self._attr_native_value = self.charger.ir_temperature
elif sensor_type == "rtc_temp":
self._attr_native_value = self.charger.rtc_temperature
elif sensor_type == "usage_session":
self._attr_native_value = float(self.charger.usage_session) / 1000
elif sensor_type == "usage_total":
self._attr_native_value = float(self.charger.usage_total) / 1000
else:
self._attr_native_value = "Unknown"

View File

@@ -0,0 +1,27 @@
{
"config": {
"abort": {
"already_configured": "This charger is already configured",
"unavailable_host": "Unable to connect to host"
},
"error": {
"cannot_connect": "Unable to connect"
},
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "Enter the IP address of your OpenEVSE. Should match the address you used to set it up."
}
}
}
},
"issues": {
"yaml_deprecated": {
"description": "Configuring OpenEVSE using YAML is being removed. Your existing YAML configuration has been imported into the UI automatically. Remove the `openevse` configuration from your configuration.yaml file and restart Home Assistant to fix this issue.",
"title": "OpenEVSE YAML configuration is deprecated"
}
}
}

View File

@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["opower"],
"quality_scale": "bronze",
"requirements": ["opower==0.15.9"]
"requirements": ["opower==0.16.0"]
}

View File

@@ -11,5 +11,13 @@
"reload": {
"service": "mdi:reload"
}
},
"triggers": {
"entered_home": {
"trigger": "mdi:account-arrow-left"
},
"left_home": {
"trigger": "mdi:account-arrow-right"
}
}
}

View File

@@ -1,4 +1,8 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted persons to trigger on.",
"trigger_behavior_name": "Behavior"
},
"entity_component": {
"_": {
"name": "[%key:component::person::title%]",
@@ -25,11 +29,42 @@
}
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"reload": {
"description": "Reloads persons from the YAML-configuration.",
"name": "[%key:common::action::reload%]"
}
},
"title": "Person"
"title": "Person",
"triggers": {
"entered_home": {
"description": "Triggers when one or more persons enter home.",
"fields": {
"behavior": {
"description": "[%key:component::person::common::trigger_behavior_description%]",
"name": "[%key:component::person::common::trigger_behavior_name%]"
}
},
"name": "Entered home"
},
"left_home": {
"description": "Triggers when one or more persons leave home.",
"fields": {
"behavior": {
"description": "[%key:component::person::common::trigger_behavior_description%]",
"name": "[%key:component::person::common::trigger_behavior_name%]"
}
},
"name": "Left home"
}
}
}

View File

@@ -0,0 +1,21 @@
"""Provides triggers for persons."""
from homeassistant.const import STATE_HOME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import (
Trigger,
make_entity_origin_state_trigger,
make_entity_target_state_trigger,
)
from .const import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"entered_home": make_entity_target_state_trigger(DOMAIN, STATE_HOME),
"left_home": make_entity_origin_state_trigger(DOMAIN, from_state=STATE_HOME),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for persons."""
return TRIGGERS

View File

@@ -0,0 +1,18 @@
.trigger_common: &trigger_common
target:
entity:
domain: person
fields:
behavior:
required: true
default: any
selector:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
entered_home: *trigger_common
left_home: *trigger_common

View File

@@ -453,10 +453,6 @@ async def _async_generate_memory_profile(hass: HomeAssistant, call: ServiceCall)
# Imports deferred to avoid loading modules
# in memory since usually only one part of this
# integration is used at a time
if sys.version_info >= (3, 14):
raise HomeAssistantError(
"Memory profiling is not supported on Python 3.14. Please use Python 3.13."
)
from guppy import hpy # noqa: PLC0415
start_time = int(time.time() * 1000000)

View File

@@ -7,7 +7,7 @@
"quality_scale": "internal",
"requirements": [
"pyprof2calltree==1.4.5",
"guppy3==3.1.5;python_version<'3.14'",
"guppy3==3.1.6",
"objgraph==3.5.0"
],
"single_config_entry": true

View File

@@ -8,7 +8,7 @@ from httpx import HTTPError, InvalidURL, TimeoutException
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_URL
from homeassistant.const import CONF_URL, CONF_VERIFY_SSL
from homeassistant.helpers.httpx_client import get_async_client
from .client import get_calendar
@@ -21,6 +21,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_CALENDAR_NAME): str,
vol.Required(CONF_URL): str,
vol.Required(CONF_VERIFY_SSL, default=True): bool,
}
)
@@ -48,7 +49,7 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
"webcal://", "https://", 1
)
self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]})
client = get_async_client(self.hass)
client = get_async_client(self.hass, verify_ssl=user_input[CONF_VERIFY_SSL])
try:
res = await get_calendar(client, user_input[CONF_URL])
if res.status_code == HTTPStatus.FORBIDDEN:

View File

@@ -7,7 +7,7 @@ from httpx import HTTPError, InvalidURL, TimeoutException
from ical.calendar import Calendar
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL
from homeassistant.const import CONF_URL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -42,7 +42,9 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]):
config_entry=config_entry,
always_update=True,
)
self._client = get_async_client(hass)
self._client = get_async_client(
hass, verify_ssl=config_entry.data.get(CONF_VERIFY_SSL, True)
)
self._url = config_entry.data[CONF_URL]
async def _async_update_data(self) -> Calendar:

View File

@@ -13,11 +13,13 @@
"user": {
"data": {
"calendar_name": "Calendar name",
"url": "Calendar URL"
"url": "Calendar URL",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"calendar_name": "The name of the calendar shown in the UI.",
"url": "The URL of the remote calendar."
"url": "The URL of the remote calendar.",
"verify_ssl": "Enable SSL certificate verification for secure connections."
},
"description": "Please choose a name for the calendar to be imported"
}

View File

@@ -128,8 +128,9 @@ class RingCam(RingEntity[RingDoorBell], Camera):
self._device = self._get_coordinator_data().get_video_device(
self._device.device_api_id
)
history_data = self._device.last_history
if history_data:
if history_data and self._device.has_subscription:
self._last_event = history_data[0]
# will call async_update to update the attributes and get the
# video url from the api
@@ -154,8 +155,16 @@ class RingCam(RingEntity[RingDoorBell], Camera):
self, width: int | None = None, height: int | None = None
) -> bytes | None:
"""Return a still image response from the camera."""
if self._video_url is None:
if not self._device.has_subscription:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="no_subscription",
)
return None
key = (width, height)
if not (image := self._images.get(key)) and self._video_url is not None:
if not (image := self._images.get(key)):
image = await ffmpeg.async_get_image(
self.hass,
self._video_url,

View File

@@ -151,6 +151,9 @@
"api_timeout": {
"message": "Timeout communicating with Ring API"
},
"no_subscription": {
"message": "Ring Protect subscription required for snapshots"
},
"sdp_m_line_index_required": {
"message": "Error negotiating stream for {device}"
}

View File

@@ -20,7 +20,7 @@
"loggers": ["roborock"],
"quality_scale": "silver",
"requirements": [
"python-roborock==4.1.0",
"python-roborock==4.2.1",
"vacuum-map-parser-roborock==0.1.4"
]
}

View File

@@ -391,15 +391,6 @@ Q7_B01_SENSOR_DESCRIPTIONS = [
translation_key="mop_life_time_left",
entity_category=EntityCategory.DIAGNOSTIC,
),
RoborockSensorDescriptionB01(
key="total_cleaning_time",
value_fn=lambda data: data.real_clean_time,
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.MINUTES,
suggested_unit_of_measurement=UnitOfTime.HOURS,
translation_key="total_cleaning_time",
entity_category=EntityCategory.DIAGNOSTIC,
),
]

View File

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

View File

@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pysaunum"],
"quality_scale": "silver",
"quality_scale": "gold",
"requirements": ["pysaunum==0.1.0"]
}

View File

@@ -49,7 +49,7 @@ rules:
status: exempt
comment: Device cannot be discovered and the Modbus TCP API does not provide MAC address or other unique network identifiers needed to update connection information.
docs-data-update: done
docs-examples: todo
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/sentry",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["sentry-sdk==1.45.1"]
"requirements": ["sentry-sdk==2.48.0"]
}

View File

@@ -17,7 +17,7 @@
"iot_class": "local_push",
"loggers": ["aioshelly"],
"quality_scale": "platinum",
"requirements": ["aioshelly==13.23.0"],
"requirements": ["aioshelly==13.23.1"],
"zeroconf": [
{
"name": "shelly*",

View File

@@ -46,7 +46,7 @@ SENSOR_TYPES = [
key="lifetime_energy",
json_key="lifeTimeData",
translation_key="lifetime_energy",
state_class=SensorStateClass.TOTAL,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
),
@@ -55,6 +55,7 @@ SENSOR_TYPES = [
json_key="lastYearData",
translation_key="energy_this_year",
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
),
@@ -63,6 +64,7 @@ SENSOR_TYPES = [
json_key="lastMonthData",
translation_key="energy_this_month",
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
),
@@ -71,6 +73,7 @@ SENSOR_TYPES = [
json_key="lastDayData",
translation_key="energy_today",
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
),
@@ -123,24 +126,32 @@ SENSOR_TYPES = [
json_key="LOAD",
translation_key="power_consumption",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
),
SolarEdgeSensorEntityDescription(
key="solar_power",
json_key="PV",
translation_key="solar_power",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
),
SolarEdgeSensorEntityDescription(
key="grid_power",
json_key="GRID",
translation_key="grid_power",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
),
SolarEdgeSensorEntityDescription(
key="storage_power",
json_key="STORAGE",
translation_key="storage_power",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
),
SolarEdgeSensorEntityDescription(
key="purchased_energy",
@@ -194,6 +205,7 @@ SENSOR_TYPES = [
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
),
]

View File

@@ -7,5 +7,5 @@
"iot_class": "local_polling",
"loggers": ["solarlog_cli"],
"quality_scale": "platinum",
"requirements": ["solarlog_cli==0.6.1"]
"requirements": ["solarlog_cli==0.7.0"]
}

View File

@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["async_upnp_client"],
"quality_scale": "internal",
"requirements": ["async-upnp-client==0.46.1"]
"requirements": ["async-upnp-client==0.46.2"]
}

View File

@@ -280,6 +280,9 @@ async def make_device_data(
"RGBICWW Strip Light",
"Ceiling Light",
"Ceiling Light Pro",
"RGBIC Neon Rope Light",
"RGBIC Neon Wire Rope Light",
"Candle Warmer Lamp",
]:
coordinator = await coordinator_for_device(
hass, entry, api, device, coordinators_by_id

View File

@@ -132,15 +132,46 @@ class SwitchBotCloudLight(SwitchBotCloudEntity, LightEntity):
)
class SwitchBotCloudCandleWarmerLamp(SwitchBotCloudLight):
"""Representation of a SwitchBotCloud CandleWarmerLamp."""
# Brightness adjustment
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
class SwitchBotCloudStripLight(SwitchBotCloudLight):
"""Representation of a SwitchBot Strip Light."""
# Brightness adjustment
# RGB color control
_attr_supported_color_modes = {ColorMode.RGB}
class SwitchBotCloudRGBICLight(SwitchBotCloudLight):
"""Representation of a SwitchBotCloudRGBICLight."""
# Brightness adjustment
# RGB color control
_attr_supported_color_modes = {ColorMode.RGB}
async def _send_rgb_color_command(self, rgb_color: tuple) -> None:
"""Send an RGB command."""
await self.send_api_command(
RGBWLightCommands.SET_COLOR,
parameters=f"{rgb_color[0]}:{rgb_color[1]}:{rgb_color[2]}",
)
class SwitchBotCloudRGBWWLight(SwitchBotCloudLight):
"""Representation of SwitchBot |Strip Light|Floor Lamp|Color Bulb."""
# Brightness adjustment
# RGB color control
# Color temperature control
_attr_max_color_temp_kelvin = 6500
_attr_min_color_temp_kelvin = 2700
@@ -164,6 +195,9 @@ class SwitchBotCloudRGBWWLight(SwitchBotCloudLight):
class SwitchBotCloudCeilingLight(SwitchBotCloudLight):
"""Representation of SwitchBot Ceiling Light."""
# Brightness adjustment
# Color temperature control
_attr_max_color_temp_kelvin = 6500
_attr_min_color_temp_kelvin = 2700
@@ -187,10 +221,14 @@ class SwitchBotCloudCeilingLight(SwitchBotCloudLight):
@callback
def _async_make_entity(
api: SwitchBotAPI, device: Device | Remote, coordinator: SwitchBotCoordinator
) -> SwitchBotCloudStripLight | SwitchBotCloudRGBWWLight | SwitchBotCloudCeilingLight:
) -> SwitchBotCloudLight:
"""Make a SwitchBotCloudLight."""
if device.device_type == "Strip Light":
return SwitchBotCloudStripLight(api, device, coordinator)
if device.device_type in ["Ceiling Light", "Ceiling Light Pro"]:
return SwitchBotCloudCeilingLight(api, device, coordinator)
if device.device_type == "Candle Warmer Lamp":
return SwitchBotCloudCandleWarmerLamp(api, device, coordinator)
if device.device_type in ["RGBIC Neon Rope Light", "RGBIC Neon Wire Rope Light"]:
return SwitchBotCloudRGBICLight(api, device, coordinator)
return SwitchBotCloudRGBWWLight(api, device, coordinator)

View File

@@ -9,6 +9,6 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["systembridgeconnector"],
"requirements": ["systembridgeconnector==5.2.4"],
"requirements": ["systembridgeconnector==5.3.1"],
"zeroconf": ["_system-bridge._tcp.local."]
}

View File

@@ -80,10 +80,6 @@ class TelegramNotificationService(BaseNotificationService):
def send_message(self, message="", **kwargs):
"""Send a message to a user."""
service_data = {ATTR_TARGET: kwargs.get(ATTR_TARGET, self._chat_id)}
if ATTR_TITLE in kwargs:
service_data.update({ATTR_TITLE: kwargs.get(ATTR_TITLE)})
if message:
service_data.update({ATTR_MESSAGE: message})
data = kwargs.get(ATTR_DATA)
# Set message tag
@@ -161,6 +157,12 @@ class TelegramNotificationService(BaseNotificationService):
)
# Send message
if ATTR_TITLE in kwargs:
service_data.update({ATTR_TITLE: kwargs.get(ATTR_TITLE)})
if message:
service_data.update({ATTR_MESSAGE: message})
_LOGGER.debug(
"TELEGRAM NOTIFIER calling %s.send_message with %s",
TELEGRAM_BOT_DOMAIN,

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import time
from typing import Any
from tesla_fleet_api.const import Scope
@@ -24,6 +25,9 @@ SCHEDULED = "scheduled"
PARALLEL_UPDATES = 0
# Show scheduled update as installing if within this many seconds
SCHEDULED_THRESHOLD_SECONDS = 120
async def async_setup_entry(
hass: HomeAssistant,
@@ -69,12 +73,9 @@ class TeslaFleetUpdateEntity(TeslaFleetVehicleEntity, UpdateEntity):
def _async_update_attrs(self) -> None:
"""Update the attributes of the entity."""
# Supported Features
if self.scoped and self._value in (
AVAILABLE,
SCHEDULED,
):
# Only allow install when an update has been fully downloaded
# Supported Features - only show install button if update is available
# but not already scheduled
if self.scoped and self._value == AVAILABLE:
self._attr_supported_features = (
UpdateEntityFeature.PROGRESS | UpdateEntityFeature.INSTALL
)
@@ -87,13 +88,9 @@ class TeslaFleetUpdateEntity(TeslaFleetVehicleEntity, UpdateEntity):
# Remove build from version
self._attr_installed_version = self._attr_installed_version.split(" ")[0]
# Latest Version
if self._value in (
AVAILABLE,
SCHEDULED,
INSTALLING,
DOWNLOADING,
WIFI_WAIT,
# Latest Version - hide update if scheduled far in the future
if self._value in (AVAILABLE, INSTALLING, DOWNLOADING, WIFI_WAIT) or (
self._value == SCHEDULED and self._is_scheduled_soon()
):
self._attr_latest_version = self.coordinator.data[
"vehicle_state_software_update_version"
@@ -101,14 +98,24 @@ class TeslaFleetUpdateEntity(TeslaFleetVehicleEntity, UpdateEntity):
else:
self._attr_latest_version = self._attr_installed_version
# In Progress
if self._value in (
SCHEDULED,
INSTALLING,
):
# In Progress - only show as installing if actually installing or
# scheduled to start within 2 minutes
if self._value == INSTALLING:
self._attr_in_progress = True
if install_perc := self.get("vehicle_state_software_update_install_perc"):
self._attr_update_percentage = install_perc
elif self._value == SCHEDULED and self._is_scheduled_soon():
self._attr_in_progress = True
self._attr_update_percentage = None
else:
self._attr_in_progress = False
self._attr_update_percentage = None
def _is_scheduled_soon(self) -> bool:
"""Check if a scheduled update is within the threshold to start."""
scheduled_time_ms = self.get("vehicle_state_software_update_scheduled_time_ms")
if scheduled_time_ms is None:
return False
# Convert milliseconds to seconds and compare to current time
scheduled_time_sec = scheduled_time_ms / 1000
return scheduled_time_sec - time.time() < SCHEDULED_THRESHOLD_SECONDS

View File

@@ -1,20 +1,36 @@
"""Support for Tibber."""
from __future__ import annotations
from dataclasses import dataclass, field
import logging
import aiohttp
from aiohttp.client_exceptions import ClientError, ClientResponseError
import tibber
from tibber import data_api as tibber_data_api
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util, ssl as ssl_util
from .const import DATA_HASS_CONFIG, DOMAIN
from .const import (
AUTH_IMPLEMENTATION,
CONF_LEGACY_ACCESS_TOKEN,
DATA_HASS_CONFIG,
DOMAIN,
TibberConfigEntry,
)
from .coordinator import TibberDataAPICoordinator
from .services import async_setup_services
PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]
@@ -24,6 +40,33 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
_LOGGER = logging.getLogger(__name__)
@dataclass
class TibberRuntimeData:
"""Runtime data for Tibber API entries."""
tibber_connection: tibber.Tibber
session: OAuth2Session
data_api_coordinator: TibberDataAPICoordinator | None = field(default=None)
_client: tibber_data_api.TibberDataAPI | None = None
async def async_get_client(
self, hass: HomeAssistant
) -> tibber_data_api.TibberDataAPI:
"""Return an authenticated Tibber Data API client."""
await self.session.async_ensure_token_valid()
token = self.session.token
access_token = token.get(CONF_ACCESS_TOKEN)
if not access_token:
raise ConfigEntryAuthFailed("Access token missing from OAuth session")
if self._client is None:
self._client = tibber_data_api.TibberDataAPI(
access_token,
websession=async_get_clientsession(hass),
)
self._client.set_access_token(access_token)
return self._client
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Tibber component."""
@@ -34,16 +77,23 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: TibberConfigEntry) -> bool:
"""Set up a config entry."""
# Added in 2026.1 to migrate existing users to OAuth2 (Tibber Data API).
# Can be removed after 2026.7
if AUTH_IMPLEMENTATION not in entry.data:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="data_api_reauth_required",
)
tibber_connection = tibber.Tibber(
access_token=entry.data[CONF_ACCESS_TOKEN],
access_token=entry.data[CONF_LEGACY_ACCESS_TOKEN],
websession=async_get_clientsession(hass),
time_zone=dt_util.get_default_time_zone(),
ssl=ssl_util.get_default_context(),
)
hass.data[DOMAIN] = tibber_connection
async def _close(event: Event) -> None:
await tibber_connection.rt_disconnect()
@@ -52,7 +102,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
try:
await tibber_connection.update_info()
except (
TimeoutError,
aiohttp.ClientError,
@@ -65,17 +114,45 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except tibber.FatalHttpExceptionError:
return False
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
try:
implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
session = OAuth2Session(hass, entry, implementation)
try:
await session.async_ensure_token_valid()
except ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(
"OAuth session is not valid, reauthentication required"
) from err
raise ConfigEntryNotReady from err
except ClientError as err:
raise ConfigEntryNotReady from err
entry.runtime_data = TibberRuntimeData(
tibber_connection=tibber_connection,
session=session,
)
coordinator = TibberDataAPICoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data.data_api_coordinator = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
async def async_unload_entry(
hass: HomeAssistant, config_entry: TibberConfigEntry
) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(
if unload_ok := await hass.config_entries.async_unload_platforms(
config_entry, PLATFORMS
)
if unload_ok:
tibber_connection = hass.data[DOMAIN]
await tibber_connection.rt_disconnect()
):
await config_entry.runtime_data.tibber_connection.rt_disconnect()
return unload_ok

View File

@@ -0,0 +1,15 @@
"""Application credentials platform for Tibber."""
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant
AUTHORIZE_URL = "https://thewall.tibber.com/connect/authorize"
TOKEN_URL = "https://thewall.tibber.com/connect/token"
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
"""Return authorization server for Tibber Data API."""
return AuthorizationServer(
authorize_url=AUTHORIZE_URL,
token_url=TOKEN_URL,
)

View File

@@ -2,80 +2,164 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
import aiohttp
import tibber
from tibber import data_api as tibber_data_api
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
from .const import DOMAIN
from .const import CONF_LEGACY_ACCESS_TOKEN, DATA_API_DEFAULT_SCOPES, DOMAIN
DATA_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str})
DATA_SCHEMA = vol.Schema({vol.Required(CONF_LEGACY_ACCESS_TOKEN): str})
ERR_TIMEOUT = "timeout"
ERR_CLIENT = "cannot_connect"
ERR_TOKEN = "invalid_access_token"
TOKEN_URL = "https://developer.tibber.com/settings/access-token"
_LOGGER = logging.getLogger(__name__)
class TibberConfigFlow(ConfigFlow, domain=DOMAIN):
class TibberConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Handle a config flow for Tibber integration."""
VERSION = 1
DOMAIN = DOMAIN
def __init__(self) -> None:
"""Initialize the config flow."""
super().__init__()
self._access_token: str | None = None
self._title = ""
@property
def logger(self) -> logging.Logger:
"""Return the logger."""
return _LOGGER
@property
def extra_authorize_data(self) -> dict[str, Any]:
"""Extra data appended to the authorize URL."""
return {
**super().extra_authorize_data,
"scope": " ".join(DATA_API_DEFAULT_SCOPES),
}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
self._async_abort_entries_match()
if user_input is not None:
access_token = user_input[CONF_ACCESS_TOKEN].replace(" ", "")
tibber_connection = tibber.Tibber(
access_token=access_token,
websession=async_get_clientsession(self.hass),
if user_input is None:
data_schema = self.add_suggested_values_to_schema(
DATA_SCHEMA, {CONF_LEGACY_ACCESS_TOKEN: self._access_token or ""}
)
errors = {}
return self.async_show_form(
step_id=SOURCE_USER,
data_schema=data_schema,
description_placeholders={"url": TOKEN_URL},
errors={},
)
try:
await tibber_connection.update_info()
except TimeoutError:
errors[CONF_ACCESS_TOKEN] = ERR_TIMEOUT
except tibber.InvalidLoginError:
errors[CONF_ACCESS_TOKEN] = ERR_TOKEN
except (
aiohttp.ClientError,
tibber.RetryableHttpExceptionError,
tibber.FatalHttpExceptionError,
):
errors[CONF_ACCESS_TOKEN] = ERR_CLIENT
self._access_token = user_input[CONF_LEGACY_ACCESS_TOKEN].replace(" ", "")
tibber_connection = tibber.Tibber(
access_token=self._access_token,
websession=async_get_clientsession(self.hass),
)
self._title = tibber_connection.name or "Tibber"
if errors:
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA,
description_placeholders={"url": TOKEN_URL},
errors=errors,
)
errors: dict[str, str] = {}
try:
await tibber_connection.update_info()
except TimeoutError:
errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_TIMEOUT
except tibber.InvalidLoginError:
errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_TOKEN
except (
aiohttp.ClientError,
tibber.RetryableHttpExceptionError,
tibber.FatalHttpExceptionError,
):
errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_CLIENT
unique_id = tibber_connection.user_id
await self.async_set_unique_id(unique_id)
if errors:
data_schema = self.add_suggested_values_to_schema(
DATA_SCHEMA, {CONF_LEGACY_ACCESS_TOKEN: self._access_token or ""}
)
return self.async_show_form(
step_id=SOURCE_USER,
data_schema=data_schema,
description_placeholders={"url": TOKEN_URL},
errors=errors,
)
await self.async_set_unique_id(tibber_connection.user_id)
if self.source == SOURCE_REAUTH:
reauth_entry = self._get_reauth_entry()
self._abort_if_unique_id_mismatch(
reason="wrong_account",
description_placeholders={"title": reauth_entry.title},
)
else:
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=tibber_connection.name,
data={CONF_ACCESS_TOKEN: access_token},
return await self.async_step_pick_implementation()
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle a reauth flow."""
reauth_entry = self._get_reauth_entry()
self._access_token = reauth_entry.data.get(CONF_LEGACY_ACCESS_TOKEN)
self._title = reauth_entry.title
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauthentication by reusing the user step."""
reauth_entry = self._get_reauth_entry()
self._access_token = reauth_entry.data.get(CONF_LEGACY_ACCESS_TOKEN)
self._title = reauth_entry.title
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
)
return await self.async_step_user()
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Finalize the OAuth flow and create the config entry."""
if self._access_token is None:
return self.async_abort(reason="missing_configuration")
data[CONF_LEGACY_ACCESS_TOKEN] = self._access_token
access_token = data[CONF_TOKEN][CONF_ACCESS_TOKEN]
data_api_client = tibber_data_api.TibberDataAPI(
access_token,
websession=async_get_clientsession(self.hass),
)
try:
await data_api_client.get_userinfo()
except (aiohttp.ClientError, TimeoutError):
return self.async_abort(reason="cannot_connect")
if self.source == SOURCE_REAUTH:
reauth_entry = self._get_reauth_entry()
return self.async_update_reload_and_abort(
reauth_entry,
data=data,
title=self._title,
)
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA,
description_placeholders={"url": TOKEN_URL},
errors={},
)
return self.async_create_entry(title=self._title, data=data)

View File

@@ -1,5 +1,34 @@
"""Constants for Tibber integration."""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN
if TYPE_CHECKING:
from . import TibberRuntimeData
type TibberConfigEntry = ConfigEntry[TibberRuntimeData]
CONF_LEGACY_ACCESS_TOKEN = CONF_ACCESS_TOKEN
AUTH_IMPLEMENTATION = "auth_implementation"
DATA_HASS_CONFIG = "tibber_hass_config"
DOMAIN = "tibber"
MANUFACTURER = "Tibber"
DATA_API_DEFAULT_SCOPES = [
"openid",
"profile",
"email",
"offline_access",
"data-api-user-read",
"data-api-chargers-read",
"data-api-energy-systems-read",
"data-api-homes-read",
"data-api-thermostats-read",
"data-api-vehicles-read",
"data-api-inverters-read",
]

View File

@@ -4,9 +4,11 @@ from __future__ import annotations
from datetime import timedelta
import logging
from typing import cast
from typing import TYPE_CHECKING, cast
from aiohttp.client_exceptions import ClientError
import tibber
from tibber.data_api import TibberDataAPI, TibberDevice
from homeassistant.components.recorder import get_instance
from homeassistant.components.recorder.models import (
@@ -19,15 +21,18 @@ from homeassistant.components.recorder.statistics import (
get_last_statistics,
statistics_during_period,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfEnergy
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import EnergyConverter
from .const import DOMAIN
if TYPE_CHECKING:
from .const import TibberConfigEntry
FIVE_YEARS = 5 * 365 * 24
_LOGGER = logging.getLogger(__name__)
@@ -36,12 +41,12 @@ _LOGGER = logging.getLogger(__name__)
class TibberDataCoordinator(DataUpdateCoordinator[None]):
"""Handle Tibber data and insert statistics."""
config_entry: ConfigEntry
config_entry: TibberConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: TibberConfigEntry,
tibber_connection: tibber.Tibber,
) -> None:
"""Initialize the data handler."""
@@ -187,3 +192,64 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]):
unit_of_measurement=unit,
)
async_add_external_statistics(self.hass, metadata, statistics)
class TibberDataAPICoordinator(DataUpdateCoordinator[dict[str, TibberDevice]]):
"""Fetch and cache Tibber Data API device capabilities."""
config_entry: TibberConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: TibberConfigEntry,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=f"{DOMAIN} Data API",
update_interval=timedelta(minutes=1),
config_entry=entry,
)
self._runtime_data = entry.runtime_data
self.sensors_by_device: dict[str, dict[str, tibber.data_api.Sensor]] = {}
def _build_sensor_lookup(self, devices: dict[str, TibberDevice]) -> None:
"""Build sensor lookup dict for efficient access."""
self.sensors_by_device = {
device_id: {sensor.id: sensor for sensor in device.sensors}
for device_id, device in devices.items()
}
def get_sensor(
self, device_id: str, sensor_id: str
) -> tibber.data_api.Sensor | None:
"""Get a sensor by device and sensor ID."""
if device_sensors := self.sensors_by_device.get(device_id):
return device_sensors.get(sensor_id)
return None
async def _async_get_client(self) -> TibberDataAPI:
"""Get the Tibber Data API client with error handling."""
try:
return await self._runtime_data.async_get_client(self.hass)
except ConfigEntryAuthFailed:
raise
except (ClientError, TimeoutError, tibber.UserAgentMissingError) as err:
raise UpdateFailed(
f"Unable to create Tibber Data API client: {err}"
) from err
async def _async_setup(self) -> None:
"""Initial load of Tibber Data API devices."""
client = await self._async_get_client()
devices = await client.get_all_devices()
self._build_sensor_lookup(devices)
async def _async_update_data(self) -> dict[str, TibberDevice]:
"""Fetch the latest device capabilities from the Tibber Data API."""
client = await self._async_get_client()
devices: dict[str, TibberDevice] = await client.update_devices()
self._build_sensor_lookup(devices)
return devices

View File

@@ -4,21 +4,18 @@ from __future__ import annotations
from typing import Any
import tibber
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .const import TibberConfigEntry
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry
hass: HomeAssistant, config_entry: TibberConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
tibber_connection: tibber.Tibber = hass.data[DOMAIN]
return {
runtime = config_entry.runtime_data
result: dict[str, Any] = {
"homes": [
{
"last_data_timestamp": home.last_data_timestamp,
@@ -27,6 +24,24 @@ async def async_get_config_entry_diagnostics(
"last_cons_data_timestamp": home.last_cons_data_timestamp,
"country": home.country,
}
for home in tibber_connection.get_homes(only_active=False)
for home in runtime.tibber_connection.get_homes(only_active=False)
]
}
devices = (
runtime.data_api_coordinator.data
if runtime.data_api_coordinator is not None
else {}
) or {}
result["devices"] = [
{
"id": device.id,
"name": device.name,
"brand": device.brand,
"model": device.model,
}
for device in devices.values()
]
return result

View File

@@ -3,9 +3,9 @@
"name": "Tibber",
"codeowners": ["@danielhiversen"],
"config_flow": true,
"dependencies": ["recorder"],
"dependencies": ["application_credentials", "recorder"],
"documentation": "https://www.home-assistant.io/integrations/tibber",
"iot_class": "cloud_polling",
"loggers": ["tibber"],
"requirements": ["pyTibber==0.32.2"]
"requirements": ["pyTibber==0.34.1"]
}

View File

@@ -2,28 +2,25 @@
from __future__ import annotations
from tibber import Tibber
from homeassistant.components.notify import (
ATTR_TITLE_DEFAULT,
NotifyEntity,
NotifyEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
from .const import DOMAIN, TibberConfigEntry
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: TibberConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tibber notification entity."""
async_add_entities([TibberNotificationEntity(entry.entry_id)])
async_add_entities([TibberNotificationEntity(entry)])
class TibberNotificationEntity(NotifyEntity):
@@ -33,13 +30,14 @@ class TibberNotificationEntity(NotifyEntity):
_attr_name = DOMAIN
_attr_icon = "mdi:message-flash"
def __init__(self, unique_id: str) -> None:
def __init__(self, entry: TibberConfigEntry) -> None:
"""Initialize Tibber notify entity."""
self._attr_unique_id = unique_id
self._attr_unique_id = entry.entry_id
self._entry = entry
async def async_send_message(self, message: str, title: str | None = None) -> None:
"""Send a message to Tibber devices."""
tibber_connection: Tibber = self.hass.data[DOMAIN]
tibber_connection = self._entry.runtime_data.tibber_connection
try:
await tibber_connection.send_notification(
title or ATTR_TITLE_DEFAULT, message

View File

@@ -10,7 +10,8 @@ from random import randrange
from typing import Any
import aiohttp
import tibber
from tibber import FatalHttpExceptionError, RetryableHttpExceptionError, TibberHome
from tibber.data_api import TibberDevice
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -27,7 +28,9 @@ from homeassistant.const import (
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfLength,
UnitOfPower,
UnitOfTemperature,
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import PlatformNotReady
@@ -41,8 +44,8 @@ from homeassistant.helpers.update_coordinator import (
)
from homeassistant.util import Throttle, dt as dt_util
from .const import DOMAIN, MANUFACTURER
from .coordinator import TibberDataCoordinator
from .const import DOMAIN, MANUFACTURER, TibberConfigEntry
from .coordinator import TibberDataAPICoordinator, TibberDataCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -260,14 +263,92 @@ SENSORS: tuple[SensorEntityDescription, ...] = (
)
DATA_API_SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="storage.stateOfCharge",
translation_key="storage_state_of_charge",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="storage.targetStateOfCharge",
translation_key="storage_target_state_of_charge",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="range.remaining",
translation_key="range_remaining",
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.METERS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
),
SensorEntityDescription(
key="charging.current.max",
translation_key="charging_current_max",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="charging.current.offlineFallback",
translation_key="charging_current_offline_fallback",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="temp.setpoint",
translation_key="temp_setpoint",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="temp.current",
translation_key="temp_current",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="temp.comfort",
translation_key="temp_comfort",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="grid.phaseCount",
translation_key="grid_phase_count",
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: TibberConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tibber sensor."""
tibber_connection = hass.data[DOMAIN]
_setup_data_api_sensors(entry, async_add_entities)
await _async_setup_graphql_sensors(hass, entry, async_add_entities)
async def _async_setup_graphql_sensors(
hass: HomeAssistant,
entry: TibberConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tibber sensor."""
tibber_connection = entry.runtime_data.tibber_connection
entity_registry = er.async_get(hass)
device_registry = dr.async_get(hass)
@@ -280,7 +361,11 @@ async def async_setup_entry(
except TimeoutError as err:
_LOGGER.error("Timeout connecting to Tibber home: %s ", err)
raise PlatformNotReady from err
except (tibber.RetryableHttpExceptionError, aiohttp.ClientError) as err:
except (
RetryableHttpExceptionError,
FatalHttpExceptionError,
aiohttp.ClientError,
) as err:
_LOGGER.error("Error connecting to Tibber home: %s ", err)
raise PlatformNotReady from err
@@ -325,7 +410,67 @@ async def async_setup_entry(
device_entry.id, new_identifiers={(DOMAIN, home.home_id)}
)
async_add_entities(entities, True)
async_add_entities(entities)
def _setup_data_api_sensors(
entry: TibberConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors backed by the Tibber Data API."""
coordinator = entry.runtime_data.data_api_coordinator
if coordinator is None:
return
entities: list[TibberDataAPISensor] = []
api_sensors = {sensor.key: sensor for sensor in DATA_API_SENSORS}
for device in coordinator.data.values():
for sensor in device.sensors:
description: SensorEntityDescription | None = api_sensors.get(sensor.id)
if description is None:
_LOGGER.debug(
"Sensor %s not found in DATA_API_SENSORS, skipping", sensor
)
continue
entities.append(TibberDataAPISensor(coordinator, device, description))
async_add_entities(entities)
class TibberDataAPISensor(CoordinatorEntity[TibberDataAPICoordinator], SensorEntity):
"""Representation of a Tibber Data API capability sensor."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: TibberDataAPICoordinator,
device: TibberDevice,
entity_description: SensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._device_id: str = device.id
self.entity_description = entity_description
self._attr_translation_key = entity_description.translation_key
self._attr_unique_id = f"{device.external_id}_{self.entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.external_id)},
name=device.name,
manufacturer=device.brand,
model=device.model,
)
@property
def native_value(self) -> StateType:
"""Return the value reported by the device."""
sensors = self.coordinator.sensors_by_device.get(self._device_id, {})
sensor = sensors.get(self.entity_description.key)
return sensor.value if sensor else None
class TibberSensor(SensorEntity):
@@ -333,9 +478,7 @@ class TibberSensor(SensorEntity):
_attr_has_entity_name = True
def __init__(
self, *args: Any, tibber_home: tibber.TibberHome, **kwargs: Any
) -> None:
def __init__(self, *args: Any, tibber_home: TibberHome, **kwargs: Any) -> None:
"""Initialize the sensor."""
super().__init__(*args, **kwargs)
self._tibber_home = tibber_home
@@ -366,7 +509,7 @@ class TibberSensorElPrice(TibberSensor):
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_translation_key = "electricity_price"
def __init__(self, tibber_home: tibber.TibberHome) -> None:
def __init__(self, tibber_home: TibberHome) -> None:
"""Initialize the sensor."""
super().__init__(tibber_home=tibber_home)
self._last_updated: datetime.datetime | None = None
@@ -443,7 +586,7 @@ class TibberDataSensor(TibberSensor, CoordinatorEntity[TibberDataCoordinator]):
def __init__(
self,
tibber_home: tibber.TibberHome,
tibber_home: TibberHome,
coordinator: TibberDataCoordinator,
entity_description: SensorEntityDescription,
) -> None:
@@ -470,7 +613,7 @@ class TibberSensorRT(TibberSensor, CoordinatorEntity["TibberRtDataCoordinator"])
def __init__(
self,
tibber_home: tibber.TibberHome,
tibber_home: TibberHome,
description: SensorEntityDescription,
initial_state: float,
coordinator: TibberRtDataCoordinator,
@@ -532,7 +675,7 @@ class TibberRtEntityCreator:
def __init__(
self,
async_add_entities: AddConfigEntryEntitiesCallback,
tibber_home: tibber.TibberHome,
tibber_home: TibberHome,
entity_registry: er.EntityRegistry,
) -> None:
"""Initialize the data handler."""
@@ -618,7 +761,7 @@ class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-en
hass: HomeAssistant,
config_entry: ConfigEntry,
add_sensor_callback: Callable[[TibberRtDataCoordinator, Any], None],
tibber_home: tibber.TibberHome,
tibber_home: TibberHome,
) -> None:
"""Initialize the data handler."""
self._add_sensor_callback = add_sensor_callback

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import datetime as dt
from datetime import datetime
from typing import Any, Final
from typing import TYPE_CHECKING, Any, Final
import voluptuous as vol
@@ -20,6 +20,9 @@ from homeassistant.util import dt as dt_util
from .const import DOMAIN
if TYPE_CHECKING:
from .const import TibberConfigEntry
PRICE_SERVICE_NAME = "get_prices"
ATTR_START: Final = "start"
ATTR_END: Final = "end"
@@ -33,7 +36,13 @@ SERVICE_SCHEMA: Final = vol.Schema(
async def __get_prices(call: ServiceCall) -> ServiceResponse:
tibber_connection = call.hass.data[DOMAIN]
entries: list[TibberConfigEntry] = call.hass.config_entries.async_entries(DOMAIN)
if not entries:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="no_config_entry",
)
tibber_connection = entries[0].runtime_data.tibber_connection
start = __get_date(call.data.get(ATTR_START), "start")
end = __get_date(call.data.get(ATTR_END), "end")
@@ -57,7 +66,7 @@ async def __get_prices(call: ServiceCall) -> ServiceResponse:
selected_data = [
price
for price in price_data
if start <= dt.datetime.fromisoformat(price["start_time"]) < end
if start <= dt.datetime.fromisoformat(str(price["start_time"])) < end
]
tibber_prices[home_nickname] = selected_data

View File

@@ -1,7 +1,11 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"wrong_account": "The connected account does not match {title}. Sign in with the same Tibber account and try again."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -9,6 +13,10 @@
"timeout": "[%key:common::config_flow::error::timeout_connect%]"
},
"step": {
"reauth_confirm": {
"description": "Reconnect your Tibber account to refresh access.",
"title": "[%key:common::config_flow::title::reauth%]"
},
"user": {
"data": {
"access_token": "[%key:common::config_flow::data::access_token%]"
@@ -40,6 +48,12 @@
"average_power": {
"name": "Average power"
},
"charging_current_max": {
"name": "Maximum allowed charge current"
},
"charging_current_offline_fallback": {
"name": "Fallback current if charger goes offline"
},
"current_l1": {
"name": "Current L1"
},
@@ -55,6 +69,9 @@
"estimated_hour_consumption": {
"name": "Estimated consumption current hour"
},
"grid_phase_count": {
"name": "Number of grid phases"
},
"last_meter_consumption": {
"name": "Last meter consumption"
},
@@ -88,9 +105,27 @@
"power_production": {
"name": "Power production"
},
"range_remaining": {
"name": "Estimated remaining driving range"
},
"signal_strength": {
"name": "Signal strength"
},
"storage_state_of_charge": {
"name": "State of charge"
},
"storage_target_state_of_charge": {
"name": "Target state of charge"
},
"temp_comfort": {
"name": "Comfort temperature"
},
"temp_current": {
"name": "Current temperature"
},
"temp_setpoint": {
"name": "Setpoint temperature"
},
"voltage_phase1": {
"name": "Voltage phase1"
},
@@ -103,9 +138,18 @@
}
},
"exceptions": {
"data_api_reauth_required": {
"message": "Reconnect Tibber so Home Assistant can enable the new Tibber Data API features."
},
"invalid_date": {
"message": "Invalid datetime provided {date}"
},
"no_config_entry": {
"message": "No Tibber integration configured"
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"send_message_timeout": {
"message": "Timeout sending message with Tibber"
}

View File

@@ -20,7 +20,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 DPCodeEnumWrapper, DPCodeRawWrapper
from .models import DeviceWrapper, DPCodeEnumWrapper, DPCodeRawWrapper
from .type_information import EnumTypeInformation
ALARM: dict[DeviceCategory, tuple[AlarmControlPanelEntityDescription, ...]] = {
@@ -170,9 +170,9 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
device_manager: Manager,
description: AlarmControlPanelEntityDescription,
*,
action_wrapper: _AlarmActionWrapper,
changed_by_wrapper: _AlarmChangedByWrapper | None,
state_wrapper: _AlarmStateWrapper,
action_wrapper: DeviceWrapper[str],
changed_by_wrapper: DeviceWrapper[str] | None,
state_wrapper: DeviceWrapper[AlarmControlPanelState],
) -> None:
"""Init Tuya Alarm."""
super().__init__(device, device_manager)
@@ -183,13 +183,12 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
self._state_wrapper = state_wrapper
# Determine supported modes
if action_wrapper.options:
if "arm_home" in action_wrapper.options:
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME
if "arm_away" in action_wrapper.options:
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY
if "trigger" in action_wrapper.options:
self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER
if "arm_home" in action_wrapper.options:
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME
if "arm_away" in action_wrapper.options:
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY
if "trigger" in action_wrapper.options:
self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER
@property
def alarm_state(self) -> AlarmControlPanelState | None:

View File

@@ -19,7 +19,12 @@ 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 DPCodeBitmapBitWrapper, DPCodeBooleanWrapper, DPCodeWrapper
from .models import (
DeviceWrapper,
DPCodeBitmapBitWrapper,
DPCodeBooleanWrapper,
DPCodeWrapper,
)
@dataclass(frozen=True)
@@ -450,7 +455,7 @@ class TuyaBinarySensorEntity(TuyaEntity, BinarySensorEntity):
device: CustomerDevice,
device_manager: Manager,
description: TuyaBinarySensorEntityDescription,
dpcode_wrapper: DPCodeWrapper,
dpcode_wrapper: DeviceWrapper[bool],
) -> None:
"""Init Tuya binary sensor."""
super().__init__(device, device_manager)

View File

@@ -13,7 +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
from .models import DeviceWrapper, DPCodeBooleanWrapper
BUTTONS: dict[DeviceCategory, tuple[ButtonEntityDescription, ...]] = {
DeviceCategory.HXD: (
@@ -107,7 +107,7 @@ class TuyaButtonEntity(TuyaEntity, ButtonEntity):
device: CustomerDevice,
device_manager: Manager,
description: ButtonEntityDescription,
dpcode_wrapper: DPCodeBooleanWrapper,
dpcode_wrapper: DeviceWrapper[bool],
) -> None:
"""Init Tuya button."""
super().__init__(device, device_manager)

View File

@@ -13,7 +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
from .models import DeviceWrapper, DPCodeBooleanWrapper
CAMERAS: tuple[DeviceCategory, ...] = (
DeviceCategory.DGHSXJ,
@@ -70,8 +70,8 @@ class TuyaCameraEntity(TuyaEntity, CameraEntity):
device: CustomerDevice,
device_manager: Manager,
*,
motion_detection_switch: DPCodeBooleanWrapper | None = None,
recording_status: DPCodeBooleanWrapper | None = None,
motion_detection_switch: DeviceWrapper[bool] | None = None,
recording_status: DeviceWrapper[bool] | None = None,
) -> None:
"""Init Tuya Camera."""
super().__init__(device, device_manager)

View File

@@ -332,14 +332,14 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
device_manager: Manager,
description: TuyaClimateEntityDescription,
*,
current_humidity_wrapper: _RoundedIntegerWrapper | None,
current_temperature_wrapper: DPCodeIntegerWrapper | None,
fan_mode_wrapper: DPCodeEnumWrapper | None,
hvac_mode_wrapper: DPCodeEnumWrapper | None,
set_temperature_wrapper: DPCodeIntegerWrapper | None,
swing_wrapper: _SwingModeWrapper | None,
switch_wrapper: DPCodeBooleanWrapper | None,
target_humidity_wrapper: _RoundedIntegerWrapper | None,
current_humidity_wrapper: DeviceWrapper[int] | None,
current_temperature_wrapper: DeviceWrapper[float] | None,
fan_mode_wrapper: DeviceWrapper[str] | None,
hvac_mode_wrapper: DeviceWrapper[str] | None,
set_temperature_wrapper: DeviceWrapper[float] | None,
swing_wrapper: DeviceWrapper[str] | None,
switch_wrapper: DeviceWrapper[bool] | None,
target_humidity_wrapper: DeviceWrapper[int] | None,
temperature_unit: UnitOfTemperature,
) -> None:
"""Determine which values to use."""
@@ -359,11 +359,11 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
# Get integer type data for the dpcode to set temperature, use
# it to define min, max & step temperatures
if self._set_temperature:
if set_temperature_wrapper:
self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE
self._attr_max_temp = self._set_temperature.max_value
self._attr_min_temp = self._set_temperature.min_value
self._attr_target_temperature_step = self._set_temperature.value_step
self._attr_max_temp = set_temperature_wrapper.max_value
self._attr_min_temp = set_temperature_wrapper.min_value
self._attr_target_temperature_step = set_temperature_wrapper.value_step
# Determine HVAC modes
self._attr_hvac_modes: list[HVACMode] = []

View File

@@ -111,23 +111,16 @@ class _SpecialInstructionEnumWrapper(_InstructionEnumWrapper):
_ACTION_MAPPINGS = {"open": "FZ", "close": "ZZ", "stop": "STOP"}
class _IsClosedWrapper:
"""Wrapper for checking if cover is closed."""
def is_closed(self, device: CustomerDevice) -> bool | None:
return None
class _IsClosedInvertedWrapper(DPCodeBooleanWrapper, _IsClosedWrapper):
class _IsClosedInvertedWrapper(DPCodeBooleanWrapper):
"""Boolean wrapper for checking if cover is closed (inverted)."""
def is_closed(self, device: CustomerDevice) -> bool | None:
if (value := self.read_device_status(device)) is None:
def read_device_status(self, device: CustomerDevice) -> bool | None:
if (value := super().read_device_status(device)) is None:
return None
return not value
class _IsClosedEnumWrapper(DPCodeEnumWrapper, _IsClosedWrapper):
class _IsClosedEnumWrapper(DPCodeEnumWrapper):
"""Enum wrapper for checking if state is closed."""
_MAPPINGS = {
@@ -137,15 +130,15 @@ class _IsClosedEnumWrapper(DPCodeEnumWrapper, _IsClosedWrapper):
"fully_open": False,
}
def is_closed(self, device: CustomerDevice) -> bool | None:
if (value := self.read_device_status(device)) is None:
def read_device_status(self, device: CustomerDevice) -> bool | None:
if (value := super().read_device_status(device)) is None:
return None
return self._MAPPINGS.get(value)
@dataclass(frozen=True)
class TuyaCoverEntityDescription(CoverEntityDescription):
"""Describe an Tuya cover entity."""
"""Describe a Tuya cover entity."""
current_state: DPCode | tuple[DPCode, ...] | None = None
current_state_wrapper: type[_IsClosedInvertedWrapper | _IsClosedEnumWrapper] = (
@@ -298,12 +291,12 @@ async def async_setup_entry(
current_position=description.position_wrapper.find_dpcode(
device, description.current_position
),
instruction_wrapper=_get_instruction_wrapper(
device, description
),
current_state_wrapper=description.current_state_wrapper.find_dpcode(
device, description.current_state
),
instruction_wrapper=_get_instruction_wrapper(
device, description
),
set_position=description.position_wrapper.find_dpcode(
device, description.set_position, prefer_function=True
),
@@ -340,11 +333,11 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
device_manager: Manager,
description: TuyaCoverEntityDescription,
*,
current_position: _DPCodePercentageMappingWrapper | None,
current_state_wrapper: _IsClosedWrapper | None,
instruction_wrapper: DeviceWrapper | None,
set_position: _DPCodePercentageMappingWrapper | None,
tilt_position: _DPCodePercentageMappingWrapper | None,
current_position: DeviceWrapper[int] | None,
current_state_wrapper: DeviceWrapper[bool] | None,
instruction_wrapper: DeviceWrapper[str] | None,
set_position: DeviceWrapper[int] | None,
tilt_position: DeviceWrapper[int] | None,
) -> None:
"""Init Tuya Cover."""
super().__init__(device, device_manager)
@@ -358,7 +351,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
self._set_position = set_position
self._tilt_position = tilt_position
if instruction_wrapper and instruction_wrapper.options is not None:
if instruction_wrapper:
if "open" in instruction_wrapper.options:
self._attr_supported_features |= CoverEntityFeature.OPEN
if "close" in instruction_wrapper.options:
@@ -391,10 +384,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
if (position := self.current_cover_position) is not None:
return position == 0
if self._current_state_wrapper:
return self._current_state_wrapper.is_closed(self.device)
return None
return self._read_wrapper(self._current_state_wrapper)
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
@@ -434,11 +424,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
if (
self._instruction_wrapper
and (options := self._instruction_wrapper.options)
and "stop" in options
):
if self._instruction_wrapper and "stop" in self._instruction_wrapper.options:
await self._async_send_wrapper_updates(self._instruction_wrapper, "stop")
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:

View File

@@ -123,6 +123,7 @@ def _async_device_as_dict(
data["status_range"][status_range.code] = {
"type": status_range.type,
"value": status_range.values,
"report_type": status_range.report_type,
}
# Gather information how this Tuya device is represented in Home Assistant

View File

@@ -70,14 +70,14 @@ class TuyaEntity(Entity):
self.device_manager.send_commands, self.device.id, commands
)
def _read_wrapper(self, wrapper: DeviceWrapper | None) -> Any | None:
def _read_wrapper[T](self, wrapper: DeviceWrapper[T] | None) -> T | None:
"""Read the wrapper device status."""
if wrapper is None:
return None
return wrapper.read_device_status(self.device)
async def _async_send_wrapper_updates(
self, wrapper: DeviceWrapper | None, value: Any
async def _async_send_wrapper_updates[T](
self, wrapper: DeviceWrapper[T] | None, value: T
) -> None:
"""Send command to the device."""
if wrapper is None:

View File

@@ -21,6 +21,7 @@ from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
from .entity import TuyaEntity
from .models import (
DeviceWrapper,
DPCodeEnumWrapper,
DPCodeRawWrapper,
DPCodeStringWrapper,
@@ -28,75 +29,58 @@ from .models import (
)
class _DPCodeEventWrapper(DPCodeTypeInformationWrapper):
"""Base class for Tuya event wrappers."""
class _EventEnumWrapper(DPCodeEnumWrapper):
"""Wrapper for event enum DP codes."""
options: list[str]
def read_device_status(self, device: CustomerDevice) -> tuple[str, None] | None:
"""Return the event details."""
if (raw_value := super().read_device_status(device)) is None:
return None
return (raw_value, None)
class _AlarmMessageWrapper(DPCodeStringWrapper):
"""Wrapper for a STRING message on DPCode.ALARM_MESSAGE."""
def __init__(self, dpcode: str, type_information: Any) -> None:
"""Init _DPCodeEventWrapper."""
"""Init _AlarmMessageWrapper."""
super().__init__(dpcode, type_information)
self.options = ["triggered"]
def get_event_type(
self, device: CustomerDevice, updated_status_properties: list[str] | None
) -> str | None:
"""Return the event type."""
if (
updated_status_properties is None
or self.dpcode not in updated_status_properties
):
return None
return "triggered"
def get_event_attributes(self, device: CustomerDevice) -> dict[str, Any] | None:
"""Return the event attributes."""
return None
class _EventEnumWrapper(DPCodeEnumWrapper, _DPCodeEventWrapper):
"""Wrapper for event enum DP codes."""
def get_event_type(
self, device: CustomerDevice, updated_status_properties: list[str] | None
) -> str | None:
"""Return the triggered event type."""
if (
updated_status_properties is None
or self.dpcode not in updated_status_properties
):
return None
return self.read_device_status(device)
class _AlarmMessageWrapper(DPCodeStringWrapper, _DPCodeEventWrapper):
"""Wrapper for a STRING message on DPCode.ALARM_MESSAGE."""
def get_event_attributes(self, device: CustomerDevice) -> dict[str, Any] | None:
def read_device_status(
self, device: CustomerDevice
) -> tuple[str, dict[str, Any]] | None:
"""Return the event attributes for the alarm message."""
if (raw_value := device.status.get(self.dpcode)) is None:
if (raw_value := super().read_device_status(device)) is None:
return None
return {"message": b64decode(raw_value).decode("utf-8")}
return ("triggered", {"message": b64decode(raw_value).decode("utf-8")})
class _DoorbellPicWrapper(DPCodeRawWrapper, _DPCodeEventWrapper):
class _DoorbellPicWrapper(DPCodeRawWrapper):
"""Wrapper for a RAW message on DPCode.DOORBELL_PIC.
It is expected that the RAW data is base64/utf8 encoded URL of the picture.
"""
def get_event_attributes(self, device: CustomerDevice) -> dict[str, Any] | None:
def __init__(self, dpcode: str, type_information: Any) -> None:
"""Init _DoorbellPicWrapper."""
super().__init__(dpcode, type_information)
self.options = ["triggered"]
def read_device_status(
self, device: CustomerDevice
) -> tuple[str, dict[str, Any]] | None:
"""Return the event attributes for the doorbell picture."""
if (status := super().read_device_status(device)) is None:
return None
return {"message": status.decode("utf-8")}
return ("triggered", {"message": status.decode("utf-8")})
@dataclass(frozen=True)
class TuyaEventEntityDescription(EventEntityDescription):
"""Describe a Tuya Event entity."""
wrapper_class: type[_DPCodeEventWrapper] = _EventEnumWrapper
wrapper_class: type[DPCodeTypeInformationWrapper] = _EventEnumWrapper
# All descriptions can be found here. Mostly the Enum data types in the
@@ -222,7 +206,7 @@ class TuyaEventEntity(TuyaEntity, EventEntity):
device: CustomerDevice,
device_manager: Manager,
description: EventEntityDescription,
dpcode_wrapper: _DPCodeEventWrapper,
dpcode_wrapper: DeviceWrapper[tuple[str, dict[str, Any] | None]],
) -> None:
"""Init Tuya event entity."""
super().__init__(device, device_manager)
@@ -236,15 +220,11 @@ class TuyaEventEntity(TuyaEntity, EventEntity):
updated_status_properties: list[str] | None,
dp_timestamps: dict | None = None,
) -> None:
if (
event_type := self._dpcode_wrapper.get_event_type(
self.device, updated_status_properties
)
) is None:
if self._dpcode_wrapper.skip_update(
self.device, updated_status_properties
) or not (event_data := self._dpcode_wrapper.read_device_status(self.device)):
return
self._trigger_event(
event_type,
self._dpcode_wrapper.get_event_attributes(self.device),
)
event_type, event_attributes = event_data
self._trigger_event(event_type, event_attributes)
self.async_write_ha_state()

View File

@@ -23,7 +23,12 @@ from homeassistant.util.percentage import (
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
from .entity import TuyaEntity
from .models import DPCodeBooleanWrapper, DPCodeEnumWrapper, DPCodeIntegerWrapper
from .models import (
DeviceWrapper,
DPCodeBooleanWrapper,
DPCodeEnumWrapper,
DPCodeIntegerWrapper,
)
from .type_information import IntegerTypeInformation
from .util import RemapHelper, get_dpcode
@@ -173,11 +178,11 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
device: CustomerDevice,
device_manager: Manager,
*,
direction_wrapper: _DirectionEnumWrapper | None,
mode_wrapper: DPCodeEnumWrapper | None,
oscillate_wrapper: DPCodeBooleanWrapper | None,
speed_wrapper: _FanSpeedEnumWrapper | _FanSpeedIntegerWrapper | None,
switch_wrapper: DPCodeBooleanWrapper | None,
direction_wrapper: DeviceWrapper[str] | None,
mode_wrapper: DeviceWrapper[str] | None,
oscillate_wrapper: DeviceWrapper[bool] | None,
speed_wrapper: DeviceWrapper[int] | None,
switch_wrapper: DeviceWrapper[bool] | None,
) -> None:
"""Init Tuya Fan Device."""
super().__init__(device, device_manager)
@@ -193,7 +198,9 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
if speed_wrapper:
self._attr_supported_features |= FanEntityFeature.SET_SPEED
if speed_wrapper.options is not None:
# if speed is from an enum, set speed count from options
# else keep entity default 100
if hasattr(speed_wrapper, "options"):
self._attr_speed_count = len(speed_wrapper.options)
if oscillate_wrapper:

View File

@@ -20,7 +20,12 @@ 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, DPCodeEnumWrapper, DPCodeIntegerWrapper
from .models import (
DeviceWrapper,
DPCodeBooleanWrapper,
DPCodeEnumWrapper,
DPCodeIntegerWrapper,
)
from .util import ActionDPCodeNotFoundError, get_dpcode
@@ -136,10 +141,10 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity):
device_manager: Manager,
description: TuyaHumidifierEntityDescription,
*,
current_humidity_wrapper: _RoundedIntegerWrapper | None = None,
mode_wrapper: DPCodeEnumWrapper | None = None,
switch_wrapper: DPCodeBooleanWrapper | None = None,
target_humidity_wrapper: _RoundedIntegerWrapper | None = None,
current_humidity_wrapper: DeviceWrapper[int] | None = None,
mode_wrapper: DeviceWrapper[str] | None = None,
switch_wrapper: DeviceWrapper[bool] | None = None,
target_humidity_wrapper: DeviceWrapper[int] | None = None,
) -> None:
"""Init Tuya (de)humidifier."""
super().__init__(device, device_manager)

View File

@@ -31,6 +31,7 @@ from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, WorkMode
from .entity import TuyaEntity
from .models import (
DeviceWrapper,
DPCodeBooleanWrapper,
DPCodeEnumWrapper,
DPCodeIntegerWrapper,
@@ -186,14 +187,14 @@ class _ColorDataWrapper(DPCodeJsonWrapper):
)
def _convert_value_to_raw_value(
self, device: CustomerDevice, value: tuple[tuple[float, float], float]
self, device: CustomerDevice, value: tuple[float, float, float]
) -> Any:
"""Convert a Home Assistant color/brightness pair back to a raw device value."""
color, brightness = value
"""Convert a Home Assistant tuple (H, S, V) back to a raw device value."""
hue, saturation, brightness = value
return json.dumps(
{
"h": round(self.h_type.remap_value_from(color[0])),
"s": round(self.s_type.remap_value_from(color[1])),
"h": round(self.h_type.remap_value_from(hue)),
"s": round(self.s_type.remap_value_from(saturation)),
"v": round(self.v_type.remap_value_from(brightness)),
}
)
@@ -673,11 +674,11 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
device_manager: Manager,
description: TuyaLightEntityDescription,
*,
brightness_wrapper: _BrightnessWrapper | None,
color_data_wrapper: _ColorDataWrapper | None,
color_mode_wrapper: DPCodeEnumWrapper | None,
color_temp_wrapper: _ColorTempWrapper | None,
switch_wrapper: DPCodeBooleanWrapper,
brightness_wrapper: DeviceWrapper[int] | None,
color_data_wrapper: DeviceWrapper[tuple[float, float, float]] | None,
color_mode_wrapper: DeviceWrapper[str] | None,
color_temp_wrapper: DeviceWrapper[int] | None,
switch_wrapper: DeviceWrapper[bool],
) -> None:
"""Init TuyaHaLight."""
super().__init__(device, device_manager)

View File

@@ -43,5 +43,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["tuya_sharing"],
"requirements": ["tuya-device-sharing-sdk==0.2.7"]
"requirements": ["tuya-device-sharing-sdk==0.2.8"]
}

View File

@@ -18,17 +18,33 @@ from .type_information import (
)
class DeviceWrapper:
class DeviceWrapper[T]:
"""Base device wrapper."""
options: list[str] | None = None
native_unit: str | None = None
suggested_unit: str | None = None
def read_device_status(self, device: CustomerDevice) -> Any | None:
max_value: float
min_value: float
value_step: float
options: list[str]
def skip_update(
self, device: CustomerDevice, updated_status_properties: list[str] | None
) -> bool:
"""Determine if the wrapper should skip an update.
The default is to always skip, unless overridden in subclasses.
"""
return True
def read_device_status(self, device: CustomerDevice) -> T | None:
"""Read device status and convert to a Home Assistant value."""
raise NotImplementedError
def get_update_commands(
self, device: CustomerDevice, value: Any
self, device: CustomerDevice, value: T
) -> list[dict[str, Any]]:
"""Generate update commands for a Home Assistant action."""
raise NotImplementedError
@@ -41,13 +57,23 @@ class DPCodeWrapper(DeviceWrapper):
access read conversion routines.
"""
native_unit: str | None = None
suggested_unit: str | None = None
def __init__(self, dpcode: str) -> None:
"""Init DPCodeWrapper."""
self.dpcode = dpcode
def skip_update(
self, device: CustomerDevice, updated_status_properties: list[str] | None
) -> bool:
"""Determine if the wrapper should skip an update.
By default, skip if updated_status_properties is given and
does not include this dpcode.
"""
return (
updated_status_properties is None
or self.dpcode not in updated_status_properties
)
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
"""Convert a Home Assistant value back to a raw device value.
@@ -135,7 +161,6 @@ class DPCodeEnumWrapper(DPCodeTypeInformationWrapper[EnumTypeInformation]):
"""Simple wrapper for EnumTypeInformation values."""
_DPTYPE = EnumTypeInformation
options: list[str]
def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None:
"""Init DPCodeEnumWrapper."""

View File

@@ -25,7 +25,7 @@ from .const import (
DPCode,
)
from .entity import TuyaEntity
from .models import DPCodeIntegerWrapper
from .models import DeviceWrapper, DPCodeIntegerWrapper
NUMBERS: dict[DeviceCategory, tuple[NumberEntityDescription, ...]] = {
DeviceCategory.BH: (
@@ -488,7 +488,7 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity):
device: CustomerDevice,
device_manager: Manager,
description: NumberEntityDescription,
dpcode_wrapper: DPCodeIntegerWrapper,
dpcode_wrapper: DeviceWrapper[float],
) -> None:
"""Init Tuya sensor."""
super().__init__(device, device_manager)

View File

@@ -13,7 +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 DPCodeEnumWrapper
from .models import DeviceWrapper, DPCodeEnumWrapper
# All descriptions can be found here. Mostly the Enum data types in the
# default instructions set of each category end up being a select.
@@ -393,7 +393,7 @@ class TuyaSelectEntity(TuyaEntity, SelectEntity):
device: CustomerDevice,
device_manager: Manager,
description: SelectEntityDescription,
dpcode_wrapper: DPCodeEnumWrapper,
dpcode_wrapper: DeviceWrapper[str],
) -> None:
"""Init Tuya sensor."""
super().__init__(device, device_manager)

View File

@@ -39,6 +39,7 @@ from .const import (
)
from .entity import TuyaEntity
from .models import (
DeviceWrapper,
DPCodeEnumWrapper,
DPCodeIntegerWrapper,
DPCodeJsonWrapper,
@@ -1779,14 +1780,13 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity):
"""Tuya Sensor Entity."""
entity_description: TuyaSensorEntityDescription
_dpcode_wrapper: DPCodeWrapper
def __init__(
self,
device: CustomerDevice,
device_manager: Manager,
description: TuyaSensorEntityDescription,
dpcode_wrapper: DPCodeWrapper,
dpcode_wrapper: DeviceWrapper[StateType],
) -> None:
"""Init Tuya sensor."""
super().__init__(device, device_manager)

View File

@@ -19,7 +19,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
from .models import DeviceWrapper, DPCodeBooleanWrapper
SIRENS: dict[DeviceCategory, tuple[SirenEntityDescription, ...]] = {
DeviceCategory.CO2BJ: (
@@ -94,7 +94,7 @@ class TuyaSirenEntity(TuyaEntity, SirenEntity):
device: CustomerDevice,
device_manager: Manager,
description: SirenEntityDescription,
dpcode_wrapper: DPCodeBooleanWrapper,
dpcode_wrapper: DeviceWrapper[bool],
) -> None:
"""Init Tuya Siren."""
super().__init__(device, device_manager)

View File

@@ -27,7 +27,7 @@ from homeassistant.helpers.issue_registry import (
from . import TuyaConfigEntry
from .const import DOMAIN, TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
from .entity import TuyaEntity
from .models import DPCodeBooleanWrapper
from .models import DeviceWrapper, DPCodeBooleanWrapper
@dataclass(frozen=True, kw_only=True)
@@ -1027,7 +1027,7 @@ class TuyaSwitchEntity(TuyaEntity, SwitchEntity):
device: CustomerDevice,
device_manager: Manager,
description: SwitchEntityDescription,
dpcode_wrapper: DPCodeBooleanWrapper,
dpcode_wrapper: DeviceWrapper[bool],
) -> None:
"""Init TuyaHaSwitch."""
super().__init__(device, device_manager)

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import Any
from typing import Any, Self
from tuya_sharing import CustomerDevice, Manager
@@ -18,34 +18,140 @@ 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, DPCodeEnumWrapper
from .models import DeviceWrapper, DPCodeBooleanWrapper, DPCodeEnumWrapper
TUYA_MODE_RETURN_HOME = "chargego"
TUYA_STATUS_TO_HA = {
"charge_done": VacuumActivity.DOCKED,
"chargecompleted": VacuumActivity.DOCKED,
"chargego": VacuumActivity.DOCKED,
"charging": VacuumActivity.DOCKED,
"cleaning": VacuumActivity.CLEANING,
"docking": VacuumActivity.RETURNING,
"goto_charge": VacuumActivity.RETURNING,
"goto_pos": VacuumActivity.CLEANING,
"mop_clean": VacuumActivity.CLEANING,
"part_clean": VacuumActivity.CLEANING,
"paused": VacuumActivity.PAUSED,
"pick_zone_clean": VacuumActivity.CLEANING,
"pos_arrived": VacuumActivity.CLEANING,
"pos_unarrive": VacuumActivity.CLEANING,
"random": VacuumActivity.CLEANING,
"sleep": VacuumActivity.IDLE,
"smart_clean": VacuumActivity.CLEANING,
"smart": VacuumActivity.CLEANING,
"spot_clean": VacuumActivity.CLEANING,
"standby": VacuumActivity.IDLE,
"wall_clean": VacuumActivity.CLEANING,
"wall_follow": VacuumActivity.CLEANING,
"zone_clean": VacuumActivity.CLEANING,
}
class _VacuumActivityWrapper(DeviceWrapper):
"""Wrapper for the state of a device."""
_TUYA_STATUS_TO_HA = {
"charge_done": VacuumActivity.DOCKED,
"chargecompleted": VacuumActivity.DOCKED,
"chargego": VacuumActivity.DOCKED,
"charging": VacuumActivity.DOCKED,
"cleaning": VacuumActivity.CLEANING,
"docking": VacuumActivity.RETURNING,
"goto_charge": VacuumActivity.RETURNING,
"goto_pos": VacuumActivity.CLEANING,
"mop_clean": VacuumActivity.CLEANING,
"part_clean": VacuumActivity.CLEANING,
"paused": VacuumActivity.PAUSED,
"pick_zone_clean": VacuumActivity.CLEANING,
"pos_arrived": VacuumActivity.CLEANING,
"pos_unarrive": VacuumActivity.CLEANING,
"random": VacuumActivity.CLEANING,
"sleep": VacuumActivity.IDLE,
"smart_clean": VacuumActivity.CLEANING,
"smart": VacuumActivity.CLEANING,
"spot_clean": VacuumActivity.CLEANING,
"standby": VacuumActivity.IDLE,
"wall_clean": VacuumActivity.CLEANING,
"wall_follow": VacuumActivity.CLEANING,
"zone_clean": VacuumActivity.CLEANING,
}
def __init__(
self,
pause_wrapper: DPCodeBooleanWrapper | None = None,
status_wrapper: DPCodeEnumWrapper | None = None,
) -> None:
"""Init _VacuumActivityWrapper."""
self._pause_wrapper = pause_wrapper
self._status_wrapper = status_wrapper
@classmethod
def find_dpcode(cls, device: CustomerDevice) -> Self | None:
"""Find and return a _VacuumActivityWrapper for the given DP codes."""
pause_wrapper = DPCodeBooleanWrapper.find_dpcode(device, DPCode.PAUSE)
status_wrapper = DPCodeEnumWrapper.find_dpcode(device, DPCode.STATUS)
if pause_wrapper or status_wrapper:
return cls(pause_wrapper=pause_wrapper, status_wrapper=status_wrapper)
return None
def read_device_status(self, device: CustomerDevice) -> VacuumActivity | None:
"""Read the device status."""
if (
self._status_wrapper
and (status := self._status_wrapper.read_device_status(device)) is not None
):
return self._TUYA_STATUS_TO_HA.get(status)
if self._pause_wrapper and self._pause_wrapper.read_device_status(device):
return VacuumActivity.PAUSED
return None
class _VacuumActionWrapper(DeviceWrapper):
"""Wrapper for sending actions to a vacuum."""
_TUYA_MODE_RETURN_HOME = "chargego"
def __init__(
self,
charge_wrapper: DPCodeBooleanWrapper | None,
locate_wrapper: DPCodeBooleanWrapper | None,
pause_wrapper: DPCodeBooleanWrapper | None,
mode_wrapper: DPCodeEnumWrapper | None,
switch_wrapper: DPCodeBooleanWrapper | None,
) -> None:
"""Init _VacuumActionWrapper."""
self._charge_wrapper = charge_wrapper
self._locate_wrapper = locate_wrapper
self._mode_wrapper = mode_wrapper
self._switch_wrapper = switch_wrapper
self.options = []
if charge_wrapper or (
mode_wrapper and self._TUYA_MODE_RETURN_HOME in mode_wrapper.options
):
self.options.append("return_to_base")
if locate_wrapper:
self.options.append("locate")
if pause_wrapper:
self.options.append("pause")
if switch_wrapper:
self.options.append("start")
self.options.append("stop")
@classmethod
def find_dpcode(cls, device: CustomerDevice) -> Self:
"""Find and return a _VacuumActionWrapper for the given DP codes."""
return cls(
charge_wrapper=DPCodeBooleanWrapper.find_dpcode(
device, DPCode.SWITCH_CHARGE, prefer_function=True
),
locate_wrapper=DPCodeBooleanWrapper.find_dpcode(
device, DPCode.SEEK, prefer_function=True
),
mode_wrapper=DPCodeEnumWrapper.find_dpcode(
device, DPCode.MODE, prefer_function=True
),
pause_wrapper=DPCodeBooleanWrapper.find_dpcode(device, DPCode.PAUSE),
switch_wrapper=DPCodeBooleanWrapper.find_dpcode(
device, DPCode.POWER_GO, prefer_function=True
),
)
def get_update_commands(
self, device: CustomerDevice, value: Any
) -> list[dict[str, Any]]:
"""Get the commands for the action wrapper."""
if value == "locate" and self._locate_wrapper:
return self._locate_wrapper.get_update_commands(device, True)
if value == "pause" and self._switch_wrapper:
return self._switch_wrapper.get_update_commands(device, False)
if value == "return_to_base":
if self._charge_wrapper:
return self._charge_wrapper.get_update_commands(device, True)
if self._mode_wrapper:
return self._mode_wrapper.get_update_commands(
device, self._TUYA_MODE_RETURN_HOME
)
if value == "start" and self._switch_wrapper:
return self._switch_wrapper.get_update_commands(device, True)
if value == "stop" and self._switch_wrapper:
return self._switch_wrapper.get_update_commands(device, False)
return []
async def async_setup_entry(
@@ -67,27 +173,11 @@ async def async_setup_entry(
TuyaVacuumEntity(
device,
manager,
charge_wrapper=DPCodeBooleanWrapper.find_dpcode(
device, DPCode.SWITCH_CHARGE, prefer_function=True
),
action_wrapper=_VacuumActionWrapper.find_dpcode(device),
activity_wrapper=_VacuumActivityWrapper.find_dpcode(device),
fan_speed_wrapper=DPCodeEnumWrapper.find_dpcode(
device, DPCode.SUCTION, prefer_function=True
),
locate_wrapper=DPCodeBooleanWrapper.find_dpcode(
device, DPCode.SEEK, prefer_function=True
),
mode_wrapper=DPCodeEnumWrapper.find_dpcode(
device, DPCode.MODE, prefer_function=True
),
pause_wrapper=DPCodeBooleanWrapper.find_dpcode(
device, DPCode.PAUSE
),
status_wrapper=DPCodeEnumWrapper.find_dpcode(
device, DPCode.STATUS
),
switch_wrapper=DPCodeBooleanWrapper.find_dpcode(
device, DPCode.POWER_GO, prefer_function=True
),
)
)
async_add_entities(entities)
@@ -109,43 +199,33 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
device: CustomerDevice,
device_manager: Manager,
*,
charge_wrapper: DPCodeBooleanWrapper | None,
fan_speed_wrapper: DPCodeEnumWrapper | None,
locate_wrapper: DPCodeBooleanWrapper | None,
mode_wrapper: DPCodeEnumWrapper | None,
pause_wrapper: DPCodeBooleanWrapper | None,
status_wrapper: DPCodeEnumWrapper | None,
switch_wrapper: DPCodeBooleanWrapper | None,
action_wrapper: DeviceWrapper[str] | None,
activity_wrapper: DeviceWrapper[VacuumActivity] | None,
fan_speed_wrapper: DeviceWrapper[str] | None,
) -> None:
"""Init Tuya vacuum."""
super().__init__(device, device_manager)
self._charge_wrapper = charge_wrapper
self._action_wrapper = action_wrapper
self._activity_wrapper = activity_wrapper
self._fan_speed_wrapper = fan_speed_wrapper
self._locate_wrapper = locate_wrapper
self._mode_wrapper = mode_wrapper
self._pause_wrapper = pause_wrapper
self._status_wrapper = status_wrapper
self._switch_wrapper = switch_wrapper
self._attr_fan_speed_list = []
self._attr_supported_features = VacuumEntityFeature.SEND_COMMAND
if status_wrapper or pause_wrapper:
if action_wrapper:
if "pause" in action_wrapper.options:
self._attr_supported_features |= VacuumEntityFeature.PAUSE
if "return_to_base" in action_wrapper.options:
self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME
if "locate" in action_wrapper.options:
self._attr_supported_features |= VacuumEntityFeature.LOCATE
if "start" in action_wrapper.options:
self._attr_supported_features |= VacuumEntityFeature.START
if "stop" in action_wrapper.options:
self._attr_supported_features |= VacuumEntityFeature.STOP
if activity_wrapper:
self._attr_supported_features |= VacuumEntityFeature.STATE
if pause_wrapper:
self._attr_supported_features |= VacuumEntityFeature.PAUSE
if charge_wrapper or (
mode_wrapper and TUYA_MODE_RETURN_HOME in mode_wrapper.options
):
self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME
if locate_wrapper:
self._attr_supported_features |= VacuumEntityFeature.LOCATE
if switch_wrapper:
self._attr_supported_features |= (
VacuumEntityFeature.STOP | VacuumEntityFeature.START
)
if fan_speed_wrapper:
self._attr_fan_speed_list = fan_speed_wrapper.options
@@ -159,37 +239,27 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
@property
def activity(self) -> VacuumActivity | None:
"""Return Tuya vacuum device state."""
if (status := self._read_wrapper(self._status_wrapper)) is not None:
return TUYA_STATUS_TO_HA.get(status)
if self._read_wrapper(self._pause_wrapper):
return VacuumActivity.PAUSED
return None
return self._read_wrapper(self._activity_wrapper)
async def async_start(self, **kwargs: Any) -> None:
"""Start the device."""
await self._async_send_wrapper_updates(self._switch_wrapper, True)
await self._async_send_wrapper_updates(self._action_wrapper, "start")
async def async_stop(self, **kwargs: Any) -> None:
"""Stop the device."""
await self._async_send_wrapper_updates(self._switch_wrapper, False)
await self._async_send_wrapper_updates(self._action_wrapper, "stop")
async def async_pause(self, **kwargs: Any) -> None:
"""Pause the device."""
await self.async_stop(**kwargs)
await self._async_send_wrapper_updates(self._action_wrapper, "pause")
async def async_return_to_base(self, **kwargs: Any) -> None:
"""Return device to dock."""
if self._charge_wrapper:
await self._async_send_wrapper_updates(self._charge_wrapper, True)
else:
await self._async_send_wrapper_updates(
self._mode_wrapper, TUYA_MODE_RETURN_HOME
)
await self._async_send_wrapper_updates(self._action_wrapper, "return_to_base")
async def async_locate(self, **kwargs: Any) -> None:
"""Locate the device."""
await self._async_send_wrapper_updates(self._locate_wrapper, True)
await self._async_send_wrapper_updates(self._action_wrapper, "locate")
async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:
"""Set fan speed."""

View File

@@ -17,7 +17,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
from .models import DeviceWrapper, DPCodeBooleanWrapper
VALVES: dict[DeviceCategory, tuple[ValveEntityDescription, ...]] = {
DeviceCategory.SFKZQ: (
@@ -122,7 +122,7 @@ class TuyaValveEntity(TuyaEntity, ValveEntity):
device: CustomerDevice,
device_manager: Manager,
description: ValveEntityDescription,
dpcode_wrapper: DPCodeBooleanWrapper,
dpcode_wrapper: DeviceWrapper[bool],
) -> None:
"""Init TuyaValveEntity."""
super().__init__(device, device_manager)

View File

@@ -41,7 +41,7 @@
"iot_class": "local_push",
"loggers": ["uiprotect", "unifi_discovery"],
"quality_scale": "platinum",
"requirements": ["uiprotect==7.33.3", "unifi-discovery==1.2.0"],
"requirements": ["uiprotect==8.0.0", "unifi-discovery==1.2.0"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",

View File

@@ -8,7 +8,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["async_upnp_client"],
"requirements": ["async-upnp-client==0.46.1", "getmac==0.9.5"],
"requirements": ["async-upnp-client==0.46.2", "getmac==0.9.5"],
"ssdp": [
{
"st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1"

View File

@@ -25,6 +25,8 @@ from .entity import VeSyncBaseEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class VeSyncBinarySensorEntityDescription(BinarySensorEntityDescription):

View File

@@ -4,6 +4,7 @@ import logging
from pyvesync.base_devices import VeSyncHumidifier
from pyvesync.base_devices.fan_base import VeSyncFanBase
from pyvesync.base_devices.fryer_base import VeSyncFryer
from pyvesync.base_devices.outlet_base import VeSyncOutlet
from pyvesync.base_devices.purifier_base import VeSyncPurifier
from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice
@@ -62,3 +63,9 @@ def is_purifier(device: VeSyncBaseDevice) -> bool:
"""Check if the device represents an air purifier."""
return isinstance(device, VeSyncPurifier)
def is_air_fryer(device: VeSyncBaseDevice) -> bool:
"""Check if the device represents an air fryer."""
return isinstance(device, VeSyncFryer)

View File

@@ -62,3 +62,14 @@ OUTLET_NIGHT_LIGHT_LEVEL_ON = "on"
PURIFIER_NIGHT_LIGHT_LEVEL_DIM = "dim"
PURIFIER_NIGHT_LIGHT_LEVEL_OFF = "off"
PURIFIER_NIGHT_LIGHT_LEVEL_ON = "on"
AIR_FRYER_MODE_MAP = {
"cookend": "cooking_end",
"cooking": "cooking",
"cookstop": "cooking_stop",
"heating": "heating",
"preheatend": "preheat_end",
"preheatstop": "preheat_stop",
"pullout": "pull_out",
"standby": "standby",
}

View File

@@ -6,10 +6,11 @@ from datetime import datetime, timedelta
import logging
from pyvesync import VeSync
from pyvesync.utils.errors import VeSyncError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import UPDATE_INTERVAL, UPDATE_INTERVAL_ENERGY
@@ -49,10 +50,12 @@ class VeSyncDataCoordinator(DataUpdateCoordinator[None]):
async def _async_update_data(self) -> None:
"""Fetch data from API endpoint."""
try:
await self.manager.update_all_devices()
await self.manager.update_all_devices()
if self.should_update_energy():
self.update_time = datetime.now()
for outlet in self.manager.devices.outlets:
await outlet.update_energy()
if self.should_update_energy():
self.update_time = datetime.now()
for outlet in self.manager.devices.outlets:
await outlet.update_energy()
except VeSyncError as err:
raise UpdateFailed(f"The service is unavailable: {err}") from err

View File

@@ -42,4 +42,4 @@ class VeSyncBaseEntity(CoordinatorEntity[VeSyncDataCoordinator]):
@property
def available(self) -> bool:
"""Return True if device is available."""
return self.device.state.connection_status == "online"
return super().available and self.device.state.connection_status == "online"

View File

@@ -47,6 +47,8 @@ VS_TO_HA_MODE_MAP = {
VS_FAN_MODE_NORMAL: VS_FAN_MODE_NORMAL,
}
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,

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