Enable strict typing for zeroconf (#48450)

* Enable strict typing for zeroconf

* Fix lutron_caseta

* Fix pylint warning

* Fix tests

* Fix xiaomi_aqara test

* Add __init__.py in homeassistant.generated module

* Restore add_job with type: ignore
pull/48520/head
Ruslan Sayfutdinov 2021-03-30 17:48:04 +01:00 committed by GitHub
parent 338be8c70b
commit 82c94826fb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 111 additions and 92 deletions

View File

@ -10,7 +10,6 @@ from pylutron_caseta.smartbridge import Smartbridge
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components.zeroconf import ATTR_HOSTNAME
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.core import callback
@ -66,7 +65,7 @@ class LutronCasetaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_zeroconf(self, discovery_info):
"""Handle a flow initialized by zeroconf discovery."""
hostname = discovery_info[ATTR_HOSTNAME]
hostname = discovery_info["hostname"]
if hostname is None or not hostname.startswith("lutron-"):
return self.async_abort(reason="not_lutron_device")

View File

@ -7,16 +7,14 @@ from functools import partial
import ipaddress
import logging
import socket
from typing import Any, TypedDict
import voluptuous as vol
from zeroconf import (
DNSPointer,
DNSRecord,
Error as ZeroconfError,
InterfaceChoice,
IPVersion,
NonUniqueNameException,
ServiceBrowser,
ServiceInfo,
ServiceStateChange,
Zeroconf,
@ -24,29 +22,24 @@ from zeroconf import (
from homeassistant import util
from homeassistant.const import (
ATTR_NAME,
EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STARTED,
EVENT_HOMEASSISTANT_STOP,
__version__,
)
from homeassistant.core import Event, HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.network import NoURLAvailableError, get_url
from homeassistant.helpers.singleton import singleton
from homeassistant.loader import async_get_homekit, async_get_zeroconf
from .models import HaServiceBrowser, HaZeroconf
from .usage import install_multiple_zeroconf_catcher
_LOGGER = logging.getLogger(__name__)
DOMAIN = "zeroconf"
ATTR_HOST = "host"
ATTR_PORT = "port"
ATTR_HOSTNAME = "hostname"
ATTR_TYPE = "type"
ATTR_PROPERTIES = "properties"
ZEROCONF_TYPE = "_home-assistant._tcp.local."
HOMEKIT_TYPES = [
"_hap._tcp.local.",
@ -59,7 +52,6 @@ CONF_IPV6 = "ipv6"
DEFAULT_DEFAULT_INTERFACE = True
DEFAULT_IPV6 = True
HOMEKIT_PROPERTIES = "properties"
HOMEKIT_PAIRED_STATUS_FLAG = "sf"
HOMEKIT_MODEL = "md"
@ -85,20 +77,31 @@ CONFIG_SCHEMA = vol.Schema(
)
class HaServiceInfo(TypedDict):
"""Prepared info from mDNS entries."""
host: str
port: int | None
hostname: str
type: str
name: str
properties: dict[str, Any]
@singleton(DOMAIN)
async def async_get_instance(hass):
async def async_get_instance(hass: HomeAssistant) -> HaZeroconf:
"""Zeroconf instance to be shared with other integrations that use it."""
return await _async_get_instance(hass)
async def _async_get_instance(hass, **zcargs):
async def _async_get_instance(hass: HomeAssistant, **zcargs: Any) -> HaZeroconf:
logging.getLogger("zeroconf").setLevel(logging.NOTSET)
zeroconf = await hass.async_add_executor_job(partial(HaZeroconf, **zcargs))
install_multiple_zeroconf_catcher(zeroconf)
def _stop_zeroconf(_):
def _stop_zeroconf(_event: Event) -> None:
"""Stop Zeroconf."""
zeroconf.ha_close()
@ -107,40 +110,10 @@ async def _async_get_instance(hass, **zcargs):
return zeroconf
class HaServiceBrowser(ServiceBrowser):
"""ServiceBrowser that only consumes DNSPointer records."""
def update_record(self, zc: Zeroconf, now: float, record: DNSRecord) -> None:
"""Pre-Filter update_record to DNSPointers for the configured type."""
#
# Each ServerBrowser currently runs in its own thread which
# processes every A or AAAA record update per instance.
#
# As the list of zeroconf names we watch for grows, each additional
# ServiceBrowser would process all the A and AAAA updates on the network.
#
# To avoid overwhemling the system we pre-filter here and only process
# DNSPointers for the configured record name (type)
#
if record.name not in self.types or not isinstance(record, DNSPointer):
return
super().update_record(zc, now, record)
class HaZeroconf(Zeroconf):
"""Zeroconf that cannot be closed."""
def close(self):
"""Fake method to avoid integrations closing it."""
ha_close = Zeroconf.close
async def async_setup(hass, config):
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Set up Zeroconf and make Home Assistant discoverable."""
zc_config = config.get(DOMAIN, {})
zc_args = {}
zc_args: dict = {}
if zc_config.get(CONF_DEFAULT_INTERFACE, DEFAULT_DEFAULT_INTERFACE):
zc_args["interfaces"] = InterfaceChoice.Default
if not zc_config.get(CONF_IPV6, DEFAULT_IPV6):
@ -148,7 +121,7 @@ async def async_setup(hass, config):
zeroconf = hass.data[DOMAIN] = await _async_get_instance(hass, **zc_args)
async def _async_zeroconf_hass_start(_event):
async def _async_zeroconf_hass_start(_event: Event) -> None:
"""Expose Home Assistant on zeroconf when it starts.
Wait till started or otherwise HTTP is not up and running.
@ -158,7 +131,7 @@ async def async_setup(hass, config):
_register_hass_zc_service, hass, zeroconf, uuid
)
async def _async_zeroconf_hass_started(_event):
async def _async_zeroconf_hass_started(_event: Event) -> None:
"""Start the service browser."""
await _async_start_zeroconf_browser(hass, zeroconf)
@ -171,7 +144,9 @@ async def async_setup(hass, config):
return True
def _register_hass_zc_service(hass, zeroconf, uuid):
def _register_hass_zc_service(
hass: HomeAssistant, zeroconf: HaZeroconf, uuid: str
) -> None:
# Get instance UUID
valid_location_name = _truncate_location_name_to_valid(hass.config.location_name)
@ -224,7 +199,9 @@ def _register_hass_zc_service(hass, zeroconf, uuid):
)
async def _async_start_zeroconf_browser(hass, zeroconf):
async def _async_start_zeroconf_browser(
hass: HomeAssistant, zeroconf: HaZeroconf
) -> None:
"""Start the zeroconf browser."""
zeroconf_types = await async_get_zeroconf(hass)
@ -236,7 +213,12 @@ async def _async_start_zeroconf_browser(hass, zeroconf):
if hk_type not in zeroconf_types:
types.append(hk_type)
def service_update(zeroconf, service_type, name, state_change):
def service_update(
zeroconf: Zeroconf,
service_type: str,
name: str,
state_change: ServiceStateChange,
) -> None:
"""Service state changed."""
nonlocal zeroconf_types
nonlocal homekit_models
@ -276,12 +258,11 @@ async def _async_start_zeroconf_browser(hass, zeroconf):
# offering a second discovery for the same device
if (
discovery_was_forwarded
and HOMEKIT_PROPERTIES in info
and HOMEKIT_PAIRED_STATUS_FLAG in info[HOMEKIT_PROPERTIES]
and HOMEKIT_PAIRED_STATUS_FLAG in info["properties"]
):
try:
# 0 means paired and not discoverable by iOS clients)
if int(info[HOMEKIT_PROPERTIES][HOMEKIT_PAIRED_STATUS_FLAG]):
if int(info["properties"][HOMEKIT_PAIRED_STATUS_FLAG]):
return
except ValueError:
# HomeKit pairing status unknown
@ -289,12 +270,12 @@ async def _async_start_zeroconf_browser(hass, zeroconf):
return
if "name" in info:
lowercase_name = info["name"].lower()
lowercase_name: str | None = info["name"].lower()
else:
lowercase_name = None
if "macaddress" in info.get("properties", {}):
uppercase_mac = info["properties"]["macaddress"].upper()
if "macaddress" in info["properties"]:
uppercase_mac: str | None = info["properties"]["macaddress"].upper()
else:
uppercase_mac = None
@ -318,20 +299,22 @@ async def _async_start_zeroconf_browser(hass, zeroconf):
hass.add_job(
hass.config_entries.flow.async_init(
entry["domain"], context={"source": DOMAIN}, data=info
)
) # type: ignore
)
_LOGGER.debug("Starting Zeroconf browser")
HaServiceBrowser(zeroconf, types, handlers=[service_update])
def handle_homekit(hass, homekit_models, info) -> bool:
def handle_homekit(
hass: HomeAssistant, homekit_models: dict[str, str], info: HaServiceInfo
) -> bool:
"""Handle a HomeKit discovery.
Return if discovery was forwarded.
"""
model = None
props = info.get(HOMEKIT_PROPERTIES, {})
props = info["properties"]
for key in props:
if key.lower() == HOMEKIT_MODEL:
@ -352,16 +335,16 @@ def handle_homekit(hass, homekit_models, info) -> bool:
hass.add_job(
hass.config_entries.flow.async_init(
homekit_models[test_model], context={"source": "homekit"}, data=info
)
) # type: ignore
)
return True
return False
def info_from_service(service):
def info_from_service(service: ServiceInfo) -> HaServiceInfo | None:
"""Return prepared info from mDNS entries."""
properties = {"_raw": {}}
properties: dict[str, Any] = {"_raw": {}}
for key, value in service.properties.items():
# See https://ietf.org/rfc/rfc6763.html#section-6.4 and
@ -386,19 +369,17 @@ def info_from_service(service):
address = service.addresses[0]
info = {
ATTR_HOST: str(ipaddress.ip_address(address)),
ATTR_PORT: service.port,
ATTR_HOSTNAME: service.server,
ATTR_TYPE: service.type,
ATTR_NAME: service.name,
ATTR_PROPERTIES: properties,
return {
"host": str(ipaddress.ip_address(address)),
"port": service.port,
"hostname": service.server,
"type": service.type,
"name": service.name,
"properties": properties,
}
return info
def _suppress_invalid_properties(properties):
def _suppress_invalid_properties(properties: dict) -> None:
"""Suppress any properties that will cause zeroconf to fail to startup."""
for prop, prop_value in properties.items():
@ -415,7 +396,7 @@ def _suppress_invalid_properties(properties):
properties[prop] = ""
def _truncate_location_name_to_valid(location_name):
def _truncate_location_name_to_valid(location_name: str) -> str:
"""Truncate or return the location name usable for zeroconf."""
if len(location_name.encode("utf-8")) < MAX_NAME_LEN:
return location_name

View File

@ -0,0 +1,33 @@
"""Models for Zeroconf."""
from zeroconf import DNSPointer, DNSRecord, ServiceBrowser, Zeroconf
class HaZeroconf(Zeroconf):
"""Zeroconf that cannot be closed."""
def close(self) -> None:
"""Fake method to avoid integrations closing it."""
ha_close = Zeroconf.close
class HaServiceBrowser(ServiceBrowser):
"""ServiceBrowser that only consumes DNSPointer records."""
def update_record(self, zc: Zeroconf, now: float, record: DNSRecord) -> None:
"""Pre-Filter update_record to DNSPointers for the configured type."""
#
# Each ServerBrowser currently runs in its own thread which
# processes every A or AAAA record update per instance.
#
# As the list of zeroconf names we watch for grows, each additional
# ServiceBrowser would process all the A and AAAA updates on the network.
#
# To avoid overwhemling the system we pre-filter here and only process
# DNSPointers for the configured record name (type)
#
if record.name not in self.types or not isinstance(record, DNSPointer):
return
super().update_record(zc, now, record)

View File

@ -2,6 +2,7 @@
from contextlib import suppress
import logging
from typing import Any
import zeroconf
@ -11,23 +12,25 @@ from homeassistant.helpers.frame import (
report_integration,
)
from .models import HaZeroconf
_LOGGER = logging.getLogger(__name__)
def install_multiple_zeroconf_catcher(hass_zc) -> None:
def install_multiple_zeroconf_catcher(hass_zc: HaZeroconf) -> None:
"""Wrap the Zeroconf class to return the shared instance if multiple instances are detected."""
def new_zeroconf_new(self, *k, **kw):
def new_zeroconf_new(self: zeroconf.Zeroconf, *k: Any, **kw: Any) -> HaZeroconf:
_report(
"attempted to create another Zeroconf instance. Please use the shared Zeroconf via await homeassistant.components.zeroconf.async_get_instance(hass)",
)
return hass_zc
def new_zeroconf_init(self, *k, **kw):
def new_zeroconf_init(self: zeroconf.Zeroconf, *k: Any, **kw: Any) -> None:
return
zeroconf.Zeroconf.__new__ = new_zeroconf_new
zeroconf.Zeroconf.__init__ = new_zeroconf_init
zeroconf.Zeroconf.__new__ = new_zeroconf_new # type: ignore
zeroconf.Zeroconf.__init__ = new_zeroconf_init # type: ignore
def _report(what: str) -> None:

View File

@ -0,0 +1,4 @@
"""All files in this module are automatically generated by hassfest.
To update, run python3 -m script.hassfest
"""

View File

@ -43,7 +43,7 @@ warn_redundant_casts = true
warn_unused_configs = true
[mypy-homeassistant.block_async_io,homeassistant.bootstrap,homeassistant.components,homeassistant.config_entries,homeassistant.config,homeassistant.const,homeassistant.core,homeassistant.data_entry_flow,homeassistant.exceptions,homeassistant.__init__,homeassistant.loader,homeassistant.__main__,homeassistant.requirements,homeassistant.runner,homeassistant.setup,homeassistant.util,homeassistant.auth.*,homeassistant.components.automation.*,homeassistant.components.binary_sensor.*,homeassistant.components.bond.*,homeassistant.components.calendar.*,homeassistant.components.cover.*,homeassistant.components.device_automation.*,homeassistant.components.frontend.*,homeassistant.components.geo_location.*,homeassistant.components.group.*,homeassistant.components.history.*,homeassistant.components.http.*,homeassistant.components.huawei_lte.*,homeassistant.components.hyperion.*,homeassistant.components.image_processing.*,homeassistant.components.integration.*,homeassistant.components.knx.*,homeassistant.components.light.*,homeassistant.components.lock.*,homeassistant.components.mailbox.*,homeassistant.components.media_player.*,homeassistant.components.notify.*,homeassistant.components.number.*,homeassistant.components.persistent_notification.*,homeassistant.components.proximity.*,homeassistant.components.recorder.purge,homeassistant.components.recorder.repack,homeassistant.components.remote.*,homeassistant.components.scene.*,homeassistant.components.sensor.*,homeassistant.components.slack.*,homeassistant.components.sun.*,homeassistant.components.switch.*,homeassistant.components.systemmonitor.*,homeassistant.components.tts.*,homeassistant.components.vacuum.*,homeassistant.components.water_heater.*,homeassistant.components.weather.*,homeassistant.components.websocket_api.*,homeassistant.components.zone.*,homeassistant.components.zwave_js.*,homeassistant.helpers.*,homeassistant.scripts.*,homeassistant.util.*,tests.components.hyperion.*]
[mypy-homeassistant.block_async_io,homeassistant.bootstrap,homeassistant.components,homeassistant.config_entries,homeassistant.config,homeassistant.const,homeassistant.core,homeassistant.data_entry_flow,homeassistant.exceptions,homeassistant.__init__,homeassistant.loader,homeassistant.__main__,homeassistant.requirements,homeassistant.runner,homeassistant.setup,homeassistant.util,homeassistant.auth.*,homeassistant.components.automation.*,homeassistant.components.binary_sensor.*,homeassistant.components.bond.*,homeassistant.components.calendar.*,homeassistant.components.cover.*,homeassistant.components.device_automation.*,homeassistant.components.frontend.*,homeassistant.components.geo_location.*,homeassistant.components.group.*,homeassistant.components.history.*,homeassistant.components.http.*,homeassistant.components.huawei_lte.*,homeassistant.components.hyperion.*,homeassistant.components.image_processing.*,homeassistant.components.integration.*,homeassistant.components.knx.*,homeassistant.components.light.*,homeassistant.components.lock.*,homeassistant.components.mailbox.*,homeassistant.components.media_player.*,homeassistant.components.notify.*,homeassistant.components.number.*,homeassistant.components.persistent_notification.*,homeassistant.components.proximity.*,homeassistant.components.recorder.purge,homeassistant.components.recorder.repack,homeassistant.components.remote.*,homeassistant.components.scene.*,homeassistant.components.sensor.*,homeassistant.components.slack.*,homeassistant.components.sun.*,homeassistant.components.switch.*,homeassistant.components.systemmonitor.*,homeassistant.components.tts.*,homeassistant.components.vacuum.*,homeassistant.components.water_heater.*,homeassistant.components.weather.*,homeassistant.components.websocket_api.*,homeassistant.components.zeroconf.*,homeassistant.components.zone.*,homeassistant.components.zwave_js.*,homeassistant.helpers.*,homeassistant.scripts.*,homeassistant.util.*,tests.components.hyperion.*]
strict = true
ignore_errors = false
warn_unreachable = true

View File

@ -17,11 +17,12 @@ from homeassistant.components.lutron_caseta.const import (
ERROR_CANNOT_CONNECT,
STEP_IMPORT_FAILED,
)
from homeassistant.components.zeroconf import ATTR_HOSTNAME
from homeassistant.const import CONF_HOST
from tests.common import MockConfigEntry
ATTR_HOSTNAME = "hostname"
EMPTY_MOCK_CONFIG_ENTRY = {
CONF_HOST: "",
CONF_KEYFILE: "",

View File

@ -5,7 +5,6 @@ from unittest.mock import Mock, patch
import pytest
from homeassistant import config_entries
from homeassistant.components import zeroconf
from homeassistant.components.xiaomi_aqara import config_flow, const
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_PROTOCOL
@ -402,7 +401,7 @@ async def test_zeroconf_success(hass):
const.DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data={
zeroconf.ATTR_HOST: TEST_HOST,
CONF_HOST: TEST_HOST,
ZEROCONF_NAME: TEST_ZEROCONF_NAME,
ZEROCONF_PROP: {ZEROCONF_MAC: TEST_MAC},
},
@ -444,7 +443,7 @@ async def test_zeroconf_missing_data(hass):
result = await hass.config_entries.flow.async_init(
const.DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data={zeroconf.ATTR_HOST: TEST_HOST, ZEROCONF_NAME: TEST_ZEROCONF_NAME},
data={CONF_HOST: TEST_HOST, ZEROCONF_NAME: TEST_ZEROCONF_NAME},
)
assert result["type"] == "abort"
@ -457,7 +456,7 @@ async def test_zeroconf_unknown_device(hass):
const.DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data={
zeroconf.ATTR_HOST: TEST_HOST,
CONF_HOST: TEST_HOST,
ZEROCONF_NAME: "not-a-xiaomi-aqara-gateway",
ZEROCONF_PROP: {ZEROCONF_MAC: TEST_MAC},
},

View File

@ -4,7 +4,6 @@ from unittest.mock import Mock, patch
from miio import DeviceException
from homeassistant import config_entries
from homeassistant.components import zeroconf
from homeassistant.components.xiaomi_miio import const
from homeassistant.components.xiaomi_miio.config_flow import DEFAULT_GATEWAY_NAME
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN
@ -106,7 +105,7 @@ async def test_zeroconf_gateway_success(hass):
const.DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data={
zeroconf.ATTR_HOST: TEST_HOST,
CONF_HOST: TEST_HOST,
ZEROCONF_NAME: TEST_ZEROCONF_NAME,
ZEROCONF_PROP: {ZEROCONF_MAC: TEST_MAC},
},
@ -146,7 +145,7 @@ async def test_zeroconf_unknown_device(hass):
const.DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data={
zeroconf.ATTR_HOST: TEST_HOST,
CONF_HOST: TEST_HOST,
ZEROCONF_NAME: "not-a-xiaomi-miio-device",
ZEROCONF_PROP: {ZEROCONF_MAC: TEST_MAC},
},
@ -171,7 +170,7 @@ async def test_zeroconf_missing_data(hass):
result = await hass.config_entries.flow.async_init(
const.DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data={zeroconf.ATTR_HOST: TEST_HOST, ZEROCONF_NAME: TEST_ZEROCONF_NAME},
data={CONF_HOST: TEST_HOST, ZEROCONF_NAME: TEST_ZEROCONF_NAME},
)
assert result["type"] == "abort"
@ -342,7 +341,7 @@ async def zeroconf_device_success(hass, zeroconf_name_to_test, model_to_test):
const.DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data={
zeroconf.ATTR_HOST: TEST_HOST,
CONF_HOST: TEST_HOST,
ZEROCONF_NAME: zeroconf_name_to_test,
ZEROCONF_PROP: {"poch": f"0:mac={TEST_MAC_DEVICE}\x00"},
},