mirror of https://github.com/home-assistant/core
656 lines
20 KiB
Python
656 lines
20 KiB
Python
"""Collection of useful functions for the HomeKit component."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import ipaddress
|
|
import logging
|
|
import os
|
|
import re
|
|
import secrets
|
|
import socket
|
|
from typing import Any, cast
|
|
|
|
from pyhap.accessory import Accessory
|
|
import pyqrcode
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.components import (
|
|
binary_sensor,
|
|
media_player,
|
|
persistent_notification,
|
|
sensor,
|
|
)
|
|
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
|
|
from homeassistant.components.event import DOMAIN as EVENT_DOMAIN
|
|
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
|
|
from homeassistant.components.media_player import (
|
|
DOMAIN as MEDIA_PLAYER_DOMAIN,
|
|
MediaPlayerDeviceClass,
|
|
MediaPlayerEntityFeature,
|
|
)
|
|
from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN, RemoteEntityFeature
|
|
from homeassistant.const import (
|
|
ATTR_CODE,
|
|
ATTR_DEVICE_CLASS,
|
|
ATTR_SUPPORTED_FEATURES,
|
|
CONF_NAME,
|
|
CONF_PORT,
|
|
CONF_TYPE,
|
|
UnitOfTemperature,
|
|
)
|
|
from homeassistant.core import (
|
|
Event,
|
|
EventStateChangedData,
|
|
HomeAssistant,
|
|
State,
|
|
callback,
|
|
split_entity_id,
|
|
)
|
|
import homeassistant.helpers.config_validation as cv
|
|
from homeassistant.helpers.storage import STORAGE_DIR
|
|
from homeassistant.util.unit_conversion import TemperatureConverter
|
|
|
|
from .const import (
|
|
AUDIO_CODEC_COPY,
|
|
AUDIO_CODEC_OPUS,
|
|
CONF_AUDIO_CODEC,
|
|
CONF_AUDIO_MAP,
|
|
CONF_AUDIO_PACKET_SIZE,
|
|
CONF_FEATURE,
|
|
CONF_FEATURE_LIST,
|
|
CONF_LINKED_BATTERY_CHARGING_SENSOR,
|
|
CONF_LINKED_BATTERY_SENSOR,
|
|
CONF_LINKED_DOORBELL_SENSOR,
|
|
CONF_LINKED_HUMIDITY_SENSOR,
|
|
CONF_LINKED_MOTION_SENSOR,
|
|
CONF_LINKED_OBSTRUCTION_SENSOR,
|
|
CONF_LOW_BATTERY_THRESHOLD,
|
|
CONF_MAX_FPS,
|
|
CONF_MAX_HEIGHT,
|
|
CONF_MAX_WIDTH,
|
|
CONF_STREAM_ADDRESS,
|
|
CONF_STREAM_COUNT,
|
|
CONF_STREAM_SOURCE,
|
|
CONF_SUPPORT_AUDIO,
|
|
CONF_THRESHOLD_CO,
|
|
CONF_THRESHOLD_CO2,
|
|
CONF_VIDEO_CODEC,
|
|
CONF_VIDEO_MAP,
|
|
CONF_VIDEO_PACKET_SIZE,
|
|
DEFAULT_AUDIO_CODEC,
|
|
DEFAULT_AUDIO_MAP,
|
|
DEFAULT_AUDIO_PACKET_SIZE,
|
|
DEFAULT_LOW_BATTERY_THRESHOLD,
|
|
DEFAULT_MAX_FPS,
|
|
DEFAULT_MAX_HEIGHT,
|
|
DEFAULT_MAX_WIDTH,
|
|
DEFAULT_STREAM_COUNT,
|
|
DEFAULT_SUPPORT_AUDIO,
|
|
DEFAULT_VIDEO_CODEC,
|
|
DEFAULT_VIDEO_MAP,
|
|
DEFAULT_VIDEO_PACKET_SIZE,
|
|
DOMAIN,
|
|
FEATURE_ON_OFF,
|
|
FEATURE_PLAY_PAUSE,
|
|
FEATURE_PLAY_STOP,
|
|
FEATURE_TOGGLE_MUTE,
|
|
MAX_NAME_LENGTH,
|
|
TYPE_FAUCET,
|
|
TYPE_OUTLET,
|
|
TYPE_SHOWER,
|
|
TYPE_SPRINKLER,
|
|
TYPE_SWITCH,
|
|
TYPE_VALVE,
|
|
VIDEO_CODEC_COPY,
|
|
VIDEO_CODEC_H264_OMX,
|
|
VIDEO_CODEC_H264_V4L2M2M,
|
|
VIDEO_CODEC_LIBX264,
|
|
)
|
|
from .models import HomeKitConfigEntry
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
NUMBERS_ONLY_RE = re.compile(r"[^\d.]+")
|
|
VERSION_RE = re.compile(r"([0-9]+)(\.[0-9]+)?(\.[0-9]+)?")
|
|
INVALID_END_CHARS = "-_"
|
|
MAX_VERSION_PART = 2**32 - 1
|
|
|
|
|
|
MAX_PORT = 65535
|
|
VALID_VIDEO_CODECS = [
|
|
VIDEO_CODEC_LIBX264,
|
|
VIDEO_CODEC_H264_OMX,
|
|
VIDEO_CODEC_H264_V4L2M2M,
|
|
AUDIO_CODEC_COPY,
|
|
]
|
|
VALID_AUDIO_CODECS = [AUDIO_CODEC_OPUS, VIDEO_CODEC_COPY]
|
|
|
|
BASIC_INFO_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Optional(CONF_NAME): cv.string,
|
|
vol.Optional(CONF_LINKED_BATTERY_SENSOR): cv.entity_domain(sensor.DOMAIN),
|
|
vol.Optional(CONF_LINKED_BATTERY_CHARGING_SENSOR): cv.entity_domain(
|
|
binary_sensor.DOMAIN
|
|
),
|
|
vol.Optional(
|
|
CONF_LOW_BATTERY_THRESHOLD, default=DEFAULT_LOW_BATTERY_THRESHOLD
|
|
): cv.positive_int,
|
|
}
|
|
)
|
|
|
|
FEATURE_SCHEMA = BASIC_INFO_SCHEMA.extend(
|
|
{vol.Optional(CONF_FEATURE_LIST, default=None): cv.ensure_list}
|
|
)
|
|
|
|
CAMERA_SCHEMA = BASIC_INFO_SCHEMA.extend(
|
|
{
|
|
vol.Optional(CONF_STREAM_ADDRESS): vol.All(ipaddress.ip_address, cv.string),
|
|
vol.Optional(CONF_STREAM_SOURCE): cv.string,
|
|
vol.Optional(CONF_AUDIO_CODEC, default=DEFAULT_AUDIO_CODEC): vol.In(
|
|
VALID_AUDIO_CODECS
|
|
),
|
|
vol.Optional(CONF_SUPPORT_AUDIO, default=DEFAULT_SUPPORT_AUDIO): cv.boolean,
|
|
vol.Optional(CONF_MAX_WIDTH, default=DEFAULT_MAX_WIDTH): cv.positive_int,
|
|
vol.Optional(CONF_MAX_HEIGHT, default=DEFAULT_MAX_HEIGHT): cv.positive_int,
|
|
vol.Optional(CONF_MAX_FPS, default=DEFAULT_MAX_FPS): cv.positive_int,
|
|
vol.Optional(CONF_AUDIO_MAP, default=DEFAULT_AUDIO_MAP): cv.string,
|
|
vol.Optional(CONF_VIDEO_MAP, default=DEFAULT_VIDEO_MAP): cv.string,
|
|
vol.Optional(CONF_STREAM_COUNT, default=DEFAULT_STREAM_COUNT): vol.All(
|
|
vol.Coerce(int), vol.Range(min=1, max=10)
|
|
),
|
|
vol.Optional(CONF_VIDEO_CODEC, default=DEFAULT_VIDEO_CODEC): vol.In(
|
|
VALID_VIDEO_CODECS
|
|
),
|
|
vol.Optional(
|
|
CONF_AUDIO_PACKET_SIZE, default=DEFAULT_AUDIO_PACKET_SIZE
|
|
): cv.positive_int,
|
|
vol.Optional(
|
|
CONF_VIDEO_PACKET_SIZE, default=DEFAULT_VIDEO_PACKET_SIZE
|
|
): cv.positive_int,
|
|
vol.Optional(CONF_LINKED_MOTION_SENSOR): cv.entity_domain(
|
|
[binary_sensor.DOMAIN, EVENT_DOMAIN]
|
|
),
|
|
vol.Optional(CONF_LINKED_DOORBELL_SENSOR): cv.entity_domain(
|
|
[binary_sensor.DOMAIN, EVENT_DOMAIN]
|
|
),
|
|
}
|
|
)
|
|
|
|
HUMIDIFIER_SCHEMA = BASIC_INFO_SCHEMA.extend(
|
|
{vol.Optional(CONF_LINKED_HUMIDITY_SENSOR): cv.entity_domain(sensor.DOMAIN)}
|
|
)
|
|
|
|
|
|
COVER_SCHEMA = BASIC_INFO_SCHEMA.extend(
|
|
{
|
|
vol.Optional(CONF_LINKED_OBSTRUCTION_SENSOR): cv.entity_domain(
|
|
binary_sensor.DOMAIN
|
|
)
|
|
}
|
|
)
|
|
|
|
CODE_SCHEMA = BASIC_INFO_SCHEMA.extend(
|
|
{vol.Optional(ATTR_CODE, default=None): vol.Any(None, cv.string)}
|
|
)
|
|
|
|
MEDIA_PLAYER_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Required(CONF_FEATURE): vol.All(
|
|
cv.string,
|
|
vol.In(
|
|
(
|
|
FEATURE_ON_OFF,
|
|
FEATURE_PLAY_PAUSE,
|
|
FEATURE_PLAY_STOP,
|
|
FEATURE_TOGGLE_MUTE,
|
|
)
|
|
),
|
|
)
|
|
}
|
|
)
|
|
|
|
SWITCH_TYPE_SCHEMA = BASIC_INFO_SCHEMA.extend(
|
|
{
|
|
vol.Optional(CONF_TYPE, default=TYPE_SWITCH): vol.All(
|
|
cv.string,
|
|
vol.In(
|
|
(
|
|
TYPE_FAUCET,
|
|
TYPE_OUTLET,
|
|
TYPE_SHOWER,
|
|
TYPE_SPRINKLER,
|
|
TYPE_SWITCH,
|
|
TYPE_VALVE,
|
|
)
|
|
),
|
|
)
|
|
}
|
|
)
|
|
|
|
SENSOR_SCHEMA = BASIC_INFO_SCHEMA.extend(
|
|
{
|
|
vol.Optional(CONF_THRESHOLD_CO): vol.Any(None, cv.positive_int),
|
|
vol.Optional(CONF_THRESHOLD_CO2): vol.Any(None, cv.positive_int),
|
|
}
|
|
)
|
|
|
|
|
|
HOMEKIT_CHAR_TRANSLATIONS = {
|
|
0: " ", # nul
|
|
10: " ", # nl
|
|
13: " ", # cr
|
|
33: "-", # !
|
|
34: " ", # "
|
|
36: "-", # $
|
|
37: "-", # %
|
|
40: "-", # (
|
|
41: "-", # )
|
|
42: "-", # *
|
|
43: "-", # +
|
|
47: "-", # /
|
|
58: "-", # :
|
|
59: "-", # ;
|
|
60: "-", # <
|
|
61: "-", # =
|
|
62: "-", # >
|
|
63: "-", # ?
|
|
64: "-", # @
|
|
91: "-", # [
|
|
92: "-", # \
|
|
93: "-", # ]
|
|
94: "-", # ^
|
|
95: " ", # _
|
|
96: "-", # `
|
|
123: "-", # {
|
|
124: "-", # |
|
|
125: "-", # }
|
|
126: "-", # ~
|
|
127: "-", # del
|
|
}
|
|
|
|
|
|
def validate_entity_config(values: dict) -> dict[str, dict]:
|
|
"""Validate config entry for CONF_ENTITY."""
|
|
if not isinstance(values, dict):
|
|
raise vol.Invalid("expected a dictionary")
|
|
|
|
entities = {}
|
|
for entity_id, config in values.items():
|
|
entity = cv.entity_id(entity_id)
|
|
domain, _ = split_entity_id(entity)
|
|
|
|
if not isinstance(config, dict):
|
|
raise vol.Invalid(f"The configuration for {entity} must be a dictionary.")
|
|
|
|
if domain in ("alarm_control_panel", "lock"):
|
|
config = CODE_SCHEMA(config)
|
|
|
|
elif domain == media_player.const.DOMAIN:
|
|
config = FEATURE_SCHEMA(config)
|
|
feature_list = {}
|
|
for feature in config[CONF_FEATURE_LIST]:
|
|
params = MEDIA_PLAYER_SCHEMA(feature)
|
|
key = params.pop(CONF_FEATURE)
|
|
if key in feature_list:
|
|
raise vol.Invalid(f"A feature can be added only once for {entity}")
|
|
feature_list[key] = params
|
|
config[CONF_FEATURE_LIST] = feature_list
|
|
|
|
elif domain == "camera":
|
|
config = CAMERA_SCHEMA(config)
|
|
|
|
elif domain == "switch":
|
|
config = SWITCH_TYPE_SCHEMA(config)
|
|
|
|
elif domain == "humidifier":
|
|
config = HUMIDIFIER_SCHEMA(config)
|
|
|
|
elif domain == "cover":
|
|
config = COVER_SCHEMA(config)
|
|
|
|
elif domain == "sensor":
|
|
config = SENSOR_SCHEMA(config)
|
|
|
|
else:
|
|
config = BASIC_INFO_SCHEMA(config)
|
|
|
|
entities[entity] = config
|
|
return entities
|
|
|
|
|
|
def get_media_player_features(state: State) -> list[str]:
|
|
"""Determine features for media players."""
|
|
features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
|
|
|
supported_modes = []
|
|
if features & (
|
|
MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF
|
|
):
|
|
supported_modes.append(FEATURE_ON_OFF)
|
|
if features & (MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PAUSE):
|
|
supported_modes.append(FEATURE_PLAY_PAUSE)
|
|
if features & (MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.STOP):
|
|
supported_modes.append(FEATURE_PLAY_STOP)
|
|
if features & MediaPlayerEntityFeature.VOLUME_MUTE:
|
|
supported_modes.append(FEATURE_TOGGLE_MUTE)
|
|
return supported_modes
|
|
|
|
|
|
def validate_media_player_features(state: State, feature_list: str) -> bool:
|
|
"""Validate features for media players."""
|
|
if not (supported_modes := get_media_player_features(state)):
|
|
_LOGGER.error("%s does not support any media_player features", state.entity_id)
|
|
return False
|
|
|
|
if not feature_list:
|
|
# Auto detected
|
|
return True
|
|
|
|
error_list = [feature for feature in feature_list if feature not in supported_modes]
|
|
|
|
if error_list:
|
|
_LOGGER.error(
|
|
"%s does not support media_player features: %s", state.entity_id, error_list
|
|
)
|
|
return False
|
|
return True
|
|
|
|
|
|
def async_show_setup_message(
|
|
hass: HomeAssistant, entry_id: str, bridge_name: str, pincode: bytes, uri: str
|
|
) -> None:
|
|
"""Display persistent notification with setup information."""
|
|
pin = pincode.decode()
|
|
_LOGGER.info("Pincode: %s", pin)
|
|
|
|
buffer = io.BytesIO()
|
|
url = pyqrcode.create(uri)
|
|
url.svg(buffer, scale=5, module_color="#000", background="#FFF")
|
|
pairing_secret = secrets.token_hex(32)
|
|
|
|
entry = cast(HomeKitConfigEntry, hass.config_entries.async_get_entry(entry_id))
|
|
entry_data = entry.runtime_data
|
|
|
|
entry_data.pairing_qr = buffer.getvalue()
|
|
entry_data.pairing_qr_secret = pairing_secret
|
|
|
|
message = (
|
|
f"To set up {bridge_name} in the Home App, "
|
|
"scan the QR code or enter the following code:\n"
|
|
f"### {pin}\n"
|
|
f"![image](/api/homekit/pairingqr?{entry_id}-{pairing_secret})"
|
|
)
|
|
persistent_notification.async_create(hass, message, "HomeKit Pairing", entry_id)
|
|
|
|
|
|
def async_dismiss_setup_message(hass: HomeAssistant, entry_id: str) -> None:
|
|
"""Dismiss persistent notification and remove QR code."""
|
|
persistent_notification.async_dismiss(hass, entry_id)
|
|
|
|
|
|
def convert_to_float(state: Any) -> float | None:
|
|
"""Return float of state, catch errors."""
|
|
try:
|
|
return float(state)
|
|
except (ValueError, TypeError):
|
|
return None
|
|
|
|
|
|
def coerce_int(state: str) -> int:
|
|
"""Return int."""
|
|
try:
|
|
return int(state)
|
|
except (ValueError, TypeError):
|
|
return 0
|
|
|
|
|
|
def cleanup_name_for_homekit(name: str | None) -> str:
|
|
"""Ensure the name of the device will not crash homekit."""
|
|
#
|
|
# This is not a security measure.
|
|
#
|
|
# UNICODE_EMOJI is also not allowed but that
|
|
# likely isn't a problem
|
|
if name is None:
|
|
return "None" # None crashes apple watches
|
|
return (
|
|
name.translate(HOMEKIT_CHAR_TRANSLATIONS)
|
|
.lstrip(INVALID_END_CHARS)[:MAX_NAME_LENGTH]
|
|
.rstrip(INVALID_END_CHARS)
|
|
)
|
|
|
|
|
|
def temperature_to_homekit(temperature: float, unit: str) -> float:
|
|
"""Convert temperature to Celsius for HomeKit."""
|
|
return round(
|
|
TemperatureConverter.convert(temperature, unit, UnitOfTemperature.CELSIUS), 1
|
|
)
|
|
|
|
|
|
def temperature_to_states(temperature: float, unit: str) -> float:
|
|
"""Convert temperature back from Celsius to Home Assistant unit."""
|
|
return (
|
|
round(
|
|
TemperatureConverter.convert(temperature, UnitOfTemperature.CELSIUS, unit)
|
|
* 2
|
|
)
|
|
/ 2
|
|
)
|
|
|
|
|
|
def density_to_air_quality(density: float) -> int:
|
|
"""Map PM2.5 µg/m3 density to HomeKit AirQuality level."""
|
|
if density <= 9: # US AQI 0-50 (HomeKit: Excellent)
|
|
return 1
|
|
if density <= 35.4: # US AQI 51-100 (HomeKit: Good)
|
|
return 2
|
|
if density <= 55.4: # US AQI 101-150 (HomeKit: Fair)
|
|
return 3
|
|
if density <= 125.4: # US AQI 151-200 (HomeKit: Inferior)
|
|
return 4
|
|
return 5 # US AQI 201+ (HomeKit: Poor)
|
|
|
|
|
|
def density_to_air_quality_pm10(density: float) -> int:
|
|
"""Map PM10 µg/m3 density to HomeKit AirQuality level."""
|
|
if density <= 54: # US AQI 0-50 (HomeKit: Excellent)
|
|
return 1
|
|
if density <= 154: # US AQI 51-100 (HomeKit: Good)
|
|
return 2
|
|
if density <= 254: # US AQI 101-150 (HomeKit: Fair)
|
|
return 3
|
|
if density <= 354: # US AQI 151-200 (HomeKit: Inferior)
|
|
return 4
|
|
return 5 # US AQI 201+ (HomeKit: Poor)
|
|
|
|
|
|
def density_to_air_quality_nitrogen_dioxide(density: float) -> int:
|
|
"""Map nitrogen dioxide µg/m3 to HomeKit AirQuality level."""
|
|
if density <= 30:
|
|
return 1
|
|
if density <= 60:
|
|
return 2
|
|
if density <= 80:
|
|
return 3
|
|
if density <= 90:
|
|
return 4
|
|
return 5
|
|
|
|
|
|
def density_to_air_quality_voc(density: float) -> int:
|
|
"""Map VOCs µg/m3 to HomeKit AirQuality level.
|
|
|
|
The VOC mappings use the IAQ guidelines for Europe released by the WHO (World Health Organization).
|
|
Referenced from Sensirion_Gas_Sensors_SGP3x_TVOC_Concept.pdf
|
|
https://github.com/paulvha/svm30/blob/master/extras/Sensirion_Gas_Sensors_SGP3x_TVOC_Concept.pdf
|
|
"""
|
|
if density <= 250: # WHO IAQ 1 (HomeKit: Excellent)
|
|
return 1
|
|
if density <= 500: # WHO IAQ 2 (HomeKit: Good)
|
|
return 2
|
|
if density <= 1000: # WHO IAQ 3 (HomeKit: Fair)
|
|
return 3
|
|
if density <= 3000: # WHO IAQ 4 (HomeKit: Inferior)
|
|
return 4
|
|
return 5 # WHOA IAQ 5 (HomeKit: Poor)
|
|
|
|
|
|
def get_persist_filename_for_entry_id(entry_id: str) -> 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) -> str:
|
|
"""Determine the filename of homekit aid storage file."""
|
|
return f"{DOMAIN}.{entry_id}.aids"
|
|
|
|
|
|
def get_iid_storage_filename_for_entry_id(entry_id: str) -> str:
|
|
"""Determine the filename of homekit iid storage file."""
|
|
return f"{DOMAIN}.{entry_id}.iids"
|
|
|
|
|
|
def get_persist_fullpath_for_entry_id(hass: HomeAssistant, entry_id: str) -> 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) -> 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 get_iid_storage_fullpath_for_entry_id(hass: HomeAssistant, entry_id: str) -> str:
|
|
"""Determine the path to the homekit iid storage file."""
|
|
return hass.config.path(
|
|
STORAGE_DIR, get_iid_storage_filename_for_entry_id(entry_id)
|
|
)
|
|
|
|
|
|
def _format_version_part(version_part: str) -> str:
|
|
return str(max(0, min(MAX_VERSION_PART, coerce_int(version_part))))
|
|
|
|
|
|
def format_version(version: str) -> str | None:
|
|
"""Extract the version string in a format homekit can consume."""
|
|
split_ver = str(version).replace("-", ".").replace(" ", ".")
|
|
num_only = NUMBERS_ONLY_RE.sub("", split_ver)
|
|
if (match := VERSION_RE.search(num_only)) is None:
|
|
return None
|
|
value = ".".join(map(_format_version_part, match.group(0).split(".")))
|
|
return None if _is_zero_but_true(value) else value
|
|
|
|
|
|
def _is_zero_but_true(value: Any) -> bool:
|
|
"""Zero but true values can crash apple watches."""
|
|
return convert_to_float(value) == 0
|
|
|
|
|
|
def remove_state_files_for_entry_id(hass: HomeAssistant, entry_id: str) -> None:
|
|
"""Remove the state files from disk."""
|
|
for path in (
|
|
get_persist_fullpath_for_entry_id(hass, entry_id),
|
|
get_aid_storage_fullpath_for_entry_id(hass, entry_id),
|
|
get_iid_storage_fullpath_for_entry_id(hass, entry_id),
|
|
):
|
|
if os.path.exists(path):
|
|
os.unlink(path)
|
|
|
|
|
|
def _get_test_socket() -> socket.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
|
|
|
|
|
|
@callback
|
|
def async_port_is_available(port: int) -> bool:
|
|
"""Check to see if a port is available."""
|
|
try:
|
|
_get_test_socket().bind(("", port))
|
|
except OSError:
|
|
return False
|
|
return True
|
|
|
|
|
|
@callback
|
|
def async_find_next_available_port(hass: HomeAssistant, start_port: int) -> int:
|
|
"""Find the next available port not assigned to a config entry."""
|
|
exclude_ports = {
|
|
entry.data[CONF_PORT]
|
|
for entry in hass.config_entries.async_entries(DOMAIN)
|
|
if CONF_PORT in entry.data
|
|
}
|
|
return _async_find_next_available_port(start_port, exclude_ports)
|
|
|
|
|
|
@callback
|
|
def _async_find_next_available_port(start_port: int, exclude_ports: set) -> int:
|
|
"""Find the next available port starting with the given port."""
|
|
test_socket = _get_test_socket()
|
|
for port in range(start_port, MAX_PORT + 1):
|
|
if port in exclude_ports:
|
|
continue
|
|
try:
|
|
test_socket.bind(("", port))
|
|
except OSError:
|
|
if port == MAX_PORT:
|
|
raise
|
|
continue
|
|
else:
|
|
return port
|
|
raise RuntimeError("unreachable")
|
|
|
|
|
|
def pid_is_alive(pid: int) -> bool:
|
|
"""Check to see if a process is alive."""
|
|
try:
|
|
os.kill(pid, 0)
|
|
except OSError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def accessory_friendly_name(hass_name: str, accessory: Accessory) -> str:
|
|
"""Return the combined name for the accessory.
|
|
|
|
The mDNS name and the Home Assistant config entry
|
|
name are usually different which means they need to
|
|
see both to identify the accessory.
|
|
"""
|
|
accessory_mdns_name = cast(str, accessory.display_name)
|
|
if hass_name.casefold().startswith(accessory_mdns_name.casefold()):
|
|
return hass_name
|
|
if accessory_mdns_name.casefold().startswith(hass_name.casefold()):
|
|
return accessory_mdns_name
|
|
return f"{hass_name} ({accessory_mdns_name})"
|
|
|
|
|
|
def state_needs_accessory_mode(state: State) -> bool:
|
|
"""Return if the entity represented by the state must be paired in accessory mode."""
|
|
if state.domain in (CAMERA_DOMAIN, LOCK_DOMAIN):
|
|
return True
|
|
|
|
return (
|
|
state.domain == MEDIA_PLAYER_DOMAIN
|
|
and state.attributes.get(ATTR_DEVICE_CLASS)
|
|
in (MediaPlayerDeviceClass.TV, MediaPlayerDeviceClass.RECEIVER)
|
|
or state.domain == REMOTE_DOMAIN
|
|
and state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
|
& RemoteEntityFeature.ACTIVITY
|
|
)
|
|
|
|
|
|
def state_changed_event_is_same_state(event: Event[EventStateChangedData]) -> bool:
|
|
"""Check if a state changed event is the same state."""
|
|
event_data = event.data
|
|
old_state = event_data["old_state"]
|
|
new_state = event_data["new_state"]
|
|
return bool(new_state and old_state and new_state.state == old_state.state)
|