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.dunehd.*
homeassistant.components.elgato.*
homeassistant.components.esphome.*
homeassistant.components.fastdotcom.*
homeassistant.components.fitbit.*
homeassistant.components.forecast_solar.*

View file

@ -2,15 +2,17 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable
from dataclasses import dataclass, field
import functools
import logging
import math
from typing import Generic, TypeVar
from typing import Any, Callable, Generic, TypeVar, cast, overload
from aioesphomeapi import (
APIClient,
APIConnectionError,
APIIntEnum,
APIVersion,
DeviceInfo as EsphomeDeviceInfo,
EntityInfo,
@ -32,13 +34,14 @@ from homeassistant.const import (
CONF_PORT,
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.helpers import template
import homeassistant.helpers.config_validation as cv
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_connect
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.json import JSONEncoder
from homeassistant.helpers.service import async_set_service_schema
@ -97,7 +100,7 @@ class DomainData:
"""Get the global DomainData instance stored in hass.data."""
# Don't use setdefault - this is a hot code path
if DOMAIN in hass.data:
return hass.data[DOMAIN]
return cast(_T, hass.data[DOMAIN])
ret = hass.data[DOMAIN] = cls()
return ret
@ -153,7 +156,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if service.data_template:
try:
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)
service_data.update(
@ -197,10 +201,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
send_state = state.state
if attribute:
send_state = state.attributes[attribute]
attr_val = state.attributes[attribute]
# ESPHome only handles "on"/"off" for boolean values
if isinstance(send_state, bool):
send_state = "on" if send_state else "off"
if isinstance(attr_val, bool):
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))
@ -253,6 +259,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
nonlocal device_id
try:
entry_data.device_info = await cli.device_info()
assert cli.api_version is not None
entry_data.api_version = cli.api_version
entry_data.available = True
device_id = await _async_setup_device_registry(
@ -304,9 +311,9 @@ class ReconnectLogic(RecordUpdateListener):
cli: APIClient,
entry: ConfigEntry,
host: str,
on_login,
on_login: Callable[[], Awaitable[None]],
zc: Zeroconf,
):
) -> None:
"""Initialize ReconnectingLogic."""
self._hass = hass
self._cli = cli
@ -322,12 +329,12 @@ class ReconnectLogic(RecordUpdateListener):
# Event the different strategies use for issuing a reconnect attempt.
self._reconnect_event = asyncio.Event()
# 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
self._tries = 0
self._tries_lock = asyncio.Lock()
# 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()
@property
@ -338,7 +345,7 @@ class ReconnectLogic(RecordUpdateListener):
except KeyError:
return None
async def _on_disconnect(self):
async def _on_disconnect(self) -> None:
"""Log and issue callbacks when disconnecting."""
if self._entry_data is None:
return
@ -364,7 +371,7 @@ class ReconnectLogic(RecordUpdateListener):
self._connected = False
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."""
async with self._tries_lock:
tries = self._tries
@ -383,7 +390,7 @@ class ReconnectLogic(RecordUpdateListener):
self._wait_task = None
self._reconnect_event.set()
async def _try_connect(self):
async def _try_connect(self) -> None:
"""Try connecting to the API client."""
async with self._tries_lock:
tries = self._tries
@ -421,7 +428,7 @@ class ReconnectLogic(RecordUpdateListener):
await self._stop_zc_listen()
self._hass.async_create_task(self._on_login())
async def _reconnect_once(self):
async def _reconnect_once(self) -> None:
# Wait and clear reconnection event
await self._reconnect_event.wait()
self._reconnect_event.clear()
@ -429,7 +436,7 @@ class ReconnectLogic(RecordUpdateListener):
# If in connected state, do not try to connect again.
async with self._connected_lock:
if self._connected:
return False
return
# 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):
@ -448,7 +455,7 @@ class ReconnectLogic(RecordUpdateListener):
await self._try_connect()
async def _reconnect_loop(self):
async def _reconnect_loop(self) -> None:
while True:
try:
await self._reconnect_once()
@ -457,7 +464,7 @@ class ReconnectLogic(RecordUpdateListener):
except Exception: # pylint: disable=broad-except
_LOGGER.error("Caught exception while reconnecting", exc_info=True)
async def start(self):
async def start(self) -> None:
"""Start the reconnecting logic background task."""
# Create reconnection loop outside of HA's tracked tasks in order
# not to delay startup.
@ -467,7 +474,7 @@ class ReconnectLogic(RecordUpdateListener):
self._connected = False
self._reconnect_event.set()
async def stop(self):
async def stop(self) -> None:
"""Stop the reconnecting logic background task. Does not disconnect the client."""
if self._loop_task is not None:
self._loop_task.cancel()
@ -478,7 +485,7 @@ class ReconnectLogic(RecordUpdateListener):
self._wait_task = None
await self._stop_zc_listen()
async def _start_zc_listen(self):
async def _start_zc_listen(self) -> None:
"""Listen for mDNS records.
This listener allows us to schedule a reconnect as soon as a
@ -491,7 +498,7 @@ class ReconnectLogic(RecordUpdateListener):
)
self._zc_listening = True
async def _stop_zc_listen(self):
async def _stop_zc_listen(self) -> None:
"""Stop listening for zeroconf updates."""
async with self._zc_lock:
if self._zc_listening:
@ -499,12 +506,12 @@ class ReconnectLogic(RecordUpdateListener):
self._zc_listening = False
@callback
def stop_callback(self):
def stop_callback(self) -> None:
"""Stop as an async callback function."""
self._hass.async_create_task(self.stop())
@callback
def _set_reconnect(self):
def _set_reconnect(self) -> None:
self._reconnect_event.set()
def update_record(self, zc: Zeroconf, now: float, record: DNSRecord) -> None:
@ -535,13 +542,13 @@ class ReconnectLogic(RecordUpdateListener):
async def _async_setup_device_registry(
hass: HomeAssistant, entry: ConfigEntry, device_info: EsphomeDeviceInfo
):
) -> str:
"""Set up device registry feature for a particular config entry."""
sw_version = device_info.esphome_version
if device_info.compilation_time:
sw_version += f" ({device_info.compilation_time})"
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,
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)},
name=device_info.name,
@ -549,63 +556,76 @@ async def _async_setup_device_registry(
model=device_info.model,
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(
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:
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": {}},
},
}[arg.type]
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"]
fields[arg.name] = {
"name": arg.name,
@ -615,8 +635,8 @@ async def _register_service(
"selector": metadata["selector"],
}
async def execute_service(call):
await entry_data.client.execute_service(service, call.data)
async def execute_service(call: ServiceCall) -> None:
await entry_data.client.execute_service(service, call.data) # type: ignore[arg-type]
hass.services.async_register(
DOMAIN, service_name, execute_service, vol.Schema(schema)
@ -632,7 +652,7 @@ async def _register_service(
async def _setup_services(
hass: HomeAssistant, entry_data: RuntimeEntryData, services: list[UserService]
):
) -> None:
old_services = entry_data.services.copy()
to_unregister = []
to_register = []
@ -653,6 +673,7 @@ async def _setup_services(
entry_data.services = {serv.key: serv for serv in services}
assert entry_data.device_info is not None
for service in to_unregister:
service_name = f"{entry_data.device_info.name}_{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()
_InfoT = TypeVar("_InfoT", bound=EntityInfo)
_EntityT = TypeVar("_EntityT", bound="EsphomeBaseEntity[Any,Any]")
_StateT = TypeVar("_StateT", bound=EntityState)
async def platform_async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities,
async_add_entities: AddEntitiesCallback,
*,
component_key: str,
info_type,
entity_type,
state_type,
info_type: type[_InfoT],
entity_type: type[_EntityT],
state_type: type[_StateT],
) -> None:
"""Set up an esphome platform.
@ -709,15 +735,17 @@ async def platform_async_setup_entry(
entry_data.state[component_key] = {}
@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."""
old_infos = entry_data.info[component_key]
new_infos = {}
new_infos: dict[int, EntityInfo] = {}
add_entities = []
for info in infos:
if not isinstance(info, info_type):
# Filter out infos that don't belong to this platform.
continue
# cast back to upper type, otherwise mypy gets confused
info = cast(EntityInfo, info)
if info.key in old_infos:
# Update existing entity
@ -746,10 +774,13 @@ async def platform_async_setup_entry(
)
@callback
def async_entity_state(state: EntityState):
def async_entity_state(state: EntityState) -> None:
"""Notify the appropriate entity of an updated state."""
if not isinstance(state, state_type):
return
# cast back to upper type, otherwise mypy gets confused
state = cast(EntityState, state)
entry_data.state[component_key][state.key] = state
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.
This checks if the state object in the entity is set, and
prevents writing NAN values to the Home Assistant state machine.
"""
@property
def _wrapper(self):
if self._state is None:
@property # type: ignore[misc]
@functools.wraps(func)
def _wrapper(self): # type: ignore[no-untyped-def]
if not self._has_state:
return None
val = func(self)
if isinstance(val, float) and math.isnan(val):
@ -777,29 +812,43 @@ def esphome_state_property(func):
return None
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."""
def __init__(self, mapping: dict[_T, str]) -> None:
def __init__(self, mapping: dict[_EnumT, _ValT]) -> None:
"""Construct a EsphomeEnumMapper."""
# Add none mapping
mapping = {None: None, **mapping}
self._mapping = mapping
self._inverse: dict[str, _T] = {v: k for k, v in mapping.items()}
augmented_mapping: dict[_EnumT | None, _ValT | None] = mapping # type: ignore[assignment]
augmented_mapping[None] = None
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."""
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."""
return self._inverse[value]
class EsphomeBaseEntity(Entity):
class EsphomeBaseEntity(Entity, Generic[_InfoT, _StateT]):
"""Define a base esphome entity."""
def __init__(
@ -850,17 +899,18 @@ class EsphomeBaseEntity(Entity):
return self._entry_data.api_version
@property
def _static_info(self) -> EntityInfo:
def _static_info(self) -> _InfoT:
# Check if value is in info database. Use a single lookup.
info = self._entry_data.info[self._component_key].get(self._key)
if info is not None:
return info
return cast(_InfoT, info)
# This entity is in the removal project and has been removed from .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
def _device_info(self) -> EsphomeDeviceInfo:
assert self._entry_data.device_info is not None
return self._entry_data.device_info
@property
@ -868,11 +918,12 @@ class EsphomeBaseEntity(Entity):
return self._entry_data.client
@property
def _state(self) -> EntityState | None:
try:
return self._entry_data.state[self._component_key][self._key]
except KeyError:
return None
def _state(self) -> _StateT:
return cast(_StateT, self._entry_data.state[self._component_key][self._key])
@property
def _has_state(self) -> bool:
return self._key in self._entry_data.state[self._component_key]
@property
def available(self) -> bool:
@ -911,7 +962,7 @@ class EsphomeBaseEntity(Entity):
return False
class EsphomeEntity(EsphomeBaseEntity):
class EsphomeEntity(EsphomeBaseEntity[_InfoT, _StateT]):
"""Define a generic esphome entity."""
async def async_added_to_hass(self) -> None:

View file

@ -4,11 +4,16 @@ from __future__ import annotations
from aioesphomeapi import BinarySensorInfo, BinarySensorState
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
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."""
await platform_async_setup_entry(
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."""
@property
def _static_info(self) -> BinarySensorInfo:
return super()._static_info
@property
def _state(self) -> BinarySensorState | None:
return super()._state
@property
def is_on(self) -> bool | None:
"""Return true if the binary sensor is on."""
@ -39,7 +42,7 @@ class EsphomeBinarySensor(EsphomeEntity, BinarySensorEntity):
# Status binary sensors indicated connected state.
# So in their case what's usually _availability_ is now state
return self._entry_data.available
if self._state is None:
if not self._has_state:
return None
if self._state.missing_state:
return None

View file

@ -2,20 +2,23 @@
from __future__ import annotations
import asyncio
from typing import Any
from aioesphomeapi import CameraInfo, CameraState
from aiohttp import web
from homeassistant.components import camera
from homeassistant.components.camera import Camera
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import EsphomeBaseEntity, platform_async_setup_entry
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up esphome cameras based on a config 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."""
def __init__(self, *args, **kwargs) -> None:
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Initialize."""
Camera.__init__(self)
EsphomeBaseEntity.__init__(self, *args, **kwargs)
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:
"""Register callbacks."""
@ -90,7 +89,9 @@ class EsphomeCamera(Camera, EsphomeBaseEntity):
return None
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."""
return await camera.async_get_still_stream(
request, self._async_camera_stream_image, camera.DEFAULT_CONTENT_TYPE, 0.0

View file

@ -1,6 +1,8 @@
"""Support for ESPHome climate devices."""
from __future__ import annotations
from typing import Any, cast
from aioesphomeapi import (
ClimateAction,
ClimateFanMode,
@ -56,6 +58,7 @@ from homeassistant.components.climate.const import (
SWING_OFF,
SWING_VERTICAL,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_TEMPERATURE,
PRECISION_HALVES,
@ -63,6 +66,8 @@ from homeassistant.const import (
PRECISION_WHOLE,
TEMP_CELSIUS,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import (
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."""
await platform_async_setup_entry(
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.HEAT_COOL: HVAC_MODE_HEAT_COOL,
@ -96,7 +103,7 @@ _CLIMATE_MODES: EsphomeEnumMapper[ClimateMode] = EsphomeEnumMapper(
ClimateMode.AUTO: HVAC_MODE_AUTO,
}
)
_CLIMATE_ACTIONS: EsphomeEnumMapper[ClimateAction] = EsphomeEnumMapper(
_CLIMATE_ACTIONS: EsphomeEnumMapper[ClimateAction, str] = EsphomeEnumMapper(
{
ClimateAction.OFF: CURRENT_HVAC_OFF,
ClimateAction.COOLING: CURRENT_HVAC_COOL,
@ -106,7 +113,7 @@ _CLIMATE_ACTIONS: EsphomeEnumMapper[ClimateAction] = EsphomeEnumMapper(
ClimateAction.FAN: CURRENT_HVAC_FAN,
}
)
_FAN_MODES: EsphomeEnumMapper[ClimateFanMode] = EsphomeEnumMapper(
_FAN_MODES: EsphomeEnumMapper[ClimateFanMode, str] = EsphomeEnumMapper(
{
ClimateFanMode.ON: FAN_ON,
ClimateFanMode.OFF: FAN_OFF,
@ -119,7 +126,7 @@ _FAN_MODES: EsphomeEnumMapper[ClimateFanMode] = EsphomeEnumMapper(
ClimateFanMode.DIFFUSE: FAN_DIFFUSE,
}
)
_SWING_MODES: EsphomeEnumMapper[ClimateSwingMode] = EsphomeEnumMapper(
_SWING_MODES: EsphomeEnumMapper[ClimateSwingMode, str] = EsphomeEnumMapper(
{
ClimateSwingMode.OFF: SWING_OFF,
ClimateSwingMode.BOTH: SWING_BOTH,
@ -127,7 +134,7 @@ _SWING_MODES: EsphomeEnumMapper[ClimateSwingMode] = EsphomeEnumMapper(
ClimateSwingMode.HORIZONTAL: SWING_HORIZONTAL,
}
)
_PRESETS: EsphomeEnumMapper[ClimatePreset] = EsphomeEnumMapper(
_PRESETS: EsphomeEnumMapper[ClimatePreset, str] = EsphomeEnumMapper(
{
ClimatePreset.NONE: PRESET_NONE,
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."""
@property
def _static_info(self) -> ClimateInfo:
return super()._static_info
@property
def _state(self) -> ClimateState | None:
return super()._state
@property
def precision(self) -> float:
"""Return the precision of the climate device."""
@ -192,7 +196,7 @@ class EsphomeClimateEntity(EsphomeEntity, ClimateEntity):
] + self._static_info.supported_custom_presets
@property
def swing_modes(self):
def swing_modes(self) -> list[str]:
"""Return the list of available swing modes."""
return [
_SWING_MODES.from_esphome(mode)
@ -231,11 +235,8 @@ class EsphomeClimateEntity(EsphomeEntity, ClimateEntity):
features |= SUPPORT_SWING_MODE
return features
# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property
# pylint: disable=invalid-overridden-method
@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 _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 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)."""
data = {"key": self._static_info.key}
data: dict[str, Any] = {"key": self._static_info.key}
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:
data["target_temperature"] = kwargs[ATTR_TEMPERATURE]
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:
"""Set preset mode."""
kwargs = {}
kwargs: dict[str, Any] = {"key": self._static_info.key}
if preset_mode in self._static_info.supported_custom_presets:
kwargs["custom_preset"] = preset_mode
else:
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:
"""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:
kwargs["custom_fan_mode"] = fan_mode
else:
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:
"""Set new swing mode."""

View file

@ -2,14 +2,16 @@
from __future__ import annotations
from collections import OrderedDict
from typing import Any
from aioesphomeapi import APIClient, APIConnectionError
from aioesphomeapi import APIClient, APIConnectionError, DeviceInfo
import voluptuous as vol
from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DOMAIN, DomainData
@ -20,20 +22,19 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
def __init__(self):
def __init__(self) -> None:
"""Initialize flow."""
self._host: str | None = None
self._port: int | 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
): # pylint: disable=arguments-differ
"""Handle a flow initialized by the user."""
) -> FlowResult:
if user_input is not None:
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.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
)
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
def _name(self):
def _name(self) -> str | None:
return self.context.get(CONF_NAME)
@_name.setter
def _name(self, value):
def _name(self, value: str) -> None:
self.context[CONF_NAME] = value
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:
return
self._host = user_input[CONF_HOST]
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)
error, device_info = await self.fetch_device_info()
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
# Only show authentication step if device uses password
@ -73,7 +81,9 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
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."""
if user_input is not 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}
)
async def async_step_zeroconf(self, discovery_info: DiscoveryInfoType):
async def async_step_zeroconf(
self, discovery_info: DiscoveryInfoType
) -> FlowResult:
"""Handle zeroconf discovery."""
# Hostname is format: livingroom.local.
local_name = discovery_info["hostname"][:-1]
@ -129,7 +141,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
return await self.async_step_discovery_confirm()
@callback
def _async_get_entry(self):
def _async_get_entry(self) -> FlowResult:
assert self._name is not None
return self.async_create_entry(
title=self._name,
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."""
if user_input is not None:
self._password = user_input[CONF_PASSWORD]
@ -160,9 +175,11 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
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."""
zeroconf_instance = await zeroconf.async_get_instance(self.hass)
assert self._host is not None
assert self._port is not None
cli = APIClient(
self.hass.loop,
self._host,
@ -183,9 +200,11 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
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."""
zeroconf_instance = await zeroconf.async_get_instance(self.hass)
assert self._host is not None
assert self._port is not None
cli = APIClient(
self.hass.loop,
self._host,

View file

@ -1,6 +1,8 @@
"""Support for ESPHome covers."""
from __future__ import annotations
from typing import Any
from aioesphomeapi import CoverInfo, CoverOperation, CoverState
from homeassistant.components.cover import (
@ -17,12 +19,13 @@ from homeassistant.components.cover import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up ESPHome covers based on a config entry."""
await platform_async_setup_entry(
@ -36,12 +39,13 @@ async def async_setup_entry(
)
class EsphomeCover(EsphomeEntity, CoverEntity):
"""A cover implementation for ESPHome."""
# 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
@property
def _static_info(self) -> CoverInfo:
return super()._static_info
class EsphomeCover(EsphomeEntity[CoverInfo, CoverState], CoverEntity):
"""A cover implementation for ESPHome."""
@property
def supported_features(self) -> int:
@ -63,13 +67,6 @@ class EsphomeCover(EsphomeEntity, CoverEntity):
"""Return true if we do optimistic updates."""
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
def is_closed(self) -> bool | None:
"""Return if the cover is closed or not."""
@ -94,39 +91,39 @@ class EsphomeCover(EsphomeEntity, CoverEntity):
return round(self._state.position * 100.0)
@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."""
if not self._static_info.supports_tilt:
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."""
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."""
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."""
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."""
await self._client.cover_command(
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."""
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."""
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."""
await self._client.cover_command(
key=self._static_info.key, tilt=kwargs[ATTR_TILT_POSITION] / 100

View file

@ -3,10 +3,11 @@ from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Callable
from typing import Any, Callable, cast
from aioesphomeapi import (
COMPONENT_TYPE_TO_INFO,
APIClient,
APIVersion,
BinarySensorInfo,
CameraInfo,
@ -29,13 +30,10 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.storage import Store
if TYPE_CHECKING:
from . import APIClient
SAVE_DELAY = 120
# Mapping from ESPHome info type to HA platform
INFO_TYPE_TO_PLATFORM = {
INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], str] = {
BinarySensorInfo: "binary_sensor",
CameraInfo: "camera",
ClimateInfo: "climate",
@ -56,14 +54,14 @@ class RuntimeEntryData:
entry_id: str
client: APIClient
store: Store
state: dict[str, dict[str, Any]] = field(default_factory=dict)
info: dict[str, dict[str, Any]] = field(default_factory=dict)
state: dict[str, dict[int, EntityState]] = field(default_factory=dict)
info: dict[str, dict[int, EntityInfo]] = field(default_factory=dict)
# A second list of EntityInfo objects
# This is necessary for when an entity is being removed. HA requires
# 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.
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)
available: bool = False
@ -73,7 +71,7 @@ class RuntimeEntryData:
disconnect_callbacks: list[Callable[[], None]] = field(default_factory=list)
loaded_platforms: set[str] = field(default_factory=set)
platform_load_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
_storage_contents: dict | None = None
_storage_contents: dict[str, Any] | None = None
@callback
def async_update_entity(
@ -93,7 +91,7 @@ class RuntimeEntryData:
async def _ensure_platforms_loaded(
self, hass: HomeAssistant, entry: ConfigEntry, platforms: set[str]
):
) -> None:
async with self.platform_load_lock:
needed = platforms - self.loaded_platforms
tasks = []
@ -139,6 +137,7 @@ class RuntimeEntryData:
restored = await self.store.async_load()
if restored is None:
return [], []
restored = cast("dict[str, Any]", restored)
self._storage_contents = restored.copy()
self.device_info = DeviceInfo.from_dict(restored.pop("device_info"))
@ -157,7 +156,9 @@ class RuntimeEntryData:
async def async_save_to_store(self) -> None:
"""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(),
"services": [],
"api_version": self.api_version.to_dict(),
@ -171,7 +172,7 @@ class RuntimeEntryData:
if store_data == self._storage_contents:
return
def _memorized_storage():
def _memorized_storage() -> dict[str, Any]:
self._storage_contents = store_data
return store_data

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import math
from typing import Any
from aioesphomeapi import FanDirection, FanInfo, FanSpeed, FanState
@ -15,6 +16,7 @@ from homeassistant.components.fan import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.percentage import (
ordered_list_item_to_percentage,
percentage_to_ordered_list_item,
@ -33,7 +35,7 @@ ORDERED_NAMED_FAN_SPEEDS = [FanSpeed.LOW, FanSpeed.MEDIUM, FanSpeed.HIGH]
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up ESPHome fans based on a config 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.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."""
@property
def _static_info(self) -> FanInfo:
return super()._static_info
@property
def _state(self) -> FanState | None:
return super()._state
@property
def _supports_speed_levels(self) -> bool:
api_version = self._api_version
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."""
if percentage == 0:
await self.async_turn_off()
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 self._supports_speed_levels:
data["speed_level"] = math.ceil(
@ -97,12 +96,12 @@ class EsphomeFan(EsphomeEntity, FanEntity):
speed: str | None = None,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs,
**kwargs: Any,
) -> None:
"""Turn on the fan."""
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."""
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
)
async def async_set_direction(self, direction: str):
async def async_set_direction(self, direction: str) -> None:
"""Set direction of the fan."""
await self._client.fan_command(
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
def is_on(self) -> bool | None:
def is_on(self) -> bool | None: # type: ignore[override]
"""Return true if the entity is on."""
return self._state.state
@ -134,7 +130,7 @@ class EsphomeFan(EsphomeEntity, FanEntity):
if not self._supports_speed_levels:
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(

View file

@ -1,6 +1,8 @@
"""Support for ESPHome lights."""
from __future__ import annotations
from typing import Any
from aioesphomeapi import LightInfo, LightState
from homeassistant.components.light import (
@ -24,6 +26,7 @@ from homeassistant.components.light import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
import homeassistant.util.color as color_util
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(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up ESPHome lights based on a config entry."""
await platform_async_setup_entry(
@ -46,28 +49,22 @@ async def async_setup_entry(
)
class EsphomeLight(EsphomeEntity, LightEntity):
"""A switch implementation for ESPHome."""
# 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
@property
def _static_info(self) -> LightInfo:
return super()._static_info
@property
def _state(self) -> LightState | None:
return super()._state
# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property
# pylint: disable=invalid-overridden-method
class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
"""A light implementation for ESPHome."""
@esphome_state_property
def is_on(self) -> bool | None:
"""Return true if the switch is on."""
def is_on(self) -> bool | None: # type: ignore[override]
"""Return true if the light is on."""
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."""
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:
hue, sat = kwargs[ATTR_HS_COLOR]
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
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."""
data = {"key": self._static_info.key, "state": False}
data: dict[str, Any] = {"key": self._static_info.key, "state": False}
if ATTR_FLASH in kwargs:
data["flash_length"] = FLASH_LENGTHS[kwargs[ATTR_FLASH]]
if ATTR_TRANSITION in kwargs:
@ -108,7 +105,7 @@ class EsphomeLight(EsphomeEntity, LightEntity):
)
@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 self._state.color_temperature
@ -145,11 +142,11 @@ class EsphomeLight(EsphomeEntity, LightEntity):
return self._static_info.effects
@property
def min_mireds(self) -> float:
def min_mireds(self) -> float: # type: ignore[override]
"""Return the coldest color_temp that this light supports."""
return self._static_info.min_mireds
@property
def max_mireds(self) -> float:
def max_mireds(self) -> float: # type: ignore[override]
"""Return the warmest color_temp that this light supports."""
return self._static_info.max_mireds

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import math
from typing import cast
from aioesphomeapi import NumberInfo, NumberState
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
# 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."""
@property
def _static_info(self) -> NumberInfo:
return super()._static_info
@property
def _state(self) -> NumberState | None:
return super()._state
@property
def icon(self) -> str | None:
"""Return the icon."""
if not self._static_info.icon:
return None
return ICON_SCHEMA(self._static_info.icon)
return cast(str, ICON_SCHEMA(self._static_info.icon))
@property
def min_value(self) -> float:
@ -72,7 +66,7 @@ class EsphomeNumber(EsphomeEntity, NumberEntity):
return super()._static_info.step
@esphome_state_property
def value(self) -> float:
def value(self) -> float | None:
"""Return the state of the entity."""
if math.isnan(self._state.state):
return None

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import math
from typing import cast
from aioesphomeapi import (
SensorInfo,
@ -21,6 +22,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt
from . import (
@ -34,7 +36,7 @@ ICON_SCHEMA = vol.Schema(cv.icon)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up esphome sensors based on a config 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
# 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.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."""
@property
def _static_info(self) -> SensorInfo:
return super()._static_info
@property
def _state(self) -> SensorState | None:
return super()._state
@property
def icon(self) -> str:
def icon(self) -> str | None:
"""Return the icon."""
if not self._static_info.icon or self._static_info.device_class:
return None
return ICON_SCHEMA(self._static_info.icon)
return cast(str, ICON_SCHEMA(self._static_info.icon))
@property
def force_update(self) -> bool:
@ -104,14 +99,14 @@ class EsphomeSensor(EsphomeEntity, SensorEntity):
return f"{self._state.state:.{self._static_info.accuracy_decimals}f}"
@property
def unit_of_measurement(self) -> str:
def unit_of_measurement(self) -> str | None:
"""Return the unit the value is expressed in."""
if not self._static_info.unit_of_measurement:
return None
return self._static_info.unit_of_measurement
@property
def device_class(self) -> str:
def device_class(self) -> str | None:
"""Return the class of this device, from component DEVICE_CLASSES."""
if self._static_info.device_class not in DEVICE_CLASSES:
return None
@ -125,17 +120,9 @@ class EsphomeSensor(EsphomeEntity, SensorEntity):
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."""
@property
def _static_info(self) -> TextSensorInfo:
return super()._static_info
@property
def _state(self) -> TextSensorState | None:
return super()._state
@property
def icon(self) -> str:
"""Return the icon."""

View file

@ -1,17 +1,20 @@
"""Support for ESPHome switches."""
from __future__ import annotations
from typing import Any
from aioesphomeapi import SwitchInfo, SwitchState
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up ESPHome switches based on a config 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."""
@property
def _static_info(self) -> SwitchInfo:
return super()._static_info
@property
def _state(self) -> SwitchState | None:
return super()._state
@property
def icon(self) -> str:
"""Return the icon."""
@ -46,17 +46,15 @@ class EsphomeSwitch(EsphomeEntity, SwitchEntity):
"""Return true if we do optimistic updates."""
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
def is_on(self) -> bool | None:
def is_on(self) -> bool | None: # type: ignore[override]
"""Return true if the switch is on."""
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."""
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."""
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_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.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@ -1153,9 +1164,6 @@ ignore_errors = true
[mypy-homeassistant.components.entur_public_transport.*]
ignore_errors = true
[mypy-homeassistant.components.esphome.*]
ignore_errors = true
[mypy-homeassistant.components.evohome.*]
ignore_errors = true

View file

@ -50,7 +50,6 @@ IGNORED_MODULES: Final[list[str]] = [
"homeassistant.components.emonitor.*",
"homeassistant.components.enphase_envoy.*",
"homeassistant.components.entur_public_transport.*",
"homeassistant.components.esphome.*",
"homeassistant.components.evohome.*",
"homeassistant.components.filter.*",
"homeassistant.components.fints.*",

View file

@ -48,6 +48,13 @@ def mock_api_connection_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):
"""Test we can finish a config flow."""
result = await hass.config_entries.flow.async_init(