* Add homekit configuration option to bind to default interface
Homekit can fail to be discoverable because the
zeroconf default is to bind to all interfaces
(InterfaceChoice.All). This does not work
on some systems and (InterfaceChoice.Default) which
binds to 0.0.0.0 is needed for homekit to zeroconf
to function.
A new option is available for homekit
zeroconf_default_interface: true
* Update tests
* Update homeassistant/components/homekit/__init__.py
Co-Authored-By: springstan <46536646+springstan@users.noreply.github.com>
* Update homeassistant/components/homekit/__init__.py
Co-Authored-By: springstan <46536646+springstan@users.noreply.github.com>
* Review items
* has a default
* Revert "has a default"
This reverts commit 24ecf0920f
.
Breaks the tests
Co-authored-by: springstan <46536646+springstan@users.noreply.github.com>
417 lines
12 KiB
Python
417 lines
12 KiB
Python
"""Support for Apple HomeKit."""
|
|
import ipaddress
|
|
import logging
|
|
from zlib import adler32
|
|
|
|
import voluptuous as vol
|
|
from zeroconf import InterfaceChoice
|
|
|
|
from homeassistant.components import cover
|
|
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_DEVICE_CLASS,
|
|
ATTR_ENTITY_ID,
|
|
ATTR_SUPPORTED_FEATURES,
|
|
ATTR_UNIT_OF_MEASUREMENT,
|
|
CONF_IP_ADDRESS,
|
|
CONF_NAME,
|
|
CONF_PORT,
|
|
CONF_TYPE,
|
|
DEVICE_CLASS_HUMIDITY,
|
|
DEVICE_CLASS_ILLUMINANCE,
|
|
DEVICE_CLASS_TEMPERATURE,
|
|
EVENT_HOMEASSISTANT_START,
|
|
EVENT_HOMEASSISTANT_STOP,
|
|
TEMP_CELSIUS,
|
|
TEMP_FAHRENHEIT,
|
|
UNIT_PERCENTAGE,
|
|
)
|
|
import homeassistant.helpers.config_validation as cv
|
|
from homeassistant.helpers.entityfilter import FILTER_SCHEMA
|
|
from homeassistant.util import get_local_ip
|
|
from homeassistant.util.decorator import Registry
|
|
|
|
from .const import (
|
|
BRIDGE_NAME,
|
|
CONF_ADVERTISE_IP,
|
|
CONF_AUTO_START,
|
|
CONF_ENTITY_CONFIG,
|
|
CONF_FEATURE_LIST,
|
|
CONF_FILTER,
|
|
CONF_SAFE_MODE,
|
|
CONF_ZEROCONF_DEFAULT_INTERFACE,
|
|
DEFAULT_AUTO_START,
|
|
DEFAULT_PORT,
|
|
DEFAULT_SAFE_MODE,
|
|
DEFAULT_ZEROCONF_DEFAULT_INTERFACE,
|
|
DEVICE_CLASS_CO,
|
|
DEVICE_CLASS_CO2,
|
|
DEVICE_CLASS_PM25,
|
|
DOMAIN,
|
|
HOMEKIT_FILE,
|
|
SERVICE_HOMEKIT_RESET_ACCESSORY,
|
|
SERVICE_HOMEKIT_START,
|
|
TYPE_FAUCET,
|
|
TYPE_OUTLET,
|
|
TYPE_SHOWER,
|
|
TYPE_SPRINKLER,
|
|
TYPE_SWITCH,
|
|
TYPE_VALVE,
|
|
)
|
|
from .util import (
|
|
show_setup_message,
|
|
validate_entity_config,
|
|
validate_media_player_features,
|
|
)
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
MAX_DEVICES = 100
|
|
TYPES = Registry()
|
|
|
|
# #### Driver Status ####
|
|
STATUS_READY = 0
|
|
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(
|
|
{
|
|
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,
|
|
}
|
|
)
|
|
},
|
|
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")
|
|
|
|
conf = config[DOMAIN]
|
|
name = conf[CONF_NAME]
|
|
port = conf[CONF_PORT]
|
|
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]
|
|
interface_choice = (
|
|
InterfaceChoice.Default if config.get(CONF_ZEROCONF_DEFAULT_INTERFACE) else None
|
|
)
|
|
|
|
homekit = HomeKit(
|
|
hass,
|
|
name,
|
|
port,
|
|
ip_address,
|
|
entity_filter,
|
|
entity_config,
|
|
safe_mode,
|
|
advertise_ip,
|
|
interface_choice,
|
|
)
|
|
await hass.async_add_executor_job(homekit.setup)
|
|
|
|
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
|
|
|
|
entity_ids = service.data.get("entity_id")
|
|
homekit.reset_accessories(entity_ids)
|
|
|
|
hass.services.async_register(
|
|
DOMAIN,
|
|
SERVICE_HOMEKIT_RESET_ACCESSORY,
|
|
handle_homekit_reset_accessory,
|
|
schema=RESET_ACCESSORY_SERVICE_SCHEMA,
|
|
)
|
|
|
|
if auto_start:
|
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, homekit.start)
|
|
return True
|
|
|
|
def 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
|
|
homekit.start()
|
|
|
|
hass.services.async_register(
|
|
DOMAIN, SERVICE_HOMEKIT_START, 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 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)
|
|
|
|
|
|
def generate_aid(entity_id):
|
|
"""Generate accessory aid with zlib adler32."""
|
|
aid = adler32(entity_id.encode("utf-8"))
|
|
if aid in (0, 1):
|
|
return None
|
|
return aid
|
|
|
|
|
|
class HomeKit:
|
|
"""Class to handle all actions between HomeKit and Home Assistant."""
|
|
|
|
def __init__(
|
|
self,
|
|
hass,
|
|
name,
|
|
port,
|
|
ip_address,
|
|
entity_filter,
|
|
entity_config,
|
|
safe_mode,
|
|
advertise_ip=None,
|
|
interface_choice=None,
|
|
):
|
|
"""Initialize a HomeKit object."""
|
|
self.hass = hass
|
|
self._name = name
|
|
self._port = port
|
|
self._ip_address = ip_address
|
|
self._filter = entity_filter
|
|
self._config = entity_config
|
|
self._safe_mode = safe_mode
|
|
self._advertise_ip = advertise_ip
|
|
self._interface_choice = interface_choice
|
|
self.status = STATUS_READY
|
|
|
|
self.bridge = None
|
|
self.driver = None
|
|
|
|
def setup(self):
|
|
"""Set up bridge and accessory driver."""
|
|
# pylint: disable=import-outside-toplevel
|
|
from .accessories import HomeBridge, HomeDriver
|
|
|
|
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop)
|
|
|
|
ip_addr = self._ip_address or get_local_ip()
|
|
path = self.hass.config.path(HOMEKIT_FILE)
|
|
self.driver = HomeDriver(
|
|
self.hass,
|
|
address=ip_addr,
|
|
port=self._port,
|
|
persist_file=path,
|
|
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")
|
|
self.driver.safe_mode = True
|
|
|
|
def reset_accessories(self, entity_ids):
|
|
"""Reset the accessory to load the latest configuration."""
|
|
removed = []
|
|
for entity_id in entity_ids:
|
|
aid = generate_aid(entity_id)
|
|
if aid not in self.bridge.accessories:
|
|
_LOGGER.warning(
|
|
"Could not reset accessory. entity_id not found %s", entity_id
|
|
)
|
|
continue
|
|
acc = self.remove_bridge_accessory(aid)
|
|
removed.append(acc)
|
|
self.driver.config_changed()
|
|
|
|
for acc in removed:
|
|
self.bridge.add_accessory(acc)
|
|
self.driver.config_changed()
|
|
|
|
def add_bridge_accessory(self, state):
|
|
"""Try adding accessory to bridge if configured beforehand."""
|
|
if not state or not self._filter(state.entity_id):
|
|
return
|
|
aid = generate_aid(state.entity_id)
|
|
conf = self._config.pop(state.entity_id, {})
|
|
acc = get_accessory(self.hass, self.driver, state, aid, conf)
|
|
if acc is not None:
|
|
self.bridge.add_accessory(acc)
|
|
|
|
def remove_bridge_accessory(self, aid):
|
|
"""Try adding accessory to bridge if configured beforehand."""
|
|
acc = None
|
|
if aid in self.bridge.accessories:
|
|
acc = self.bridge.accessories.pop(aid)
|
|
return acc
|
|
|
|
def start(self, *args):
|
|
"""Start the accessory driver."""
|
|
if self.status != STATUS_READY:
|
|
return
|
|
self.status = STATUS_WAIT
|
|
|
|
from . import ( # noqa: F401 pylint: disable=unused-import, import-outside-toplevel
|
|
type_covers,
|
|
type_fans,
|
|
type_lights,
|
|
type_locks,
|
|
type_media_players,
|
|
type_security_systems,
|
|
type_sensors,
|
|
type_switches,
|
|
type_thermostats,
|
|
)
|
|
|
|
for state in self.hass.states.all():
|
|
self.add_bridge_accessory(state)
|
|
self.driver.add_accessory(self.bridge)
|
|
|
|
if not self.driver.state.paired:
|
|
show_setup_message(self.hass, self.driver.state.pincode)
|
|
|
|
if len(self.bridge.accessories) > MAX_DEVICES:
|
|
_LOGGER.warning(
|
|
"You have exceeded the device limit, which might "
|
|
"cause issues. Consider using the filter option."
|
|
)
|
|
|
|
_LOGGER.debug("Driver start")
|
|
self.hass.add_job(self.driver.start)
|
|
self.status = STATUS_RUNNING
|
|
|
|
def stop(self, *args):
|
|
"""Stop the accessory driver."""
|
|
if self.status != STATUS_RUNNING:
|
|
return
|
|
self.status = STATUS_STOPPED
|
|
|
|
_LOGGER.debug("Driver stop")
|
|
self.hass.add_job(self.driver.stop)
|