ESPHome enable static type checking (#52348)
This commit is contained in:
parent
9b2107b71f
commit
4d16cda957
16 changed files with 364 additions and 304 deletions
|
@ -31,6 +31,7 @@ homeassistant.components.dnsip.*
|
||||||
homeassistant.components.dsmr.*
|
homeassistant.components.dsmr.*
|
||||||
homeassistant.components.dunehd.*
|
homeassistant.components.dunehd.*
|
||||||
homeassistant.components.elgato.*
|
homeassistant.components.elgato.*
|
||||||
|
homeassistant.components.esphome.*
|
||||||
homeassistant.components.fastdotcom.*
|
homeassistant.components.fastdotcom.*
|
||||||
homeassistant.components.fitbit.*
|
homeassistant.components.fitbit.*
|
||||||
homeassistant.components.forecast_solar.*
|
homeassistant.components.forecast_solar.*
|
||||||
|
|
|
@ -2,15 +2,17 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from collections.abc import Awaitable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
from typing import Generic, TypeVar
|
from typing import Any, Callable, Generic, TypeVar, cast, overload
|
||||||
|
|
||||||
from aioesphomeapi import (
|
from aioesphomeapi import (
|
||||||
APIClient,
|
APIClient,
|
||||||
APIConnectionError,
|
APIConnectionError,
|
||||||
|
APIIntEnum,
|
||||||
APIVersion,
|
APIVersion,
|
||||||
DeviceInfo as EsphomeDeviceInfo,
|
DeviceInfo as EsphomeDeviceInfo,
|
||||||
EntityInfo,
|
EntityInfo,
|
||||||
|
@ -32,13 +34,14 @@ from homeassistant.const import (
|
||||||
CONF_PORT,
|
CONF_PORT,
|
||||||
EVENT_HOMEASSISTANT_STOP,
|
EVENT_HOMEASSISTANT_STOP,
|
||||||
)
|
)
|
||||||
from homeassistant.core import Event, HomeAssistant, State, callback
|
from homeassistant.core import Event, HomeAssistant, ServiceCall, State, callback
|
||||||
from homeassistant.exceptions import TemplateError
|
from homeassistant.exceptions import TemplateError
|
||||||
from homeassistant.helpers import template
|
from homeassistant.helpers import template
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
import homeassistant.helpers.device_registry as dr
|
import homeassistant.helpers.device_registry as dr
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||||
from homeassistant.helpers.entity import DeviceInfo, Entity
|
from homeassistant.helpers.entity import DeviceInfo, Entity
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.event import async_track_state_change_event
|
from homeassistant.helpers.event import async_track_state_change_event
|
||||||
from homeassistant.helpers.json import JSONEncoder
|
from homeassistant.helpers.json import JSONEncoder
|
||||||
from homeassistant.helpers.service import async_set_service_schema
|
from homeassistant.helpers.service import async_set_service_schema
|
||||||
|
@ -97,7 +100,7 @@ class DomainData:
|
||||||
"""Get the global DomainData instance stored in hass.data."""
|
"""Get the global DomainData instance stored in hass.data."""
|
||||||
# Don't use setdefault - this is a hot code path
|
# Don't use setdefault - this is a hot code path
|
||||||
if DOMAIN in hass.data:
|
if DOMAIN in hass.data:
|
||||||
return hass.data[DOMAIN]
|
return cast(_T, hass.data[DOMAIN])
|
||||||
ret = hass.data[DOMAIN] = cls()
|
ret = hass.data[DOMAIN] = cls()
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
@ -153,7 +156,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
if service.data_template:
|
if service.data_template:
|
||||||
try:
|
try:
|
||||||
data_template = {
|
data_template = {
|
||||||
key: Template(value) for key, value in service.data_template.items()
|
key: Template(value) # type: ignore[no-untyped-call]
|
||||||
|
for key, value in service.data_template.items()
|
||||||
}
|
}
|
||||||
template.attach(hass, data_template)
|
template.attach(hass, data_template)
|
||||||
service_data.update(
|
service_data.update(
|
||||||
|
@ -197,10 +201,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
|
||||||
send_state = state.state
|
send_state = state.state
|
||||||
if attribute:
|
if attribute:
|
||||||
send_state = state.attributes[attribute]
|
attr_val = state.attributes[attribute]
|
||||||
# ESPHome only handles "on"/"off" for boolean values
|
# ESPHome only handles "on"/"off" for boolean values
|
||||||
if isinstance(send_state, bool):
|
if isinstance(attr_val, bool):
|
||||||
send_state = "on" if send_state else "off"
|
send_state = "on" if attr_val else "off"
|
||||||
|
else:
|
||||||
|
send_state = attr_val
|
||||||
|
|
||||||
await cli.send_home_assistant_state(entity_id, attribute, str(send_state))
|
await cli.send_home_assistant_state(entity_id, attribute, str(send_state))
|
||||||
|
|
||||||
|
@ -253,6 +259,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
nonlocal device_id
|
nonlocal device_id
|
||||||
try:
|
try:
|
||||||
entry_data.device_info = await cli.device_info()
|
entry_data.device_info = await cli.device_info()
|
||||||
|
assert cli.api_version is not None
|
||||||
entry_data.api_version = cli.api_version
|
entry_data.api_version = cli.api_version
|
||||||
entry_data.available = True
|
entry_data.available = True
|
||||||
device_id = await _async_setup_device_registry(
|
device_id = await _async_setup_device_registry(
|
||||||
|
@ -304,9 +311,9 @@ class ReconnectLogic(RecordUpdateListener):
|
||||||
cli: APIClient,
|
cli: APIClient,
|
||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
host: str,
|
host: str,
|
||||||
on_login,
|
on_login: Callable[[], Awaitable[None]],
|
||||||
zc: Zeroconf,
|
zc: Zeroconf,
|
||||||
):
|
) -> None:
|
||||||
"""Initialize ReconnectingLogic."""
|
"""Initialize ReconnectingLogic."""
|
||||||
self._hass = hass
|
self._hass = hass
|
||||||
self._cli = cli
|
self._cli = cli
|
||||||
|
@ -322,12 +329,12 @@ class ReconnectLogic(RecordUpdateListener):
|
||||||
# Event the different strategies use for issuing a reconnect attempt.
|
# Event the different strategies use for issuing a reconnect attempt.
|
||||||
self._reconnect_event = asyncio.Event()
|
self._reconnect_event = asyncio.Event()
|
||||||
# The task containing the infinite reconnect loop while running
|
# The task containing the infinite reconnect loop while running
|
||||||
self._loop_task: asyncio.Task | None = None
|
self._loop_task: asyncio.Task[None] | None = None
|
||||||
# How many reconnect attempts have there been already, used for exponential wait time
|
# How many reconnect attempts have there been already, used for exponential wait time
|
||||||
self._tries = 0
|
self._tries = 0
|
||||||
self._tries_lock = asyncio.Lock()
|
self._tries_lock = asyncio.Lock()
|
||||||
# Track the wait task to cancel it on HA shutdown
|
# Track the wait task to cancel it on HA shutdown
|
||||||
self._wait_task: asyncio.Task | None = None
|
self._wait_task: asyncio.Task[None] | None = None
|
||||||
self._wait_task_lock = asyncio.Lock()
|
self._wait_task_lock = asyncio.Lock()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -338,7 +345,7 @@ class ReconnectLogic(RecordUpdateListener):
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _on_disconnect(self):
|
async def _on_disconnect(self) -> None:
|
||||||
"""Log and issue callbacks when disconnecting."""
|
"""Log and issue callbacks when disconnecting."""
|
||||||
if self._entry_data is None:
|
if self._entry_data is None:
|
||||||
return
|
return
|
||||||
|
@ -364,7 +371,7 @@ class ReconnectLogic(RecordUpdateListener):
|
||||||
self._connected = False
|
self._connected = False
|
||||||
self._reconnect_event.set()
|
self._reconnect_event.set()
|
||||||
|
|
||||||
async def _wait_and_start_reconnect(self):
|
async def _wait_and_start_reconnect(self) -> None:
|
||||||
"""Wait for exponentially increasing time to issue next reconnect event."""
|
"""Wait for exponentially increasing time to issue next reconnect event."""
|
||||||
async with self._tries_lock:
|
async with self._tries_lock:
|
||||||
tries = self._tries
|
tries = self._tries
|
||||||
|
@ -383,7 +390,7 @@ class ReconnectLogic(RecordUpdateListener):
|
||||||
self._wait_task = None
|
self._wait_task = None
|
||||||
self._reconnect_event.set()
|
self._reconnect_event.set()
|
||||||
|
|
||||||
async def _try_connect(self):
|
async def _try_connect(self) -> None:
|
||||||
"""Try connecting to the API client."""
|
"""Try connecting to the API client."""
|
||||||
async with self._tries_lock:
|
async with self._tries_lock:
|
||||||
tries = self._tries
|
tries = self._tries
|
||||||
|
@ -421,7 +428,7 @@ class ReconnectLogic(RecordUpdateListener):
|
||||||
await self._stop_zc_listen()
|
await self._stop_zc_listen()
|
||||||
self._hass.async_create_task(self._on_login())
|
self._hass.async_create_task(self._on_login())
|
||||||
|
|
||||||
async def _reconnect_once(self):
|
async def _reconnect_once(self) -> None:
|
||||||
# Wait and clear reconnection event
|
# Wait and clear reconnection event
|
||||||
await self._reconnect_event.wait()
|
await self._reconnect_event.wait()
|
||||||
self._reconnect_event.clear()
|
self._reconnect_event.clear()
|
||||||
|
@ -429,7 +436,7 @@ class ReconnectLogic(RecordUpdateListener):
|
||||||
# If in connected state, do not try to connect again.
|
# If in connected state, do not try to connect again.
|
||||||
async with self._connected_lock:
|
async with self._connected_lock:
|
||||||
if self._connected:
|
if self._connected:
|
||||||
return False
|
return
|
||||||
|
|
||||||
# Check if the entry got removed or disabled, in which case we shouldn't reconnect
|
# Check if the entry got removed or disabled, in which case we shouldn't reconnect
|
||||||
if not DomainData.get(self._hass).is_entry_loaded(self._entry):
|
if not DomainData.get(self._hass).is_entry_loaded(self._entry):
|
||||||
|
@ -448,7 +455,7 @@ class ReconnectLogic(RecordUpdateListener):
|
||||||
|
|
||||||
await self._try_connect()
|
await self._try_connect()
|
||||||
|
|
||||||
async def _reconnect_loop(self):
|
async def _reconnect_loop(self) -> None:
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
await self._reconnect_once()
|
await self._reconnect_once()
|
||||||
|
@ -457,7 +464,7 @@ class ReconnectLogic(RecordUpdateListener):
|
||||||
except Exception: # pylint: disable=broad-except
|
except Exception: # pylint: disable=broad-except
|
||||||
_LOGGER.error("Caught exception while reconnecting", exc_info=True)
|
_LOGGER.error("Caught exception while reconnecting", exc_info=True)
|
||||||
|
|
||||||
async def start(self):
|
async def start(self) -> None:
|
||||||
"""Start the reconnecting logic background task."""
|
"""Start the reconnecting logic background task."""
|
||||||
# Create reconnection loop outside of HA's tracked tasks in order
|
# Create reconnection loop outside of HA's tracked tasks in order
|
||||||
# not to delay startup.
|
# not to delay startup.
|
||||||
|
@ -467,7 +474,7 @@ class ReconnectLogic(RecordUpdateListener):
|
||||||
self._connected = False
|
self._connected = False
|
||||||
self._reconnect_event.set()
|
self._reconnect_event.set()
|
||||||
|
|
||||||
async def stop(self):
|
async def stop(self) -> None:
|
||||||
"""Stop the reconnecting logic background task. Does not disconnect the client."""
|
"""Stop the reconnecting logic background task. Does not disconnect the client."""
|
||||||
if self._loop_task is not None:
|
if self._loop_task is not None:
|
||||||
self._loop_task.cancel()
|
self._loop_task.cancel()
|
||||||
|
@ -478,7 +485,7 @@ class ReconnectLogic(RecordUpdateListener):
|
||||||
self._wait_task = None
|
self._wait_task = None
|
||||||
await self._stop_zc_listen()
|
await self._stop_zc_listen()
|
||||||
|
|
||||||
async def _start_zc_listen(self):
|
async def _start_zc_listen(self) -> None:
|
||||||
"""Listen for mDNS records.
|
"""Listen for mDNS records.
|
||||||
|
|
||||||
This listener allows us to schedule a reconnect as soon as a
|
This listener allows us to schedule a reconnect as soon as a
|
||||||
|
@ -491,7 +498,7 @@ class ReconnectLogic(RecordUpdateListener):
|
||||||
)
|
)
|
||||||
self._zc_listening = True
|
self._zc_listening = True
|
||||||
|
|
||||||
async def _stop_zc_listen(self):
|
async def _stop_zc_listen(self) -> None:
|
||||||
"""Stop listening for zeroconf updates."""
|
"""Stop listening for zeroconf updates."""
|
||||||
async with self._zc_lock:
|
async with self._zc_lock:
|
||||||
if self._zc_listening:
|
if self._zc_listening:
|
||||||
|
@ -499,12 +506,12 @@ class ReconnectLogic(RecordUpdateListener):
|
||||||
self._zc_listening = False
|
self._zc_listening = False
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def stop_callback(self):
|
def stop_callback(self) -> None:
|
||||||
"""Stop as an async callback function."""
|
"""Stop as an async callback function."""
|
||||||
self._hass.async_create_task(self.stop())
|
self._hass.async_create_task(self.stop())
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _set_reconnect(self):
|
def _set_reconnect(self) -> None:
|
||||||
self._reconnect_event.set()
|
self._reconnect_event.set()
|
||||||
|
|
||||||
def update_record(self, zc: Zeroconf, now: float, record: DNSRecord) -> None:
|
def update_record(self, zc: Zeroconf, now: float, record: DNSRecord) -> None:
|
||||||
|
@ -535,13 +542,13 @@ class ReconnectLogic(RecordUpdateListener):
|
||||||
|
|
||||||
async def _async_setup_device_registry(
|
async def _async_setup_device_registry(
|
||||||
hass: HomeAssistant, entry: ConfigEntry, device_info: EsphomeDeviceInfo
|
hass: HomeAssistant, entry: ConfigEntry, device_info: EsphomeDeviceInfo
|
||||||
):
|
) -> str:
|
||||||
"""Set up device registry feature for a particular config entry."""
|
"""Set up device registry feature for a particular config entry."""
|
||||||
sw_version = device_info.esphome_version
|
sw_version = device_info.esphome_version
|
||||||
if device_info.compilation_time:
|
if device_info.compilation_time:
|
||||||
sw_version += f" ({device_info.compilation_time})"
|
sw_version += f" ({device_info.compilation_time})"
|
||||||
device_registry = await dr.async_get_registry(hass)
|
device_registry = await dr.async_get_registry(hass)
|
||||||
entry = device_registry.async_get_or_create(
|
device_entry = device_registry.async_get_or_create(
|
||||||
config_entry_id=entry.entry_id,
|
config_entry_id=entry.entry_id,
|
||||||
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)},
|
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)},
|
||||||
name=device_info.name,
|
name=device_info.name,
|
||||||
|
@ -549,18 +556,10 @@ async def _async_setup_device_registry(
|
||||||
model=device_info.model,
|
model=device_info.model,
|
||||||
sw_version=sw_version,
|
sw_version=sw_version,
|
||||||
)
|
)
|
||||||
return entry.id
|
return device_entry.id
|
||||||
|
|
||||||
|
|
||||||
async def _register_service(
|
ARG_TYPE_METADATA = {
|
||||||
hass: HomeAssistant, entry_data: RuntimeEntryData, service: UserService
|
|
||||||
):
|
|
||||||
service_name = f"{entry_data.device_info.name.replace('-', '_')}_{service.name}"
|
|
||||||
schema = {}
|
|
||||||
fields = {}
|
|
||||||
|
|
||||||
for arg in service.args:
|
|
||||||
metadata = {
|
|
||||||
UserServiceArgType.BOOL: {
|
UserServiceArgType.BOOL: {
|
||||||
"validator": cv.boolean,
|
"validator": cv.boolean,
|
||||||
"example": "False",
|
"example": "False",
|
||||||
|
@ -605,7 +604,28 @@ async def _register_service(
|
||||||
"example": "['Example text', 'Another example']",
|
"example": "['Example text', 'Another example']",
|
||||||
"selector": {"object": {}},
|
"selector": {"object": {}},
|
||||||
},
|
},
|
||||||
}[arg.type]
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _register_service(
|
||||||
|
hass: HomeAssistant, entry_data: RuntimeEntryData, service: UserService
|
||||||
|
) -> None:
|
||||||
|
if entry_data.device_info is None:
|
||||||
|
raise ValueError("Device Info needs to be fetched first")
|
||||||
|
service_name = f"{entry_data.device_info.name.replace('-', '_')}_{service.name}"
|
||||||
|
schema = {}
|
||||||
|
fields = {}
|
||||||
|
|
||||||
|
for arg in service.args:
|
||||||
|
if arg.type not in ARG_TYPE_METADATA:
|
||||||
|
_LOGGER.error(
|
||||||
|
"Can't register service %s because %s is of unknown type %s",
|
||||||
|
service_name,
|
||||||
|
arg.name,
|
||||||
|
arg.type,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
metadata = ARG_TYPE_METADATA[arg.type]
|
||||||
schema[vol.Required(arg.name)] = metadata["validator"]
|
schema[vol.Required(arg.name)] = metadata["validator"]
|
||||||
fields[arg.name] = {
|
fields[arg.name] = {
|
||||||
"name": arg.name,
|
"name": arg.name,
|
||||||
|
@ -615,8 +635,8 @@ async def _register_service(
|
||||||
"selector": metadata["selector"],
|
"selector": metadata["selector"],
|
||||||
}
|
}
|
||||||
|
|
||||||
async def execute_service(call):
|
async def execute_service(call: ServiceCall) -> None:
|
||||||
await entry_data.client.execute_service(service, call.data)
|
await entry_data.client.execute_service(service, call.data) # type: ignore[arg-type]
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
DOMAIN, service_name, execute_service, vol.Schema(schema)
|
DOMAIN, service_name, execute_service, vol.Schema(schema)
|
||||||
|
@ -632,7 +652,7 @@ async def _register_service(
|
||||||
|
|
||||||
async def _setup_services(
|
async def _setup_services(
|
||||||
hass: HomeAssistant, entry_data: RuntimeEntryData, services: list[UserService]
|
hass: HomeAssistant, entry_data: RuntimeEntryData, services: list[UserService]
|
||||||
):
|
) -> None:
|
||||||
old_services = entry_data.services.copy()
|
old_services = entry_data.services.copy()
|
||||||
to_unregister = []
|
to_unregister = []
|
||||||
to_register = []
|
to_register = []
|
||||||
|
@ -653,6 +673,7 @@ async def _setup_services(
|
||||||
|
|
||||||
entry_data.services = {serv.key: serv for serv in services}
|
entry_data.services = {serv.key: serv for serv in services}
|
||||||
|
|
||||||
|
assert entry_data.device_info is not None
|
||||||
for service in to_unregister:
|
for service in to_unregister:
|
||||||
service_name = f"{entry_data.device_info.name}_{service.name}"
|
service_name = f"{entry_data.device_info.name}_{service.name}"
|
||||||
hass.services.async_remove(DOMAIN, service_name)
|
hass.services.async_remove(DOMAIN, service_name)
|
||||||
|
@ -688,15 +709,20 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
await DomainData.get(hass).get_or_create_store(hass, entry).async_remove()
|
await DomainData.get(hass).get_or_create_store(hass, entry).async_remove()
|
||||||
|
|
||||||
|
|
||||||
|
_InfoT = TypeVar("_InfoT", bound=EntityInfo)
|
||||||
|
_EntityT = TypeVar("_EntityT", bound="EsphomeBaseEntity[Any,Any]")
|
||||||
|
_StateT = TypeVar("_StateT", bound=EntityState)
|
||||||
|
|
||||||
|
|
||||||
async def platform_async_setup_entry(
|
async def platform_async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
async_add_entities,
|
async_add_entities: AddEntitiesCallback,
|
||||||
*,
|
*,
|
||||||
component_key: str,
|
component_key: str,
|
||||||
info_type,
|
info_type: type[_InfoT],
|
||||||
entity_type,
|
entity_type: type[_EntityT],
|
||||||
state_type,
|
state_type: type[_StateT],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up an esphome platform.
|
"""Set up an esphome platform.
|
||||||
|
|
||||||
|
@ -709,15 +735,17 @@ async def platform_async_setup_entry(
|
||||||
entry_data.state[component_key] = {}
|
entry_data.state[component_key] = {}
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_list_entities(infos: list[EntityInfo]):
|
def async_list_entities(infos: list[EntityInfo]) -> None:
|
||||||
"""Update entities of this platform when entities are listed."""
|
"""Update entities of this platform when entities are listed."""
|
||||||
old_infos = entry_data.info[component_key]
|
old_infos = entry_data.info[component_key]
|
||||||
new_infos = {}
|
new_infos: dict[int, EntityInfo] = {}
|
||||||
add_entities = []
|
add_entities = []
|
||||||
for info in infos:
|
for info in infos:
|
||||||
if not isinstance(info, info_type):
|
if not isinstance(info, info_type):
|
||||||
# Filter out infos that don't belong to this platform.
|
# Filter out infos that don't belong to this platform.
|
||||||
continue
|
continue
|
||||||
|
# cast back to upper type, otherwise mypy gets confused
|
||||||
|
info = cast(EntityInfo, info)
|
||||||
|
|
||||||
if info.key in old_infos:
|
if info.key in old_infos:
|
||||||
# Update existing entity
|
# Update existing entity
|
||||||
|
@ -746,10 +774,13 @@ async def platform_async_setup_entry(
|
||||||
)
|
)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_entity_state(state: EntityState):
|
def async_entity_state(state: EntityState) -> None:
|
||||||
"""Notify the appropriate entity of an updated state."""
|
"""Notify the appropriate entity of an updated state."""
|
||||||
if not isinstance(state, state_type):
|
if not isinstance(state, state_type):
|
||||||
return
|
return
|
||||||
|
# cast back to upper type, otherwise mypy gets confused
|
||||||
|
state = cast(EntityState, state)
|
||||||
|
|
||||||
entry_data.state[component_key][state.key] = state
|
entry_data.state[component_key][state.key] = state
|
||||||
entry_data.async_update_entity(hass, component_key, state.key)
|
entry_data.async_update_entity(hass, component_key, state.key)
|
||||||
|
|
||||||
|
@ -759,16 +790,20 @@ async def platform_async_setup_entry(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def esphome_state_property(func):
|
_PropT = TypeVar("_PropT", bound=Callable[..., Any])
|
||||||
|
|
||||||
|
|
||||||
|
def esphome_state_property(func: _PropT) -> _PropT:
|
||||||
"""Wrap a state property of an esphome entity.
|
"""Wrap a state property of an esphome entity.
|
||||||
|
|
||||||
This checks if the state object in the entity is set, and
|
This checks if the state object in the entity is set, and
|
||||||
prevents writing NAN values to the Home Assistant state machine.
|
prevents writing NAN values to the Home Assistant state machine.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@property
|
@property # type: ignore[misc]
|
||||||
def _wrapper(self):
|
@functools.wraps(func)
|
||||||
if self._state is None:
|
def _wrapper(self): # type: ignore[no-untyped-def]
|
||||||
|
if not self._has_state:
|
||||||
return None
|
return None
|
||||||
val = func(self)
|
val = func(self)
|
||||||
if isinstance(val, float) and math.isnan(val):
|
if isinstance(val, float) and math.isnan(val):
|
||||||
|
@ -777,29 +812,43 @@ def esphome_state_property(func):
|
||||||
return None
|
return None
|
||||||
return val
|
return val
|
||||||
|
|
||||||
return _wrapper
|
return cast(_PropT, _wrapper)
|
||||||
|
|
||||||
|
|
||||||
class EsphomeEnumMapper(Generic[_T]):
|
_EnumT = TypeVar("_EnumT", bound=APIIntEnum)
|
||||||
|
_ValT = TypeVar("_ValT")
|
||||||
|
|
||||||
|
|
||||||
|
class EsphomeEnumMapper(Generic[_EnumT, _ValT]):
|
||||||
"""Helper class to convert between hass and esphome enum values."""
|
"""Helper class to convert between hass and esphome enum values."""
|
||||||
|
|
||||||
def __init__(self, mapping: dict[_T, str]) -> None:
|
def __init__(self, mapping: dict[_EnumT, _ValT]) -> None:
|
||||||
"""Construct a EsphomeEnumMapper."""
|
"""Construct a EsphomeEnumMapper."""
|
||||||
# Add none mapping
|
# Add none mapping
|
||||||
mapping = {None: None, **mapping}
|
augmented_mapping: dict[_EnumT | None, _ValT | None] = mapping # type: ignore[assignment]
|
||||||
self._mapping = mapping
|
augmented_mapping[None] = None
|
||||||
self._inverse: dict[str, _T] = {v: k for k, v in mapping.items()}
|
|
||||||
|
|
||||||
def from_esphome(self, value: _T | None) -> str | None:
|
self._mapping = augmented_mapping
|
||||||
|
self._inverse: dict[_ValT, _EnumT] = {v: k for k, v in mapping.items()}
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def from_esphome(self, value: _EnumT) -> _ValT:
|
||||||
|
...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def from_esphome(self, value: _EnumT | None) -> _ValT | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def from_esphome(self, value: _EnumT | None) -> _ValT | None:
|
||||||
"""Convert from an esphome int representation to a hass string."""
|
"""Convert from an esphome int representation to a hass string."""
|
||||||
return self._mapping[value]
|
return self._mapping[value]
|
||||||
|
|
||||||
def from_hass(self, value: str) -> _T:
|
def from_hass(self, value: _ValT) -> _EnumT:
|
||||||
"""Convert from a hass string to a esphome int representation."""
|
"""Convert from a hass string to a esphome int representation."""
|
||||||
return self._inverse[value]
|
return self._inverse[value]
|
||||||
|
|
||||||
|
|
||||||
class EsphomeBaseEntity(Entity):
|
class EsphomeBaseEntity(Entity, Generic[_InfoT, _StateT]):
|
||||||
"""Define a base esphome entity."""
|
"""Define a base esphome entity."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
|
@ -850,17 +899,18 @@ class EsphomeBaseEntity(Entity):
|
||||||
return self._entry_data.api_version
|
return self._entry_data.api_version
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _static_info(self) -> EntityInfo:
|
def _static_info(self) -> _InfoT:
|
||||||
# Check if value is in info database. Use a single lookup.
|
# Check if value is in info database. Use a single lookup.
|
||||||
info = self._entry_data.info[self._component_key].get(self._key)
|
info = self._entry_data.info[self._component_key].get(self._key)
|
||||||
if info is not None:
|
if info is not None:
|
||||||
return info
|
return cast(_InfoT, info)
|
||||||
# This entity is in the removal project and has been removed from .info
|
# This entity is in the removal project and has been removed from .info
|
||||||
# already, look in old_info
|
# already, look in old_info
|
||||||
return self._entry_data.old_info[self._component_key].get(self._key)
|
return cast(_InfoT, self._entry_data.old_info[self._component_key][self._key])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _device_info(self) -> EsphomeDeviceInfo:
|
def _device_info(self) -> EsphomeDeviceInfo:
|
||||||
|
assert self._entry_data.device_info is not None
|
||||||
return self._entry_data.device_info
|
return self._entry_data.device_info
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -868,11 +918,12 @@ class EsphomeBaseEntity(Entity):
|
||||||
return self._entry_data.client
|
return self._entry_data.client
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _state(self) -> EntityState | None:
|
def _state(self) -> _StateT:
|
||||||
try:
|
return cast(_StateT, self._entry_data.state[self._component_key][self._key])
|
||||||
return self._entry_data.state[self._component_key][self._key]
|
|
||||||
except KeyError:
|
@property
|
||||||
return None
|
def _has_state(self) -> bool:
|
||||||
|
return self._key in self._entry_data.state[self._component_key]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
|
@ -911,7 +962,7 @@ class EsphomeBaseEntity(Entity):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
class EsphomeEntity(EsphomeBaseEntity):
|
class EsphomeEntity(EsphomeBaseEntity[_InfoT, _StateT]):
|
||||||
"""Define a generic esphome entity."""
|
"""Define a generic esphome entity."""
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
|
|
|
@ -4,11 +4,16 @@ from __future__ import annotations
|
||||||
from aioesphomeapi import BinarySensorInfo, BinarySensorState
|
from aioesphomeapi import BinarySensorInfo, BinarySensorState
|
||||||
|
|
||||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from . import EsphomeEntity, platform_async_setup_entry
|
from . import EsphomeEntity, platform_async_setup_entry
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass, entry, async_add_entities):
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||||
|
) -> None:
|
||||||
"""Set up ESPHome binary sensors based on a config entry."""
|
"""Set up ESPHome binary sensors based on a config entry."""
|
||||||
await platform_async_setup_entry(
|
await platform_async_setup_entry(
|
||||||
hass,
|
hass,
|
||||||
|
@ -21,17 +26,15 @@ async def async_setup_entry(hass, entry, async_add_entities):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class EsphomeBinarySensor(EsphomeEntity, BinarySensorEntity):
|
# Pylint gets confused with the EsphomeEntity generics -> let mypy handle member checking
|
||||||
|
# pylint: disable=no-member
|
||||||
|
|
||||||
|
|
||||||
|
class EsphomeBinarySensor(
|
||||||
|
EsphomeEntity[BinarySensorInfo, BinarySensorState], BinarySensorEntity
|
||||||
|
):
|
||||||
"""A binary sensor implementation for ESPHome."""
|
"""A binary sensor implementation for ESPHome."""
|
||||||
|
|
||||||
@property
|
|
||||||
def _static_info(self) -> BinarySensorInfo:
|
|
||||||
return super()._static_info
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _state(self) -> BinarySensorState | None:
|
|
||||||
return super()._state
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_on(self) -> bool | None:
|
def is_on(self) -> bool | None:
|
||||||
"""Return true if the binary sensor is on."""
|
"""Return true if the binary sensor is on."""
|
||||||
|
@ -39,7 +42,7 @@ class EsphomeBinarySensor(EsphomeEntity, BinarySensorEntity):
|
||||||
# Status binary sensors indicated connected state.
|
# Status binary sensors indicated connected state.
|
||||||
# So in their case what's usually _availability_ is now state
|
# So in their case what's usually _availability_ is now state
|
||||||
return self._entry_data.available
|
return self._entry_data.available
|
||||||
if self._state is None:
|
if not self._has_state:
|
||||||
return None
|
return None
|
||||||
if self._state.missing_state:
|
if self._state.missing_state:
|
||||||
return None
|
return None
|
||||||
|
|
|
@ -2,20 +2,23 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from aioesphomeapi import CameraInfo, CameraState
|
from aioesphomeapi import CameraInfo, CameraState
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
from homeassistant.components import camera
|
from homeassistant.components import camera
|
||||||
from homeassistant.components.camera import Camera
|
from homeassistant.components.camera import Camera
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from . import EsphomeBaseEntity, platform_async_setup_entry
|
from . import EsphomeBaseEntity, platform_async_setup_entry
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities
|
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up esphome cameras based on a config entry."""
|
"""Set up esphome cameras based on a config entry."""
|
||||||
await platform_async_setup_entry(
|
await platform_async_setup_entry(
|
||||||
|
@ -29,23 +32,19 @@ async def async_setup_entry(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class EsphomeCamera(Camera, EsphomeBaseEntity):
|
# Pylint gets confused with the EsphomeEntity generics -> let mypy handle member checking
|
||||||
|
# pylint: disable=no-member
|
||||||
|
|
||||||
|
|
||||||
|
class EsphomeCamera(Camera, EsphomeBaseEntity[CameraInfo, CameraState]):
|
||||||
"""A camera implementation for ESPHome."""
|
"""A camera implementation for ESPHome."""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs) -> None:
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||||
"""Initialize."""
|
"""Initialize."""
|
||||||
Camera.__init__(self)
|
Camera.__init__(self)
|
||||||
EsphomeBaseEntity.__init__(self, *args, **kwargs)
|
EsphomeBaseEntity.__init__(self, *args, **kwargs)
|
||||||
self._image_cond = asyncio.Condition()
|
self._image_cond = asyncio.Condition()
|
||||||
|
|
||||||
@property
|
|
||||||
def _static_info(self) -> CameraInfo:
|
|
||||||
return super()._static_info
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _state(self) -> CameraState | None:
|
|
||||||
return super()._state
|
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Register callbacks."""
|
"""Register callbacks."""
|
||||||
|
|
||||||
|
@ -90,7 +89,9 @@ class EsphomeCamera(Camera, EsphomeBaseEntity):
|
||||||
return None
|
return None
|
||||||
return self._state.data[:]
|
return self._state.data[:]
|
||||||
|
|
||||||
async def handle_async_mjpeg_stream(self, request):
|
async def handle_async_mjpeg_stream(
|
||||||
|
self, request: web.Request
|
||||||
|
) -> web.StreamResponse:
|
||||||
"""Serve an HTTP MJPEG stream from the camera."""
|
"""Serve an HTTP MJPEG stream from the camera."""
|
||||||
return await camera.async_get_still_stream(
|
return await camera.async_get_still_stream(
|
||||||
request, self._async_camera_stream_image, camera.DEFAULT_CONTENT_TYPE, 0.0
|
request, self._async_camera_stream_image, camera.DEFAULT_CONTENT_TYPE, 0.0
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
"""Support for ESPHome climate devices."""
|
"""Support for ESPHome climate devices."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
from aioesphomeapi import (
|
from aioesphomeapi import (
|
||||||
ClimateAction,
|
ClimateAction,
|
||||||
ClimateFanMode,
|
ClimateFanMode,
|
||||||
|
@ -56,6 +58,7 @@ from homeassistant.components.climate.const import (
|
||||||
SWING_OFF,
|
SWING_OFF,
|
||||||
SWING_VERTICAL,
|
SWING_VERTICAL,
|
||||||
)
|
)
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
ATTR_TEMPERATURE,
|
ATTR_TEMPERATURE,
|
||||||
PRECISION_HALVES,
|
PRECISION_HALVES,
|
||||||
|
@ -63,6 +66,8 @@ from homeassistant.const import (
|
||||||
PRECISION_WHOLE,
|
PRECISION_WHOLE,
|
||||||
TEMP_CELSIUS,
|
TEMP_CELSIUS,
|
||||||
)
|
)
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from . import (
|
from . import (
|
||||||
EsphomeEntity,
|
EsphomeEntity,
|
||||||
|
@ -72,7 +77,9 @@ from . import (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass, entry, async_add_entities):
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||||
|
) -> None:
|
||||||
"""Set up ESPHome climate devices based on a config entry."""
|
"""Set up ESPHome climate devices based on a config entry."""
|
||||||
await platform_async_setup_entry(
|
await platform_async_setup_entry(
|
||||||
hass,
|
hass,
|
||||||
|
@ -85,7 +92,7 @@ async def async_setup_entry(hass, entry, async_add_entities):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_CLIMATE_MODES: EsphomeEnumMapper[ClimateMode] = EsphomeEnumMapper(
|
_CLIMATE_MODES: EsphomeEnumMapper[ClimateMode, str] = EsphomeEnumMapper(
|
||||||
{
|
{
|
||||||
ClimateMode.OFF: HVAC_MODE_OFF,
|
ClimateMode.OFF: HVAC_MODE_OFF,
|
||||||
ClimateMode.HEAT_COOL: HVAC_MODE_HEAT_COOL,
|
ClimateMode.HEAT_COOL: HVAC_MODE_HEAT_COOL,
|
||||||
|
@ -96,7 +103,7 @@ _CLIMATE_MODES: EsphomeEnumMapper[ClimateMode] = EsphomeEnumMapper(
|
||||||
ClimateMode.AUTO: HVAC_MODE_AUTO,
|
ClimateMode.AUTO: HVAC_MODE_AUTO,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
_CLIMATE_ACTIONS: EsphomeEnumMapper[ClimateAction] = EsphomeEnumMapper(
|
_CLIMATE_ACTIONS: EsphomeEnumMapper[ClimateAction, str] = EsphomeEnumMapper(
|
||||||
{
|
{
|
||||||
ClimateAction.OFF: CURRENT_HVAC_OFF,
|
ClimateAction.OFF: CURRENT_HVAC_OFF,
|
||||||
ClimateAction.COOLING: CURRENT_HVAC_COOL,
|
ClimateAction.COOLING: CURRENT_HVAC_COOL,
|
||||||
|
@ -106,7 +113,7 @@ _CLIMATE_ACTIONS: EsphomeEnumMapper[ClimateAction] = EsphomeEnumMapper(
|
||||||
ClimateAction.FAN: CURRENT_HVAC_FAN,
|
ClimateAction.FAN: CURRENT_HVAC_FAN,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
_FAN_MODES: EsphomeEnumMapper[ClimateFanMode] = EsphomeEnumMapper(
|
_FAN_MODES: EsphomeEnumMapper[ClimateFanMode, str] = EsphomeEnumMapper(
|
||||||
{
|
{
|
||||||
ClimateFanMode.ON: FAN_ON,
|
ClimateFanMode.ON: FAN_ON,
|
||||||
ClimateFanMode.OFF: FAN_OFF,
|
ClimateFanMode.OFF: FAN_OFF,
|
||||||
|
@ -119,7 +126,7 @@ _FAN_MODES: EsphomeEnumMapper[ClimateFanMode] = EsphomeEnumMapper(
|
||||||
ClimateFanMode.DIFFUSE: FAN_DIFFUSE,
|
ClimateFanMode.DIFFUSE: FAN_DIFFUSE,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
_SWING_MODES: EsphomeEnumMapper[ClimateSwingMode] = EsphomeEnumMapper(
|
_SWING_MODES: EsphomeEnumMapper[ClimateSwingMode, str] = EsphomeEnumMapper(
|
||||||
{
|
{
|
||||||
ClimateSwingMode.OFF: SWING_OFF,
|
ClimateSwingMode.OFF: SWING_OFF,
|
||||||
ClimateSwingMode.BOTH: SWING_BOTH,
|
ClimateSwingMode.BOTH: SWING_BOTH,
|
||||||
|
@ -127,7 +134,7 @@ _SWING_MODES: EsphomeEnumMapper[ClimateSwingMode] = EsphomeEnumMapper(
|
||||||
ClimateSwingMode.HORIZONTAL: SWING_HORIZONTAL,
|
ClimateSwingMode.HORIZONTAL: SWING_HORIZONTAL,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
_PRESETS: EsphomeEnumMapper[ClimatePreset] = EsphomeEnumMapper(
|
_PRESETS: EsphomeEnumMapper[ClimatePreset, str] = EsphomeEnumMapper(
|
||||||
{
|
{
|
||||||
ClimatePreset.NONE: PRESET_NONE,
|
ClimatePreset.NONE: PRESET_NONE,
|
||||||
ClimatePreset.HOME: PRESET_HOME,
|
ClimatePreset.HOME: PRESET_HOME,
|
||||||
|
@ -141,17 +148,14 @@ _PRESETS: EsphomeEnumMapper[ClimatePreset] = EsphomeEnumMapper(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class EsphomeClimateEntity(EsphomeEntity, ClimateEntity):
|
# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property
|
||||||
|
# Pylint gets confused with the EsphomeEntity generics -> let mypy handle member checking
|
||||||
|
# pylint: disable=invalid-overridden-method,no-member
|
||||||
|
|
||||||
|
|
||||||
|
class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEntity):
|
||||||
"""A climate implementation for ESPHome."""
|
"""A climate implementation for ESPHome."""
|
||||||
|
|
||||||
@property
|
|
||||||
def _static_info(self) -> ClimateInfo:
|
|
||||||
return super()._static_info
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _state(self) -> ClimateState | None:
|
|
||||||
return super()._state
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def precision(self) -> float:
|
def precision(self) -> float:
|
||||||
"""Return the precision of the climate device."""
|
"""Return the precision of the climate device."""
|
||||||
|
@ -192,7 +196,7 @@ class EsphomeClimateEntity(EsphomeEntity, ClimateEntity):
|
||||||
] + self._static_info.supported_custom_presets
|
] + self._static_info.supported_custom_presets
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def swing_modes(self):
|
def swing_modes(self) -> list[str]:
|
||||||
"""Return the list of available swing modes."""
|
"""Return the list of available swing modes."""
|
||||||
return [
|
return [
|
||||||
_SWING_MODES.from_esphome(mode)
|
_SWING_MODES.from_esphome(mode)
|
||||||
|
@ -231,11 +235,8 @@ class EsphomeClimateEntity(EsphomeEntity, ClimateEntity):
|
||||||
features |= SUPPORT_SWING_MODE
|
features |= SUPPORT_SWING_MODE
|
||||||
return features
|
return features
|
||||||
|
|
||||||
# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property
|
|
||||||
# pylint: disable=invalid-overridden-method
|
|
||||||
|
|
||||||
@esphome_state_property
|
@esphome_state_property
|
||||||
def hvac_mode(self) -> str | None:
|
def hvac_mode(self) -> str | None: # type: ignore[override]
|
||||||
"""Return current operation ie. heat, cool, idle."""
|
"""Return current operation ie. heat, cool, idle."""
|
||||||
return _CLIMATE_MODES.from_esphome(self._state.mode)
|
return _CLIMATE_MODES.from_esphome(self._state.mode)
|
||||||
|
|
||||||
|
@ -286,11 +287,11 @@ class EsphomeClimateEntity(EsphomeEntity, ClimateEntity):
|
||||||
"""Return the highbound target temperature we try to reach."""
|
"""Return the highbound target temperature we try to reach."""
|
||||||
return self._state.target_temperature_high
|
return self._state.target_temperature_high
|
||||||
|
|
||||||
async def async_set_temperature(self, **kwargs) -> None:
|
async def async_set_temperature(self, **kwargs: float | str) -> None:
|
||||||
"""Set new target temperature (and operation mode if set)."""
|
"""Set new target temperature (and operation mode if set)."""
|
||||||
data = {"key": self._static_info.key}
|
data: dict[str, Any] = {"key": self._static_info.key}
|
||||||
if ATTR_HVAC_MODE in kwargs:
|
if ATTR_HVAC_MODE in kwargs:
|
||||||
data["mode"] = _CLIMATE_MODES.from_hass(kwargs[ATTR_HVAC_MODE])
|
data["mode"] = _CLIMATE_MODES.from_hass(cast(str, kwargs[ATTR_HVAC_MODE]))
|
||||||
if ATTR_TEMPERATURE in kwargs:
|
if ATTR_TEMPERATURE in kwargs:
|
||||||
data["target_temperature"] = kwargs[ATTR_TEMPERATURE]
|
data["target_temperature"] = kwargs[ATTR_TEMPERATURE]
|
||||||
if ATTR_TARGET_TEMP_LOW in kwargs:
|
if ATTR_TARGET_TEMP_LOW in kwargs:
|
||||||
|
@ -307,21 +308,21 @@ class EsphomeClimateEntity(EsphomeEntity, ClimateEntity):
|
||||||
|
|
||||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||||
"""Set preset mode."""
|
"""Set preset mode."""
|
||||||
kwargs = {}
|
kwargs: dict[str, Any] = {"key": self._static_info.key}
|
||||||
if preset_mode in self._static_info.supported_custom_presets:
|
if preset_mode in self._static_info.supported_custom_presets:
|
||||||
kwargs["custom_preset"] = preset_mode
|
kwargs["custom_preset"] = preset_mode
|
||||||
else:
|
else:
|
||||||
kwargs["preset"] = _PRESETS.from_hass(preset_mode)
|
kwargs["preset"] = _PRESETS.from_hass(preset_mode)
|
||||||
await self._client.climate_command(key=self._static_info.key, **kwargs)
|
await self._client.climate_command(**kwargs)
|
||||||
|
|
||||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||||
"""Set new fan mode."""
|
"""Set new fan mode."""
|
||||||
kwargs = {}
|
kwargs: dict[str, Any] = {"key": self._static_info.key}
|
||||||
if fan_mode in self._static_info.supported_custom_fan_modes:
|
if fan_mode in self._static_info.supported_custom_fan_modes:
|
||||||
kwargs["custom_fan_mode"] = fan_mode
|
kwargs["custom_fan_mode"] = fan_mode
|
||||||
else:
|
else:
|
||||||
kwargs["fan_mode"] = _FAN_MODES.from_hass(fan_mode)
|
kwargs["fan_mode"] = _FAN_MODES.from_hass(fan_mode)
|
||||||
await self._client.climate_command(key=self._static_info.key, **kwargs)
|
await self._client.climate_command(**kwargs)
|
||||||
|
|
||||||
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
||||||
"""Set new swing mode."""
|
"""Set new swing mode."""
|
||||||
|
|
|
@ -2,14 +2,16 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from aioesphomeapi import APIClient, APIConnectionError
|
from aioesphomeapi import APIClient, APIConnectionError, DeviceInfo
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components import zeroconf
|
from homeassistant.components import zeroconf
|
||||||
from homeassistant.config_entries import ConfigFlow
|
from homeassistant.config_entries import ConfigFlow
|
||||||
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT
|
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
|
||||||
from . import DOMAIN, DomainData
|
from . import DOMAIN, DomainData
|
||||||
|
@ -20,20 +22,19 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||||
|
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self) -> None:
|
||||||
"""Initialize flow."""
|
"""Initialize flow."""
|
||||||
self._host: str | None = None
|
self._host: str | None = None
|
||||||
self._port: int | None = None
|
self._port: int | None = None
|
||||||
self._password: str | None = None
|
self._password: str | None = None
|
||||||
|
|
||||||
async def async_step_user(
|
async def _async_step_user_base(
|
||||||
self, user_input: ConfigType | None = None, error: str | None = None
|
self, user_input: ConfigType | None = None, error: str | None = None
|
||||||
): # pylint: disable=arguments-differ
|
) -> FlowResult:
|
||||||
"""Handle a flow initialized by the user."""
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
return await self._async_authenticate_or_add(user_input)
|
return await self._async_authenticate_or_add(user_input)
|
||||||
|
|
||||||
fields = OrderedDict()
|
fields: dict[Any, type] = OrderedDict()
|
||||||
fields[vol.Required(CONF_HOST, default=self._host or vol.UNDEFINED)] = str
|
fields[vol.Required(CONF_HOST, default=self._host or vol.UNDEFINED)] = str
|
||||||
fields[vol.Optional(CONF_PORT, default=self._port or 6053)] = int
|
fields[vol.Optional(CONF_PORT, default=self._port or 6053)] = int
|
||||||
|
|
||||||
|
@ -45,26 +46,33 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||||
step_id="user", data_schema=vol.Schema(fields), errors=errors
|
step_id="user", data_schema=vol.Schema(fields), errors=errors
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def async_step_user(self, user_input: ConfigType | None = None) -> FlowResult:
|
||||||
|
"""Handle a flow initialized by the user."""
|
||||||
|
return await self._async_step_user_base(user_input=user_input)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _name(self):
|
def _name(self) -> str | None:
|
||||||
return self.context.get(CONF_NAME)
|
return self.context.get(CONF_NAME)
|
||||||
|
|
||||||
@_name.setter
|
@_name.setter
|
||||||
def _name(self, value):
|
def _name(self, value: str) -> None:
|
||||||
self.context[CONF_NAME] = value
|
self.context[CONF_NAME] = value
|
||||||
self.context["title_placeholders"] = {"name": self._name}
|
self.context["title_placeholders"] = {"name": self._name}
|
||||||
|
|
||||||
def _set_user_input(self, user_input):
|
def _set_user_input(self, user_input: ConfigType | None) -> None:
|
||||||
if user_input is None:
|
if user_input is None:
|
||||||
return
|
return
|
||||||
self._host = user_input[CONF_HOST]
|
self._host = user_input[CONF_HOST]
|
||||||
self._port = user_input[CONF_PORT]
|
self._port = user_input[CONF_PORT]
|
||||||
|
|
||||||
async def _async_authenticate_or_add(self, user_input):
|
async def _async_authenticate_or_add(
|
||||||
|
self, user_input: ConfigType | None
|
||||||
|
) -> FlowResult:
|
||||||
self._set_user_input(user_input)
|
self._set_user_input(user_input)
|
||||||
error, device_info = await self.fetch_device_info()
|
error, device_info = await self.fetch_device_info()
|
||||||
if error is not None:
|
if error is not None:
|
||||||
return await self.async_step_user(error=error)
|
return await self._async_step_user_base(error=error)
|
||||||
|
assert device_info is not None
|
||||||
self._name = device_info.name
|
self._name = device_info.name
|
||||||
|
|
||||||
# Only show authentication step if device uses password
|
# Only show authentication step if device uses password
|
||||||
|
@ -73,7 +81,9 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||||
|
|
||||||
return self._async_get_entry()
|
return self._async_get_entry()
|
||||||
|
|
||||||
async def async_step_discovery_confirm(self, user_input=None):
|
async def async_step_discovery_confirm(
|
||||||
|
self, user_input: ConfigType | None = None
|
||||||
|
) -> FlowResult:
|
||||||
"""Handle user-confirmation of discovered node."""
|
"""Handle user-confirmation of discovered node."""
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
return await self._async_authenticate_or_add(None)
|
return await self._async_authenticate_or_add(None)
|
||||||
|
@ -81,7 +91,9 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||||
step_id="discovery_confirm", description_placeholders={"name": self._name}
|
step_id="discovery_confirm", description_placeholders={"name": self._name}
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_step_zeroconf(self, discovery_info: DiscoveryInfoType):
|
async def async_step_zeroconf(
|
||||||
|
self, discovery_info: DiscoveryInfoType
|
||||||
|
) -> FlowResult:
|
||||||
"""Handle zeroconf discovery."""
|
"""Handle zeroconf discovery."""
|
||||||
# Hostname is format: livingroom.local.
|
# Hostname is format: livingroom.local.
|
||||||
local_name = discovery_info["hostname"][:-1]
|
local_name = discovery_info["hostname"][:-1]
|
||||||
|
@ -129,7 +141,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||||
return await self.async_step_discovery_confirm()
|
return await self.async_step_discovery_confirm()
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _async_get_entry(self):
|
def _async_get_entry(self) -> FlowResult:
|
||||||
|
assert self._name is not None
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title=self._name,
|
title=self._name,
|
||||||
data={
|
data={
|
||||||
|
@ -140,7 +153,9 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_step_authenticate(self, user_input=None, error=None):
|
async def async_step_authenticate(
|
||||||
|
self, user_input: ConfigType | None = None, error: str | None = None
|
||||||
|
) -> FlowResult:
|
||||||
"""Handle getting password for authentication."""
|
"""Handle getting password for authentication."""
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
self._password = user_input[CONF_PASSWORD]
|
self._password = user_input[CONF_PASSWORD]
|
||||||
|
@ -160,9 +175,11 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def fetch_device_info(self):
|
async def fetch_device_info(self) -> tuple[str | None, DeviceInfo | None]:
|
||||||
"""Fetch device info from API and return any errors."""
|
"""Fetch device info from API and return any errors."""
|
||||||
zeroconf_instance = await zeroconf.async_get_instance(self.hass)
|
zeroconf_instance = await zeroconf.async_get_instance(self.hass)
|
||||||
|
assert self._host is not None
|
||||||
|
assert self._port is not None
|
||||||
cli = APIClient(
|
cli = APIClient(
|
||||||
self.hass.loop,
|
self.hass.loop,
|
||||||
self._host,
|
self._host,
|
||||||
|
@ -183,9 +200,11 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||||
|
|
||||||
return None, device_info
|
return None, device_info
|
||||||
|
|
||||||
async def try_login(self):
|
async def try_login(self) -> str | None:
|
||||||
"""Try logging in to device and return any errors."""
|
"""Try logging in to device and return any errors."""
|
||||||
zeroconf_instance = await zeroconf.async_get_instance(self.hass)
|
zeroconf_instance = await zeroconf.async_get_instance(self.hass)
|
||||||
|
assert self._host is not None
|
||||||
|
assert self._port is not None
|
||||||
cli = APIClient(
|
cli = APIClient(
|
||||||
self.hass.loop,
|
self.hass.loop,
|
||||||
self._host,
|
self._host,
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
"""Support for ESPHome covers."""
|
"""Support for ESPHome covers."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from aioesphomeapi import CoverInfo, CoverOperation, CoverState
|
from aioesphomeapi import CoverInfo, CoverOperation, CoverState
|
||||||
|
|
||||||
from homeassistant.components.cover import (
|
from homeassistant.components.cover import (
|
||||||
|
@ -17,12 +19,13 @@ from homeassistant.components.cover import (
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry
|
from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities
|
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up ESPHome covers based on a config entry."""
|
"""Set up ESPHome covers based on a config entry."""
|
||||||
await platform_async_setup_entry(
|
await platform_async_setup_entry(
|
||||||
|
@ -36,12 +39,13 @@ async def async_setup_entry(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class EsphomeCover(EsphomeEntity, CoverEntity):
|
# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property
|
||||||
"""A cover implementation for ESPHome."""
|
# Pylint gets confused with the EsphomeEntity generics -> let mypy handle member checking
|
||||||
|
# pylint: disable=invalid-overridden-method,no-member
|
||||||
|
|
||||||
@property
|
|
||||||
def _static_info(self) -> CoverInfo:
|
class EsphomeCover(EsphomeEntity[CoverInfo, CoverState], CoverEntity):
|
||||||
return super()._static_info
|
"""A cover implementation for ESPHome."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def supported_features(self) -> int:
|
def supported_features(self) -> int:
|
||||||
|
@ -63,13 +67,6 @@ class EsphomeCover(EsphomeEntity, CoverEntity):
|
||||||
"""Return true if we do optimistic updates."""
|
"""Return true if we do optimistic updates."""
|
||||||
return self._static_info.assumed_state
|
return self._static_info.assumed_state
|
||||||
|
|
||||||
@property
|
|
||||||
def _state(self) -> CoverState | None:
|
|
||||||
return super()._state
|
|
||||||
|
|
||||||
# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property
|
|
||||||
# pylint: disable=invalid-overridden-method
|
|
||||||
|
|
||||||
@esphome_state_property
|
@esphome_state_property
|
||||||
def is_closed(self) -> bool | None:
|
def is_closed(self) -> bool | None:
|
||||||
"""Return if the cover is closed or not."""
|
"""Return if the cover is closed or not."""
|
||||||
|
@ -94,39 +91,39 @@ class EsphomeCover(EsphomeEntity, CoverEntity):
|
||||||
return round(self._state.position * 100.0)
|
return round(self._state.position * 100.0)
|
||||||
|
|
||||||
@esphome_state_property
|
@esphome_state_property
|
||||||
def current_cover_tilt_position(self) -> float | None:
|
def current_cover_tilt_position(self) -> int | None:
|
||||||
"""Return current position of cover tilt. 0 is closed, 100 is open."""
|
"""Return current position of cover tilt. 0 is closed, 100 is open."""
|
||||||
if not self._static_info.supports_tilt:
|
if not self._static_info.supports_tilt:
|
||||||
return None
|
return None
|
||||||
return self._state.tilt * 100.0
|
return round(self._state.tilt * 100.0)
|
||||||
|
|
||||||
async def async_open_cover(self, **kwargs) -> None:
|
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||||
"""Open the cover."""
|
"""Open the cover."""
|
||||||
await self._client.cover_command(key=self._static_info.key, position=1.0)
|
await self._client.cover_command(key=self._static_info.key, position=1.0)
|
||||||
|
|
||||||
async def async_close_cover(self, **kwargs) -> None:
|
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||||
"""Close cover."""
|
"""Close cover."""
|
||||||
await self._client.cover_command(key=self._static_info.key, position=0.0)
|
await self._client.cover_command(key=self._static_info.key, position=0.0)
|
||||||
|
|
||||||
async def async_stop_cover(self, **kwargs) -> None:
|
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||||
"""Stop the cover."""
|
"""Stop the cover."""
|
||||||
await self._client.cover_command(key=self._static_info.key, stop=True)
|
await self._client.cover_command(key=self._static_info.key, stop=True)
|
||||||
|
|
||||||
async def async_set_cover_position(self, **kwargs) -> None:
|
async def async_set_cover_position(self, **kwargs: int) -> None:
|
||||||
"""Move the cover to a specific position."""
|
"""Move the cover to a specific position."""
|
||||||
await self._client.cover_command(
|
await self._client.cover_command(
|
||||||
key=self._static_info.key, position=kwargs[ATTR_POSITION] / 100
|
key=self._static_info.key, position=kwargs[ATTR_POSITION] / 100
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_open_cover_tilt(self, **kwargs) -> None:
|
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
|
||||||
"""Open the cover tilt."""
|
"""Open the cover tilt."""
|
||||||
await self._client.cover_command(key=self._static_info.key, tilt=1.0)
|
await self._client.cover_command(key=self._static_info.key, tilt=1.0)
|
||||||
|
|
||||||
async def async_close_cover_tilt(self, **kwargs) -> None:
|
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
|
||||||
"""Close the cover tilt."""
|
"""Close the cover tilt."""
|
||||||
await self._client.cover_command(key=self._static_info.key, tilt=0.0)
|
await self._client.cover_command(key=self._static_info.key, tilt=0.0)
|
||||||
|
|
||||||
async def async_set_cover_tilt_position(self, **kwargs) -> None:
|
async def async_set_cover_tilt_position(self, **kwargs: int) -> None:
|
||||||
"""Move the cover tilt to a specific position."""
|
"""Move the cover tilt to a specific position."""
|
||||||
await self._client.cover_command(
|
await self._client.cover_command(
|
||||||
key=self._static_info.key, tilt=kwargs[ATTR_TILT_POSITION] / 100
|
key=self._static_info.key, tilt=kwargs[ATTR_TILT_POSITION] / 100
|
||||||
|
|
|
@ -3,10 +3,11 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import TYPE_CHECKING, Any, Callable
|
from typing import Any, Callable, cast
|
||||||
|
|
||||||
from aioesphomeapi import (
|
from aioesphomeapi import (
|
||||||
COMPONENT_TYPE_TO_INFO,
|
COMPONENT_TYPE_TO_INFO,
|
||||||
|
APIClient,
|
||||||
APIVersion,
|
APIVersion,
|
||||||
BinarySensorInfo,
|
BinarySensorInfo,
|
||||||
CameraInfo,
|
CameraInfo,
|
||||||
|
@ -29,13 +30,10 @@ from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||||
from homeassistant.helpers.storage import Store
|
from homeassistant.helpers.storage import Store
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from . import APIClient
|
|
||||||
|
|
||||||
SAVE_DELAY = 120
|
SAVE_DELAY = 120
|
||||||
|
|
||||||
# Mapping from ESPHome info type to HA platform
|
# Mapping from ESPHome info type to HA platform
|
||||||
INFO_TYPE_TO_PLATFORM = {
|
INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], str] = {
|
||||||
BinarySensorInfo: "binary_sensor",
|
BinarySensorInfo: "binary_sensor",
|
||||||
CameraInfo: "camera",
|
CameraInfo: "camera",
|
||||||
ClimateInfo: "climate",
|
ClimateInfo: "climate",
|
||||||
|
@ -56,14 +54,14 @@ class RuntimeEntryData:
|
||||||
entry_id: str
|
entry_id: str
|
||||||
client: APIClient
|
client: APIClient
|
||||||
store: Store
|
store: Store
|
||||||
state: dict[str, dict[str, Any]] = field(default_factory=dict)
|
state: dict[str, dict[int, EntityState]] = field(default_factory=dict)
|
||||||
info: dict[str, dict[str, Any]] = field(default_factory=dict)
|
info: dict[str, dict[int, EntityInfo]] = field(default_factory=dict)
|
||||||
|
|
||||||
# A second list of EntityInfo objects
|
# A second list of EntityInfo objects
|
||||||
# This is necessary for when an entity is being removed. HA requires
|
# This is necessary for when an entity is being removed. HA requires
|
||||||
# some static info to be accessible during removal (unique_id, maybe others)
|
# some static info to be accessible during removal (unique_id, maybe others)
|
||||||
# If an entity can't find anything in the info array, it will look for info here.
|
# If an entity can't find anything in the info array, it will look for info here.
|
||||||
old_info: dict[str, dict[str, Any]] = field(default_factory=dict)
|
old_info: dict[str, dict[int, EntityInfo]] = field(default_factory=dict)
|
||||||
|
|
||||||
services: dict[int, UserService] = field(default_factory=dict)
|
services: dict[int, UserService] = field(default_factory=dict)
|
||||||
available: bool = False
|
available: bool = False
|
||||||
|
@ -73,7 +71,7 @@ class RuntimeEntryData:
|
||||||
disconnect_callbacks: list[Callable[[], None]] = field(default_factory=list)
|
disconnect_callbacks: list[Callable[[], None]] = field(default_factory=list)
|
||||||
loaded_platforms: set[str] = field(default_factory=set)
|
loaded_platforms: set[str] = field(default_factory=set)
|
||||||
platform_load_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
platform_load_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||||
_storage_contents: dict | None = None
|
_storage_contents: dict[str, Any] | None = None
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_update_entity(
|
def async_update_entity(
|
||||||
|
@ -93,7 +91,7 @@ class RuntimeEntryData:
|
||||||
|
|
||||||
async def _ensure_platforms_loaded(
|
async def _ensure_platforms_loaded(
|
||||||
self, hass: HomeAssistant, entry: ConfigEntry, platforms: set[str]
|
self, hass: HomeAssistant, entry: ConfigEntry, platforms: set[str]
|
||||||
):
|
) -> None:
|
||||||
async with self.platform_load_lock:
|
async with self.platform_load_lock:
|
||||||
needed = platforms - self.loaded_platforms
|
needed = platforms - self.loaded_platforms
|
||||||
tasks = []
|
tasks = []
|
||||||
|
@ -139,6 +137,7 @@ class RuntimeEntryData:
|
||||||
restored = await self.store.async_load()
|
restored = await self.store.async_load()
|
||||||
if restored is None:
|
if restored is None:
|
||||||
return [], []
|
return [], []
|
||||||
|
restored = cast("dict[str, Any]", restored)
|
||||||
self._storage_contents = restored.copy()
|
self._storage_contents = restored.copy()
|
||||||
|
|
||||||
self.device_info = DeviceInfo.from_dict(restored.pop("device_info"))
|
self.device_info = DeviceInfo.from_dict(restored.pop("device_info"))
|
||||||
|
@ -157,7 +156,9 @@ class RuntimeEntryData:
|
||||||
|
|
||||||
async def async_save_to_store(self) -> None:
|
async def async_save_to_store(self) -> None:
|
||||||
"""Generate dynamic data to store and save it to the filesystem."""
|
"""Generate dynamic data to store and save it to the filesystem."""
|
||||||
store_data = {
|
if self.device_info is None:
|
||||||
|
raise ValueError("device_info is not set yet")
|
||||||
|
store_data: dict[str, Any] = {
|
||||||
"device_info": self.device_info.to_dict(),
|
"device_info": self.device_info.to_dict(),
|
||||||
"services": [],
|
"services": [],
|
||||||
"api_version": self.api_version.to_dict(),
|
"api_version": self.api_version.to_dict(),
|
||||||
|
@ -171,7 +172,7 @@ class RuntimeEntryData:
|
||||||
if store_data == self._storage_contents:
|
if store_data == self._storage_contents:
|
||||||
return
|
return
|
||||||
|
|
||||||
def _memorized_storage():
|
def _memorized_storage() -> dict[str, Any]:
|
||||||
self._storage_contents = store_data
|
self._storage_contents = store_data
|
||||||
return store_data
|
return store_data
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from aioesphomeapi import FanDirection, FanInfo, FanSpeed, FanState
|
from aioesphomeapi import FanDirection, FanInfo, FanSpeed, FanState
|
||||||
|
|
||||||
|
@ -15,6 +16,7 @@ from homeassistant.components.fan import (
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.util.percentage import (
|
from homeassistant.util.percentage import (
|
||||||
ordered_list_item_to_percentage,
|
ordered_list_item_to_percentage,
|
||||||
percentage_to_ordered_list_item,
|
percentage_to_ordered_list_item,
|
||||||
|
@ -33,7 +35,7 @@ ORDERED_NAMED_FAN_SPEEDS = [FanSpeed.LOW, FanSpeed.MEDIUM, FanSpeed.HIGH]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities
|
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up ESPHome fans based on a config entry."""
|
"""Set up ESPHome fans based on a config entry."""
|
||||||
await platform_async_setup_entry(
|
await platform_async_setup_entry(
|
||||||
|
@ -47,7 +49,7 @@ async def async_setup_entry(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_FAN_DIRECTIONS: EsphomeEnumMapper[FanDirection] = EsphomeEnumMapper(
|
_FAN_DIRECTIONS: EsphomeEnumMapper[FanDirection, str] = EsphomeEnumMapper(
|
||||||
{
|
{
|
||||||
FanDirection.FORWARD: DIRECTION_FORWARD,
|
FanDirection.FORWARD: DIRECTION_FORWARD,
|
||||||
FanDirection.REVERSE: DIRECTION_REVERSE,
|
FanDirection.REVERSE: DIRECTION_REVERSE,
|
||||||
|
@ -55,29 +57,26 @@ _FAN_DIRECTIONS: EsphomeEnumMapper[FanDirection] = EsphomeEnumMapper(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class EsphomeFan(EsphomeEntity, FanEntity):
|
# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property
|
||||||
|
# Pylint gets confused with the EsphomeEntity generics -> let mypy handle member checking
|
||||||
|
# pylint: disable=invalid-overridden-method,no-member
|
||||||
|
|
||||||
|
|
||||||
|
class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity):
|
||||||
"""A fan implementation for ESPHome."""
|
"""A fan implementation for ESPHome."""
|
||||||
|
|
||||||
@property
|
|
||||||
def _static_info(self) -> FanInfo:
|
|
||||||
return super()._static_info
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _state(self) -> FanState | None:
|
|
||||||
return super()._state
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _supports_speed_levels(self) -> bool:
|
def _supports_speed_levels(self) -> bool:
|
||||||
api_version = self._api_version
|
api_version = self._api_version
|
||||||
return api_version.major == 1 and api_version.minor > 3
|
return api_version.major == 1 and api_version.minor > 3
|
||||||
|
|
||||||
async def async_set_percentage(self, percentage: int) -> None:
|
async def async_set_percentage(self, percentage: int | None) -> None:
|
||||||
"""Set the speed percentage of the fan."""
|
"""Set the speed percentage of the fan."""
|
||||||
if percentage == 0:
|
if percentage == 0:
|
||||||
await self.async_turn_off()
|
await self.async_turn_off()
|
||||||
return
|
return
|
||||||
|
|
||||||
data = {"key": self._static_info.key, "state": True}
|
data: dict[str, Any] = {"key": self._static_info.key, "state": True}
|
||||||
if percentage is not None:
|
if percentage is not None:
|
||||||
if self._supports_speed_levels:
|
if self._supports_speed_levels:
|
||||||
data["speed_level"] = math.ceil(
|
data["speed_level"] = math.ceil(
|
||||||
|
@ -97,12 +96,12 @@ class EsphomeFan(EsphomeEntity, FanEntity):
|
||||||
speed: str | None = None,
|
speed: str | None = None,
|
||||||
percentage: int | None = None,
|
percentage: int | None = None,
|
||||||
preset_mode: str | None = None,
|
preset_mode: str | None = None,
|
||||||
**kwargs,
|
**kwargs: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Turn on the fan."""
|
"""Turn on the fan."""
|
||||||
await self.async_set_percentage(percentage)
|
await self.async_set_percentage(percentage)
|
||||||
|
|
||||||
async def async_turn_off(self, **kwargs) -> None:
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
"""Turn off the fan."""
|
"""Turn off the fan."""
|
||||||
await self._client.fan_command(key=self._static_info.key, state=False)
|
await self._client.fan_command(key=self._static_info.key, state=False)
|
||||||
|
|
||||||
|
@ -112,17 +111,14 @@ class EsphomeFan(EsphomeEntity, FanEntity):
|
||||||
key=self._static_info.key, oscillating=oscillating
|
key=self._static_info.key, oscillating=oscillating
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_set_direction(self, direction: str):
|
async def async_set_direction(self, direction: str) -> None:
|
||||||
"""Set direction of the fan."""
|
"""Set direction of the fan."""
|
||||||
await self._client.fan_command(
|
await self._client.fan_command(
|
||||||
key=self._static_info.key, direction=_FAN_DIRECTIONS.from_hass(direction)
|
key=self._static_info.key, direction=_FAN_DIRECTIONS.from_hass(direction)
|
||||||
)
|
)
|
||||||
|
|
||||||
# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property
|
|
||||||
# pylint: disable=invalid-overridden-method
|
|
||||||
|
|
||||||
@esphome_state_property
|
@esphome_state_property
|
||||||
def is_on(self) -> bool | None:
|
def is_on(self) -> bool | None: # type: ignore[override]
|
||||||
"""Return true if the entity is on."""
|
"""Return true if the entity is on."""
|
||||||
return self._state.state
|
return self._state.state
|
||||||
|
|
||||||
|
@ -134,7 +130,7 @@ class EsphomeFan(EsphomeEntity, FanEntity):
|
||||||
|
|
||||||
if not self._supports_speed_levels:
|
if not self._supports_speed_levels:
|
||||||
return ordered_list_item_to_percentage(
|
return ordered_list_item_to_percentage(
|
||||||
ORDERED_NAMED_FAN_SPEEDS, self._state.speed
|
ORDERED_NAMED_FAN_SPEEDS, self._state.speed # type: ignore[misc]
|
||||||
)
|
)
|
||||||
|
|
||||||
return ranged_value_to_percentage(
|
return ranged_value_to_percentage(
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
"""Support for ESPHome lights."""
|
"""Support for ESPHome lights."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from aioesphomeapi import LightInfo, LightState
|
from aioesphomeapi import LightInfo, LightState
|
||||||
|
|
||||||
from homeassistant.components.light import (
|
from homeassistant.components.light import (
|
||||||
|
@ -24,6 +26,7 @@ from homeassistant.components.light import (
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
import homeassistant.util.color as color_util
|
import homeassistant.util.color as color_util
|
||||||
|
|
||||||
from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry
|
from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry
|
||||||
|
@ -32,7 +35,7 @@ FLASH_LENGTHS = {FLASH_SHORT: 2, FLASH_LONG: 10}
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities
|
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up ESPHome lights based on a config entry."""
|
"""Set up ESPHome lights based on a config entry."""
|
||||||
await platform_async_setup_entry(
|
await platform_async_setup_entry(
|
||||||
|
@ -46,28 +49,22 @@ async def async_setup_entry(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class EsphomeLight(EsphomeEntity, LightEntity):
|
# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property
|
||||||
"""A switch implementation for ESPHome."""
|
# Pylint gets confused with the EsphomeEntity generics -> let mypy handle member checking
|
||||||
|
# pylint: disable=invalid-overridden-method,no-member
|
||||||
|
|
||||||
@property
|
|
||||||
def _static_info(self) -> LightInfo:
|
|
||||||
return super()._static_info
|
|
||||||
|
|
||||||
@property
|
class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
||||||
def _state(self) -> LightState | None:
|
"""A light implementation for ESPHome."""
|
||||||
return super()._state
|
|
||||||
|
|
||||||
# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property
|
|
||||||
# pylint: disable=invalid-overridden-method
|
|
||||||
|
|
||||||
@esphome_state_property
|
@esphome_state_property
|
||||||
def is_on(self) -> bool | None:
|
def is_on(self) -> bool | None: # type: ignore[override]
|
||||||
"""Return true if the switch is on."""
|
"""Return true if the light is on."""
|
||||||
return self._state.state
|
return self._state.state
|
||||||
|
|
||||||
async def async_turn_on(self, **kwargs) -> None:
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
"""Turn the entity on."""
|
"""Turn the entity on."""
|
||||||
data = {"key": self._static_info.key, "state": True}
|
data: dict[str, Any] = {"key": self._static_info.key, "state": True}
|
||||||
if ATTR_HS_COLOR in kwargs:
|
if ATTR_HS_COLOR in kwargs:
|
||||||
hue, sat = kwargs[ATTR_HS_COLOR]
|
hue, sat = kwargs[ATTR_HS_COLOR]
|
||||||
red, green, blue = color_util.color_hsv_to_RGB(hue, sat, 100)
|
red, green, blue = color_util.color_hsv_to_RGB(hue, sat, 100)
|
||||||
|
@ -86,9 +83,9 @@ class EsphomeLight(EsphomeEntity, LightEntity):
|
||||||
data["white"] = kwargs[ATTR_WHITE_VALUE] / 255
|
data["white"] = kwargs[ATTR_WHITE_VALUE] / 255
|
||||||
await self._client.light_command(**data)
|
await self._client.light_command(**data)
|
||||||
|
|
||||||
async def async_turn_off(self, **kwargs) -> None:
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
"""Turn the entity off."""
|
"""Turn the entity off."""
|
||||||
data = {"key": self._static_info.key, "state": False}
|
data: dict[str, Any] = {"key": self._static_info.key, "state": False}
|
||||||
if ATTR_FLASH in kwargs:
|
if ATTR_FLASH in kwargs:
|
||||||
data["flash_length"] = FLASH_LENGTHS[kwargs[ATTR_FLASH]]
|
data["flash_length"] = FLASH_LENGTHS[kwargs[ATTR_FLASH]]
|
||||||
if ATTR_TRANSITION in kwargs:
|
if ATTR_TRANSITION in kwargs:
|
||||||
|
@ -108,7 +105,7 @@ class EsphomeLight(EsphomeEntity, LightEntity):
|
||||||
)
|
)
|
||||||
|
|
||||||
@esphome_state_property
|
@esphome_state_property
|
||||||
def color_temp(self) -> float | None:
|
def color_temp(self) -> float | None: # type: ignore[override]
|
||||||
"""Return the CT color value in mireds."""
|
"""Return the CT color value in mireds."""
|
||||||
return self._state.color_temperature
|
return self._state.color_temperature
|
||||||
|
|
||||||
|
@ -145,11 +142,11 @@ class EsphomeLight(EsphomeEntity, LightEntity):
|
||||||
return self._static_info.effects
|
return self._static_info.effects
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def min_mireds(self) -> float:
|
def min_mireds(self) -> float: # type: ignore[override]
|
||||||
"""Return the coldest color_temp that this light supports."""
|
"""Return the coldest color_temp that this light supports."""
|
||||||
return self._static_info.min_mireds
|
return self._static_info.min_mireds
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def max_mireds(self) -> float:
|
def max_mireds(self) -> float: # type: ignore[override]
|
||||||
"""Return the warmest color_temp that this light supports."""
|
"""Return the warmest color_temp that this light supports."""
|
||||||
return self._static_info.max_mireds
|
return self._static_info.max_mireds
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
from aioesphomeapi import NumberInfo, NumberState
|
from aioesphomeapi import NumberInfo, NumberState
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
@ -35,26 +36,19 @@ async def async_setup_entry(
|
||||||
|
|
||||||
|
|
||||||
# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property
|
# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property
|
||||||
# pylint: disable=invalid-overridden-method
|
# Pylint gets confused with the EsphomeEntity generics -> let mypy handle member checking
|
||||||
|
# pylint: disable=invalid-overridden-method,no-member
|
||||||
|
|
||||||
|
|
||||||
class EsphomeNumber(EsphomeEntity, NumberEntity):
|
class EsphomeNumber(EsphomeEntity[NumberInfo, NumberState], NumberEntity):
|
||||||
"""A number implementation for esphome."""
|
"""A number implementation for esphome."""
|
||||||
|
|
||||||
@property
|
|
||||||
def _static_info(self) -> NumberInfo:
|
|
||||||
return super()._static_info
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _state(self) -> NumberState | None:
|
|
||||||
return super()._state
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def icon(self) -> str | None:
|
def icon(self) -> str | None:
|
||||||
"""Return the icon."""
|
"""Return the icon."""
|
||||||
if not self._static_info.icon:
|
if not self._static_info.icon:
|
||||||
return None
|
return None
|
||||||
return ICON_SCHEMA(self._static_info.icon)
|
return cast(str, ICON_SCHEMA(self._static_info.icon))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def min_value(self) -> float:
|
def min_value(self) -> float:
|
||||||
|
@ -72,7 +66,7 @@ class EsphomeNumber(EsphomeEntity, NumberEntity):
|
||||||
return super()._static_info.step
|
return super()._static_info.step
|
||||||
|
|
||||||
@esphome_state_property
|
@esphome_state_property
|
||||||
def value(self) -> float:
|
def value(self) -> float | None:
|
||||||
"""Return the state of the entity."""
|
"""Return the state of the entity."""
|
||||||
if math.isnan(self._state.state):
|
if math.isnan(self._state.state):
|
||||||
return None
|
return None
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
from aioesphomeapi import (
|
from aioesphomeapi import (
|
||||||
SensorInfo,
|
SensorInfo,
|
||||||
|
@ -21,6 +22,7 @@ from homeassistant.components.sensor import (
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.util import dt
|
from homeassistant.util import dt
|
||||||
|
|
||||||
from . import (
|
from . import (
|
||||||
|
@ -34,7 +36,7 @@ ICON_SCHEMA = vol.Schema(cv.icon)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities
|
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up esphome sensors based on a config entry."""
|
"""Set up esphome sensors based on a config entry."""
|
||||||
await platform_async_setup_entry(
|
await platform_async_setup_entry(
|
||||||
|
@ -58,10 +60,11 @@ async def async_setup_entry(
|
||||||
|
|
||||||
|
|
||||||
# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property
|
# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property
|
||||||
# pylint: disable=invalid-overridden-method
|
# Pylint gets confused with the EsphomeEntity generics -> let mypy handle member checking
|
||||||
|
# pylint: disable=invalid-overridden-method,no-member
|
||||||
|
|
||||||
|
|
||||||
_STATE_CLASSES: EsphomeEnumMapper[SensorStateClass] = EsphomeEnumMapper(
|
_STATE_CLASSES: EsphomeEnumMapper[SensorStateClass, str | None] = EsphomeEnumMapper(
|
||||||
{
|
{
|
||||||
SensorStateClass.NONE: None,
|
SensorStateClass.NONE: None,
|
||||||
SensorStateClass.MEASUREMENT: STATE_CLASS_MEASUREMENT,
|
SensorStateClass.MEASUREMENT: STATE_CLASS_MEASUREMENT,
|
||||||
|
@ -69,23 +72,15 @@ _STATE_CLASSES: EsphomeEnumMapper[SensorStateClass] = EsphomeEnumMapper(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class EsphomeSensor(EsphomeEntity, SensorEntity):
|
class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity):
|
||||||
"""A sensor implementation for esphome."""
|
"""A sensor implementation for esphome."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _static_info(self) -> SensorInfo:
|
def icon(self) -> str | None:
|
||||||
return super()._static_info
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _state(self) -> SensorState | None:
|
|
||||||
return super()._state
|
|
||||||
|
|
||||||
@property
|
|
||||||
def icon(self) -> str:
|
|
||||||
"""Return the icon."""
|
"""Return the icon."""
|
||||||
if not self._static_info.icon or self._static_info.device_class:
|
if not self._static_info.icon or self._static_info.device_class:
|
||||||
return None
|
return None
|
||||||
return ICON_SCHEMA(self._static_info.icon)
|
return cast(str, ICON_SCHEMA(self._static_info.icon))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def force_update(self) -> bool:
|
def force_update(self) -> bool:
|
||||||
|
@ -104,14 +99,14 @@ class EsphomeSensor(EsphomeEntity, SensorEntity):
|
||||||
return f"{self._state.state:.{self._static_info.accuracy_decimals}f}"
|
return f"{self._state.state:.{self._static_info.accuracy_decimals}f}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def unit_of_measurement(self) -> str:
|
def unit_of_measurement(self) -> str | None:
|
||||||
"""Return the unit the value is expressed in."""
|
"""Return the unit the value is expressed in."""
|
||||||
if not self._static_info.unit_of_measurement:
|
if not self._static_info.unit_of_measurement:
|
||||||
return None
|
return None
|
||||||
return self._static_info.unit_of_measurement
|
return self._static_info.unit_of_measurement
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def device_class(self) -> str:
|
def device_class(self) -> str | None:
|
||||||
"""Return the class of this device, from component DEVICE_CLASSES."""
|
"""Return the class of this device, from component DEVICE_CLASSES."""
|
||||||
if self._static_info.device_class not in DEVICE_CLASSES:
|
if self._static_info.device_class not in DEVICE_CLASSES:
|
||||||
return None
|
return None
|
||||||
|
@ -125,17 +120,9 @@ class EsphomeSensor(EsphomeEntity, SensorEntity):
|
||||||
return _STATE_CLASSES.from_esphome(self._static_info.state_class)
|
return _STATE_CLASSES.from_esphome(self._static_info.state_class)
|
||||||
|
|
||||||
|
|
||||||
class EsphomeTextSensor(EsphomeEntity, SensorEntity):
|
class EsphomeTextSensor(EsphomeEntity[TextSensorInfo, TextSensorState], SensorEntity):
|
||||||
"""A text sensor implementation for ESPHome."""
|
"""A text sensor implementation for ESPHome."""
|
||||||
|
|
||||||
@property
|
|
||||||
def _static_info(self) -> TextSensorInfo:
|
|
||||||
return super()._static_info
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _state(self) -> TextSensorState | None:
|
|
||||||
return super()._state
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def icon(self) -> str:
|
def icon(self) -> str:
|
||||||
"""Return the icon."""
|
"""Return the icon."""
|
||||||
|
|
|
@ -1,17 +1,20 @@
|
||||||
"""Support for ESPHome switches."""
|
"""Support for ESPHome switches."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from aioesphomeapi import SwitchInfo, SwitchState
|
from aioesphomeapi import SwitchInfo, SwitchState
|
||||||
|
|
||||||
from homeassistant.components.switch import SwitchEntity
|
from homeassistant.components.switch import SwitchEntity
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry
|
from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities
|
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up ESPHome switches based on a config entry."""
|
"""Set up ESPHome switches based on a config entry."""
|
||||||
await platform_async_setup_entry(
|
await platform_async_setup_entry(
|
||||||
|
@ -25,17 +28,14 @@ async def async_setup_entry(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class EsphomeSwitch(EsphomeEntity, SwitchEntity):
|
# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property
|
||||||
|
# Pylint gets confused with the EsphomeEntity generics -> let mypy handle member checking
|
||||||
|
# pylint: disable=invalid-overridden-method,no-member
|
||||||
|
|
||||||
|
|
||||||
|
class EsphomeSwitch(EsphomeEntity[SwitchInfo, SwitchState], SwitchEntity):
|
||||||
"""A switch implementation for ESPHome."""
|
"""A switch implementation for ESPHome."""
|
||||||
|
|
||||||
@property
|
|
||||||
def _static_info(self) -> SwitchInfo:
|
|
||||||
return super()._static_info
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _state(self) -> SwitchState | None:
|
|
||||||
return super()._state
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def icon(self) -> str:
|
def icon(self) -> str:
|
||||||
"""Return the icon."""
|
"""Return the icon."""
|
||||||
|
@ -46,17 +46,15 @@ class EsphomeSwitch(EsphomeEntity, SwitchEntity):
|
||||||
"""Return true if we do optimistic updates."""
|
"""Return true if we do optimistic updates."""
|
||||||
return self._static_info.assumed_state
|
return self._static_info.assumed_state
|
||||||
|
|
||||||
# https://github.com/PyCQA/pylint/issues/3150 for @esphome_state_property
|
|
||||||
# pylint: disable=invalid-overridden-method
|
|
||||||
@esphome_state_property
|
@esphome_state_property
|
||||||
def is_on(self) -> bool | None:
|
def is_on(self) -> bool | None: # type: ignore[override]
|
||||||
"""Return true if the switch is on."""
|
"""Return true if the switch is on."""
|
||||||
return self._state.state
|
return self._state.state
|
||||||
|
|
||||||
async def async_turn_on(self, **kwargs) -> None:
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
"""Turn the entity on."""
|
"""Turn the entity on."""
|
||||||
await self._client.switch_command(self._static_info.key, True)
|
await self._client.switch_command(self._static_info.key, True)
|
||||||
|
|
||||||
async def async_turn_off(self, **kwargs) -> None:
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
"""Turn the entity off."""
|
"""Turn the entity off."""
|
||||||
await self._client.switch_command(self._static_info.key, False)
|
await self._client.switch_command(self._static_info.key, False)
|
||||||
|
|
14
mypy.ini
14
mypy.ini
|
@ -352,6 +352,17 @@ no_implicit_optional = true
|
||||||
warn_return_any = true
|
warn_return_any = true
|
||||||
warn_unreachable = true
|
warn_unreachable = true
|
||||||
|
|
||||||
|
[mypy-homeassistant.components.esphome.*]
|
||||||
|
check_untyped_defs = true
|
||||||
|
disallow_incomplete_defs = true
|
||||||
|
disallow_subclassing_any = true
|
||||||
|
disallow_untyped_calls = true
|
||||||
|
disallow_untyped_decorators = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
no_implicit_optional = true
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unreachable = true
|
||||||
|
|
||||||
[mypy-homeassistant.components.fastdotcom.*]
|
[mypy-homeassistant.components.fastdotcom.*]
|
||||||
check_untyped_defs = true
|
check_untyped_defs = true
|
||||||
disallow_incomplete_defs = true
|
disallow_incomplete_defs = true
|
||||||
|
@ -1153,9 +1164,6 @@ ignore_errors = true
|
||||||
[mypy-homeassistant.components.entur_public_transport.*]
|
[mypy-homeassistant.components.entur_public_transport.*]
|
||||||
ignore_errors = true
|
ignore_errors = true
|
||||||
|
|
||||||
[mypy-homeassistant.components.esphome.*]
|
|
||||||
ignore_errors = true
|
|
||||||
|
|
||||||
[mypy-homeassistant.components.evohome.*]
|
[mypy-homeassistant.components.evohome.*]
|
||||||
ignore_errors = true
|
ignore_errors = true
|
||||||
|
|
||||||
|
|
|
@ -50,7 +50,6 @@ IGNORED_MODULES: Final[list[str]] = [
|
||||||
"homeassistant.components.emonitor.*",
|
"homeassistant.components.emonitor.*",
|
||||||
"homeassistant.components.enphase_envoy.*",
|
"homeassistant.components.enphase_envoy.*",
|
||||||
"homeassistant.components.entur_public_transport.*",
|
"homeassistant.components.entur_public_transport.*",
|
||||||
"homeassistant.components.esphome.*",
|
|
||||||
"homeassistant.components.evohome.*",
|
"homeassistant.components.evohome.*",
|
||||||
"homeassistant.components.filter.*",
|
"homeassistant.components.filter.*",
|
||||||
"homeassistant.components.fints.*",
|
"homeassistant.components.fints.*",
|
||||||
|
|
|
@ -48,6 +48,13 @@ def mock_api_connection_error():
|
||||||
yield mock_error
|
yield mock_error
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_setup_entry():
|
||||||
|
"""Mock setting up a config entry."""
|
||||||
|
with patch("homeassistant.components.esphome.async_setup_entry", return_value=True):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
async def test_user_connection_works(hass, mock_client):
|
async def test_user_connection_works(hass, mock_client):
|
||||||
"""Test we can finish a config flow."""
|
"""Test we can finish a config flow."""
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue