Config flow for homekit (#34560)

* Config flow for homekit

Allows multiple homekit bridges to run

HAP-python state is now stored at .storage/homekit.{entry_id}.state
aids is now stored at .storage/homekit.{entry_id}.aids

Overcomes 150 device limit by supporting
multiple bridges.

Name and port are now automatically allocated
to avoid conflicts which was one of the main
reasons pairing failed.

YAML configuration remains available in order to offer entity
specific configuration.

Entries created by config flow can add and remove
included domains and entities without having to restart

* Fix services as there are multiple now

* migrate in executor

* drop title from strings

* Update homeassistant/components/homekit/strings.json

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>

* Make auto_start advanced mode only, add coverage

* put back title

* more references

* delete port since manual config is no longer needed

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
J. Nick Koston 2020-04-30 23:05:06 -05:00 committed by GitHub
parent 5699cb8a09
commit 793592b2b8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 1754 additions and 322 deletions

View file

@ -1,4 +1,5 @@
"""Support for Apple HomeKit."""
import asyncio
import ipaddress
import logging
@ -6,41 +7,36 @@ from aiohttp import web
import voluptuous as vol
from zeroconf import InterfaceChoice
from homeassistant.components import cover, vacuum
from homeassistant.components.binary_sensor import DEVICE_CLASS_BATTERY_CHARGING
from homeassistant.components.cover import DEVICE_CLASS_GARAGE, DEVICE_CLASS_GATE
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.media_player import DEVICE_CLASS_TV
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
ATTR_BATTERY_CHARGING,
ATTR_BATTERY_LEVEL,
ATTR_DEVICE_CLASS,
ATTR_ENTITY_ID,
ATTR_SERVICE,
ATTR_SUPPORTED_FEATURES,
ATTR_UNIT_OF_MEASUREMENT,
CONF_IP_ADDRESS,
CONF_NAME,
CONF_PORT,
CONF_TYPE,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_ILLUMINANCE,
DEVICE_CLASS_TEMPERATURE,
EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STARTED,
EVENT_HOMEASSISTANT_STOP,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
UNIT_PERCENTAGE,
)
from homeassistant.core import callback
from homeassistant.exceptions import Unauthorized
from homeassistant.helpers import entity_registry
from homeassistant.core import CoreState, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady, Unauthorized
from homeassistant.helpers import device_registry, entity_registry
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entityfilter import FILTER_SCHEMA
from homeassistant.helpers.entityfilter import (
BASE_FILTER_SCHEMA,
CONF_EXCLUDE_DOMAINS,
CONF_EXCLUDE_ENTITIES,
CONF_INCLUDE_DOMAINS,
CONF_INCLUDE_ENTITIES,
convert_filter,
)
from homeassistant.util import get_local_ip
from homeassistant.util.decorator import Registry
from .accessories import get_accessory
from .aidmanager import AccessoryAidStorage
from .const import (
AID_STORAGE,
@ -50,43 +46,41 @@ from .const import (
CONF_ADVERTISE_IP,
CONF_AUTO_START,
CONF_ENTITY_CONFIG,
CONF_FEATURE_LIST,
CONF_ENTRY_INDEX,
CONF_FILTER,
CONF_LINKED_BATTERY_CHARGING_SENSOR,
CONF_LINKED_BATTERY_SENSOR,
CONF_SAFE_MODE,
CONF_ZEROCONF_DEFAULT_INTERFACE,
CONFIG_OPTIONS,
DEFAULT_AUTO_START,
DEFAULT_PORT,
DEFAULT_SAFE_MODE,
DEFAULT_ZEROCONF_DEFAULT_INTERFACE,
DEVICE_CLASS_CO,
DEVICE_CLASS_CO2,
DEVICE_CLASS_PM25,
DOMAIN,
EVENT_HOMEKIT_CHANGED,
HOMEKIT_FILE,
HOMEKIT,
HOMEKIT_PAIRING_QR,
HOMEKIT_PAIRING_QR_SECRET,
MANUFACTURER,
SERVICE_HOMEKIT_RESET_ACCESSORY,
SERVICE_HOMEKIT_START,
TYPE_FAUCET,
TYPE_OUTLET,
TYPE_SHOWER,
TYPE_SPRINKLER,
TYPE_SWITCH,
TYPE_VALVE,
SHUTDOWN_TIMEOUT,
UNDO_UPDATE_LISTENER,
)
from .util import (
dismiss_setup_message,
get_persist_fullpath_for_entry_id,
migrate_filesystem_state_data_for_primary_imported_entry_id,
port_is_available,
remove_state_files_for_entry_id,
show_setup_message,
validate_entity_config,
validate_media_player_features,
)
_LOGGER = logging.getLogger(__name__)
MAX_DEVICES = 150
TYPES = Registry()
# #### Driver Status ####
STATUS_READY = 0
@ -94,66 +88,139 @@ STATUS_RUNNING = 1
STATUS_STOPPED = 2
STATUS_WAIT = 3
SWITCH_TYPES = {
TYPE_FAUCET: "Valve",
TYPE_OUTLET: "Outlet",
TYPE_SHOWER: "Valve",
TYPE_SPRINKLER: "Valve",
TYPE_SWITCH: "Switch",
TYPE_VALVE: "Valve",
}
CONFIG_SCHEMA = vol.Schema(
def _has_all_unique_names_and_ports(bridges):
"""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]
vol.Schema(vol.Unique())(names)
vol.Schema(vol.Unique())(ports)
return bridges
BRIDGE_SCHEMA = vol.Schema(
{
DOMAIN: vol.All(
{
vol.Optional(CONF_NAME, default=BRIDGE_NAME): vol.All(
cv.string, vol.Length(min=3, max=25)
),
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_IP_ADDRESS): vol.All(ipaddress.ip_address, cv.string),
vol.Optional(CONF_ADVERTISE_IP): vol.All(
ipaddress.ip_address, cv.string
),
vol.Optional(CONF_AUTO_START, default=DEFAULT_AUTO_START): cv.boolean,
vol.Optional(CONF_SAFE_MODE, default=DEFAULT_SAFE_MODE): cv.boolean,
vol.Optional(CONF_FILTER, default={}): FILTER_SCHEMA,
vol.Optional(CONF_ENTITY_CONFIG, default={}): validate_entity_config,
vol.Optional(
CONF_ZEROCONF_DEFAULT_INTERFACE,
default=DEFAULT_ZEROCONF_DEFAULT_INTERFACE,
): cv.boolean,
}
)
vol.Optional(CONF_NAME, default=BRIDGE_NAME): vol.All(
cv.string, vol.Length(min=3, max=25)
),
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_IP_ADDRESS): vol.All(ipaddress.ip_address, cv.string),
vol.Optional(CONF_ADVERTISE_IP): vol.All(ipaddress.ip_address, cv.string),
vol.Optional(CONF_AUTO_START, default=DEFAULT_AUTO_START): cv.boolean,
vol.Optional(CONF_SAFE_MODE, default=DEFAULT_SAFE_MODE): cv.boolean,
vol.Optional(CONF_FILTER, default={}): BASE_FILTER_SCHEMA,
vol.Optional(CONF_ENTITY_CONFIG, default={}): validate_entity_config,
vol.Optional(
CONF_ZEROCONF_DEFAULT_INTERFACE, default=DEFAULT_ZEROCONF_DEFAULT_INTERFACE,
): cv.boolean,
},
extra=vol.ALLOW_EXTRA,
)
CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.All(cv.ensure_list, [BRIDGE_SCHEMA], _has_all_unique_names_and_ports)},
extra=vol.ALLOW_EXTRA,
)
RESET_ACCESSORY_SERVICE_SCHEMA = vol.Schema(
{vol.Required(ATTR_ENTITY_ID): cv.entity_ids}
)
async def async_setup(hass, config):
"""Set up the HomeKit component."""
_LOGGER.debug("Begin setup HomeKit")
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the HomeKit from yaml."""
aid_storage = hass.data[AID_STORAGE] = AccessoryAidStorage(hass)
await aid_storage.async_initialize()
hass.data.setdefault(DOMAIN, {})
hass.http.register_view(HomeKitPairingQRView)
_async_register_events_and_services(hass)
if DOMAIN not in config:
return True
current_entries = hass.config_entries.async_entries(DOMAIN)
entries_by_name = {entry.data[CONF_NAME]: entry for entry in current_entries}
for index, conf in enumerate(config[DOMAIN]):
bridge_name = conf[CONF_NAME]
if (
bridge_name in entries_by_name
and entries_by_name[bridge_name].source == SOURCE_IMPORT
):
entry = entries_by_name[bridge_name]
# If they alter the yaml config we import the changes
# since there currently is no practical way to support
# all the options in the UI at this time.
data = conf.copy()
options = {}
for key in CONFIG_OPTIONS:
options[key] = data[key]
del data[key]
hass.config_entries.async_update_entry(entry, data=data, options=options)
continue
conf[CONF_ENTRY_INDEX] = index
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=conf,
)
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up HomeKit from a config entry."""
_async_import_options_from_data_if_missing(hass, entry)
conf = entry.data
options = entry.options
conf = config[DOMAIN]
name = conf[CONF_NAME]
port = conf[CONF_PORT]
_LOGGER.debug("Begin setup HomeKit for %s", name)
# If the previous instance hasn't cleaned up yet
# we need to wait a bit
if not await hass.async_add_executor_job(port_is_available, port):
raise ConfigEntryNotReady
if CONF_ENTRY_INDEX in conf and conf[CONF_ENTRY_INDEX] == 0:
_LOGGER.debug("Migrating legacy HomeKit data for %s", name)
hass.async_add_executor_job(
migrate_filesystem_state_data_for_primary_imported_entry_id,
hass,
entry.entry_id,
)
aid_storage = AccessoryAidStorage(hass, entry.entry_id)
await aid_storage.async_initialize()
# These are yaml only
ip_address = conf.get(CONF_IP_ADDRESS)
advertise_ip = conf.get(CONF_ADVERTISE_IP)
auto_start = conf[CONF_AUTO_START]
safe_mode = conf[CONF_SAFE_MODE]
entity_filter = conf[CONF_FILTER]
entity_config = conf[CONF_ENTITY_CONFIG]
entity_config = conf.get(CONF_ENTITY_CONFIG, {})
auto_start = options.get(CONF_AUTO_START, DEFAULT_AUTO_START)
safe_mode = options.get(CONF_SAFE_MODE, DEFAULT_SAFE_MODE)
entity_filter = convert_filter(
options.get(
CONF_FILTER,
{
CONF_INCLUDE_DOMAINS: [],
CONF_EXCLUDE_DOMAINS: [],
CONF_INCLUDE_ENTITIES: [],
CONF_EXCLUDE_ENTITIES: [],
},
)
)
interface_choice = (
InterfaceChoice.Default if conf.get(CONF_ZEROCONF_DEFAULT_INTERFACE) else None
InterfaceChoice.Default
if options.get(CONF_ZEROCONF_DEFAULT_INTERFACE)
else None
)
homekit = HomeKit(
@ -166,20 +233,100 @@ async def async_setup(hass, config):
safe_mode,
advertise_ip,
interface_choice,
entry.entry_id,
)
await hass.async_add_executor_job(homekit.setup)
undo_listener = entry.add_update_listener(_async_update_listener)
hass.data[DOMAIN][entry.entry_id] = {
AID_STORAGE: aid_storage,
HOMEKIT: homekit,
UNDO_UPDATE_LISTENER: undo_listener,
}
if hass.state == CoreState.running:
await homekit.async_start()
elif auto_start:
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, homekit.async_start)
return True
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry):
"""Handle options update."""
if entry.source == SOURCE_IMPORT:
return
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry."""
dismiss_setup_message(hass, entry.entry_id)
hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]()
homekit = hass.data[DOMAIN][entry.entry_id][HOMEKIT]
if homekit.status == STATUS_RUNNING:
await homekit.async_stop()
for _ in range(0, SHUTDOWN_TIMEOUT):
if not await hass.async_add_executor_job(
port_is_available, entry.data[CONF_PORT]
):
_LOGGER.info("Waiting for the HomeKit server to shutdown.")
await asyncio.sleep(1)
hass.data[DOMAIN].pop(entry.entry_id)
return True
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Remove a config entry."""
return 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):
options = dict(entry.options)
data = dict(entry.data)
modified = False
for importable_option in CONFIG_OPTIONS:
if importable_option not in entry.options and importable_option in entry.data:
options[importable_option] = entry.data[importable_option]
del data[importable_option]
modified = True
if modified:
hass.config_entries.async_update_entry(entry, data=data, options=options)
@callback
def _async_register_events_and_services(hass: HomeAssistant):
"""Register events and services for HomeKit."""
hass.http.register_view(HomeKitPairingQRView)
def handle_homekit_reset_accessory(service):
"""Handle start HomeKit service call."""
if homekit.status != STATUS_RUNNING:
_LOGGER.warning(
"HomeKit is not running. Either it is waiting to be "
"started or has been stopped."
)
return
for entry_id in hass.data[DOMAIN]:
if HOMEKIT not in hass.data[DOMAIN][entry_id]:
continue
homekit = hass.data[DOMAIN][entry_id][HOMEKIT]
if homekit.status != STATUS_RUNNING:
_LOGGER.warning(
"HomeKit is not running. Either it is waiting to be "
"started or has been stopped."
)
continue
entity_ids = service.data.get("entity_id")
homekit.reset_accessories(entity_ids)
entity_ids = service.data.get("entity_id")
homekit.reset_accessories(entity_ids)
hass.services.async_register(
DOMAIN,
@ -208,124 +355,24 @@ async def async_setup(hass, config):
DOMAIN, EVENT_HOMEKIT_CHANGED, async_describe_logbook_event
)
if auto_start:
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, homekit.async_start)
return True
async def async_handle_homekit_service_start(service):
"""Handle start HomeKit service call."""
if homekit.status != STATUS_READY:
_LOGGER.warning(
"HomeKit is not ready. Either it is already running or has "
"been stopped."
)
return
await homekit.async_start()
for entry_id in hass.data[DOMAIN]:
if HOMEKIT not in hass.data[DOMAIN][entry_id]:
continue
homekit = hass.data[DOMAIN][entry_id][HOMEKIT]
if homekit.status != STATUS_READY:
_LOGGER.warning(
"HomeKit is not ready. Either it is already running or has "
"been stopped."
)
continue
await homekit.async_start()
hass.services.async_register(
DOMAIN, SERVICE_HOMEKIT_START, async_handle_homekit_service_start
)
return True
def get_accessory(hass, driver, state, aid, config):
"""Take state and return an accessory object if supported."""
if not aid:
_LOGGER.warning(
'The entity "%s" is not supported, since it '
"generates an invalid aid, please change it.",
state.entity_id,
)
return None
a_type = None
name = config.get(CONF_NAME, state.name)
if state.domain == "alarm_control_panel":
a_type = "SecuritySystem"
elif state.domain in ("binary_sensor", "device_tracker", "person"):
a_type = "BinarySensor"
elif state.domain == "climate":
a_type = "Thermostat"
elif state.domain == "cover":
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if device_class in (DEVICE_CLASS_GARAGE, DEVICE_CLASS_GATE) and features & (
cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE
):
a_type = "GarageDoorOpener"
elif features & cover.SUPPORT_SET_POSITION:
a_type = "WindowCovering"
elif features & (cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE):
a_type = "WindowCoveringBasic"
elif state.domain == "fan":
a_type = "Fan"
elif state.domain == "light":
a_type = "Light"
elif state.domain == "lock":
a_type = "Lock"
elif state.domain == "media_player":
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
feature_list = config.get(CONF_FEATURE_LIST)
if device_class == DEVICE_CLASS_TV:
a_type = "TelevisionMediaPlayer"
else:
if feature_list and validate_media_player_features(state, feature_list):
a_type = "MediaPlayer"
elif state.domain == "sensor":
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
if device_class == DEVICE_CLASS_TEMPERATURE or unit in (
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
):
a_type = "TemperatureSensor"
elif device_class == DEVICE_CLASS_HUMIDITY and unit == UNIT_PERCENTAGE:
a_type = "HumiditySensor"
elif device_class == DEVICE_CLASS_PM25 or DEVICE_CLASS_PM25 in state.entity_id:
a_type = "AirQualitySensor"
elif device_class == DEVICE_CLASS_CO:
a_type = "CarbonMonoxideSensor"
elif device_class == DEVICE_CLASS_CO2 or DEVICE_CLASS_CO2 in state.entity_id:
a_type = "CarbonDioxideSensor"
elif device_class == DEVICE_CLASS_ILLUMINANCE or unit in ("lm", "lx"):
a_type = "LightSensor"
elif state.domain == "switch":
switch_type = config.get(CONF_TYPE, TYPE_SWITCH)
a_type = SWITCH_TYPES[switch_type]
elif state.domain == "vacuum":
features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if features & (vacuum.SUPPORT_START | vacuum.SUPPORT_RETURN_HOME):
a_type = "DockVacuum"
else:
a_type = "Switch"
elif state.domain in ("automation", "input_boolean", "remote", "scene", "script"):
a_type = "Switch"
elif state.domain == "water_heater":
a_type = "WaterHeater"
if a_type is None:
return None
_LOGGER.debug('Add "%s" as "%s"', state.entity_id, a_type)
return TYPES[a_type](hass, driver, name, state.entity_id, aid, config)
class HomeKit:
"""Class to handle all actions between HomeKit and Home Assistant."""
@ -341,6 +388,7 @@ class HomeKit:
safe_mode,
advertise_ip=None,
interface_choice=None,
entry_id=None,
):
"""Initialize a HomeKit object."""
self.hass = hass
@ -352,6 +400,7 @@ class HomeKit:
self._safe_mode = safe_mode
self._advertise_ip = advertise_ip
self._interface_choice = interface_choice
self._entry_id = entry_id
self.status = STATUS_READY
self.bridge = None
@ -363,25 +412,26 @@ class HomeKit:
from .accessories import HomeBridge, HomeDriver
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop)
ip_addr = self._ip_address or get_local_ip()
path = self.hass.config.path(HOMEKIT_FILE)
persist_file = get_persist_fullpath_for_entry_id(self.hass, self._entry_id)
self.driver = HomeDriver(
self.hass,
self._entry_id,
self._name,
address=ip_addr,
port=self._port,
persist_file=path,
persist_file=persist_file,
advertised_address=self._advertise_ip,
interface_choice=self._interface_choice,
)
self.bridge = HomeBridge(self.hass, self.driver, self._name)
if self._safe_mode:
_LOGGER.debug("Safe_mode selected")
_LOGGER.debug("Safe_mode selected for %s", self._name)
self.driver.safe_mode = True
def reset_accessories(self, entity_ids):
"""Reset the accessory to load the latest configuration."""
aid_storage = self.hass.data[AID_STORAGE]
aid_storage = self.hass.data[DOMAIN][self._entry_id][AID_STORAGE]
removed = []
for entity_id in entity_ids:
aid = aid_storage.get_or_allocate_aid_for_entity_id(entity_id)
@ -412,9 +462,9 @@ class HomeKit:
)
return
aid = self.hass.data[AID_STORAGE].get_or_allocate_aid_for_entity_id(
state.entity_id
)
aid = self.hass.data[DOMAIN][self._entry_id][
AID_STORAGE
].get_or_allocate_aid_for_entity_id(state.entity_id)
conf = self._config.pop(state.entity_id, {})
# If an accessory cannot be created or added due to an exception
# of any kind (usually in pyhap) it should not prevent
@ -437,6 +487,7 @@ class HomeKit:
async def async_start(self, *args):
"""Start the accessory driver."""
if self.status != STATUS_READY:
return
self.status = STATUS_WAIT
@ -459,6 +510,20 @@ class HomeKit:
bridged_states.append(state)
await self.hass.async_add_executor_job(self._start, bridged_states)
await self._async_register_bridge()
async def _async_register_bridge(self):
"""Register the bridge as a device so homekit_controller and exclude it from discovery."""
registry = await device_registry.async_get_registry(self.hass)
registry.async_get_or_create(
config_entry_id=self._entry_id,
connections={
(device_registry.CONNECTION_NETWORK_MAC, self.driver.state.mac)
},
manufacturer=MANUFACTURER,
name=self._name,
model="Home Assistant HomeKit Bridge",
)
def _start(self, bridged_states):
from . import ( # noqa: F401 pylint: disable=unused-import, import-outside-toplevel
@ -480,10 +545,14 @@ class HomeKit:
if not self.driver.state.paired:
show_setup_message(
self.hass, self.driver.state.pincode, self.bridge.xhm_uri()
self.hass,
self._entry_id,
self._name,
self.driver.state.pincode,
self.bridge.xhm_uri(),
)
_LOGGER.debug("Driver start")
_LOGGER.debug("Driver start for %s", self._name)
self.hass.add_job(self.driver.start)
self.status = STATUS_RUNNING
@ -492,8 +561,7 @@ class HomeKit:
if self.status != STATUS_RUNNING:
return
self.status = STATUS_STOPPED
_LOGGER.debug("Driver stop")
_LOGGER.debug("Driver stop for %s", self._name)
self.hass.add_job(self.driver.stop)
@callback
@ -539,9 +607,17 @@ class HomeKitPairingQRView(HomeAssistantView):
# pylint: disable=no-self-use
async def get(self, request):
"""Retrieve the pairing QRCode image."""
if request.query_string != request.app["hass"].data[HOMEKIT_PAIRING_QR_SECRET]:
if not request.query_string:
raise Unauthorized()
entry_id, secret = request.query_string.split("-")
if (
entry_id not in request.app["hass"].data[DOMAIN]
or secret
!= request.app["hass"].data[DOMAIN][entry_id][HOMEKIT_PAIRING_QR_SECRET]
):
raise Unauthorized()
return web.Response(
body=request.app["hass"].data[HOMEKIT_PAIRING_QR],
body=request.app["hass"].data[DOMAIN][entry_id][HOMEKIT_PAIRING_QR],
content_type="image/svg+xml",
)

View file

@ -8,12 +8,26 @@ from pyhap.accessory import Accessory, Bridge
from pyhap.accessory_driver import AccessoryDriver
from pyhap.const import CATEGORY_OTHER
from homeassistant.components import cover, vacuum
from homeassistant.components.cover import DEVICE_CLASS_GARAGE, DEVICE_CLASS_GATE
from homeassistant.components.media_player import DEVICE_CLASS_TV
from homeassistant.const import (
ATTR_BATTERY_CHARGING,
ATTR_BATTERY_LEVEL,
ATTR_DEVICE_CLASS,
ATTR_ENTITY_ID,
ATTR_SERVICE,
ATTR_SUPPORTED_FEATURES,
ATTR_UNIT_OF_MEASUREMENT,
CONF_NAME,
CONF_TYPE,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_ILLUMINANCE,
DEVICE_CLASS_TEMPERATURE,
STATE_ON,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
UNIT_PERCENTAGE,
__version__,
)
from homeassistant.core import callback as ha_callback, split_entity_id
@ -22,6 +36,7 @@ from homeassistant.helpers.event import (
track_point_in_utc_time,
)
from homeassistant.util import dt as dt_util
from homeassistant.util.decorator import Registry
from .const import (
ATTR_DISPLAY_NAME,
@ -31,21 +46,45 @@ from .const import (
CHAR_BATTERY_LEVEL,
CHAR_CHARGING_STATE,
CHAR_STATUS_LOW_BATTERY,
CONF_FEATURE_LIST,
CONF_LINKED_BATTERY_CHARGING_SENSOR,
CONF_LINKED_BATTERY_SENSOR,
CONF_LOW_BATTERY_THRESHOLD,
DEBOUNCE_TIMEOUT,
DEFAULT_LOW_BATTERY_THRESHOLD,
DEVICE_CLASS_CO,
DEVICE_CLASS_CO2,
DEVICE_CLASS_PM25,
EVENT_HOMEKIT_CHANGED,
HK_CHARGING,
HK_NOT_CHARGABLE,
HK_NOT_CHARGING,
MANUFACTURER,
SERV_BATTERY_SERVICE,
TYPE_FAUCET,
TYPE_OUTLET,
TYPE_SHOWER,
TYPE_SPRINKLER,
TYPE_SWITCH,
TYPE_VALVE,
)
from .util import (
convert_to_float,
dismiss_setup_message,
show_setup_message,
validate_media_player_features,
)
from .util import convert_to_float, dismiss_setup_message, show_setup_message
_LOGGER = logging.getLogger(__name__)
SWITCH_TYPES = {
TYPE_FAUCET: "Valve",
TYPE_OUTLET: "Outlet",
TYPE_SHOWER: "Valve",
TYPE_SPRINKLER: "Valve",
TYPE_SWITCH: "Switch",
TYPE_VALVE: "Valve",
}
TYPES = Registry()
def debounce(func):
@ -79,6 +118,104 @@ def debounce(func):
return wrapper
def get_accessory(hass, driver, state, aid, config):
"""Take state and return an accessory object if supported."""
if not aid:
_LOGGER.warning(
'The entity "%s" is not supported, since it '
"generates an invalid aid, please change it.",
state.entity_id,
)
return None
a_type = None
name = config.get(CONF_NAME, state.name)
if state.domain == "alarm_control_panel":
a_type = "SecuritySystem"
elif state.domain in ("binary_sensor", "device_tracker", "person"):
a_type = "BinarySensor"
elif state.domain == "climate":
a_type = "Thermostat"
elif state.domain == "cover":
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if device_class in (DEVICE_CLASS_GARAGE, DEVICE_CLASS_GATE) and features & (
cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE
):
a_type = "GarageDoorOpener"
elif features & cover.SUPPORT_SET_POSITION:
a_type = "WindowCovering"
elif features & (cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE):
a_type = "WindowCoveringBasic"
elif state.domain == "fan":
a_type = "Fan"
elif state.domain == "light":
a_type = "Light"
elif state.domain == "lock":
a_type = "Lock"
elif state.domain == "media_player":
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
feature_list = config.get(CONF_FEATURE_LIST)
if device_class == DEVICE_CLASS_TV:
a_type = "TelevisionMediaPlayer"
else:
if feature_list and validate_media_player_features(state, feature_list):
a_type = "MediaPlayer"
elif state.domain == "sensor":
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
if device_class == DEVICE_CLASS_TEMPERATURE or unit in (
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
):
a_type = "TemperatureSensor"
elif device_class == DEVICE_CLASS_HUMIDITY and unit == UNIT_PERCENTAGE:
a_type = "HumiditySensor"
elif device_class == DEVICE_CLASS_PM25 or DEVICE_CLASS_PM25 in state.entity_id:
a_type = "AirQualitySensor"
elif device_class == DEVICE_CLASS_CO:
a_type = "CarbonMonoxideSensor"
elif device_class == DEVICE_CLASS_CO2 or DEVICE_CLASS_CO2 in state.entity_id:
a_type = "CarbonDioxideSensor"
elif device_class == DEVICE_CLASS_ILLUMINANCE or unit in ("lm", "lx"):
a_type = "LightSensor"
elif state.domain == "switch":
switch_type = config.get(CONF_TYPE, TYPE_SWITCH)
a_type = SWITCH_TYPES[switch_type]
elif state.domain == "vacuum":
features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if features & (vacuum.SUPPORT_START | vacuum.SUPPORT_RETURN_HOME):
a_type = "DockVacuum"
else:
a_type = "Switch"
elif state.domain in ("automation", "input_boolean", "remote", "scene", "script"):
a_type = "Switch"
elif state.domain == "water_heater":
a_type = "WaterHeater"
if a_type is None:
return None
_LOGGER.debug('Add "%s" as "%s"', state.entity_id, a_type)
return TYPES[a_type](hass, driver, name, state.entity_id, aid, config)
class HomeAccessory(Accessory):
"""Adapter class for Accessory."""
@ -327,19 +464,27 @@ class HomeBridge(Bridge):
class HomeDriver(AccessoryDriver):
"""Adapter class for AccessoryDriver."""
def __init__(self, hass, **kwargs):
def __init__(self, hass, entry_id, bridge_name, **kwargs):
"""Initialize a AccessoryDriver object."""
super().__init__(**kwargs)
self.hass = hass
self._entry_id = entry_id
self._bridge_name = bridge_name
def pair(self, client_uuid, client_public):
"""Override super function to dismiss setup message if paired."""
success = super().pair(client_uuid, client_public)
if success:
dismiss_setup_message(self.hass)
dismiss_setup_message(self.hass, self._entry_id)
return success
def unpair(self, client_uuid):
"""Override super function to show setup message if unpaired."""
super().unpair(client_uuid)
show_setup_message(self.hass, self.state.pincode, self.accessory.xhm_uri())
show_setup_message(
self.hass,
self._entry_id,
self._bridge_name,
self.state.pincode,
self.accessory.xhm_uri(),
)

View file

@ -15,13 +15,13 @@ from zlib import adler32
from fnvhash import fnv1a_32
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_registry import RegistryEntry
from homeassistant.helpers.storage import Store
from .const import DOMAIN
from .util import get_aid_storage_filename_for_entry_id
AID_MANAGER_STORAGE_KEY = f"{DOMAIN}.aids"
AID_MANAGER_STORAGE_VERSION = 1
AID_MANAGER_SAVE_DELAY = 2
@ -74,13 +74,13 @@ class AccessoryAidStorage:
persist over reboots.
"""
def __init__(self, hass: HomeAssistant):
def __init__(self, hass: HomeAssistant, entry: ConfigEntry):
"""Create a new entity map store."""
self.hass = hass
self.store = Store(hass, AID_MANAGER_STORAGE_VERSION, AID_MANAGER_STORAGE_KEY)
self.allocations = {}
self.allocated_aids = set()
self._entry = entry
self.store = None
self._entity_registry = None
async def async_initialize(self):
@ -88,6 +88,8 @@ class AccessoryAidStorage:
self._entity_registry = (
await self.hass.helpers.entity_registry.async_get_registry()
)
aidstore = get_aid_storage_filename_for_entry_id(self._entry)
self.store = Store(self.hass, AID_MANAGER_STORAGE_VERSION, aidstore)
raw_storage = await self.store.async_load()
if not raw_storage:

View file

@ -0,0 +1,301 @@
"""Config flow for HomeKit integration."""
import logging
import random
import string
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_NAME, CONF_PORT
from homeassistant.core import callback, split_entity_id
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entityfilter import (
CONF_EXCLUDE_DOMAINS,
CONF_EXCLUDE_ENTITIES,
CONF_INCLUDE_DOMAINS,
CONF_INCLUDE_ENTITIES,
)
from .const import (
CONF_AUTO_START,
CONF_FILTER,
CONF_SAFE_MODE,
CONF_ZEROCONF_DEFAULT_INTERFACE,
DEFAULT_AUTO_START,
DEFAULT_CONFIG_FLOW_PORT,
DEFAULT_SAFE_MODE,
DEFAULT_ZEROCONF_DEFAULT_INTERFACE,
SHORT_BRIDGE_NAME,
)
from .const import DOMAIN # pylint:disable=unused-import
from .util import find_next_available_port
_LOGGER = logging.getLogger(__name__)
CONF_DOMAINS = "domains"
SUPPORTED_DOMAINS = [
"alarm_control_panel",
"automation",
"binary_sensor",
"climate",
"cover",
"demo",
"device_tracker",
"fan",
"input_boolean",
"light",
"lock",
"media_player",
"person",
"remote",
"scene",
"script",
"sensor",
"switch",
"vacuum",
"water_heater",
]
DEFAULT_DOMAINS = [
"alarm_control_panel",
"climate",
"cover",
"light",
"lock",
"media_player",
"switch",
"vacuum",
"water_heater",
]
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for HomeKit."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
def __init__(self):
"""Initialize config flow."""
self.homekit_data = {}
self.entry_title = None
async def async_step_pairing(self, user_input=None):
"""Pairing instructions."""
if user_input is not None:
return self.async_create_entry(
title=self.entry_title, data=self.homekit_data
)
return self.async_show_form(
step_id="pairing",
description_placeholders={CONF_NAME: self.homekit_data[CONF_NAME]},
)
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
if user_input is not None:
port = await self._async_available_port()
name = self._async_available_name()
title = f"{name}:{port}"
self.homekit_data = user_input.copy()
self.homekit_data[CONF_NAME] = name
self.homekit_data[CONF_PORT] = port
self.homekit_data[CONF_FILTER] = {
CONF_INCLUDE_DOMAINS: user_input[CONF_INCLUDE_DOMAINS],
CONF_INCLUDE_ENTITIES: [],
CONF_EXCLUDE_DOMAINS: [],
CONF_EXCLUDE_ENTITIES: [],
}
del self.homekit_data[CONF_INCLUDE_DOMAINS]
self.entry_title = title
return await self.async_step_pairing()
default_domains = [] if self._async_current_entries() else DEFAULT_DOMAINS
setup_schema = vol.Schema(
{
vol.Optional(CONF_AUTO_START, default=DEFAULT_AUTO_START): bool,
vol.Required(
CONF_INCLUDE_DOMAINS, default=default_domains
): cv.multi_select(SUPPORTED_DOMAINS),
}
)
return self.async_show_form(
step_id="user", data_schema=setup_schema, errors=errors
)
async def async_step_import(self, user_input=None):
"""Handle import from yaml."""
if not self._async_is_unique_name_port(user_input):
return self.async_abort(reason="port_name_in_use")
return self.async_create_entry(
title=f"{user_input[CONF_NAME]}:{user_input[CONF_PORT]}", data=user_input
)
async def _async_available_port(self):
"""Return an available port the bridge."""
return await self.hass.async_add_executor_job(
find_next_available_port, DEFAULT_CONFIG_FLOW_PORT
)
@callback
def _async_available_name(self):
"""Return an available for the bridge."""
current_entries = self._async_current_entries()
# We always pick a RANDOM name to avoid Zeroconf
# name collisions. If the name has been seen before
# pairing will probably fail.
acceptable_chars = string.ascii_uppercase + string.digits
trailer = "".join(random.choices(acceptable_chars, k=4))
all_names = {entry.data[CONF_NAME] for entry in current_entries}
suggested_name = f"{SHORT_BRIDGE_NAME} {trailer}"
while suggested_name in all_names:
trailer = "".join(random.choices(acceptable_chars, k=4))
suggested_name = f"{SHORT_BRIDGE_NAME} {trailer}"
return suggested_name
@callback
def _async_is_unique_name_port(self, user_input):
"""Determine is a name or port is already used."""
name = user_input[CONF_NAME]
port = user_input[CONF_PORT]
for entry in self._async_current_entries():
if entry.data[CONF_NAME] == name or entry.data[CONF_PORT] == port:
return False
return True
@staticmethod
@callback
def async_get_options_flow(config_entry):
"""Get the options flow for this handler."""
return OptionsFlowHandler(config_entry)
class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle a option flow for tado."""
def __init__(self, config_entry: config_entries.ConfigEntry):
"""Initialize options flow."""
self.config_entry = config_entry
self.homekit_options = {}
async def async_step_yaml(self, user_input=None):
"""No options for yaml managed entries."""
if user_input is not None:
# Apparently not possible to abort an options flow
# at the moment
return self.async_create_entry(title="", data=self.config_entry.options)
return self.async_show_form(step_id="yaml")
async def async_step_advanced(self, user_input=None):
"""Choose advanced options."""
if user_input is not None:
self.homekit_options.update(user_input)
del self.homekit_options[CONF_INCLUDE_DOMAINS]
return self.async_create_entry(title="", data=self.homekit_options)
schema_base = {}
if self.show_advanced_options:
schema_base[
vol.Optional(
CONF_AUTO_START,
default=self.homekit_options.get(
CONF_AUTO_START, DEFAULT_AUTO_START
),
)
] = bool
else:
self.homekit_options[CONF_AUTO_START] = self.homekit_options.get(
CONF_AUTO_START, DEFAULT_AUTO_START
)
schema_base.update(
{
vol.Optional(
CONF_SAFE_MODE,
default=self.homekit_options.get(CONF_SAFE_MODE, DEFAULT_SAFE_MODE),
): bool,
vol.Optional(
CONF_ZEROCONF_DEFAULT_INTERFACE,
default=self.homekit_options.get(
CONF_ZEROCONF_DEFAULT_INTERFACE,
DEFAULT_ZEROCONF_DEFAULT_INTERFACE,
),
): bool,
}
)
return self.async_show_form(
step_id="advanced", data_schema=vol.Schema(schema_base)
)
async def async_step_exclude(self, user_input=None):
"""Choose entities to exclude from the domain."""
if user_input is not None:
self.homekit_options[CONF_FILTER] = {
CONF_INCLUDE_DOMAINS: self.homekit_options[CONF_INCLUDE_DOMAINS],
CONF_EXCLUDE_DOMAINS: self.homekit_options.get(
CONF_EXCLUDE_DOMAINS, []
),
CONF_INCLUDE_ENTITIES: self.homekit_options.get(
CONF_INCLUDE_ENTITIES, []
),
CONF_EXCLUDE_ENTITIES: user_input[CONF_EXCLUDE_ENTITIES],
}
return await self.async_step_advanced()
entity_filter = self.homekit_options.get(CONF_FILTER, {})
all_supported_entities = await self.hass.async_add_executor_job(
_get_entities_matching_domains,
self.hass,
self.homekit_options[CONF_INCLUDE_DOMAINS],
)
data_schema = vol.Schema(
{
vol.Optional(
CONF_EXCLUDE_ENTITIES,
default=entity_filter.get(CONF_EXCLUDE_ENTITIES, []),
): cv.multi_select(all_supported_entities),
}
)
return self.async_show_form(step_id="exclude", data_schema=data_schema)
async def async_step_init(self, user_input=None):
"""Handle options flow."""
if self.config_entry.source == SOURCE_IMPORT:
return await self.async_step_yaml(user_input)
if user_input is not None:
self.homekit_options.update(user_input)
return await self.async_step_exclude()
self.homekit_options = dict(self.config_entry.options)
entity_filter = self.homekit_options.get(CONF_FILTER, {})
data_schema = vol.Schema(
{
vol.Optional(
CONF_INCLUDE_DOMAINS,
default=entity_filter.get(CONF_INCLUDE_DOMAINS, []),
): cv.multi_select(SUPPORTED_DOMAINS)
}
)
return self.async_show_form(step_id="init", data_schema=data_schema)
def _get_entities_matching_domains(hass, domains):
"""List entities in the given domains."""
included_domains = set(domains)
entity_ids = [
state.entity_id
for state in hass.states.all()
if (split_entity_id(state.entity_id))[0] in included_domains
]
entity_ids.sort()
return entity_ids

View file

@ -1,13 +1,17 @@
"""Constants used be the HomeKit component."""
# #### Misc ####
DEBOUNCE_TIMEOUT = 0.5
DEVICE_PRECISION_LEEWAY = 6
DOMAIN = "homekit"
HOMEKIT_FILE = ".homekit.state"
HOMEKIT_NOTIFY_ID = 4663548
AID_STORAGE = "homekit-aid-allocations"
HOMEKIT_PAIRING_QR = "homekit-pairing-qr"
HOMEKIT_PAIRING_QR_SECRET = "homekit-pairing-qr-secret"
HOMEKIT = "homekit"
UNDO_UPDATE_LISTENER = "undo_update_listener"
SHUTDOWN_TIMEOUT = 30
CONF_ENTRY_INDEX = "index"
# #### Attributes ####
ATTR_DISPLAY_NAME = "display_name"
@ -30,6 +34,7 @@ CONF_ZEROCONF_DEFAULT_INTERFACE = "zeroconf_default_interface"
DEFAULT_AUTO_START = True
DEFAULT_LOW_BATTERY_THRESHOLD = 20
DEFAULT_PORT = 51827
DEFAULT_CONFIG_FLOW_PORT = 51828
DEFAULT_SAFE_MODE = False
DEFAULT_ZEROCONF_DEFAULT_INTERFACE = False
@ -49,6 +54,7 @@ SERVICE_HOMEKIT_RESET_ACCESSORY = "reset_accessory"
# #### String Constants ####
BRIDGE_MODEL = "Bridge"
BRIDGE_NAME = "Home Assistant Bridge"
SHORT_BRIDGE_NAME = "HASS Bridge"
BRIDGE_SERIAL_NUMBER = "homekit.bridge"
MANUFACTURER = "Home Assistant"
@ -203,3 +209,11 @@ HK_POSITION_STOPPED = 2
HK_NOT_CHARGING = 0
HK_CHARGING = 1
HK_NOT_CHARGABLE = 2
# ### Config Options ###
CONFIG_OPTIONS = [
CONF_FILTER,
CONF_AUTO_START,
CONF_ZEROCONF_DEFAULT_INTERFACE,
CONF_SAFE_MODE,
]

View file

@ -5,5 +5,6 @@
"requirements": ["HAP-python==2.8.2","fnvhash==0.1.0","PyQRCode==1.2.1","base36==0.1.1"],
"dependencies": ["http"],
"after_dependencies": ["logbook"],
"codeowners": ["@bdraco"]
"codeowners": ["@bdraco"],
"config_flow": true
}

View file

@ -0,0 +1,54 @@
{
"title" : "HomeKit Bridge",
"options" : {
"step" : {
"yaml" : {
"title" : "Adjust HomeKit Bridge Options",
"description" : "This entry is controlled via YAML"
},
"init" : {
"data" : {
"include_domains" : "[%key:component::homekit::config::step::user::data::include_domains%]"
},
"description" : "Entities in the “Domains to include” will be bridged to HomeKit. You will be able to select which entities to exclude from this list on the next screen.",
"title" : "Select domains to bridge."
},
"exclude" : {
"data" : {
"exclude_entities" : "Entities to exclude"
},
"description" : "Choose the entities that you do NOT want to be bridged.",
"title" : "Exclude entities in selected domains from bridge"
},
"advanced" : {
"data" : {
"auto_start" : "[%key:component::homekit::config::step::user::data::auto_start%]",
"safe_mode" : "Safe Mode (enable only if pairing fails)",
"zeroconf_default_interface" : "Use default zeroconf interface (enable if the bridge cannot be found in the Home app)"
},
"description" : "These settings only need to be adjusted if the HomeKit bridge is not functional.",
"title" : "Advanced Configuration"
}
}
},
"config" : {
"step" : {
"user" : {
"data" : {
"auto_start" : "Autostart (disable if using Z-Wave or other delayed start system)",
"include_domains" : "Domains to include"
},
"description" : "A HomeKit Bridge will allow you to access your Home Assistant entities in HomeKit. HomeKit Bridges are limited to 150 accessories per instance including the bridge itself. If you wish to bridge more than the maximum number of accessories, it is recommended that you use multiple HomeKit bridges for different domains. Detailed entity configuration is only available via YAML for the primary bridge.",
"title" : "Activate HomeKit Bridge"
},
"pairing": {
"title": "Pair HomeKit Bridge",
"description": "As soon as the {name} bridge is ready, pairing will be available in “Notifications” as “HomeKit Bridge Setup”."
}
},
"abort" : {
"port_name_in_use" : "A bridge with the same name or port is already configured."
}
}
}

View file

@ -0,0 +1,54 @@
{
"title" : "HomeKit Bridge",
"options" : {
"step" : {
"yaml" : {
"title" : "Adjust HomeKit Bridge Options",
"description" : "This entry is controlled via YAML"
},
"init" : {
"data" : {
"include_domains" : "[%key:component::homekit::config::step::user::data::include_domains%]"
},
"description" : "Entities in the “Domains to include” will be bridged to HomeKit. You will be able to select which entities to exclude from this list on the next screen.",
"title" : "Select domains to bridge."
},
"exclude" : {
"data" : {
"exclude_entities" : "Entities to exclude"
},
"description" : "Choose the entities that you do NOT want to be bridged.",
"title" : "Exclude entities in selected domains from bridge"
},
"advanced" : {
"data" : {
"auto_start" : "[%key:component::homekit::config::step::user::data::auto_start%]",
"safe_mode" : "Safe Mode (enable only if pairing fails)",
"zeroconf_default_interface" : "Use default zeroconf interface (enable if the bridge cannot be found in the Home app)"
},
"description" : "These settings only need to be adjusted if the HomeKit bridge is not functional.",
"title" : "Advanced Configuration"
}
}
},
"config" : {
"step" : {
"user" : {
"data" : {
"auto_start" : "Autostart (disable if using Z-Wave or other delayed start system)",
"include_domains" : "Domains to include"
},
"description" : "A HomeKit Bridge will allow you to access your Home Assistant entities in HomeKit. HomeKit Bridges are limited to 150 accessories per instance including the bridge itself. If you wish to bridge more than the maximum number of accessories, it is recommended that you use multiple HomeKit bridges for different domains. Detailed entity configuration is only available via YAML for the primary bridge.",
"title" : "Activate HomeKit Bridge"
},
"pairing": {
"title": "Pair HomeKit Bridge",
"description": "As soon as the {name} bridge is ready, pairing will be available in “Notifications” as “HomeKit Bridge Setup”."
}
},
"abort" : {
"port_name_in_use" : "A bridge with the same name or port is already configured."
}
}
}

View file

@ -26,8 +26,7 @@ from homeassistant.const import (
STATE_OPENING,
)
from . import TYPES
from .accessories import HomeAccessory, debounce
from .accessories import TYPES, HomeAccessory, debounce
from .const import (
CHAR_CURRENT_DOOR_STATE,
CHAR_CURRENT_POSITION,

View file

@ -27,8 +27,7 @@ from homeassistant.const import (
STATE_ON,
)
from . import TYPES
from .accessories import HomeAccessory
from .accessories import TYPES, HomeAccessory
from .const import (
CHAR_ACTIVE,
CHAR_ROTATION_DIRECTION,

View file

@ -24,8 +24,7 @@ from homeassistant.const import (
STATE_ON,
)
from . import TYPES
from .accessories import HomeAccessory
from .accessories import TYPES, HomeAccessory
from .const import (
CHAR_BRIGHTNESS,
CHAR_COLOR_TEMPERATURE,

View file

@ -6,8 +6,7 @@ from pyhap.const import CATEGORY_DOOR_LOCK
from homeassistant.components.lock import DOMAIN, STATE_LOCKED, STATE_UNLOCKED
from homeassistant.const import ATTR_CODE, ATTR_ENTITY_ID, STATE_UNKNOWN
from . import TYPES
from .accessories import HomeAccessory
from .accessories import TYPES, HomeAccessory
from .const import CHAR_LOCK_CURRENT_STATE, CHAR_LOCK_TARGET_STATE, SERV_LOCK
_LOGGER = logging.getLogger(__name__)

View file

@ -37,8 +37,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from . import TYPES
from .accessories import HomeAccessory
from .accessories import TYPES, HomeAccessory
from .const import (
CHAR_ACTIVE,
CHAR_ACTIVE_IDENTIFIER,

View file

@ -18,8 +18,7 @@ from homeassistant.const import (
STATE_ALARM_TRIGGERED,
)
from . import TYPES
from .accessories import HomeAccessory
from .accessories import TYPES, HomeAccessory
from .const import (
CHAR_CURRENT_SECURITY_STATE,
CHAR_TARGET_SECURITY_STATE,

View file

@ -11,8 +11,7 @@ from homeassistant.const import (
TEMP_CELSIUS,
)
from . import TYPES
from .accessories import HomeAccessory
from .accessories import TYPES, HomeAccessory
from .const import (
CHAR_AIR_PARTICULATE_DENSITY,
CHAR_AIR_QUALITY,

View file

@ -27,8 +27,7 @@ from homeassistant.const import (
from homeassistant.core import split_entity_id
from homeassistant.helpers.event import call_later
from . import TYPES
from .accessories import HomeAccessory
from .accessories import TYPES, HomeAccessory
from .const import (
CHAR_ACTIVE,
CHAR_IN_USE,

View file

@ -52,8 +52,7 @@ from homeassistant.const import (
UNIT_PERCENTAGE,
)
from . import TYPES
from .accessories import HomeAccessory
from .accessories import TYPES, HomeAccessory
from .const import (
CHAR_COOLING_THRESHOLD_TEMPERATURE,
CHAR_CURRENT_HEATING_COOLING,

View file

@ -2,7 +2,9 @@
from collections import OrderedDict, namedtuple
import io
import logging
import os
import secrets
import socket
import pyqrcode
import voluptuous as vol
@ -15,8 +17,9 @@ from homeassistant.const import (
CONF_TYPE,
TEMP_CELSIUS,
)
from homeassistant.core import split_entity_id
from homeassistant.core import HomeAssistant, split_entity_id
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.storage import STORAGE_DIR
import homeassistant.util.temperature as temp_util
from .const import (
@ -25,11 +28,12 @@ from .const import (
CONF_LINKED_BATTERY_SENSOR,
CONF_LOW_BATTERY_THRESHOLD,
DEFAULT_LOW_BATTERY_THRESHOLD,
DOMAIN,
FEATURE_ON_OFF,
FEATURE_PLAY_PAUSE,
FEATURE_PLAY_STOP,
FEATURE_TOGGLE_MUTE,
HOMEKIT_NOTIFY_ID,
HOMEKIT_FILE,
HOMEKIT_PAIRING_QR,
HOMEKIT_PAIRING_QR_SECRET,
TYPE_FAUCET,
@ -42,6 +46,7 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
MAX_PORT = 65535
BASIC_INFO_SCHEMA = vol.Schema(
{
@ -210,7 +215,7 @@ class HomeKitSpeedMapping:
return list(self.speed_ranges.keys())[0]
def show_setup_message(hass, pincode, uri):
def show_setup_message(hass, entry_id, bridge_name, pincode, uri):
"""Display persistent notification with setup information."""
pin = pincode.decode()
_LOGGER.info("Pincode: %s", pin)
@ -220,23 +225,23 @@ def show_setup_message(hass, pincode, uri):
url.svg(buffer, scale=5)
pairing_secret = secrets.token_hex(32)
hass.data[HOMEKIT_PAIRING_QR] = buffer.getvalue()
hass.data[HOMEKIT_PAIRING_QR_SECRET] = pairing_secret
hass.data[DOMAIN][entry_id][HOMEKIT_PAIRING_QR] = buffer.getvalue()
hass.data[DOMAIN][entry_id][HOMEKIT_PAIRING_QR_SECRET] = pairing_secret
message = (
f"To set up Home Assistant in the Home App, "
f"To set up {bridge_name} in the Home App, "
f"scan the QR code or enter the following code:\n"
f"### {pin}\n"
f"![image](/api/homekit/pairingqr?{pairing_secret})"
f"![image](/api/homekit/pairingqr?{entry_id}-{pairing_secret})"
)
hass.components.persistent_notification.create(
message, "HomeKit Setup", HOMEKIT_NOTIFY_ID
message, "HomeKit Bridge Setup", entry_id
)
def dismiss_setup_message(hass):
def dismiss_setup_message(hass, entry_id):
"""Dismiss persistent notification and remove QR code."""
hass.components.persistent_notification.dismiss(HOMEKIT_NOTIFY_ID)
hass.components.persistent_notification.dismiss(entry_id)
def convert_to_float(state):
@ -268,3 +273,85 @@ def density_to_air_quality(density):
if density <= 150:
return 4
return 5
def get_persist_filename_for_entry_id(entry_id: str):
"""Determine the filename of the homekit state file."""
return f"{DOMAIN}.{entry_id}.state"
def get_aid_storage_filename_for_entry_id(entry_id: str):
"""Determine the ilename of homekit aid storage file."""
return f"{DOMAIN}.{entry_id}.aids"
def get_persist_fullpath_for_entry_id(hass: HomeAssistant, entry_id: str):
"""Determine the path to the homekit state file."""
return hass.config.path(STORAGE_DIR, get_persist_filename_for_entry_id(entry_id))
def get_aid_storage_fullpath_for_entry_id(hass: HomeAssistant, entry_id: str):
"""Determine the path to the homekit aid storage file."""
return hass.config.path(
STORAGE_DIR, get_aid_storage_filename_for_entry_id(entry_id)
)
def migrate_filesystem_state_data_for_primary_imported_entry_id(
hass: HomeAssistant, entry_id: str
):
"""Migrate the old paths to the storage directory."""
legacy_persist_file_path = hass.config.path(HOMEKIT_FILE)
if os.path.exists(legacy_persist_file_path):
os.rename(
legacy_persist_file_path, get_persist_fullpath_for_entry_id(hass, entry_id)
)
legacy_aid_storage_path = hass.config.path(STORAGE_DIR, "homekit.aids")
if os.path.exists(legacy_aid_storage_path):
os.rename(
legacy_aid_storage_path,
get_aid_storage_fullpath_for_entry_id(hass, entry_id),
)
def remove_state_files_for_entry_id(hass: HomeAssistant, entry_id: str):
"""Remove the state files from disk."""
persist_file_path = get_persist_fullpath_for_entry_id(hass, entry_id)
aid_storage_path = get_aid_storage_fullpath_for_entry_id(hass, entry_id)
os.unlink(persist_file_path)
if os.path.exists(aid_storage_path):
os.unlink(aid_storage_path)
return True
def _get_test_socket():
"""Create a socket to test binding ports."""
test_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
test_socket.setblocking(False)
test_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
return test_socket
def port_is_available(port: int):
"""Check to see if a port is available."""
test_socket = _get_test_socket()
try:
test_socket.bind(("", port))
except OSError:
return False
return True
def find_next_available_port(start_port: int):
"""Find the next available port starting with the given port."""
test_socket = _get_test_socket()
for port in range(start_port, MAX_PORT):
try:
test_socket.bind(("", port))
return port
except OSError:
if port == MAX_PORT:
raise
continue

View file

@ -50,6 +50,7 @@ FLOWS = [
"harmony",
"heos",
"hisense_aehw4a1",
"homekit",
"homekit_controller",
"homematicip_cloud",
"huawei_lte",

View file

@ -12,7 +12,8 @@ CONF_EXCLUDE_DOMAINS = "exclude_domains"
CONF_EXCLUDE_ENTITIES = "exclude_entities"
def _convert_filter(config: Dict[str, List[str]]) -> Callable[[str], bool]:
def convert_filter(config: Dict[str, List[str]]) -> Callable[[str], bool]:
"""Convert the filter schema into a filter."""
filt = generate_filter(
config[CONF_INCLUDE_DOMAINS],
config[CONF_INCLUDE_ENTITIES],
@ -24,22 +25,21 @@ def _convert_filter(config: Dict[str, List[str]]) -> Callable[[str], bool]:
return filt
FILTER_SCHEMA = vol.All(
vol.Schema(
{
vol.Optional(CONF_EXCLUDE_DOMAINS, default=[]): vol.All(
cv.ensure_list, [cv.string]
),
vol.Optional(CONF_EXCLUDE_ENTITIES, default=[]): cv.entity_ids,
vol.Optional(CONF_INCLUDE_DOMAINS, default=[]): vol.All(
cv.ensure_list, [cv.string]
),
vol.Optional(CONF_INCLUDE_ENTITIES, default=[]): cv.entity_ids,
}
),
_convert_filter,
BASE_FILTER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_EXCLUDE_DOMAINS, default=[]): vol.All(
cv.ensure_list, [cv.string]
),
vol.Optional(CONF_EXCLUDE_ENTITIES, default=[]): cv.entity_ids,
vol.Optional(CONF_INCLUDE_DOMAINS, default=[]): vol.All(
cv.ensure_list, [cv.string]
),
vol.Optional(CONF_INCLUDE_ENTITIES, default=[]): cv.entity_ids,
}
)
FILTER_SCHEMA = vol.All(BASE_FILTER_SCHEMA, convert_filter)
def generate_filter(
include_domains: List[str],

View file

@ -453,7 +453,9 @@ def test_home_driver():
pin = b"123-45-678"
with patch("pyhap.accessory_driver.AccessoryDriver.__init__") as mock_driver:
driver = HomeDriver("hass", address=ip_address, port=port, persist_file=path)
driver = HomeDriver(
"hass", "entry_id", "name", address=ip_address, port=port, persist_file=path
)
mock_driver.assert_called_with(address=ip_address, port=port, persist_file=path)
driver.state = Mock(pincode=pin)
@ -467,7 +469,7 @@ def test_home_driver():
driver.pair("client_uuid", "client_public")
mock_pair.assert_called_with("client_uuid", "client_public")
mock_dissmiss_msg.assert_called_with("hass")
mock_dissmiss_msg.assert_called_with("hass", "entry_id")
# unpair
with patch("pyhap.accessory_driver.AccessoryDriver.unpair") as mock_unpair, patch(
@ -476,4 +478,4 @@ def test_home_driver():
driver.unpair("client_uuid")
mock_unpair.assert_called_with("client_uuid")
mock_show_msg.assert_called_with("hass", pin, "X-HM://0")
mock_show_msg.assert_called_with("hass", "entry_id", "name", pin, "X-HM://0")

View file

@ -5,8 +5,8 @@ from zlib import adler32
import pytest
from homeassistant.components.homekit.aidmanager import (
AID_MANAGER_STORAGE_KEY,
AccessoryAidStorage,
get_aid_storage_filename_for_entry_id,
get_system_unique_id,
)
from homeassistant.helpers import device_registry
@ -53,7 +53,7 @@ async def test_aid_generation(hass, device_reg, entity_reg):
with patch(
"homeassistant.components.homekit.aidmanager.AccessoryAidStorage.async_schedule_save"
):
aid_storage = AccessoryAidStorage(hass)
aid_storage = AccessoryAidStorage(hass, config_entry)
await aid_storage.async_initialize()
for _ in range(0, 2):
@ -110,7 +110,7 @@ async def test_aid_adler32_collision(hass, device_reg, entity_reg):
with patch(
"homeassistant.components.homekit.aidmanager.AccessoryAidStorage.async_schedule_save"
):
aid_storage = AccessoryAidStorage(hass)
aid_storage = AccessoryAidStorage(hass, config_entry)
await aid_storage.async_initialize()
seen_aids = set()
@ -129,8 +129,8 @@ async def test_aid_generation_no_unique_ids_handles_collision(
hass, device_reg, entity_reg
):
"""Test colliding aids is stable."""
aid_storage = AccessoryAidStorage(hass)
config_entry = MockConfigEntry(domain="test", data={})
aid_storage = AccessoryAidStorage(hass, config_entry)
await aid_storage.async_initialize()
seen_aids = set()
@ -394,7 +394,7 @@ async def test_aid_generation_no_unique_ids_handles_collision(
await aid_storage.async_save()
await hass.async_block_till_done()
aid_storage = AccessoryAidStorage(hass)
aid_storage = AccessoryAidStorage(hass, config_entry)
await aid_storage.async_initialize()
assert aid_storage.allocations == {
@ -620,6 +620,7 @@ async def test_aid_generation_no_unique_ids_handles_collision(
"light.light99": 596247761,
}
aid_storage_path = hass.config.path(STORAGE_DIR, AID_MANAGER_STORAGE_KEY)
aidstore = get_aid_storage_filename_for_entry_id(config_entry.entry_id)
aid_storage_path = hass.config.path(STORAGE_DIR, aidstore)
if await hass.async_add_executor_job(os.path.exists, aid_storage_path):
await hass.async_add_executor_job(os.unlink, aid_storage_path)

View file

@ -0,0 +1,259 @@
"""Test the HomeKit config flow."""
from asynctest import patch
from homeassistant import config_entries, data_entry_flow, setup
from homeassistant.components.homekit.const import DOMAIN
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_NAME, CONF_PORT
from tests.common import MockConfigEntry
def _mock_config_entry_with_options_populated():
"""Create a mock config entry with options populated."""
return MockConfigEntry(
domain=DOMAIN,
data={CONF_NAME: "mock_name", CONF_PORT: 12345},
options={
"filter": {
"include_domains": [
"fan",
"vacuum",
"media_player",
"climate",
"alarm_control_panel",
],
"exclude_entities": ["climate.front_gate"],
},
"auto_start": False,
"safe_mode": False,
"zeroconf_default_interface": True,
},
)
async def test_user_form(hass):
"""Test we can setup a new instance."""
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["errors"] == {}
with patch(
"homeassistant.components.homekit.config_flow.find_next_available_port",
return_value=12345,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], {"auto_start": True, "include_domains": ["light"]},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["step_id"] == "pairing"
with patch(
"homeassistant.components.homekit.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.homekit.async_setup_entry", return_value=True,
) as mock_setup_entry:
result3 = await hass.config_entries.flow.async_configure(result["flow_id"], {},)
assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result3["title"][:11] == "HASS Bridge"
bridge_name = (result3["title"].split(":"))[0]
assert result3["data"] == {
"auto_start": True,
"filter": {
"exclude_domains": [],
"exclude_entities": [],
"include_domains": ["light"],
"include_entities": [],
},
"name": bridge_name,
"port": 12345,
}
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_import(hass):
"""Test we can import instance."""
await setup.async_setup_component(hass, "persistent_notification", {})
entry = MockConfigEntry(
domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345}
)
entry.add_to_hass(hass)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={CONF_NAME: "mock_name", CONF_PORT: 12345},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "port_name_in_use"
with patch(
"homeassistant.components.homekit.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.homekit.async_setup_entry", return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={CONF_NAME: "othername", CONF_PORT: 56789},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result2["title"] == "othername:56789"
assert result2["data"] == {
"name": "othername",
"port": 56789,
}
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 2
async def test_options_flow_advanced(hass):
"""Test config flow options."""
config_entry = _mock_config_entry_with_options_populated()
config_entry.add_to_hass(hass)
hass.states.async_set("climate.old", "off")
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(
config_entry.entry_id, context={"show_advanced_options": True}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={"include_domains": ["fan", "vacuum", "climate"]},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "exclude"
result2 = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={"exclude_entities": ["climate.old"]},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["step_id"] == "advanced"
with patch("homeassistant.components.homekit.async_setup_entry", return_value=True):
result3 = await hass.config_entries.options.async_configure(
result2["flow_id"],
user_input={
"auto_start": True,
"safe_mode": True,
"zeroconf_default_interface": False,
},
)
assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert config_entry.options == {
"auto_start": True,
"filter": {
"exclude_domains": [],
"exclude_entities": ["climate.old"],
"include_domains": ["fan", "vacuum", "climate"],
"include_entities": [],
},
"safe_mode": True,
"zeroconf_default_interface": False,
}
async def test_options_flow_basic(hass):
"""Test config flow options."""
config_entry = _mock_config_entry_with_options_populated()
config_entry.add_to_hass(hass)
hass.states.async_set("climate.old", "off")
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(
config_entry.entry_id, context={"show_advanced_options": False}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={"include_domains": ["fan", "vacuum", "climate"]},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "exclude"
result2 = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={"exclude_entities": ["climate.old"]},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["step_id"] == "advanced"
with patch("homeassistant.components.homekit.async_setup_entry", return_value=True):
result3 = await hass.config_entries.options.async_configure(
result2["flow_id"],
user_input={"safe_mode": True, "zeroconf_default_interface": False},
)
assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert config_entry.options == {
"auto_start": False,
"filter": {
"exclude_domains": [],
"exclude_entities": ["climate.old"],
"include_domains": ["fan", "vacuum", "climate"],
"include_entities": [],
},
"safe_mode": True,
"zeroconf_default_interface": False,
}
async def test_options_flow_blocked_when_from_yaml(hass):
"""Test config flow options."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_NAME: "mock_name", CONF_PORT: 12345},
options={
"auto_start": True,
"filter": {
"include_domains": [
"fan",
"vacuum",
"media_player",
"climate",
"alarm_control_panel",
],
"exclude_entities": ["climate.front_gate"],
},
"safe_mode": False,
"zeroconf_default_interface": True,
},
source=SOURCE_IMPORT,
)
config_entry.add_to_hass(hass)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "yaml"
with patch("homeassistant.components.homekit.async_setup_entry", return_value=True):
result2 = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY

View file

@ -5,7 +5,7 @@ import pytest
import homeassistant.components.climate as climate
import homeassistant.components.cover as cover
from homeassistant.components.homekit import TYPES, get_accessory
from homeassistant.components.homekit.accessories import TYPES, get_accessory
from homeassistant.components.homekit.const import (
CONF_FEATURE_LIST,
FEATURE_ON_OFF,
@ -17,6 +17,7 @@ from homeassistant.components.homekit.const import (
TYPE_VALVE,
)
import homeassistant.components.media_player.const as media_player_c
import homeassistant.components.vacuum as vacuum
from homeassistant.const import (
ATTR_CODE,
ATTR_DEVICE_CLASS,
@ -239,3 +240,27 @@ def test_type_switches(type_name, entity_id, state, attrs, config):
entity_state = State(entity_id, state, attrs)
get_accessory(None, None, entity_state, 2, config)
assert mock_type.called
@pytest.mark.parametrize(
"type_name, entity_id, state, attrs",
[
(
"DockVacuum",
"vacuum.dock_vacuum",
"docked",
{
ATTR_SUPPORTED_FEATURES: vacuum.SUPPORT_START
| vacuum.SUPPORT_RETURN_HOME
},
),
("Switch", "vacuum.basic_vacuum", "off", {},),
],
)
def test_type_vacuum(type_name, entity_id, state, attrs):
"""Test if vacuum types are associated correctly."""
mock_type = Mock()
with patch.dict(TYPES, {type_name: mock_type}):
entity_state = State(entity_id, state, attrs)
get_accessory(None, None, entity_state, 2, {})
assert mock_type.called

View file

@ -1,10 +1,11 @@
"""Tests for the HomeKit component."""
import os
from typing import Dict
from unittest.mock import ANY, Mock, patch
import pytest
from zeroconf import InterfaceChoice
from homeassistant import setup
from homeassistant.components.binary_sensor import DEVICE_CLASS_BATTERY_CHARGING
from homeassistant.components.homekit import (
MAX_DEVICES,
@ -19,6 +20,7 @@ from homeassistant.components.homekit.const import (
AID_STORAGE,
BRIDGE_NAME,
CONF_AUTO_START,
CONF_ENTRY_INDEX,
CONF_SAFE_MODE,
CONF_ZEROCONF_DEFAULT_INTERFACE,
DEFAULT_PORT,
@ -28,6 +30,11 @@ from homeassistant.components.homekit.const import (
SERVICE_HOMEKIT_RESET_ACCESSORY,
SERVICE_HOMEKIT_START,
)
from homeassistant.components.homekit.util import (
get_aid_storage_fullpath_for_entry_id,
get_persist_fullpath_for_entry_id,
)
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_ENTITY_ID,
@ -42,13 +49,17 @@ from homeassistant.const import (
from homeassistant.core import State
from homeassistant.helpers import device_registry
from homeassistant.helpers.entityfilter import generate_filter
from homeassistant.helpers.storage import STORAGE_DIR
from homeassistant.setup import async_setup_component
from homeassistant.util import json as json_util
from .util import PATH_HOMEKIT, async_init_entry, async_init_integration
from tests.async_mock import AsyncMock
from tests.common import MockConfigEntry, mock_device_registry, mock_registry
from tests.components.homekit.common import patch_debounce
IP_ADDRESS = "127.0.0.1"
PATH_HOMEKIT = "homeassistant.components.homekit"
@pytest.fixture
@ -73,11 +84,31 @@ def debounce_patcher():
async def test_setup_min(hass):
"""Test async_setup with min config options."""
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_NAME: BRIDGE_NAME, CONF_PORT: DEFAULT_PORT},
options={},
)
entry.add_to_hass(hass)
with patch(f"{PATH_HOMEKIT}.HomeKit") as mock_homekit:
assert await setup.async_setup_component(hass, DOMAIN, {DOMAIN: {}})
mock_homekit.return_value = homekit = Mock()
type(homekit).async_start = AsyncMock()
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
mock_homekit.assert_any_call(
hass, BRIDGE_NAME, DEFAULT_PORT, None, ANY, {}, DEFAULT_SAFE_MODE, None, None
hass,
BRIDGE_NAME,
DEFAULT_PORT,
None,
ANY,
{},
DEFAULT_SAFE_MODE,
None,
None,
entry.entry_id,
)
assert mock_homekit().setup.called is True
@ -86,26 +117,27 @@ async def test_setup_min(hass):
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
await hass.async_block_till_done()
mock_homekit().async_start.assert_called_with(ANY)
mock_homekit().async_start.assert_called()
async def test_setup_auto_start_disabled(hass):
"""Test async_setup with auto start disabled and test service calls."""
config = {
DOMAIN: {
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_NAME: "Test Name", CONF_PORT: 11111, CONF_IP_ADDRESS: "172.0.0.0"},
options={
CONF_AUTO_START: False,
CONF_NAME: "Test Name",
CONF_PORT: 11111,
CONF_IP_ADDRESS: "172.0.0.0",
CONF_SAFE_MODE: DEFAULT_SAFE_MODE,
CONF_ZEROCONF_DEFAULT_INTERFACE: True,
}
}
},
)
entry.add_to_hass(hass)
with patch(f"{PATH_HOMEKIT}.HomeKit") as mock_homekit:
mock_homekit.return_value = homekit = Mock()
type(homekit).async_start = AsyncMock()
assert await setup.async_setup_component(hass, DOMAIN, config)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
mock_homekit.assert_any_call(
hass,
@ -117,6 +149,7 @@ async def test_setup_auto_start_disabled(hass):
DEFAULT_SAFE_MODE,
None,
InterfaceChoice.Default,
entry.entry_id,
)
assert mock_homekit().setup.called is True
@ -148,7 +181,23 @@ async def test_setup_auto_start_disabled(hass):
async def test_homekit_setup(hass, hk_driver):
"""Test setup of bridge and driver."""
homekit = HomeKit(hass, BRIDGE_NAME, DEFAULT_PORT, None, {}, {}, DEFAULT_SAFE_MODE)
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_NAME: "mock_name", CONF_PORT: 12345},
source=SOURCE_IMPORT,
)
homekit = HomeKit(
hass,
BRIDGE_NAME,
DEFAULT_PORT,
None,
{},
{},
DEFAULT_SAFE_MODE,
advertise_ip=None,
interface_choice=None,
entry_id=entry.entry_id,
)
with patch(
f"{PATH_HOMEKIT}.accessories.HomeDriver", return_value=hk_driver
@ -156,10 +205,12 @@ async def test_homekit_setup(hass, hk_driver):
mock_ip.return_value = IP_ADDRESS
await hass.async_add_executor_job(homekit.setup)
path = hass.config.path(HOMEKIT_FILE)
path = get_persist_fullpath_for_entry_id(hass, entry.entry_id)
assert isinstance(homekit.bridge, HomeBridge)
mock_driver.assert_called_with(
hass,
entry.entry_id,
BRIDGE_NAME,
address=IP_ADDRESS,
port=DEFAULT_PORT,
persist_file=path,
@ -174,17 +225,36 @@ async def test_homekit_setup(hass, hk_driver):
async def test_homekit_setup_ip_address(hass, hk_driver):
"""Test setup with given IP address."""
homekit = HomeKit(hass, BRIDGE_NAME, DEFAULT_PORT, "172.0.0.0", {}, {}, None)
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_NAME: "mock_name", CONF_PORT: 12345},
source=SOURCE_IMPORT,
)
homekit = HomeKit(
hass,
BRIDGE_NAME,
DEFAULT_PORT,
"172.0.0.0",
{},
{},
None,
None,
interface_choice=None,
entry_id=entry.entry_id,
)
path = get_persist_fullpath_for_entry_id(hass, entry.entry_id)
with patch(
f"{PATH_HOMEKIT}.accessories.HomeDriver", return_value=hk_driver
) as mock_driver:
await hass.async_add_executor_job(homekit.setup)
mock_driver.assert_called_with(
hass,
entry.entry_id,
BRIDGE_NAME,
address="172.0.0.0",
port=DEFAULT_PORT,
persist_file=ANY,
persist_file=path,
advertised_address=None,
interface_choice=None,
)
@ -192,19 +262,36 @@ async def test_homekit_setup_ip_address(hass, hk_driver):
async def test_homekit_setup_advertise_ip(hass, hk_driver):
"""Test setup with given IP address to advertise."""
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_NAME: "mock_name", CONF_PORT: 12345},
source=SOURCE_IMPORT,
)
homekit = HomeKit(
hass, BRIDGE_NAME, DEFAULT_PORT, "0.0.0.0", {}, {}, None, "192.168.1.100"
hass,
BRIDGE_NAME,
DEFAULT_PORT,
"0.0.0.0",
{},
{},
None,
"192.168.1.100",
interface_choice=None,
entry_id=entry.entry_id,
)
path = get_persist_fullpath_for_entry_id(hass, entry.entry_id)
with patch(
f"{PATH_HOMEKIT}.accessories.HomeDriver", return_value=hk_driver
) as mock_driver:
await hass.async_add_executor_job(homekit.setup)
mock_driver.assert_called_with(
hass,
entry.entry_id,
BRIDGE_NAME,
address="0.0.0.0",
port=DEFAULT_PORT,
persist_file=ANY,
persist_file=path,
advertised_address="192.168.1.100",
interface_choice=None,
)
@ -212,6 +299,11 @@ async def test_homekit_setup_advertise_ip(hass, hk_driver):
async def test_homekit_setup_interface_choice(hass, hk_driver):
"""Test setup with interface choice of Default."""
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_NAME: "mock_name", CONF_PORT: 12345},
source=SOURCE_IMPORT,
)
homekit = HomeKit(
hass,
BRIDGE_NAME,
@ -222,17 +314,21 @@ async def test_homekit_setup_interface_choice(hass, hk_driver):
None,
None,
InterfaceChoice.Default,
entry_id=entry.entry_id,
)
path = get_persist_fullpath_for_entry_id(hass, entry.entry_id)
with patch(
f"{PATH_HOMEKIT}.accessories.HomeDriver", return_value=hk_driver
) as mock_driver:
await hass.async_add_executor_job(homekit.setup)
mock_driver.assert_called_with(
hass,
entry.entry_id,
BRIDGE_NAME,
address="0.0.0.0",
port=DEFAULT_PORT,
persist_file=ANY,
persist_file=path,
advertised_address=None,
interface_choice=InterfaceChoice.Default,
)
@ -240,7 +336,23 @@ async def test_homekit_setup_interface_choice(hass, hk_driver):
async def test_homekit_setup_safe_mode(hass, hk_driver):
"""Test if safe_mode flag is set."""
homekit = HomeKit(hass, BRIDGE_NAME, DEFAULT_PORT, None, {}, {}, True, None)
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_NAME: "mock_name", CONF_PORT: 12345},
source=SOURCE_IMPORT,
)
homekit = HomeKit(
hass,
BRIDGE_NAME,
DEFAULT_PORT,
None,
{},
{},
True,
advertise_ip=None,
interface_choice=None,
entry_id=entry.entry_id,
)
with patch(f"{PATH_HOMEKIT}.accessories.HomeDriver", return_value=hk_driver):
await hass.async_add_executor_job(homekit.setup)
@ -249,12 +361,25 @@ async def test_homekit_setup_safe_mode(hass, hk_driver):
async def test_homekit_add_accessory(hass):
"""Add accessory if config exists and get_acc returns an accessory."""
homekit = HomeKit(hass, None, None, None, lambda entity_id: True, {}, None, None)
entry = await async_init_integration(hass)
homekit = HomeKit(
hass,
None,
None,
None,
lambda entity_id: True,
{},
DEFAULT_SAFE_MODE,
advertise_ip=None,
interface_choice=None,
entry_id=entry.entry_id,
)
homekit.driver = "driver"
homekit.bridge = mock_bridge = Mock()
homekit.bridge.accessories = range(10)
assert await setup.async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await async_init_integration(hass)
with patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc:
mock_get_acc.side_effect = [None, "acc", None]
@ -273,7 +398,20 @@ async def test_homekit_add_accessory(hass):
async def test_homekit_remove_accessory(hass):
"""Remove accessory from bridge."""
homekit = HomeKit("hass", None, None, None, lambda entity_id: True, {}, None, None)
entry = await async_init_integration(hass)
homekit = HomeKit(
hass,
None,
None,
None,
lambda entity_id: True,
{},
DEFAULT_SAFE_MODE,
advertise_ip=None,
interface_choice=None,
entry_id=entry.entry_id,
)
homekit.driver = "driver"
homekit.bridge = mock_bridge = Mock()
mock_bridge.accessories = {"light.demo": "acc"}
@ -285,10 +423,21 @@ async def test_homekit_remove_accessory(hass):
async def test_homekit_entity_filter(hass):
"""Test the entity filter."""
assert await setup.async_setup_component(hass, DOMAIN, {DOMAIN: {}})
entry = await async_init_integration(hass)
entity_filter = generate_filter(["cover"], ["demo.test"], [], [])
homekit = HomeKit(hass, None, None, None, entity_filter, {}, None, None)
homekit = HomeKit(
hass,
None,
None,
None,
entity_filter,
{},
DEFAULT_SAFE_MODE,
advertise_ip=None,
interface_choice=None,
entry_id=entry.entry_id,
)
homekit.bridge = Mock()
homekit.bridge.accessories = {}
@ -309,8 +458,21 @@ async def test_homekit_entity_filter(hass):
async def test_homekit_start(hass, hk_driver, debounce_patcher):
"""Test HomeKit start method."""
entry = await async_init_integration(hass)
pin = b"123-45-678"
homekit = HomeKit(hass, None, None, None, {}, {"cover.demo": {}}, None, None)
homekit = HomeKit(
hass,
None,
None,
None,
{},
{},
DEFAULT_SAFE_MODE,
advertise_ip=None,
interface_choice=None,
entry_id=entry.entry_id,
)
homekit.bridge = Mock()
homekit.bridge.accessories = []
homekit.driver = hk_driver
@ -330,7 +492,7 @@ async def test_homekit_start(hass, hk_driver, debounce_patcher):
await hass.async_block_till_done()
mock_add_acc.assert_called_with(state)
mock_setup_msg.assert_called_with(hass, pin, ANY)
mock_setup_msg.assert_called_with(hass, entry.entry_id, None, pin, ANY)
hk_driver_add_acc.assert_called_with(homekit.bridge)
assert hk_driver_start.called
assert homekit.status == STATUS_RUNNING
@ -345,11 +507,25 @@ async def test_homekit_start(hass, hk_driver, debounce_patcher):
async def test_homekit_start_with_a_broken_accessory(hass, hk_driver, debounce_patcher):
"""Test HomeKit start method."""
pin = b"123-45-678"
entry = MockConfigEntry(
domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345}
)
entity_filter = generate_filter(["cover", "light"], ["demo.test"], [], [])
assert await setup.async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await async_init_entry(hass, entry)
homekit = HomeKit(
hass,
None,
None,
None,
entity_filter,
{},
DEFAULT_SAFE_MODE,
advertise_ip=None,
interface_choice=None,
entry_id=entry.entry_id,
)
homekit = HomeKit(hass, None, None, None, entity_filter, {}, None, None)
homekit.bridge = Mock()
homekit.bridge.accessories = []
homekit.driver = hk_driver
@ -367,7 +543,7 @@ async def test_homekit_start_with_a_broken_accessory(hass, hk_driver, debounce_p
await homekit.async_start()
await hass.async_block_till_done()
mock_setup_msg.assert_called_with(hass, pin, ANY)
mock_setup_msg.assert_called_with(hass, entry.entry_id, None, pin, ANY)
hk_driver_add_acc.assert_called_with(homekit.bridge)
assert hk_driver_start.called
assert homekit.status == STATUS_RUNNING
@ -381,10 +557,23 @@ async def test_homekit_start_with_a_broken_accessory(hass, hk_driver, debounce_p
async def test_homekit_stop(hass):
"""Test HomeKit stop method."""
homekit = HomeKit(hass, None, None, None, None, None, None)
entry = await async_init_integration(hass)
homekit = HomeKit(
hass,
None,
None,
None,
{},
{},
DEFAULT_SAFE_MODE,
advertise_ip=None,
interface_choice=None,
entry_id=entry.entry_id,
)
homekit.driver = Mock()
assert await setup.async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await async_init_integration(hass)
assert homekit.status == STATUS_READY
await homekit.async_stop()
@ -406,8 +595,23 @@ async def test_homekit_stop(hass):
async def test_homekit_reset_accessories(hass):
"""Test adding too many accessories to HomeKit."""
entry = MockConfigEntry(
domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345}
)
entity_id = "light.demo"
homekit = HomeKit(hass, None, None, None, {}, {entity_id: {}}, None)
homekit = HomeKit(
hass,
None,
None,
None,
{},
{entity_id: {}},
DEFAULT_SAFE_MODE,
advertise_ip=None,
interface_choice=None,
entry_id=entry.entry_id,
)
homekit.bridge = Mock()
homekit.bridge.accessories = {}
@ -415,11 +619,14 @@ async def test_homekit_reset_accessories(hass):
f"{PATH_HOMEKIT}.HomeKit.setup"
), patch("pyhap.accessory.Bridge.add_accessory") as mock_add_accessory, patch(
"pyhap.accessory_driver.AccessoryDriver.config_changed"
) as hk_driver_config_changed:
) as hk_driver_config_changed, patch(
"pyhap.accessory_driver.AccessoryDriver.start"
):
await async_init_entry(hass, entry)
assert await setup.async_setup_component(hass, DOMAIN, {DOMAIN: {}})
aid = hass.data[AID_STORAGE].get_or_allocate_aid_for_entity_id(entity_id)
aid = hass.data[DOMAIN][entry.entry_id][
AID_STORAGE
].get_or_allocate_aid_for_entity_id(entity_id)
homekit.bridge.accessories = {aid: "acc"}
homekit.status = STATUS_RUNNING
@ -438,10 +645,22 @@ async def test_homekit_reset_accessories(hass):
async def test_homekit_too_many_accessories(hass, hk_driver):
"""Test adding too many accessories to HomeKit."""
entry = await async_init_integration(hass)
entity_filter = generate_filter(["cover", "light"], ["demo.test"], [], [])
homekit = HomeKit(hass, None, None, None, entity_filter, {}, None, None)
homekit = HomeKit(
hass,
None,
None,
None,
entity_filter,
{},
DEFAULT_SAFE_MODE,
advertise_ip=None,
interface_choice=None,
entry_id=entry.entry_id,
)
homekit.bridge = Mock()
# The bridge itself counts as an accessory
homekit.bridge.accessories = range(MAX_DEVICES)
@ -463,9 +682,20 @@ async def test_homekit_finds_linked_batteries(
hass, hk_driver, debounce_patcher, device_reg, entity_reg
):
"""Test HomeKit start method."""
assert await setup.async_setup_component(hass, DOMAIN, {DOMAIN: {}})
entry = await async_init_integration(hass)
homekit = HomeKit(hass, None, None, None, {}, {"light.demo": {}}, None, None)
homekit = HomeKit(
hass,
None,
None,
None,
{},
{"light.demo": {}},
DEFAULT_SAFE_MODE,
advertise_ip=None,
interface_choice=None,
entry_id=entry.entry_id,
)
homekit.driver = hk_driver
homekit._filter = Mock(return_value=True)
homekit.bridge = HomeBridge(hass, hk_driver, "mock_bridge")
@ -526,3 +756,132 @@ async def test_homekit_finds_linked_batteries(
"linked_battery_sensor": "sensor.light_battery",
},
)
async def test_setup_imported(hass):
"""Test async_setup with imported config options."""
legacy_persist_file_path = hass.config.path(HOMEKIT_FILE)
legacy_aid_storage_path = hass.config.path(STORAGE_DIR, "homekit.aids")
legacy_homekit_state_contents = {"homekit.state": 1}
legacy_homekit_aids_contents = {"homekit.aids": 1}
await hass.async_add_executor_job(
_write_data, legacy_persist_file_path, legacy_homekit_state_contents
)
await hass.async_add_executor_job(
_write_data, legacy_aid_storage_path, legacy_homekit_aids_contents
)
entry = MockConfigEntry(
domain=DOMAIN,
source=SOURCE_IMPORT,
data={CONF_NAME: BRIDGE_NAME, CONF_PORT: DEFAULT_PORT, CONF_ENTRY_INDEX: 0},
options={},
)
entry.add_to_hass(hass)
with patch(f"{PATH_HOMEKIT}.HomeKit") as mock_homekit:
mock_homekit.return_value = homekit = Mock()
type(homekit).async_start = AsyncMock()
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
mock_homekit.assert_any_call(
hass,
BRIDGE_NAME,
DEFAULT_PORT,
None,
ANY,
{},
DEFAULT_SAFE_MODE,
None,
None,
entry.entry_id,
)
assert mock_homekit().setup.called is True
# Test auto start enabled
mock_homekit.reset_mock()
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
await hass.async_block_till_done()
mock_homekit().async_start.assert_called()
migrated_persist_file_path = get_persist_fullpath_for_entry_id(hass, entry.entry_id)
assert (
await hass.async_add_executor_job(
json_util.load_json, migrated_persist_file_path
)
== legacy_homekit_state_contents
)
os.unlink(migrated_persist_file_path)
migrated_aid_file_path = get_aid_storage_fullpath_for_entry_id(hass, entry.entry_id)
assert (
await hass.async_add_executor_job(json_util.load_json, migrated_aid_file_path)
== legacy_homekit_aids_contents
)
os.unlink(migrated_aid_file_path)
async def test_yaml_updates_update_config_entry_for_name(hass):
"""Test async_setup with imported config."""
entry = MockConfigEntry(
domain=DOMAIN,
source=SOURCE_IMPORT,
data={CONF_NAME: BRIDGE_NAME, CONF_PORT: DEFAULT_PORT},
options={},
)
entry.add_to_hass(hass)
with patch(f"{PATH_HOMEKIT}.HomeKit") as mock_homekit:
mock_homekit.return_value = homekit = Mock()
type(homekit).async_start = AsyncMock()
assert await async_setup_component(
hass, "homekit", {"homekit": {CONF_NAME: BRIDGE_NAME, CONF_PORT: 12345}}
)
await hass.async_block_till_done()
mock_homekit.assert_any_call(
hass,
BRIDGE_NAME,
12345,
None,
ANY,
{},
DEFAULT_SAFE_MODE,
None,
None,
entry.entry_id,
)
assert mock_homekit().setup.called is True
# Test auto start enabled
mock_homekit.reset_mock()
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
await hass.async_block_till_done()
mock_homekit().async_start.assert_called()
async def test_raise_config_entry_not_ready(hass):
"""Test async_setup when the port is not available."""
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_NAME: BRIDGE_NAME, CONF_PORT: DEFAULT_PORT},
options={},
)
entry.add_to_hass(hass)
with patch(
"homeassistant.components.homekit.port_is_available", return_value=False,
):
assert not await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
def _write_data(path: str, data: Dict) -> None:
"""Write the data."""
if not os.path.isdir(os.path.dirname(path)):
os.makedirs(os.path.dirname(path))
json_util.save_json(path, data)

View file

@ -348,6 +348,7 @@ async def test_media_player_television_basic(hass, hk_driver, events, caplog):
await hass.async_block_till_done()
acc = TelevisionMediaPlayer(hass, hk_driver, "MediaPlayer", entity_id, 2, None)
await acc.run_handler()
await hass.async_block_till_done()
assert acc.chars_tv == []
assert acc.chars_speaker == []

View file

@ -28,6 +28,7 @@ async def test_switch_set_state(hass, hk_driver, events):
await hass.async_block_till_done()
acc = SecuritySystem(hass, hk_driver, "SecuritySystem", entity_id, 2, config)
await acc.run_handler()
await hass.async_block_till_done()
assert acc.aid == 2
assert acc.category == 11 # AlarmSystem

View file

@ -7,9 +7,10 @@ from homeassistant.components.homekit.const import (
CONF_FEATURE_LIST,
CONF_LINKED_BATTERY_SENSOR,
CONF_LOW_BATTERY_THRESHOLD,
DEFAULT_CONFIG_FLOW_PORT,
DOMAIN,
FEATURE_ON_OFF,
FEATURE_PLAY_PAUSE,
HOMEKIT_NOTIFY_ID,
HOMEKIT_PAIRING_QR,
HOMEKIT_PAIRING_QR_SECRET,
TYPE_FAUCET,
@ -25,6 +26,8 @@ from homeassistant.components.homekit.util import (
convert_to_float,
density_to_air_quality,
dismiss_setup_message,
find_next_available_port,
port_is_available,
show_setup_message,
temperature_to_homekit,
temperature_to_states,
@ -34,7 +37,7 @@ from homeassistant.components.homekit.util import (
from homeassistant.components.persistent_notification import (
ATTR_MESSAGE,
ATTR_NOTIFICATION_ID,
DOMAIN,
DOMAIN as PERSISTENT_NOTIFICATION_DOMAIN,
)
from homeassistant.const import (
ATTR_CODE,
@ -47,6 +50,8 @@ from homeassistant.const import (
)
from homeassistant.core import State
from .util import async_init_integration
from tests.common import async_mock_service
@ -199,27 +204,36 @@ async def test_show_setup_msg(hass):
"""Test show setup message as persistence notification."""
pincode = b"123-45-678"
call_create_notification = async_mock_service(hass, DOMAIN, "create")
entry = await async_init_integration(hass)
assert entry
await hass.async_add_executor_job(show_setup_message, hass, pincode, "X-HM://0")
call_create_notification = async_mock_service(
hass, PERSISTENT_NOTIFICATION_DOMAIN, "create"
)
await hass.async_add_executor_job(
show_setup_message, hass, entry.entry_id, "bridge_name", pincode, "X-HM://0"
)
await hass.async_block_till_done()
assert hass.data[HOMEKIT_PAIRING_QR_SECRET]
assert hass.data[HOMEKIT_PAIRING_QR]
assert hass.data[DOMAIN][entry.entry_id][HOMEKIT_PAIRING_QR_SECRET]
assert hass.data[DOMAIN][entry.entry_id][HOMEKIT_PAIRING_QR]
assert call_create_notification
assert call_create_notification[0].data[ATTR_NOTIFICATION_ID] == HOMEKIT_NOTIFY_ID
assert call_create_notification[0].data[ATTR_NOTIFICATION_ID] == entry.entry_id
assert pincode.decode() in call_create_notification[0].data[ATTR_MESSAGE]
async def test_dismiss_setup_msg(hass):
"""Test dismiss setup message."""
call_dismiss_notification = async_mock_service(hass, DOMAIN, "dismiss")
call_dismiss_notification = async_mock_service(
hass, PERSISTENT_NOTIFICATION_DOMAIN, "dismiss"
)
await hass.async_add_executor_job(dismiss_setup_message, hass)
await hass.async_add_executor_job(dismiss_setup_message, hass, "entry_id")
await hass.async_block_till_done()
assert call_dismiss_notification
assert call_dismiss_notification[0].data[ATTR_NOTIFICATION_ID] == HOMEKIT_NOTIFY_ID
assert call_dismiss_notification[0].data[ATTR_NOTIFICATION_ID] == "entry_id"
def test_homekit_speed_mapping():
@ -277,3 +291,13 @@ def test_speed_to_states():
assert speed_mapping.speed_to_states(66) == "low"
assert speed_mapping.speed_to_states(67) == "high"
assert speed_mapping.speed_to_states(100) == "high"
async def test_port_is_available(hass):
"""Test we can get an available port and it is actually available."""
next_port = await hass.async_add_executor_job(
find_next_available_port, DEFAULT_CONFIG_FLOW_PORT
)
assert next_port
assert await hass.async_add_executor_job(port_is_available, next_port)

View file

@ -0,0 +1,34 @@
"""Test util for the homekit integration."""
from asynctest import patch
from homeassistant.components.homekit.const import DOMAIN
from homeassistant.const import CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
PATH_HOMEKIT = "homeassistant.components.homekit"
async def async_init_integration(hass: HomeAssistant) -> MockConfigEntry:
"""Set up the homekit integration in Home Assistant."""
with patch(f"{PATH_HOMEKIT}.HomeKit.async_start"):
entry = MockConfigEntry(
domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345}
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return entry
async def async_init_entry(hass: HomeAssistant, entry: MockConfigEntry):
"""Set up the homekit integration in Home Assistant."""
with patch(f"{PATH_HOMEKIT}.HomeKit.async_start"):
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return entry