Add strict typing to Comelit (#139455)

* Add quality scale and strict typing to Comelit

* mypy

* fix strings

* remove quality scale

* revert quality scale changes

* improve typing

* letfover

* update typing based on new lib

* align to platform

* cleanup

* apply review comments (part 1)

* apply review comment ( part 2)

* apply review comments

* align

* align test data

* TypedDict

* better casting
This commit is contained in:
Simone Chemelli 2025-03-03 17:57:42 +01:00 committed by GitHub
parent b17ee78dec
commit aaecb47125
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 140 additions and 157 deletions

View File

@ -136,6 +136,7 @@ homeassistant.components.clicksend.*
homeassistant.components.climate.* homeassistant.components.climate.*
homeassistant.components.cloud.* homeassistant.components.cloud.*
homeassistant.components.co2signal.* homeassistant.components.co2signal.*
homeassistant.components.comelit.*
homeassistant.components.command_line.* homeassistant.components.command_line.*
homeassistant.components.config.* homeassistant.components.config.*
homeassistant.components.configurator.* homeassistant.components.configurator.*

View File

@ -6,7 +6,7 @@ import logging
from typing import cast from typing import cast
from aiocomelit.api import ComelitVedoAreaObject from aiocomelit.api import ComelitVedoAreaObject
from aiocomelit.const import ALARM_AREAS, AlarmAreaState from aiocomelit.const import AlarmAreaState
from homeassistant.components.alarm_control_panel import ( from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntity, AlarmControlPanelEntity,
@ -56,7 +56,7 @@ async def async_setup_entry(
async_add_entities( async_add_entities(
ComelitAlarmEntity(coordinator, device, config_entry.entry_id) ComelitAlarmEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data[ALARM_AREAS].values() for device in coordinator.data["alarm_areas"].values()
) )
@ -92,7 +92,7 @@ class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanel
@property @property
def _area(self) -> ComelitVedoAreaObject: def _area(self) -> ComelitVedoAreaObject:
"""Return area object.""" """Return area object."""
return self.coordinator.data[ALARM_AREAS][self._area_index] return self.coordinator.data["alarm_areas"][self._area_index]
@property @property
def available(self) -> bool: def available(self) -> bool:

View File

@ -5,7 +5,6 @@ from __future__ import annotations
from typing import cast from typing import cast
from aiocomelit import ComelitVedoZoneObject from aiocomelit import ComelitVedoZoneObject
from aiocomelit.const import ALARM_ZONES
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass, BinarySensorDeviceClass,
@ -29,7 +28,7 @@ async def async_setup_entry(
async_add_entities( async_add_entities(
ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id) ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data[ALARM_ZONES].values() for device in coordinator.data["alarm_zones"].values()
) )
@ -49,7 +48,7 @@ class ComelitVedoBinarySensorEntity(
) -> None: ) -> None:
"""Init sensor entity.""" """Init sensor entity."""
self._api = coordinator.api self._api = coordinator.api
self._zone = zone self._zone_index = zone.index
super().__init__(coordinator) super().__init__(coordinator)
# Use config_entry.entry_id as base for unique_id # Use config_entry.entry_id as base for unique_id
# because no serial number or mac is available # because no serial number or mac is available
@ -59,4 +58,6 @@ class ComelitVedoBinarySensorEntity(
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Presence detected.""" """Presence detected."""
return self.coordinator.data[ALARM_ZONES][self._zone.index].status_api == "0001" return (
self.coordinator.data["alarm_zones"][self._zone_index].status_api == "0001"
)

View File

@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from enum import StrEnum from enum import StrEnum
from typing import Any, cast from typing import Any, TypedDict, cast
from aiocomelit import ComelitSerialBridgeObject from aiocomelit import ComelitSerialBridgeObject
from aiocomelit.const import CLIMATE from aiocomelit.const import CLIMATE
@ -16,7 +16,8 @@ from homeassistant.components.climate import (
UnitOfTemperature, UnitOfTemperature,
) )
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
@ -42,22 +43,23 @@ class ClimaComelitCommand(StrEnum):
AUTO = "auto" AUTO = "auto"
API_STATUS: dict[str, dict[str, Any]] = { class ClimaComelitApiStatus(TypedDict):
ClimaComelitMode.OFF: { """Comelit Clima API status."""
"action": "off",
"hvac_mode": HVACMode.OFF, hvac_mode: HVACMode
"hvac_action": HVACAction.OFF, hvac_action: HVACAction
},
ClimaComelitMode.LOWER: {
"action": "lower", API_STATUS: dict[str, ClimaComelitApiStatus] = {
"hvac_mode": HVACMode.COOL, ClimaComelitMode.OFF: ClimaComelitApiStatus(
"hvac_action": HVACAction.COOLING, hvac_mode=HVACMode.OFF, hvac_action=HVACAction.OFF
}, ),
ClimaComelitMode.UPPER: { ClimaComelitMode.LOWER: ClimaComelitApiStatus(
"action": "upper", hvac_mode=HVACMode.COOL, hvac_action=HVACAction.COOLING
"hvac_mode": HVACMode.HEAT, ),
"hvac_action": HVACAction.HEATING, ClimaComelitMode.UPPER: ClimaComelitApiStatus(
}, hvac_mode=HVACMode.HEAT, hvac_action=HVACAction.HEATING
),
} }
MODE_TO_ACTION: dict[HVACMode, ClimaComelitCommand] = { MODE_TO_ACTION: dict[HVACMode, ClimaComelitCommand] = {
@ -114,69 +116,41 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity
self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" self._attr_unique_id = f"{config_entry_entry_id}-{device.index}"
self._attr_device_info = coordinator.platform_device_info(device, device.type) self._attr_device_info = coordinator.platform_device_info(device, device.type)
@property @callback
def _clima(self) -> list[Any]: def _handle_coordinator_update(self) -> None:
"""Return clima device data.""" """Handle updated data from the coordinator."""
device = self.coordinator.data[CLIMATE][self._device.index]
if not isinstance(device.val, list):
raise HomeAssistantError("Invalid clima data")
# CLIMATE has a 2 item tuple: # CLIMATE has a 2 item tuple:
# - first for Clima # - first for Clima
# - second for Humidifier # - second for Humidifier
return self.coordinator.data[CLIMATE][self._device.index].val[0] values = device.val[0]
@property _active = values[1]
def _api_mode(self) -> str: _mode = values[2] # Values from API: "O", "L", "U"
"""Return device mode.""" _automatic = values[3] == ClimaComelitMode.AUTO
# Values from API: "O", "L", "U"
return self._clima[2]
@property self._attr_current_temperature = values[0] / 10
def _api_active(self) -> bool:
"Return device active/idle."
return self._clima[1]
@property self._attr_hvac_action = None
def _api_automatic(self) -> bool: if _mode == ClimaComelitMode.OFF:
"""Return device in automatic/manual mode.""" self._attr_hvac_action = HVACAction.OFF
return self._clima[3] == ClimaComelitMode.AUTO if not _active:
self._attr_hvac_action = HVACAction.IDLE
if _mode in API_STATUS:
self._attr_hvac_action = API_STATUS[_mode]["hvac_action"]
@property self._attr_hvac_mode = None
def target_temperature(self) -> float: if _mode == ClimaComelitMode.OFF:
"""Set target temperature.""" self._attr_hvac_mode = HVACMode.OFF
return self._clima[4] / 10 if _automatic:
self._attr_hvac_mode = HVACMode.AUTO
if _mode in API_STATUS:
self._attr_hvac_mode = API_STATUS[_mode]["hvac_mode"]
@property self._attr_target_temperature = values[4] / 10
def current_temperature(self) -> float:
"""Return current temperature."""
return self._clima[0] / 10
@property
def hvac_mode(self) -> HVACMode | None:
"""HVAC current mode."""
if self._api_mode == ClimaComelitMode.OFF:
return HVACMode.OFF
if self._api_automatic:
return HVACMode.AUTO
if self._api_mode in API_STATUS:
return API_STATUS[self._api_mode]["hvac_mode"]
return None
@property
def hvac_action(self) -> HVACAction | None:
"""HVAC current action."""
if self._api_mode == ClimaComelitMode.OFF:
return HVACAction.OFF
if not self._api_active:
return HVACAction.IDLE
if self._api_mode in API_STATUS:
return API_STATUS[self._api_mode]["hvac_action"]
return None
async def async_set_temperature(self, **kwargs: Any) -> None: async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature.""" """Set new target temperature."""

View File

@ -2,7 +2,7 @@
from abc import abstractmethod from abc import abstractmethod
from datetime import timedelta from datetime import timedelta
from typing import Any from typing import TypedDict, TypeVar, cast
from aiocomelit import ( from aiocomelit import (
ComeliteSerialBridgeApi, ComeliteSerialBridgeApi,
@ -13,7 +13,7 @@ from aiocomelit import (
exceptions, exceptions,
) )
from aiocomelit.api import ComelitCommonApi from aiocomelit.api import ComelitCommonApi
from aiocomelit.const import BRIDGE, VEDO from aiocomelit.const import ALARM_AREAS, ALARM_ZONES, BRIDGE, VEDO
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -26,7 +26,20 @@ from .const import _LOGGER, DOMAIN
type ComelitConfigEntry = ConfigEntry[ComelitBaseCoordinator] type ComelitConfigEntry = ConfigEntry[ComelitBaseCoordinator]
class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]): class AlarmDataObject(TypedDict):
"""TypedDict for Alarm data objects."""
alarm_areas: dict[int, ComelitVedoAreaObject]
alarm_zones: dict[int, ComelitVedoZoneObject]
T = TypeVar(
"T",
bound=dict[str, dict[int, ComelitSerialBridgeObject]] | AlarmDataObject,
)
class ComelitBaseCoordinator(DataUpdateCoordinator[T]):
"""Base coordinator for Comelit Devices.""" """Base coordinator for Comelit Devices."""
_hw_version: str _hw_version: str
@ -81,7 +94,7 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]):
hw_version=self._hw_version, hw_version=self._hw_version,
) )
async def _async_update_data(self) -> dict[str, Any]: async def _async_update_data(self) -> T:
"""Update device data.""" """Update device data."""
_LOGGER.debug("Polling Comelit %s host: %s", self._device, self._host) _LOGGER.debug("Polling Comelit %s host: %s", self._device, self._host)
try: try:
@ -93,11 +106,13 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]):
raise ConfigEntryAuthFailed from err raise ConfigEntryAuthFailed from err
@abstractmethod @abstractmethod
async def _async_update_system_data(self) -> dict[str, Any]: async def _async_update_system_data(self) -> T:
"""Class method for updating data.""" """Class method for updating data."""
class ComelitSerialBridge(ComelitBaseCoordinator): class ComelitSerialBridge(
ComelitBaseCoordinator[dict[str, dict[int, ComelitSerialBridgeObject]]]
):
"""Queries Comelit Serial Bridge.""" """Queries Comelit Serial Bridge."""
_hw_version = "20003101" _hw_version = "20003101"
@ -115,12 +130,14 @@ class ComelitSerialBridge(ComelitBaseCoordinator):
self.api = ComeliteSerialBridgeApi(host, port, pin) self.api = ComeliteSerialBridgeApi(host, port, pin)
super().__init__(hass, entry, BRIDGE, host) super().__init__(hass, entry, BRIDGE, host)
async def _async_update_system_data(self) -> dict[str, Any]: async def _async_update_system_data(
self,
) -> dict[str, dict[int, ComelitSerialBridgeObject]]:
"""Specific method for updating data.""" """Specific method for updating data."""
return await self.api.get_all_devices() return await self.api.get_all_devices()
class ComelitVedoSystem(ComelitBaseCoordinator): class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]):
"""Queries Comelit VEDO system.""" """Queries Comelit VEDO system."""
_hw_version = "VEDO IP" _hw_version = "VEDO IP"
@ -138,6 +155,13 @@ class ComelitVedoSystem(ComelitBaseCoordinator):
self.api = ComelitVedoApi(host, port, pin) self.api = ComelitVedoApi(host, port, pin)
super().__init__(hass, entry, VEDO, host) super().__init__(hass, entry, VEDO, host)
async def _async_update_system_data(self) -> dict[str, Any]: async def _async_update_system_data(
self,
) -> AlarmDataObject:
"""Specific method for updating data.""" """Specific method for updating data."""
return await self.api.get_all_areas_and_zones() data = await self.api.get_all_areas_and_zones()
return AlarmDataObject(
alarm_areas=cast(dict[int, ComelitVedoAreaObject], data[ALARM_AREAS]),
alarm_zones=cast(dict[int, ComelitVedoZoneObject], data[ALARM_ZONES]),
)

View File

@ -16,8 +16,8 @@ from homeassistant.components.humidifier import (
HumidifierEntity, HumidifierEntity,
HumidifierEntityFeature, HumidifierEntityFeature,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
@ -122,61 +122,32 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier
self._active_action = active_action self._active_action = active_action
self._set_command = set_command self._set_command = set_command
@property @callback
def _humidifier(self) -> list[Any]: def _handle_coordinator_update(self) -> None:
"""Return humidifier device data.""" """Handle updated data from the coordinator."""
device = self.coordinator.data[CLIMATE][self._device.index]
if not isinstance(device.val, list):
raise HomeAssistantError("Invalid clima data")
# CLIMATE has a 2 item tuple: # CLIMATE has a 2 item tuple:
# - first for Clima # - first for Clima
# - second for Humidifier # - second for Humidifier
return self.coordinator.data[CLIMATE][self._device.index].val[1] values = device.val[1]
@property _active = values[1]
def _api_mode(self) -> str: _mode = values[2] # Values from API: "O", "L", "U"
"""Return device mode.""" _automatic = values[3] == HumidifierComelitMode.AUTO
# Values from API: "O", "L", "U"
return self._humidifier[2]
@property self._attr_action = HumidifierAction.IDLE
def _api_active(self) -> bool: if _mode == HumidifierComelitMode.OFF:
"Return device active/idle." self._attr_action = HumidifierAction.OFF
return self._humidifier[1] if _active and _mode == self._active_mode:
self._attr_action = self._active_action
@property self._attr_current_humidity = values[0] / 10
def _api_automatic(self) -> bool: self._attr_is_on = _mode == self._active_mode
"""Return device in automatic/manual mode.""" self._attr_mode = MODE_AUTO if _automatic else MODE_NORMAL
return self._humidifier[3] == HumidifierComelitMode.AUTO self._attr_target_humidity = values[4] / 10
@property
def target_humidity(self) -> float:
"""Set target humidity."""
return self._humidifier[4] / 10
@property
def current_humidity(self) -> float:
"""Return current humidity."""
return self._humidifier[0] / 10
@property
def is_on(self) -> bool | None:
"""Return true is humidifier is on."""
return self._api_mode == self._active_mode
@property
def mode(self) -> str | None:
"""Return current mode."""
return MODE_AUTO if self._api_automatic else MODE_NORMAL
@property
def action(self) -> HumidifierAction | None:
"""Return current action."""
if self._api_mode == HumidifierComelitMode.OFF:
return HumidifierAction.OFF
if self._api_active and self._api_mode == self._active_mode:
return self._active_action
return HumidifierAction.IDLE
async def async_set_humidity(self, humidity: int) -> None: async def async_set_humidity(self, humidity: int) -> None:
"""Set new target humidity.""" """Set new target humidity."""

View File

@ -5,7 +5,7 @@ from __future__ import annotations
from typing import Final, cast from typing import Final, cast
from aiocomelit import ComelitSerialBridgeObject, ComelitVedoZoneObject from aiocomelit import ComelitSerialBridgeObject, ComelitVedoZoneObject
from aiocomelit.const import ALARM_ZONES, BRIDGE, OTHER, AlarmZoneState from aiocomelit.const import BRIDGE, OTHER, AlarmZoneState
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
@ -82,7 +82,7 @@ async def async_setup_vedo_entry(
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data) coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
entities: list[ComelitVedoSensorEntity] = [] entities: list[ComelitVedoSensorEntity] = []
for device in coordinator.data[ALARM_ZONES].values(): for device in coordinator.data["alarm_zones"].values():
entities.extend( entities.extend(
ComelitVedoSensorEntity( ComelitVedoSensorEntity(
coordinator, device, config_entry.entry_id, sensor_desc coordinator, device, config_entry.entry_id, sensor_desc
@ -119,9 +119,12 @@ class ComelitBridgeSensorEntity(CoordinatorEntity[ComelitSerialBridge], SensorEn
@property @property
def native_value(self) -> StateType: def native_value(self) -> StateType:
"""Sensor value.""" """Sensor value."""
return getattr( return cast(
StateType,
getattr(
self.coordinator.data[OTHER][self._device.index], self.coordinator.data[OTHER][self._device.index],
self.entity_description.key, self.entity_description.key,
),
) )
@ -139,7 +142,7 @@ class ComelitVedoSensorEntity(CoordinatorEntity[ComelitVedoSystem], SensorEntity
) -> None: ) -> None:
"""Init sensor entity.""" """Init sensor entity."""
self._api = coordinator.api self._api = coordinator.api
self._zone = zone self._zone_index = zone.index
super().__init__(coordinator) super().__init__(coordinator)
# Use config_entry.entry_id as base for unique_id # Use config_entry.entry_id as base for unique_id
# because no serial number or mac is available # because no serial number or mac is available
@ -151,7 +154,7 @@ class ComelitVedoSensorEntity(CoordinatorEntity[ComelitVedoSystem], SensorEntity
@property @property
def _zone_object(self) -> ComelitVedoZoneObject: def _zone_object(self) -> ComelitVedoZoneObject:
"""Zone object.""" """Zone object."""
return self.coordinator.data[ALARM_ZONES][self._zone.index] return self.coordinator.data["alarm_zones"][self._zone_index]
@property @property
def available(self) -> bool: def available(self) -> bool:
@ -164,4 +167,4 @@ class ComelitVedoSensorEntity(CoordinatorEntity[ComelitVedoSystem], SensorEntity
if (status := self._zone_object.human_status) == AlarmZoneState.UNKNOWN: if (status := self._zone_object.human_status) == AlarmZoneState.UNKNOWN:
return None return None
return status.value return cast(str, status.value)

View File

@ -77,7 +77,4 @@ class ComelitSwitchEntity(CoordinatorEntity[ComelitSerialBridge], SwitchEntity):
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return True if switch is on.""" """Return True if switch is on."""
return ( return self.coordinator.data[OTHER][self._device.index].status == STATE_ON
self.coordinator.data[self._device.type][self._device.index].status
== STATE_ON
)

10
mypy.ini generated
View File

@ -1115,6 +1115,16 @@ disallow_untyped_defs = true
warn_return_any = true warn_return_any = true
warn_unreachable = true warn_unreachable = true
[mypy-homeassistant.components.comelit.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.command_line.*] [mypy-homeassistant.components.command_line.*]
check_untyped_defs = true check_untyped_defs = true
disallow_incomplete_defs = true disallow_incomplete_defs = true

View File

@ -6,6 +6,8 @@ from aiocomelit import (
ComelitVedoZoneObject, ComelitVedoZoneObject,
) )
from aiocomelit.const import ( from aiocomelit.const import (
ALARM_AREAS,
ALARM_ZONES,
CLIMATE, CLIMATE,
COVER, COVER,
IRRIGATION, IRRIGATION,
@ -63,7 +65,7 @@ BRIDGE_DEVICE_QUERY = {
} }
VEDO_DEVICE_QUERY = { VEDO_DEVICE_QUERY = {
"aree": { ALARM_AREAS: {
0: ComelitVedoAreaObject( 0: ComelitVedoAreaObject(
index=0, index=0,
name="Area0", name="Area0",
@ -80,7 +82,7 @@ VEDO_DEVICE_QUERY = {
human_status=AlarmAreaState.UNKNOWN, human_status=AlarmAreaState.UNKNOWN,
) )
}, },
"zone": { ALARM_ZONES: {
0: ComelitVedoZoneObject( 0: ComelitVedoZoneObject(
index=0, index=0,
name="Zone0", name="Zone0",

View File

@ -86,7 +86,7 @@
'device_info': dict({ 'device_info': dict({
'devices': list([ 'devices': list([
dict({ dict({
'aree': list([ 'alarm_areas': list([
dict({ dict({
'0': dict({ '0': dict({
'alarm': False, 'alarm': False,
@ -106,7 +106,7 @@
]), ]),
}), }),
dict({ dict({
'zone': list([ 'alarm_zones': list([
dict({ dict({
'0': dict({ '0': dict({
'human_status': 'rest', 'human_status': 'rest',