mirror of
https://github.com/home-assistant/core.git
synced 2026-01-11 09:38:26 +00:00
Compare commits
72 Commits
knx-name-r
...
condition_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45f1fc32e2 | ||
|
|
1e95598c96 | ||
|
|
e963adfdf0 | ||
|
|
fd7bbc68c6 | ||
|
|
9281ab018c | ||
|
|
80baf86e23 | ||
|
|
db497b23fe | ||
|
|
a2fb8f5a72 | ||
|
|
6953bd4599 | ||
|
|
225be65f71 | ||
|
|
7b0463f763 | ||
|
|
4d305b657a | ||
|
|
d5a553c8c7 | ||
|
|
9169b68254 | ||
|
|
fde9bd95d5 | ||
|
|
e4db8ff86e | ||
|
|
a084e51345 | ||
|
|
00381e6dfd | ||
|
|
b6d493696a | ||
|
|
5f0500c3cd | ||
|
|
c61a63cc6f | ||
|
|
5445a4f40f | ||
|
|
2888cacc3f | ||
|
|
16f3e6d2c9 | ||
|
|
7a872970fa | ||
|
|
4f5ca986ce | ||
|
|
b58e058da5 | ||
|
|
badebe0c7f | ||
|
|
7817ec1a52 | ||
|
|
c773998946 | ||
|
|
2bc9397103 | ||
|
|
685534b17c | ||
|
|
c740f44bfa | ||
|
|
ce471d0222 | ||
|
|
53ed344fe0 | ||
|
|
5f8f3c961a | ||
|
|
9d0c5530f2 | ||
|
|
d114fe4fbd | ||
|
|
f03d44d5b5 | ||
|
|
35f4464d4a | ||
|
|
fc2530e979 | ||
|
|
354fafda1a | ||
|
|
5b0dab479d | ||
|
|
1e1f414849 | ||
|
|
7c81df6c5c | ||
|
|
95d7c42e6a | ||
|
|
19fd80035e | ||
|
|
8e30787ae6 | ||
|
|
7133da928f | ||
|
|
3f9a41d393 | ||
|
|
f4caf36204 | ||
|
|
079866e384 | ||
|
|
dce0db78aa | ||
|
|
fffc18d28b | ||
|
|
c7cbcbc32d | ||
|
|
aebcdd6e7a | ||
|
|
3be92510f8 | ||
|
|
4d8448e82a | ||
|
|
625bc467d4 | ||
|
|
47573b7f6a | ||
|
|
7c95c92525 | ||
|
|
1aed46e39e | ||
|
|
6659166df0 | ||
|
|
1e6b0ba9ec | ||
|
|
1f23098638 | ||
|
|
98ee0421b7 | ||
|
|
6aaa57f660 | ||
|
|
fad817853f | ||
|
|
7ef7d3f570 | ||
|
|
14bca5a052 | ||
|
|
18769730f0 | ||
|
|
de6d117d9a |
3
CODEOWNERS
generated
3
CODEOWNERS
generated
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
89
homeassistant/components/airobot/button.py
Normal file
89
homeassistant/components/airobot/button.py
Normal 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
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -136,6 +136,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"person",
|
||||
"scene",
|
||||
"siren",
|
||||
"switch",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Integrate with DuckDNS."""
|
||||
"""Duck DNS integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)])
|
||||
|
||||
|
||||
@@ -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." }
|
||||
]
|
||||
|
||||
@@ -23,5 +23,5 @@
|
||||
"winter_mode": {}
|
||||
},
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20251229.0"]
|
||||
"requirements": ["home-assistant-frontend==20251229.1"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
"trigger": "mdi:air-humidifier-off"
|
||||
},
|
||||
"turned_on": {
|
||||
"trigger": "mdi:air-humidifier-on"
|
||||
"trigger": "mdi:air-humidifier"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["jvcprojector"],
|
||||
"requirements": ["pyjvcprojector==1.1.2"]
|
||||
"requirements": ["pyjvcprojector==1.1.3"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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])
|
||||
|
||||
64
homeassistant/components/openevse/config_flow.py
Normal file
64
homeassistant/components/openevse/config_flow.py
Normal 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,
|
||||
)
|
||||
4
homeassistant/components/openevse/const.py
Normal file
4
homeassistant/components/openevse/const.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Constants for the OpenEVSE integration."""
|
||||
|
||||
DOMAIN = "openevse"
|
||||
INTEGRATION_TITLE = "OpenEVSE"
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
27
homeassistant/components/openevse/strings.json
Normal file
27
homeassistant/components/openevse/strings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["opower"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["opower==0.15.9"]
|
||||
"requirements": ["opower==0.16.0"]
|
||||
}
|
||||
|
||||
@@ -11,5 +11,13 @@
|
||||
"reload": {
|
||||
"service": "mdi:reload"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"entered_home": {
|
||||
"trigger": "mdi:account-arrow-left"
|
||||
},
|
||||
"left_home": {
|
||||
"trigger": "mdi:account-arrow-right"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
21
homeassistant/components/person/trigger.py
Normal file
21
homeassistant/components/person/trigger.py
Normal 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
|
||||
18
homeassistant/components/person/triggers.yaml
Normal file
18
homeassistant/components/person/triggers.yaml
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pysaunum"],
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "gold",
|
||||
"requirements": ["pysaunum==0.1.0"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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*",
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
15
homeassistant/components/tibber/application_credentials.py
Normal file
15
homeassistant/components/tibber/application_credentials.py
Normal 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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -25,6 +25,8 @@ from .entity import VeSyncBaseEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class VeSyncBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user