Improve Elk-M1 Control typing (#69924)

* Add types to __init__.py

* Fixup typing.

* Fix type error.

* Bump lib to fix login error.

Co-authored-by: Shay Levy <levyshay1@gmail.com>
This commit is contained in:
Glenn Waters 2022-04-15 17:14:45 -04:00 committed by GitHub
parent f8367d3c01
commit c80853496d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 78 additions and 46 deletions

View file

@ -81,6 +81,7 @@ homeassistant.components.dsmr.*
homeassistant.components.dunehd.* homeassistant.components.dunehd.*
homeassistant.components.efergy.* homeassistant.components.efergy.*
homeassistant.components.elgato.* homeassistant.components.elgato.*
homeassistant.components.elkm1.__init__
homeassistant.components.esphome.* homeassistant.components.esphome.*
homeassistant.components.energy.* homeassistant.components.energy.*
homeassistant.components.evil_genius_labs.* homeassistant.components.evil_genius_labs.*

View file

@ -5,11 +5,12 @@ import asyncio
import logging import logging
import re import re
from types import MappingProxyType from types import MappingProxyType
from typing import Any from typing import Any, cast
from urllib.parse import urlparse from urllib.parse import urlparse
import async_timeout import async_timeout
import elkm1_lib as elkm1 from elkm1_lib.elements import Element
from elkm1_lib.elk import Elk
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
@ -22,6 +23,7 @@ from homeassistant.const import (
CONF_PREFIX, CONF_PREFIX,
CONF_TEMPERATURE_UNIT, CONF_TEMPERATURE_UNIT,
CONF_USERNAME, CONF_USERNAME,
CONF_ZONE,
TEMP_CELSIUS, TEMP_CELSIUS,
TEMP_FAHRENHEIT, TEMP_FAHRENHEIT,
Platform, Platform,
@ -50,7 +52,6 @@ from .const import (
CONF_SETTING, CONF_SETTING,
CONF_TASK, CONF_TASK,
CONF_THERMOSTAT, CONF_THERMOSTAT,
CONF_ZONE,
DISCOVER_SCAN_TIMEOUT, DISCOVER_SCAN_TIMEOUT,
DISCOVERY_INTERVAL, DISCOVERY_INTERVAL,
DOMAIN, DOMAIN,
@ -92,7 +93,7 @@ SET_TIME_SERVICE_SCHEMA = vol.Schema(
) )
def _host_validator(config): def _host_validator(config: dict[str, str]) -> dict[str, str]:
"""Validate that a host is properly configured.""" """Validate that a host is properly configured."""
if config[CONF_HOST].startswith("elks://"): if config[CONF_HOST].startswith("elks://"):
if CONF_USERNAME not in config or CONF_PASSWORD not in config: if CONF_USERNAME not in config or CONF_PASSWORD not in config:
@ -104,14 +105,14 @@ def _host_validator(config):
return config return config
def _elk_range_validator(rng): def _elk_range_validator(rng: str) -> tuple[int, int]:
def _housecode_to_int(val): def _housecode_to_int(val: str) -> int:
match = re.search(r"^([a-p])(0[1-9]|1[0-6]|[1-9])$", val.lower()) match = re.search(r"^([a-p])(0[1-9]|1[0-6]|[1-9])$", val.lower())
if match: if match:
return (ord(match.group(1)) - ord("a")) * 16 + int(match.group(2)) return (ord(match.group(1)) - ord("a")) * 16 + int(match.group(2))
raise vol.Invalid("Invalid range") raise vol.Invalid("Invalid range")
def _elk_value(val): def _elk_value(val: str) -> int:
return int(val) if val.isdigit() else _housecode_to_int(val) return int(val) if val.isdigit() else _housecode_to_int(val)
vals = [s.strip() for s in str(rng).split("-")] vals = [s.strip() for s in str(rng).split("-")]
@ -120,7 +121,7 @@ def _elk_range_validator(rng):
return (start, end) return (start, end)
def _has_all_unique_prefixes(value): def _has_all_unique_prefixes(value: list[dict[str, str]]) -> list[dict[str, str]]:
"""Validate that each m1 configured has a unique prefix. """Validate that each m1 configured has a unique prefix.
Uniqueness is determined case-independently. Uniqueness is determined case-independently.
@ -214,10 +215,13 @@ async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool:
@callback @callback
def _async_find_matching_config_entry(hass, prefix): def _async_find_matching_config_entry(
hass: HomeAssistant, prefix: str
) -> ConfigEntry | None:
for entry in hass.config_entries.async_entries(DOMAIN): for entry in hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == prefix: if entry.unique_id == prefix:
return entry return entry
return None
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@ -253,7 +257,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.error("Config item: %s; %s", item, err) _LOGGER.error("Config item: %s; %s", item, err)
return False return False
elk = elkm1.Elk( elk = Elk(
{ {
"url": conf[CONF_HOST], "url": conf[CONF_HOST],
"userid": conf[CONF_USERNAME], "userid": conf[CONF_USERNAME],
@ -262,7 +266,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
) )
elk.connect() elk.connect()
def _element_changed(element, changeset): def _element_changed(element: Element, changeset: dict[str, Any]) -> None:
if (keypress := changeset.get("last_keypress")) is None: if (keypress := changeset.get("last_keypress")) is None:
return return
@ -275,7 +279,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
}, },
) )
for keypad in elk.keypads: # pylint: disable=no-member for keypad in elk.keypads:
keypad.add_callback(_element_changed) keypad.add_callback(_element_changed)
try: try:
@ -284,7 +288,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except asyncio.TimeoutError as exc: except asyncio.TimeoutError as exc:
raise ConfigEntryNotReady(f"Timed out connecting to {conf[CONF_HOST]}") from exc raise ConfigEntryNotReady(f"Timed out connecting to {conf[CONF_HOST]}") from exc
elk_temp_unit = elk.panel.temperature_units # pylint: disable=no-member elk_temp_unit = elk.panel.temperature_units
temperature_unit = TEMP_CELSIUS if elk_temp_unit == "C" else TEMP_FAHRENHEIT temperature_unit = TEMP_CELSIUS if elk_temp_unit == "C" else TEMP_FAHRENHEIT
config["temperature_unit"] = temperature_unit config["temperature_unit"] = temperature_unit
hass.data[DOMAIN][entry.entry_id] = { hass.data[DOMAIN][entry.entry_id] = {
@ -301,18 +305,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True return True
def _included(ranges, set_to, values): def _included(ranges: list[tuple[int, int]], set_to: bool, values: list[bool]) -> None:
for rng in ranges: for rng in ranges:
if not rng[0] <= rng[1] <= len(values): if not rng[0] <= rng[1] <= len(values):
raise vol.Invalid(f"Invalid range {rng}") raise vol.Invalid(f"Invalid range {rng}")
values[rng[0] - 1 : rng[1]] = [set_to] * (rng[1] - rng[0] + 1) values[rng[0] - 1 : rng[1]] = [set_to] * (rng[1] - rng[0] + 1)
def _find_elk_by_prefix(hass, prefix): def _find_elk_by_prefix(hass: HomeAssistant, prefix: str) -> Elk | None:
"""Search all config entries for a given prefix.""" """Search all config entries for a given prefix."""
for entry_id in hass.data[DOMAIN]: for entry_id in hass.data[DOMAIN]:
if hass.data[DOMAIN][entry_id]["prefix"] == prefix: if hass.data[DOMAIN][entry_id]["prefix"] == prefix:
return hass.data[DOMAIN][entry_id]["elk"] return cast(Elk, hass.data[DOMAIN][entry_id]["elk"])
return None
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@ -329,7 +334,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_wait_for_elk_to_sync( async def async_wait_for_elk_to_sync(
elk: elkm1.Elk, elk: Elk,
login_timeout: int, login_timeout: int,
sync_timeout: int, sync_timeout: int,
) -> bool: ) -> bool:
@ -338,7 +343,9 @@ async def async_wait_for_elk_to_sync(
sync_event = asyncio.Event() sync_event = asyncio.Event()
login_event = asyncio.Event() login_event = asyncio.Event()
def login_status(succeeded): success = True
def login_status(succeeded: bool) -> None:
nonlocal success nonlocal success
success = succeeded success = succeeded
@ -351,10 +358,9 @@ async def async_wait_for_elk_to_sync(
login_event.set() login_event.set()
sync_event.set() sync_event.set()
def sync_complete(): def sync_complete() -> None:
sync_event.set() sync_event.set()
success = True
elk.add_handler("login", login_status) elk.add_handler("login", login_status)
elk.add_handler("sync_complete", sync_complete) elk.add_handler("sync_complete", sync_complete)
for name, event, timeout in ( for name, event, timeout in (
@ -374,8 +380,8 @@ async def async_wait_for_elk_to_sync(
return success return success
def _create_elk_services(hass): def _create_elk_services(hass: HomeAssistant) -> None:
def _getelk(service): def _getelk(service: ServiceCall) -> Elk:
prefix = service.data["prefix"] prefix = service.data["prefix"]
elk = _find_elk_by_prefix(hass, prefix) elk = _find_elk_by_prefix(hass, prefix)
if elk is None: if elk is None:
@ -402,12 +408,18 @@ def _create_elk_services(hass):
) )
def create_elk_entities(elk_data, elk_elements, element_type, class_, entities): def create_elk_entities(
elk_data: dict[str, Any],
elk_elements: list[Element],
element_type: str,
class_: Any,
entities: list[ElkEntity],
) -> list[ElkEntity] | None:
"""Create the ElkM1 devices of a particular class.""" """Create the ElkM1 devices of a particular class."""
auto_configure = elk_data["auto_configure"] auto_configure = elk_data["auto_configure"]
if not auto_configure and not elk_data["config"][element_type]["enabled"]: if not auto_configure and not elk_data["config"][element_type]["enabled"]:
return return None
elk = elk_data["elk"] elk = elk_data["elk"]
_LOGGER.debug("Creating elk entities for %s", elk) _LOGGER.debug("Creating elk entities for %s", elk)
@ -427,7 +439,7 @@ def create_elk_entities(elk_data, elk_elements, element_type, class_, entities):
class ElkEntity(Entity): class ElkEntity(Entity):
"""Base class for all Elk entities.""" """Base class for all Elk entities."""
def __init__(self, element, elk, elk_data): def __init__(self, element: Element, elk: Elk, elk_data: dict[str, Any]) -> None:
"""Initialize the base of all Elk devices.""" """Initialize the base of all Elk devices."""
self._elk = elk self._elk = elk
self._element = element self._element = element
@ -450,12 +462,12 @@ class ElkEntity(Entity):
self._unique_id = f"{uid_start}_{self._element.default_name('_')}".lower() self._unique_id = f"{uid_start}_{self._element.default_name('_')}".lower()
@property @property
def name(self): def name(self) -> str:
"""Name of the element.""" """Name of the element."""
return f"{self._name_prefix}{self._element.name}" return f"{self._name_prefix}{self._element.name}"
@property @property
def unique_id(self): def unique_id(self) -> str:
"""Return unique id of the element.""" """Return unique id of the element."""
return self._unique_id return self._unique_id
@ -465,31 +477,31 @@ class ElkEntity(Entity):
return False return False
@property @property
def extra_state_attributes(self): def extra_state_attributes(self) -> dict[str, Any]:
"""Return the default attributes of the element.""" """Return the default attributes of the element."""
return {**self._element.as_dict(), **self.initial_attrs()} return {**self._element.as_dict(), **self.initial_attrs()}
@property @property
def available(self): def available(self) -> bool:
"""Is the entity available to be updated.""" """Is the entity available to be updated."""
return self._elk.is_connected() return self._elk.is_connected()
def initial_attrs(self): def initial_attrs(self) -> dict[str, int]:
"""Return the underlying element's attributes as a dict.""" """Return the underlying element's attributes as a dict."""
attrs = {} attrs = {}
attrs["index"] = self._element.index + 1 attrs["index"] = self._element.index + 1
return attrs return attrs
def _element_changed(self, element, changeset): def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
pass pass
@callback @callback
def _element_callback(self, element, changeset): def _element_callback(self, element: Element, changeset: dict[str, Any]) -> None:
"""Handle callback from an Elk element that has changed.""" """Handle callback from an Elk element that has changed."""
self._element_changed(element, changeset) self._element_changed(element, changeset)
self.async_write_ha_state() self.async_write_ha_state()
async def async_added_to_hass(self): async def async_added_to_hass(self) -> None:
"""Register callback for ElkM1 changes and update entity state.""" """Register callback for ElkM1 changes and update entity state."""
self._element.add_callback(self._element_callback) self._element.add_callback(self._element_callback)
self._element_callback(self._element, {}) self._element_callback(self._element, {})

View file

@ -26,7 +26,7 @@ import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.restore_state import RestoreEntity
from . import ElkAttachedEntity, create_elk_entities from . import ElkAttachedEntity, ElkEntity, create_elk_entities
from .const import ( from .const import (
ATTR_CHANGED_BY_ID, ATTR_CHANGED_BY_ID,
ATTR_CHANGED_BY_KEYPAD, ATTR_CHANGED_BY_KEYPAD,
@ -61,7 +61,7 @@ async def async_setup_entry(
"""Set up the ElkM1 alarm platform.""" """Set up the ElkM1 alarm platform."""
elk_data = hass.data[DOMAIN][config_entry.entry_id] elk_data = hass.data[DOMAIN][config_entry.entry_id]
elk = elk_data["elk"] elk = elk_data["elk"]
entities: list[ElkArea] = [] entities: list[ElkEntity] = []
create_elk_entities(elk_data, elk.areas, "area", ElkArea, entities) create_elk_entities(elk_data, elk.areas, "area", ElkArea, entities)
async_add_entities(entities, True) async_add_entities(entities, True)

View file

@ -37,7 +37,7 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Create the Elk-M1 thermostat platform.""" """Create the Elk-M1 thermostat platform."""
elk_data = hass.data[DOMAIN][config_entry.entry_id] elk_data = hass.data[DOMAIN][config_entry.entry_id]
entities: list[ElkThermostat] = [] entities: list[ElkEntity] = []
elk = elk_data["elk"] elk = elk_data["elk"]
create_elk_entities( create_elk_entities(
elk_data, elk.thermostats, "thermostat", ElkThermostat, entities elk_data, elk.thermostats, "thermostat", ElkThermostat, entities

View file

@ -21,7 +21,7 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up the Elk light platform.""" """Set up the Elk light platform."""
elk_data = hass.data[DOMAIN][config_entry.entry_id] elk_data = hass.data[DOMAIN][config_entry.entry_id]
entities: list[ElkLight] = [] entities: list[ElkEntity] = []
elk = elk_data["elk"] elk = elk_data["elk"]
create_elk_entities(elk_data, elk.lights, "plc", ElkLight, entities) create_elk_entities(elk_data, elk.lights, "plc", ElkLight, entities)
async_add_entities(entities, True) async_add_entities(entities, True)

View file

@ -2,7 +2,7 @@
"domain": "elkm1", "domain": "elkm1",
"name": "Elk-M1 Control", "name": "Elk-M1 Control",
"documentation": "https://www.home-assistant.io/integrations/elkm1", "documentation": "https://www.home-assistant.io/integrations/elkm1",
"requirements": ["elkm1-lib==1.3.0"], "requirements": ["elkm1-lib==1.3.3"],
"dhcp": [{ "registered_devices": true }, { "macaddress": "00409D*" }], "dhcp": [{ "registered_devices": true }, { "macaddress": "00409D*" }],
"codeowners": ["@gwww", "@bdraco"], "codeowners": ["@gwww", "@bdraco"],
"dependencies": ["network"], "dependencies": ["network"],

View file

@ -3,12 +3,14 @@ from __future__ import annotations
from typing import Any from typing import Any
from elkm1_lib.tasks import Task
from homeassistant.components.scene import Scene from homeassistant.components.scene import Scene
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ElkAttachedEntity, create_elk_entities from . import ElkAttachedEntity, ElkEntity, create_elk_entities
from .const import DOMAIN from .const import DOMAIN
@ -19,7 +21,7 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Create the Elk-M1 scene platform.""" """Create the Elk-M1 scene platform."""
elk_data = hass.data[DOMAIN][config_entry.entry_id] elk_data = hass.data[DOMAIN][config_entry.entry_id]
entities: list[ElkTask] = [] entities: list[ElkEntity] = []
elk = elk_data["elk"] elk = elk_data["elk"]
create_elk_entities(elk_data, elk.tasks, "task", ElkTask, entities) create_elk_entities(elk_data, elk.tasks, "task", ElkTask, entities)
async_add_entities(entities, True) async_add_entities(entities, True)
@ -28,6 +30,8 @@ async def async_setup_entry(
class ElkTask(ElkAttachedEntity, Scene): class ElkTask(ElkAttachedEntity, Scene):
"""Elk-M1 task as scene.""" """Elk-M1 task as scene."""
_element: Task
async def async_activate(self, **kwargs: Any) -> None: async def async_activate(self, **kwargs: Any) -> None:
"""Activate the task.""" """Activate the task."""
self._element.activate() self._element.activate()

View file

@ -19,7 +19,7 @@ from homeassistant.helpers import entity_platform
from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ElkAttachedEntity, create_elk_entities from . import ElkAttachedEntity, ElkEntity, create_elk_entities
from .const import ATTR_VALUE, DOMAIN, ELK_USER_CODE_SERVICE_SCHEMA from .const import ATTR_VALUE, DOMAIN, ELK_USER_CODE_SERVICE_SCHEMA
SERVICE_SENSOR_COUNTER_REFRESH = "sensor_counter_refresh" SERVICE_SENSOR_COUNTER_REFRESH = "sensor_counter_refresh"
@ -40,7 +40,7 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Create the Elk-M1 sensor platform.""" """Create the Elk-M1 sensor platform."""
elk_data = hass.data[DOMAIN][config_entry.entry_id] elk_data = hass.data[DOMAIN][config_entry.entry_id]
entities: list[ElkSensor] = [] entities: list[ElkEntity] = []
elk = elk_data["elk"] elk = elk_data["elk"]
create_elk_entities(elk_data, elk.counters, "counter", ElkCounter, entities) create_elk_entities(elk_data, elk.counters, "counter", ElkCounter, entities)
create_elk_entities(elk_data, elk.keypads, "keypad", ElkKeypad, entities) create_elk_entities(elk_data, elk.keypads, "keypad", ElkKeypad, entities)

View file

@ -1,12 +1,14 @@
"""Support for control of ElkM1 outputs (relays).""" """Support for control of ElkM1 outputs (relays)."""
from __future__ import annotations from __future__ import annotations
from elkm1_lib.outputs import Output
from homeassistant.components.switch import SwitchEntity from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ElkAttachedEntity, create_elk_entities from . import ElkAttachedEntity, ElkEntity, create_elk_entities
from .const import DOMAIN from .const import DOMAIN
@ -17,7 +19,7 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Create the Elk-M1 switch platform.""" """Create the Elk-M1 switch platform."""
elk_data = hass.data[DOMAIN][config_entry.entry_id] elk_data = hass.data[DOMAIN][config_entry.entry_id]
entities: list[ElkOutput] = [] entities: list[ElkEntity] = []
elk = elk_data["elk"] elk = elk_data["elk"]
create_elk_entities(elk_data, elk.outputs, "output", ElkOutput, entities) create_elk_entities(elk_data, elk.outputs, "output", ElkOutput, entities)
async_add_entities(entities, True) async_add_entities(entities, True)
@ -26,6 +28,8 @@ async def async_setup_entry(
class ElkOutput(ElkAttachedEntity, SwitchEntity): class ElkOutput(ElkAttachedEntity, SwitchEntity):
"""Elk output as switch.""" """Elk output as switch."""
_element: Output
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Get the current output status.""" """Get the current output status."""

View file

@ -693,6 +693,17 @@ no_implicit_optional = true
warn_return_any = true warn_return_any = true
warn_unreachable = true warn_unreachable = true
[mypy-homeassistant.components.elkm1.__init__]
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.esphome.*] [mypy-homeassistant.components.esphome.*]
check_untyped_defs = true check_untyped_defs = true
disallow_incomplete_defs = true disallow_incomplete_defs = true

View file

@ -574,7 +574,7 @@ elgato==3.0.0
eliqonline==1.2.2 eliqonline==1.2.2
# homeassistant.components.elkm1 # homeassistant.components.elkm1
elkm1-lib==1.3.0 elkm1-lib==1.3.3
# homeassistant.components.elmax # homeassistant.components.elmax
elmax_api==0.0.2 elmax_api==0.0.2

View file

@ -408,7 +408,7 @@ dynalite_devices==0.1.46
elgato==3.0.0 elgato==3.0.0
# homeassistant.components.elkm1 # homeassistant.components.elkm1
elkm1-lib==1.3.0 elkm1-lib==1.3.3
# homeassistant.components.elmax # homeassistant.components.elmax
elmax_api==0.0.2 elmax_api==0.0.2