diff --git a/.strict-typing b/.strict-typing index 77d00928eddef..1a12b1c5299b1 100644 --- a/.strict-typing +++ b/.strict-typing @@ -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.* diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index aa7da100505bd..0db72ad8f3b51 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -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 @@ 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 @@ def get(cls: type[_T], hass: HomeAssistant) -> _T: """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 @@ def async_on_service_call(service: HomeassistantServiceCall) -> None: 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 _send_home_assistant_state( 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 on_login() -> None: 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 @@ def __init__( 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 @@ def __init__( # 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 @@ def _entry_data(self) -> RuntimeEntryData | None: 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 @@ async def _on_disconnect(self): 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 @@ async def _wait_and_start_reconnect(self): 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 @@ async def _try_connect(self): 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 @@ async def _reconnect_once(self): # 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 @@ async def _reconnect_once(self): 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 @@ async def _reconnect_loop(self): 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 @@ async def start(self): 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 @@ async def stop(self): 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 @@ async def _start_zc_listen(self): ) 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 @@ async def _stop_zc_listen(self): 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 @@ def update_record(self, zc: Zeroconf, now: float, record: DNSRecord) -> None: 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 execute_service(call): 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 @@ def async_list_entities(infos: list[EntityInfo]): ) @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 @@ def async_entity_state(state: EntityState): ) -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 _wrapper(self): return None return val - return _wrapper + return cast(_PropT, _wrapper) + + +_EnumT = TypeVar("_EnumT", bound=APIIntEnum) +_ValT = TypeVar("_ValT") -class EsphomeEnumMapper(Generic[_T]): +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 + + 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: _T | None) -> str | 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 @@ def _api_version(self) -> APIVersion: 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 @@ def _client(self) -> APIClient: 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 @@ def should_poll(self) -> bool: return False -class EsphomeEntity(EsphomeBaseEntity): +class EsphomeEntity(EsphomeBaseEntity[_InfoT, _StateT]): """Define a generic esphome entity.""" async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py index 28cc47691f56b..44ed1806ed681 100644 --- a/homeassistant/components/esphome/binary_sensor.py +++ b/homeassistant/components/esphome/binary_sensor.py @@ -4,11 +4,16 @@ 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,16 +26,14 @@ async def async_setup_entry(hass, entry, async_add_entities): ) -class EsphomeBinarySensor(EsphomeEntity, BinarySensorEntity): - """A binary sensor implementation for ESPHome.""" +# Pylint gets confused with the EsphomeEntity generics -> let mypy handle member checking +# pylint: disable=no-member - @property - def _static_info(self) -> BinarySensorInfo: - return super()._static_info - @property - def _state(self) -> BinarySensorState | None: - return super()._state +class EsphomeBinarySensor( + EsphomeEntity[BinarySensorInfo, BinarySensorState], BinarySensorEntity +): + """A binary sensor implementation for ESPHome.""" @property def is_on(self) -> bool | None: @@ -39,7 +42,7 @@ def is_on(self) -> bool | None: # 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 diff --git a/homeassistant/components/esphome/camera.py b/homeassistant/components/esphome/camera.py index f047d5c1bdd8e..34b6d90f4d4e4 100644 --- a/homeassistant/components/esphome/camera.py +++ b/homeassistant/components/esphome/camera.py @@ -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 @@ async def _async_camera_stream_image(self) -> bytes | None: 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 diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index f7ebccc8434ac..8715cb368c248 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -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 @@ SWING_OFF, SWING_VERTICAL, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, PRECISION_HALVES, @@ -63,6 +66,8 @@ PRECISION_WHOLE, TEMP_CELSIUS, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ( EsphomeEntity, @@ -72,7 +77,9 @@ ) -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 @@ async def async_setup_entry(hass, entry, async_add_entities): 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 @@ async def async_setup_entry(hass, entry, async_add_entities): 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 @@ async def async_setup_entry(hass, entry, async_add_entities): 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 @@ async def async_setup_entry(hass, entry, async_add_entities): ClimateSwingMode.HORIZONTAL: SWING_HORIZONTAL, } ) -_PRESETS: EsphomeEnumMapper[ClimatePreset] = EsphomeEnumMapper( +_PRESETS: EsphomeEnumMapper[ClimatePreset, str] = EsphomeEnumMapper( { ClimatePreset.NONE: PRESET_NONE, ClimatePreset.HOME: PRESET_HOME, @@ -141,16 +148,13 @@ async def async_setup_entry(hass, entry, async_add_entities): ) -class EsphomeClimateEntity(EsphomeEntity, ClimateEntity): - """A climate 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) -> ClimateInfo: - return super()._static_info - @property - def _state(self) -> ClimateState | None: - return super()._state +class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEntity): + """A climate implementation for ESPHome.""" @property def precision(self) -> float: @@ -192,7 +196,7 @@ def preset_modes(self) -> list[str]: ] + 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 @@ def supported_features(self) -> int: 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 @@ def target_temperature_high(self) -> float | None: """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 @@ async def async_set_hvac_mode(self, hvac_mode: str) -> None: 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.""" diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 38e44b1250895..3062b9690bf5b 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -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 @@ async def async_step_user( 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 @@ async def _async_authenticate_or_add(self, user_input): 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 @@ async def async_step_discovery_confirm(self, user_input=None): 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 @@ async def async_step_zeroconf(self, discovery_info: DiscoveryInfoType): 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 @@ def _async_get_entry(self): }, ) - 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 @@ async def async_step_authenticate(self, user_input=None, error=None): 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 @@ async def fetch_device_info(self): 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, diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py index 3064f827d7f93..d0d89cf40ada8 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -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.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 @@ def assumed_state(self) -> bool: """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 @@ def current_cover_position(self) -> int | None: 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 diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index f60d7cfefb540..3ab933f75f90b 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -3,10 +3,11 @@ 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.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 @@ def async_remove_entity( 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 @@ async def async_load_from_store(self) -> tuple[list[EntityInfo], list[UserServic 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 @@ async def async_load_from_store(self) -> tuple[list[EntityInfo], list[UserServic 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 @@ async def async_save_to_store(self) -> None: if store_data == self._storage_contents: return - def _memorized_storage(): + def _memorized_storage() -> dict[str, Any]: self._storage_contents = store_data return store_data diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index e02958d5885bb..7052ee42861c8 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -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.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 @@ 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 @@ async def async_setup_entry( ) -class EsphomeFan(EsphomeEntity, FanEntity): - """A fan 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) -> FanInfo: - return super()._static_info - @property - def _state(self) -> FanState | None: - return super()._state +class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): + """A fan implementation for ESPHome.""" @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 @@ async def async_turn_on( 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 @@ async def async_oscillate(self, oscillating: bool) -> None: 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 @@ def percentage(self) -> int | None: 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( diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index d1f567c3c8efa..0dd024832bbef 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -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.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 @@ 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.""" - - @property - def _static_info(self) -> LightInfo: - return super()._static_info +# 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 _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 @@ async def async_turn_on(self, **kwargs) -> None: 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 @@ def hs_color(self) -> tuple[float, float] | None: ) @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 @@ def effect_list(self) -> list[str]: 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 diff --git a/homeassistant/components/esphome/number.py b/homeassistant/components/esphome/number.py index 08b31e91b7961..9100073148361 100644 --- a/homeassistant/components/esphome/number.py +++ b/homeassistant/components/esphome/number.py @@ -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 @@ def step(self) -> float: 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 diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index d3dce2dea1b5e..48e328094560f 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations import math +from typing import cast from aioesphomeapi import ( SensorInfo, @@ -21,6 +22,7 @@ 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 @@ 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 @@ async def async_setup_entry( ) -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 @@ def state(self) -> str | None: 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 @@ def state_class(self) -> str | None: 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.""" diff --git a/homeassistant/components/esphome/switch.py b/homeassistant/components/esphome/switch.py index 341068b05ad38..c2c88ee93760f 100644 --- a/homeassistant/components/esphome/switch.py +++ b/homeassistant/components/esphome/switch.py @@ -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,16 +28,13 @@ async def async_setup_entry( ) -class EsphomeSwitch(EsphomeEntity, SwitchEntity): - """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) -> SwitchInfo: - return super()._static_info - @property - def _state(self) -> SwitchState | None: - return super()._state +class EsphomeSwitch(EsphomeEntity[SwitchInfo, SwitchState], SwitchEntity): + """A switch implementation for ESPHome.""" @property def icon(self) -> str: @@ -46,17 +46,15 @@ def assumed_state(self) -> bool: """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) diff --git a/mypy.ini b/mypy.ini index 4c693dcc5ee83..11330acad6f72 100644 --- a/mypy.ini +++ b/mypy.ini @@ -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 diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 1873ebd46c189..447bbab51f668 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -50,7 +50,6 @@ "homeassistant.components.emonitor.*", "homeassistant.components.enphase_envoy.*", "homeassistant.components.entur_public_transport.*", - "homeassistant.components.esphome.*", "homeassistant.components.evohome.*", "homeassistant.components.filter.*", "homeassistant.components.fints.*", diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 735a02e960c65..a5de14d946d94 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -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(