Strict typing for homekit part 1 (#67657)

Co-authored-by: Franck Nijhof <frenck@frenck.nl>
This commit is contained in:
J. Nick Koston 2022-03-29 23:21:07 -10:00 committed by GitHub
parent 496d90bf00
commit af6a62ca79
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 443 additions and 217 deletions

View file

@ -2,14 +2,18 @@
from __future__ import annotations
import asyncio
from collections.abc import Iterable
from copy import deepcopy
import ipaddress
import logging
import os
from typing import Any, cast
from uuid import UUID
from aiohttp import web
from pyhap.const import STANDALONE_AID
import voluptuous as vol
from zeroconf.asyncio import AsyncZeroconf
from homeassistant.components import device_automation, network, zeroconf
from homeassistant.components.binary_sensor import (
@ -39,7 +43,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
SERVICE_RELOAD,
)
from homeassistant.core import CoreState, HomeAssistant, ServiceCall, callback
from homeassistant.core import CoreState, HomeAssistant, ServiceCall, State, callback
from homeassistant.exceptions import HomeAssistantError, Unauthorized
from homeassistant.helpers import device_registry, entity_registry
import homeassistant.helpers.config_validation as cv
@ -67,7 +71,7 @@ from . import ( # noqa: F401
type_switches,
type_thermostats,
)
from .accessories import HomeBridge, HomeDriver, get_accessory
from .accessories import HomeAccessory, HomeBridge, HomeDriver, get_accessory
from .aidmanager import AccessoryAidStorage
from .const import (
ATTR_INTEGRATION,
@ -114,7 +118,7 @@ from .util import (
_LOGGER = logging.getLogger(__name__)
MAX_DEVICES = 150
MAX_DEVICES = 150 # includes the bridge
# #### Driver Status ####
STATUS_READY = 0
@ -129,7 +133,9 @@ _HOMEKIT_CONFIG_UPDATE_TIME = (
)
def _has_all_unique_names_and_ports(bridges):
def _has_all_unique_names_and_ports(
bridges: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""Validate that each homekit bridge configured has a unique name."""
names = [bridge[CONF_NAME] for bridge in bridges]
ports = [bridge[CONF_PORT] for bridge in bridges]
@ -184,7 +190,9 @@ def _async_all_homekit_instances(hass: HomeAssistant) -> list[HomeKit]:
]
def _async_get_entries_by_name(current_entries):
def _async_get_entries_by_name(
current_entries: list[ConfigEntry],
) -> dict[str, ConfigEntry]:
"""Return a dict of the entries by name."""
# For backwards compat, its possible the first bridge is using the default
@ -221,7 +229,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@callback
def _async_update_config_entry_if_from_yaml(hass, entries_by_name, conf):
def _async_update_config_entry_if_from_yaml(
hass: HomeAssistant, entries_by_name: dict[str, ConfigEntry], conf: ConfigType
) -> bool:
"""Update a config entry with the latest yaml.
Returns True if a matching config entry was found
@ -346,13 +356,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove a config entry."""
return await hass.async_add_executor_job(
await hass.async_add_executor_job(
remove_state_files_for_entry_id, hass, entry.entry_id
)
@callback
def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: ConfigEntry):
def _async_import_options_from_data_if_missing(
hass: HomeAssistant, entry: ConfigEntry
) -> None:
options = deepcopy(dict(entry.options))
data = deepcopy(dict(entry.data))
modified = False
@ -367,7 +379,7 @@ def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: Confi
@callback
def _async_register_events_and_services(hass: HomeAssistant):
def _async_register_events_and_services(hass: HomeAssistant) -> None:
"""Register events and services for HomeKit."""
hass.http.register_view(HomeKitPairingQRView)
@ -381,7 +393,7 @@ def _async_register_events_and_services(hass: HomeAssistant):
)
continue
entity_ids = service.data.get("entity_id")
entity_ids = cast(list[str], service.data.get("entity_id"))
await homekit.async_reset_accessories(entity_ids)
hass.services.async_register(
@ -453,27 +465,29 @@ def _async_register_events_and_services(hass: HomeAssistant):
class HomeKit:
"""Class to handle all actions between HomeKit and Home Assistant."""
driver: HomeDriver
def __init__(
self,
hass,
name,
port,
ip_address,
entity_filter,
exclude_accessory_mode,
entity_config,
homekit_mode,
advertise_ip=None,
entry_id=None,
entry_title=None,
devices=None,
):
hass: HomeAssistant,
name: str,
port: int,
ip_address: str | None,
entity_filter: EntityFilter,
exclude_accessory_mode: bool,
entity_config: dict,
homekit_mode: str,
advertise_ip: str | None,
entry_id: str,
entry_title: str,
devices: Iterable[str] | None = None,
) -> None:
"""Initialize a HomeKit object."""
self.hass = hass
self._name = name
self._port = port
self._ip_address = ip_address
self._filter: EntityFilter = entity_filter
self._filter = entity_filter
self._config = entity_config
self._exclude_accessory_mode = exclude_accessory_mode
self._advertise_ip = advertise_ip
@ -481,13 +495,12 @@ class HomeKit:
self._entry_title = entry_title
self._homekit_mode = homekit_mode
self._devices = devices or []
self.aid_storage = None
self.aid_storage: AccessoryAidStorage | None = None
self.status = STATUS_READY
self.bridge = None
self.driver = None
self.bridge: HomeBridge | None = None
def setup(self, async_zeroconf_instance, uuid):
def setup(self, async_zeroconf_instance: AsyncZeroconf, uuid: UUID) -> None:
"""Set up bridge and accessory driver."""
persist_file = get_persist_fullpath_for_entry_id(self.hass, self._entry_id)
@ -510,22 +523,24 @@ class HomeKit:
if os.path.exists(persist_file):
self.driver.load()
async def async_reset_accessories(self, entity_ids):
async def async_reset_accessories(self, entity_ids: Iterable[str]) -> None:
"""Reset the accessory to load the latest configuration."""
if not self.bridge:
await self.async_reset_accessories_in_accessory_mode(entity_ids)
return
await self.async_reset_accessories_in_bridge_mode(entity_ids)
async def async_reset_accessories_in_accessory_mode(self, entity_ids):
async def async_reset_accessories_in_accessory_mode(
self, entity_ids: Iterable[str]
) -> None:
"""Reset accessories in accessory mode."""
acc = self.driver.accessory
acc = cast(HomeAccessory, self.driver.accessory)
if acc.entity_id not in entity_ids:
return
await acc.stop()
if not (state := self.hass.states.get(acc.entity_id)):
_LOGGER.warning(
"The underlying entity %s disappeared during reset", acc.entity
"The underlying entity %s disappeared during reset", acc.entity_id
)
return
if new_acc := self._async_create_single_accessory([state]):
@ -533,9 +548,14 @@ class HomeKit:
self.hass.async_add_job(new_acc.run)
await self.async_config_changed()
async def async_reset_accessories_in_bridge_mode(self, entity_ids):
async def async_reset_accessories_in_bridge_mode(
self, entity_ids: Iterable[str]
) -> None:
"""Reset accessories in bridge mode."""
assert self.aid_storage is not None
assert self.bridge is not None
new = []
acc: HomeAccessory | None
for entity_id in entity_ids:
aid = self.aid_storage.get_or_allocate_aid_for_entity_id(entity_id)
if aid not in self.bridge.accessories:
@ -545,12 +565,13 @@ class HomeKit:
self._name,
entity_id,
)
acc = await self.async_remove_bridge_accessory(aid)
if state := self.hass.states.get(acc.entity_id):
if (acc := await self.async_remove_bridge_accessory(aid)) and (
state := self.hass.states.get(acc.entity_id)
):
new.append(state)
else:
_LOGGER.warning(
"The underlying entity %s disappeared during reset", acc.entity
"The underlying entity %s disappeared during reset", entity_id
)
if not new:
@ -560,23 +581,22 @@ class HomeKit:
await self.async_config_changed()
await asyncio.sleep(_HOMEKIT_CONFIG_UPDATE_TIME)
for state in new:
acc = self.add_bridge_accessory(state)
if acc:
if acc := self.add_bridge_accessory(state):
self.hass.async_add_job(acc.run)
await self.async_config_changed()
async def async_config_changed(self):
async def async_config_changed(self) -> None:
"""Call config changed which writes out the new config to disk."""
await self.hass.async_add_executor_job(self.driver.config_changed)
def add_bridge_accessory(self, state):
def add_bridge_accessory(self, state: State) -> HomeAccessory | None:
"""Try adding accessory to bridge if configured beforehand."""
if self._would_exceed_max_devices(state.entity_id):
return
return None
if state_needs_accessory_mode(state):
if self._exclude_accessory_mode:
return
return None
_LOGGER.warning(
"The bridge %s has entity %s. For best performance, "
"and to prevent unexpected unavailability, create and "
@ -586,6 +606,8 @@ class HomeKit:
state.entity_id,
)
assert self.aid_storage is not None
assert self.bridge is not None
aid = self.aid_storage.get_or_allocate_aid_for_entity_id(state.entity_id)
conf = self._config.get(state.entity_id, {}).copy()
# If an accessory cannot be created or added due to an exception
@ -602,9 +624,10 @@ class HomeKit:
)
return None
def _would_exceed_max_devices(self, name):
def _would_exceed_max_devices(self, name: str | None) -> bool:
"""Check if adding another devices would reach the limit and log."""
# The bridge itself counts as an accessory
assert self.bridge is not None
if len(self.bridge.accessories) + 1 >= MAX_DEVICES:
_LOGGER.warning(
"Cannot add %s as this would exceed the %d device limit. Consider using the filter option",
@ -614,16 +637,20 @@ class HomeKit:
return True
return False
def add_bridge_triggers_accessory(self, device, device_triggers):
def add_bridge_triggers_accessory(
self, device: device_registry.DeviceEntry, device_triggers: list[dict[str, Any]]
) -> None:
"""Add device automation triggers to the bridge."""
if self._would_exceed_max_devices(device.name):
return
assert self.aid_storage is not None
assert self.bridge is not None
aid = self.aid_storage.get_or_allocate_aid(device.id, device.id)
# If an accessory cannot be created or added due to an exception
# of any kind (usually in pyhap) it should not prevent
# the rest of the accessories from being created
config = {}
config: dict[str, Any] = {}
self._fill_config_from_device_registry_entry(device, config)
self.bridge.add_accessory(
DeviceTriggerAccessory(
@ -638,13 +665,15 @@ class HomeKit:
)
)
async def async_remove_bridge_accessory(self, aid):
async def async_remove_bridge_accessory(self, aid: int) -> HomeAccessory | None:
"""Try adding accessory to bridge if configured beforehand."""
assert self.bridge is not None
if acc := self.bridge.accessories.pop(aid, None):
await acc.stop()
return acc
return cast(HomeAccessory, acc)
return None
async def async_configure_accessories(self):
async def async_configure_accessories(self) -> list[State]:
"""Configure accessories for the included states."""
dev_reg = device_registry.async_get(self.hass)
ent_reg = entity_registry.async_get(self.hass)
@ -680,7 +709,7 @@ class HomeKit:
return entity_states
async def async_start(self, *args):
async def async_start(self, *args: Any) -> None:
"""Load storage and start."""
if self.status != STATUS_READY:
return
@ -704,7 +733,7 @@ class HomeKit:
self._async_show_setup_message()
@callback
def _async_show_setup_message(self):
def _async_show_setup_message(self) -> None:
"""Show the pairing setup message."""
async_show_setup_message(
self.hass,
@ -715,7 +744,7 @@ class HomeKit:
)
@callback
def async_unpair(self):
def async_unpair(self) -> None:
"""Remove all pairings for an accessory so it can be repaired."""
state = self.driver.state
for client_uuid in list(state.paired_clients):
@ -730,8 +759,9 @@ class HomeKit:
self._async_show_setup_message()
@callback
def _async_register_bridge(self):
def _async_register_bridge(self) -> None:
"""Register the bridge as a device so homekit_controller and exclude it from discovery."""
assert self.driver is not None
dev_reg = device_registry.async_get(self.hass)
formatted_mac = device_registry.format_mac(self.driver.state.mac)
# Connections and identifiers are both used here.
@ -753,7 +783,9 @@ class HomeKit:
hk_mode_name = "Accessory" if is_accessory_mode else "Bridge"
dev_reg.async_get_or_create(
config_entry_id=self._entry_id,
identifiers={identifier},
identifiers={
identifier # type: ignore[arg-type]
}, # this needs to be migrated as a 2 item tuple at some point
connections={connection},
manufacturer=MANUFACTURER,
name=accessory_friendly_name(self._entry_title, self.driver.accessory),
@ -762,12 +794,17 @@ class HomeKit:
)
@callback
def _async_purge_old_bridges(self, dev_reg, identifier, connection):
def _async_purge_old_bridges(
self,
dev_reg: device_registry.DeviceRegistry,
identifier: tuple[str, str, str],
connection: tuple[str, str],
) -> None:
"""Purge bridges that exist from failed pairing or manual resets."""
devices_to_purge = []
for entry in dev_reg.devices.values():
if self._entry_id in entry.config_entries and (
identifier not in entry.identifiers
identifier not in entry.identifiers # type: ignore[comparison-overlap]
or connection not in entry.connections
):
devices_to_purge.append(entry.id)
@ -776,7 +813,9 @@ class HomeKit:
dev_reg.async_remove_device(device_id)
@callback
def _async_create_single_accessory(self, entity_states):
def _async_create_single_accessory(
self, entity_states: list[State]
) -> HomeAccessory | None:
"""Create a single HomeKit accessory (accessory mode)."""
if not entity_states:
_LOGGER.error(
@ -796,7 +835,9 @@ class HomeKit:
)
return acc
async def _async_create_bridge_accessory(self, entity_states):
async def _async_create_bridge_accessory(
self, entity_states: Iterable[State]
) -> HomeAccessory:
"""Create a HomeKit bridge with accessories. (bridge mode)."""
self.bridge = HomeBridge(self.hass, self.driver, self._name)
for state in entity_states:
@ -820,12 +861,11 @@ class HomeKit:
valid_device_ids,
)
).items():
self.add_bridge_triggers_accessory(
dev_reg.async_get(device_id), device_triggers
)
if device := dev_reg.async_get(device_id):
self.add_bridge_triggers_accessory(device, device_triggers)
return self.bridge
async def _async_create_accessories(self):
async def _async_create_accessories(self) -> bool:
"""Create the accessories."""
entity_states = await self.async_configure_accessories()
if self._homekit_mode == HOMEKIT_MODE_ACCESSORY:
@ -839,7 +879,7 @@ class HomeKit:
self.driver.accessory = acc
return True
async def async_stop(self, *args):
async def async_stop(self, *args: Any) -> None:
"""Stop the accessory driver."""
if self.status != STATUS_RUNNING:
return
@ -848,7 +888,12 @@ class HomeKit:
await self.driver.async_stop()
@callback
def _async_configure_linked_sensors(self, ent_reg_ent, device_lookup, state):
def _async_configure_linked_sensors(
self,
ent_reg_ent: entity_registry.RegistryEntry,
device_lookup: dict[str, dict[tuple[str, str | None], str]],
state: State,
) -> None:
if (
ent_reg_ent is None
or ent_reg_ent.device_id is None
@ -905,7 +950,12 @@ class HomeKit:
current_humidity_sensor_entity_id,
)
async def _async_set_device_info_attributes(self, ent_reg_ent, dev_reg, entity_id):
async def _async_set_device_info_attributes(
self,
ent_reg_ent: entity_registry.RegistryEntry,
dev_reg: device_registry.DeviceRegistry,
entity_id: str,
) -> None:
"""Set attributes that will be used for homekit device info."""
ent_cfg = self._config.setdefault(entity_id, {})
if ent_reg_ent.device_id:
@ -920,7 +970,9 @@ class HomeKit:
except IntegrationNotFound:
ent_cfg[ATTR_INTEGRATION] = ent_reg_ent.platform
def _fill_config_from_device_registry_entry(self, device_entry, config):
def _fill_config_from_device_registry_entry(
self, device_entry: device_registry.DeviceEntry, config: dict[str, Any]
) -> None:
"""Populate a config dict from the registry."""
if device_entry.manufacturer:
config[ATTR_MANUFACTURER] = device_entry.manufacturer
@ -943,7 +995,7 @@ class HomeKitPairingQRView(HomeAssistantView):
name = "api:homekit:pairingqr"
requires_auth = False
async def get(self, request):
async def get(self, request: web.Request) -> web.Response:
"""Retrieve the pairing QRCode image."""
# pylint: disable=no-self-use
if not request.query_string: