ESPHome enable static type checking (#52348)

This commit is contained in:
Otto Winter 2021-07-12 22:56:10 +02:00 committed by GitHub
parent 9b2107b71f
commit 4d16cda957
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 364 additions and 304 deletions

View file

@ -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.*

View file

@ -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,63 +556,76 @@ 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
ARG_TYPE_METADATA = {
UserServiceArgType.BOOL: {
"validator": cv.boolean,
"example": "False",
"selector": {"boolean": None},
},
UserServiceArgType.INT: {
"validator": vol.Coerce(int),
"example": "42",
"selector": {"number": {CONF_MODE: "box"}},
},
UserServiceArgType.FLOAT: {
"validator": vol.Coerce(float),
"example": "12.3",
"selector": {"number": {CONF_MODE: "box", "step": 1e-3}},
},
UserServiceArgType.STRING: {
"validator": cv.string,
"example": "Example text",
"selector": {"text": None},
},
UserServiceArgType.BOOL_ARRAY: {
"validator": [cv.boolean],
"description": "A list of boolean values.",
"example": "[True, False]",
"selector": {"object": {}},
},
UserServiceArgType.INT_ARRAY: {
"validator": [vol.Coerce(int)],
"description": "A list of integer values.",
"example": "[42, 34]",
"selector": {"object": {}},
},
UserServiceArgType.FLOAT_ARRAY: {
"validator": [vol.Coerce(float)],
"description": "A list of floating point numbers.",
"example": "[ 12.3, 34.5 ]",
"selector": {"object": {}},
},
UserServiceArgType.STRING_ARRAY: {
"validator": [cv.string],
"description": "A list of strings.",
"example": "['Example text', 'Another example']",
"selector": {"object": {}},
},
}
async def _register_service( async def _register_service(
hass: HomeAssistant, entry_data: RuntimeEntryData, service: UserService 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}" service_name = f"{entry_data.device_info.name.replace('-', '_')}_{service.name}"
schema = {} schema = {}
fields = {} fields = {}
for arg in service.args: for arg in service.args:
metadata = { if arg.type not in ARG_TYPE_METADATA:
UserServiceArgType.BOOL: { _LOGGER.error(
"validator": cv.boolean, "Can't register service %s because %s is of unknown type %s",
"example": "False", service_name,
"selector": {"boolean": None}, arg.name,
}, arg.type,
UserServiceArgType.INT: { )
"validator": vol.Coerce(int), return
"example": "42", metadata = ARG_TYPE_METADATA[arg.type]
"selector": {"number": {CONF_MODE: "box"}},
},
UserServiceArgType.FLOAT: {
"validator": vol.Coerce(float),
"example": "12.3",
"selector": {"number": {CONF_MODE: "box", "step": 1e-3}},
},
UserServiceArgType.STRING: {
"validator": cv.string,
"example": "Example text",
"selector": {"text": None},
},
UserServiceArgType.BOOL_ARRAY: {
"validator": [cv.boolean],
"description": "A list of boolean values.",
"example": "[True, False]",
"selector": {"object": {}},
},
UserServiceArgType.INT_ARRAY: {
"validator": [vol.Coerce(int)],
"description": "A list of integer values.",
"example": "[42, 34]",
"selector": {"object": {}},
},
UserServiceArgType.FLOAT_ARRAY: {
"validator": [vol.Coerce(float)],
"description": "A list of floating point numbers.",
"example": "[ 12.3, 34.5 ]",
"selector": {"object": {}},
},
UserServiceArgType.STRING_ARRAY: {
"validator": [cv.string],
"description": "A list of strings.",
"example": "['Example text', 'Another example']",
"selector": {"object": {}},
},
}[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:

View file

@ -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

View file

@ -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

View file

@ -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."""

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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(

View file

@ -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

View file

@ -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

View file

@ -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."""

View file

@ -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)

View file

@ -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

View file

@ -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.*",

View file

@ -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(