mirror of
https://github.com/home-assistant/core.git
synced 2026-01-05 23:35:24 +01:00
Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be39a3cee0 | ||
|
|
826423d8ed | ||
|
|
99c0ef9ab8 | ||
|
|
4041dd61c5 | ||
|
|
fe36ff5fa9 | ||
|
|
9acd1471a0 | ||
|
|
d1a6eb55b1 | ||
|
|
112732c673 | ||
|
|
45638c78ae | ||
|
|
d5e80ed2a5 | ||
|
|
9b3c3232be | ||
|
|
feea5af3d0 | ||
|
|
be5d816fbe | ||
|
|
911de94345 | ||
|
|
53fa6c138a | ||
|
|
85e6b3950c | ||
|
|
6fd4355314 | ||
|
|
050600375d | ||
|
|
1114877062 | ||
|
|
454cb44ee8 | ||
|
|
9636435ff2 | ||
|
|
f85781dc51 | ||
|
|
49edaf2f68 | ||
|
|
2be9798fb8 | ||
|
|
3bf0a64e21 | ||
|
|
23e9aa6ad2 | ||
|
|
a8e1f57058 | ||
|
|
caee432901 | ||
|
|
df5c09e483 | ||
|
|
38eb007f63 | ||
|
|
7dd9bfa92f | ||
|
|
54b7f13a54 | ||
|
|
774f2b9b82 | ||
|
|
bc14385317 | ||
|
|
9352ed1286 | ||
|
|
f7fd781a27 | ||
|
|
b1153720c0 | ||
|
|
27d275e6f7 | ||
|
|
1191c095f8 | ||
|
|
b86d115764 | ||
|
|
479a230da7 | ||
|
|
7aecd69e3b | ||
|
|
69587dd50a | ||
|
|
6d8bd6af4d | ||
|
|
31b19e09b5 | ||
|
|
a42ba9e10a | ||
|
|
a285478cf8 | ||
|
|
c95d55e6d6 | ||
|
|
c0860931b3 | ||
|
|
898af3e04c | ||
|
|
3de341099f | ||
|
|
7fb76c68bb | ||
|
|
7de5e070fb | ||
|
|
1bfb01e0d1 | ||
|
|
ca664ab5a5 | ||
|
|
5a39e63d25 | ||
|
|
c608cafebd | ||
|
|
07e70c81b0 | ||
|
|
cad397d6a7 | ||
|
|
c22af2c82a | ||
|
|
f5b6d93706 | ||
|
|
28b3edf6b2 | ||
|
|
737c502e94 | ||
|
|
a1abcbc7eb | ||
|
|
b09ab2dafb | ||
|
|
4e6fc3615b | ||
|
|
580c998552 | ||
|
|
97ba17d1ec | ||
|
|
8d7cdceb75 | ||
|
|
dfa1c3abb3 | ||
|
|
c807c57a9b | ||
|
|
f4ec7e0902 | ||
|
|
814c96834e | ||
|
|
87492e6b3e | ||
|
|
4aaafb0a99 | ||
|
|
2aecdd3d6d | ||
|
|
76336df91a | ||
|
|
88e0380aa2 | ||
|
|
10a2c97cab | ||
|
|
92c3c08a10 | ||
|
|
4f8b69d985 | ||
|
|
f5aaf44e50 | ||
|
|
f3c85b3459 | ||
|
|
d7348718e0 | ||
|
|
2a6d5ea7bd | ||
|
|
5ae83e3c40 | ||
|
|
5657a9e6bd | ||
|
|
b290e62170 | ||
|
|
679ddbd1be | ||
|
|
b54652a849 | ||
|
|
24013ad94c | ||
|
|
9849b86a84 | ||
|
|
8bbf55c85d | ||
|
|
0541c708da | ||
|
|
ba40d62081 | ||
|
|
73765a1f29 | ||
|
|
b5b945ab4d | ||
|
|
d361643500 | ||
|
|
eff7a12557 | ||
|
|
63f8e9ee08 | ||
|
|
ee0bdaa2de | ||
|
|
48d9e9a83c |
@@ -88,8 +88,6 @@ class AbodeCamera(AbodeDevice, Camera):
|
||||
self, width: int | None = None, height: int | None = None
|
||||
) -> bytes | None:
|
||||
"""Get a camera image."""
|
||||
if not self.capture():
|
||||
return None
|
||||
self.refresh_image()
|
||||
|
||||
if self._response:
|
||||
|
||||
@@ -52,6 +52,7 @@ from .const import (
|
||||
DATA_AMCREST,
|
||||
DEVICES,
|
||||
DOMAIN,
|
||||
RESOLUTION_LIST,
|
||||
SERVICE_EVENT,
|
||||
SERVICE_UPDATE,
|
||||
)
|
||||
@@ -76,8 +77,6 @@ RECHECK_INTERVAL = timedelta(minutes=1)
|
||||
NOTIFICATION_ID = "amcrest_notification"
|
||||
NOTIFICATION_TITLE = "Amcrest Camera Setup"
|
||||
|
||||
RESOLUTION_LIST = {"high": 0, "low": 1}
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=10)
|
||||
|
||||
AUTHENTICATION_LIST = {"basic": "basic"}
|
||||
|
||||
@@ -35,6 +35,7 @@ from .const import (
|
||||
DATA_AMCREST,
|
||||
DEVICES,
|
||||
DOMAIN,
|
||||
RESOLUTION_TO_STREAM,
|
||||
SERVICE_UPDATE,
|
||||
SNAPSHOT_TIMEOUT,
|
||||
)
|
||||
@@ -533,13 +534,14 @@ class AmcrestCam(Camera):
|
||||
return
|
||||
|
||||
async def _async_get_video(self) -> bool:
|
||||
stream = {0: "Main", 1: "Extra"}
|
||||
return await self._api.async_is_video_enabled(
|
||||
channel=0, stream=stream[self._resolution]
|
||||
channel=0, stream=RESOLUTION_TO_STREAM[self._resolution]
|
||||
)
|
||||
|
||||
async def _async_set_video(self, enable: bool) -> None:
|
||||
await self._api.async_set_video_enabled(enable, channel=0)
|
||||
await self._api.async_set_video_enabled(
|
||||
enable, channel=0, stream=RESOLUTION_TO_STREAM[self._resolution]
|
||||
)
|
||||
|
||||
async def _async_enable_video(self, enable: bool) -> None:
|
||||
"""Enable or disable camera video stream."""
|
||||
@@ -548,7 +550,7 @@ class AmcrestCam(Camera):
|
||||
# recording on if video stream is being turned off.
|
||||
if self.is_recording and not enable:
|
||||
await self._async_enable_recording(False)
|
||||
await self._async_change_setting(enable, "video", "is_streaming")
|
||||
await self._async_change_setting(enable, "video", "_attr_is_streaming")
|
||||
if self._control_light:
|
||||
await self._async_change_light()
|
||||
|
||||
@@ -585,10 +587,14 @@ class AmcrestCam(Camera):
|
||||
)
|
||||
|
||||
async def _async_get_audio(self) -> bool:
|
||||
return await self._api.async_audio_enabled
|
||||
return await self._api.async_is_audio_enabled(
|
||||
channel=0, stream=RESOLUTION_TO_STREAM[self._resolution]
|
||||
)
|
||||
|
||||
async def _async_set_audio(self, enable: bool) -> None:
|
||||
await self._api.async_set_audio_enabled(enable)
|
||||
await self._api.async_set_audio_enabled(
|
||||
enable, channel=0, stream=RESOLUTION_TO_STREAM[self._resolution]
|
||||
)
|
||||
|
||||
async def _async_enable_audio(self, enable: bool) -> None:
|
||||
"""Enable or disable audio stream."""
|
||||
|
||||
@@ -13,3 +13,6 @@ SNAPSHOT_TIMEOUT = 20
|
||||
|
||||
SERVICE_EVENT = "event"
|
||||
SERVICE_UPDATE = "update"
|
||||
|
||||
RESOLUTION_LIST = {"high": 0, "low": 1}
|
||||
RESOLUTION_TO_STREAM = {0: "Main", 1: "Extra"}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "amcrest",
|
||||
"name": "Amcrest",
|
||||
"documentation": "https://www.home-assistant.io/integrations/amcrest",
|
||||
"requirements": ["amcrest==1.9.4"],
|
||||
"requirements": ["amcrest==1.9.7"],
|
||||
"dependencies": ["ffmpeg"],
|
||||
"codeowners": ["@flacjacket"],
|
||||
"iot_class": "local_polling",
|
||||
|
||||
@@ -204,13 +204,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
# Init ip webcam
|
||||
cam = PyDroidIPCam(
|
||||
hass.loop,
|
||||
websession,
|
||||
host,
|
||||
cam_config[CONF_PORT],
|
||||
username=username,
|
||||
password=password,
|
||||
timeout=cam_config[CONF_TIMEOUT],
|
||||
ssl=False,
|
||||
)
|
||||
|
||||
if switches is None:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "android_ip_webcam",
|
||||
"name": "Android IP Webcam",
|
||||
"documentation": "https://www.home-assistant.io/integrations/android_ip_webcam",
|
||||
"requirements": ["pydroid-ipcam==0.8"],
|
||||
"requirements": ["pydroid-ipcam==1.3.1"],
|
||||
"codeowners": [],
|
||||
"iot_class": "local_polling"
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_PIN
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.util.network import is_ipv6_address
|
||||
|
||||
from .const import CONF_CREDENTIALS, CONF_IDENTIFIERS, CONF_START_OFF, DOMAIN
|
||||
|
||||
@@ -166,6 +167,8 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle device found via zeroconf."""
|
||||
host = discovery_info.host
|
||||
if is_ipv6_address(host):
|
||||
return self.async_abort(reason="ipv6_not_supported")
|
||||
self._async_abort_entries_match({CONF_ADDRESS: host})
|
||||
service_type = discovery_info.type[:-1] # Remove leading .
|
||||
name = discovery_info.name.replace(f".{service_type}.", "")
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
},
|
||||
"abort": {
|
||||
"ipv6_not_supported": "IPv6 is not supported.",
|
||||
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"device_did_not_pair": "No attempt to finish pairing process was made from the device.",
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Device is already configured",
|
||||
"already_configured_device": "Device is already configured",
|
||||
"already_in_progress": "Configuration flow is already in progress",
|
||||
"backoff": "Device does not accept pairing requests at this time (you might have entered an invalid PIN code too many times), try again later.",
|
||||
"device_did_not_pair": "No attempt to finish pairing process was made from the device.",
|
||||
"device_not_found": "Device was not found during discovery, please try adding it again.",
|
||||
"inconsistent_device": "Expected protocols were not found during discovery. This normally indicates a problem with multicast DNS (Zeroconf). Please try adding the device again.",
|
||||
"invalid_config": "The configuration for this device is incomplete. Please try adding it again.",
|
||||
"ipv6_not_supported": "IPv6 is not supported.",
|
||||
"no_devices_found": "No devices found on the network",
|
||||
"reauth_successful": "Re-authentication was successful",
|
||||
"setup_failed": "Failed to set up device.",
|
||||
@@ -18,7 +17,6 @@
|
||||
"already_configured": "Device is already configured",
|
||||
"invalid_auth": "Invalid authentication",
|
||||
"no_devices_found": "No devices found on the network",
|
||||
"no_usable_service": "A device was found but could not identify any way to establish a connection to it. If you keep seeing this message, try specifying its IP address or restarting your Apple TV.",
|
||||
"unknown": "Unexpected error"
|
||||
},
|
||||
"flow_title": "{name} ({type})",
|
||||
@@ -72,6 +70,5 @@
|
||||
"description": "Configure general device settings"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Apple TV"
|
||||
}
|
||||
}
|
||||
@@ -149,9 +149,19 @@ def _async_register_mac(
|
||||
return
|
||||
|
||||
# Make sure entity has a config entry and was disabled by the
|
||||
# default disable logic in the integration.
|
||||
# default disable logic in the integration and new entities
|
||||
# are allowed to be added.
|
||||
if (
|
||||
entity_entry.config_entry_id is None
|
||||
or (
|
||||
(
|
||||
config_entry := hass.config_entries.async_get_entry(
|
||||
entity_entry.config_entry_id
|
||||
)
|
||||
)
|
||||
is not None
|
||||
and config_entry.pref_disable_new_entities
|
||||
)
|
||||
or entity_entry.disabled_by != er.RegistryEntryDisabler.INTEGRATION
|
||||
):
|
||||
return
|
||||
|
||||
@@ -20,9 +20,13 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_EMBED = "embed"
|
||||
ATTR_EMBED_AUTHOR = "author"
|
||||
ATTR_EMBED_COLOR = "color"
|
||||
ATTR_EMBED_DESCRIPTION = "description"
|
||||
ATTR_EMBED_FIELDS = "fields"
|
||||
ATTR_EMBED_FOOTER = "footer"
|
||||
ATTR_EMBED_TITLE = "title"
|
||||
ATTR_EMBED_THUMBNAIL = "thumbnail"
|
||||
ATTR_EMBED_URL = "url"
|
||||
ATTR_IMAGES = "images"
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_TOKEN): cv.string})
|
||||
@@ -64,10 +68,16 @@ class DiscordNotificationService(BaseNotificationService):
|
||||
embeds: list[nextcord.Embed] = []
|
||||
if ATTR_EMBED in data:
|
||||
embedding = data[ATTR_EMBED]
|
||||
title = embedding.get(ATTR_EMBED_TITLE) or nextcord.Embed.Empty
|
||||
description = embedding.get(ATTR_EMBED_DESCRIPTION) or nextcord.Embed.Empty
|
||||
color = embedding.get(ATTR_EMBED_COLOR) or nextcord.Embed.Empty
|
||||
url = embedding.get(ATTR_EMBED_URL) or nextcord.Embed.Empty
|
||||
fields = embedding.get(ATTR_EMBED_FIELDS) or []
|
||||
|
||||
if embedding:
|
||||
embed = nextcord.Embed(**embedding)
|
||||
embed = nextcord.Embed(
|
||||
title=title, description=description, color=color, url=url
|
||||
)
|
||||
for field in fields:
|
||||
embed.add_field(**field)
|
||||
if ATTR_EMBED_FOOTER in embedding:
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.components import zeroconf
|
||||
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.util.network import is_link_local
|
||||
from homeassistant.util.network import is_ipv4_address, is_link_local
|
||||
|
||||
from .const import CONF_EVENTS, DOMAIN, DOORBIRD_OUI
|
||||
from .util import get_mac_address_from_doorstation_info
|
||||
@@ -103,6 +103,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return self.async_abort(reason="not_doorbird_device")
|
||||
if is_link_local(ip_address(host)):
|
||||
return self.async_abort(reason="link_local_address")
|
||||
if not is_ipv4_address(host):
|
||||
return self.async_abort(reason="not_ipv4_address")
|
||||
|
||||
await self.async_set_unique_id(macaddress)
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"abort": {
|
||||
"already_configured": "Device is already configured",
|
||||
"link_local_address": "Link local addresses are not supported",
|
||||
"not_doorbird_device": "This device is not a DoorBird"
|
||||
"not_doorbird_device": "This device is not a DoorBird",
|
||||
"not_ipv4_address": "Only IPv4 addresess are supported"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""Support for Elgato Lights."""
|
||||
from typing import NamedTuple
|
||||
|
||||
from elgato import Elgato, Info, State
|
||||
from elgato import Elgato, ElgatoConnectionError, Info, State
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, LOGGER, SCAN_INTERVAL
|
||||
|
||||
@@ -31,12 +31,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
session=session,
|
||||
)
|
||||
|
||||
async def _async_update_data() -> State:
|
||||
"""Fetch Elgato data."""
|
||||
try:
|
||||
return await elgato.state()
|
||||
except ElgatoConnectionError as err:
|
||||
raise UpdateFailed(err) from err
|
||||
|
||||
coordinator: DataUpdateCoordinator[State] = DataUpdateCoordinator(
|
||||
hass,
|
||||
LOGGER,
|
||||
name=f"{DOMAIN}_{entry.data[CONF_HOST]}",
|
||||
update_interval=SCAN_INTERVAL,
|
||||
update_method=elgato.state,
|
||||
update_method=_async_update_data,
|
||||
)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
|
||||
@@ -279,9 +279,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
keypad.add_callback(_element_changed)
|
||||
|
||||
try:
|
||||
if not await async_wait_for_elk_to_sync(
|
||||
elk, LOGIN_TIMEOUT, SYNC_TIMEOUT, bool(conf[CONF_USERNAME])
|
||||
):
|
||||
if not await async_wait_for_elk_to_sync(elk, LOGIN_TIMEOUT, SYNC_TIMEOUT):
|
||||
return False
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise ConfigEntryNotReady(f"Timed out connecting to {conf[CONF_HOST]}") from exc
|
||||
@@ -334,7 +332,6 @@ async def async_wait_for_elk_to_sync(
|
||||
elk: elkm1.Elk,
|
||||
login_timeout: int,
|
||||
sync_timeout: int,
|
||||
password_auth: bool,
|
||||
) -> bool:
|
||||
"""Wait until the elk has finished sync. Can fail login or timeout."""
|
||||
|
||||
@@ -354,18 +351,23 @@ async def async_wait_for_elk_to_sync(
|
||||
login_event.set()
|
||||
sync_event.set()
|
||||
|
||||
def first_response(*args, **kwargs):
|
||||
_LOGGER.debug("ElkM1 received first response (VN)")
|
||||
login_event.set()
|
||||
|
||||
def sync_complete():
|
||||
sync_event.set()
|
||||
|
||||
success = True
|
||||
elk.add_handler("login", login_status)
|
||||
# VN is the first command sent for panel, when we get
|
||||
# it back we now we are logged in either with or without a password
|
||||
elk.add_handler("VN", first_response)
|
||||
elk.add_handler("sync_complete", sync_complete)
|
||||
events = []
|
||||
if password_auth:
|
||||
events.append(("login", login_event, login_timeout))
|
||||
events.append(("sync_complete", sync_event, sync_timeout))
|
||||
|
||||
for name, event, timeout in events:
|
||||
for name, event, timeout in (
|
||||
("login", login_event, login_timeout),
|
||||
("sync_complete", sync_event, sync_timeout),
|
||||
):
|
||||
_LOGGER.debug("Waiting for %s event for %s seconds", name, timeout)
|
||||
try:
|
||||
async with async_timeout.timeout(timeout):
|
||||
|
||||
@@ -37,7 +37,9 @@ from .discovery import (
|
||||
|
||||
CONF_DEVICE = "device"
|
||||
|
||||
NON_SECURE_PORT = 2101
|
||||
SECURE_PORT = 2601
|
||||
STANDARD_PORTS = {NON_SECURE_PORT, SECURE_PORT}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -48,6 +50,7 @@ PROTOCOL_MAP = {
|
||||
"serial": "serial://",
|
||||
}
|
||||
|
||||
|
||||
VALIDATE_TIMEOUT = 35
|
||||
|
||||
BASE_SCHEMA = {
|
||||
@@ -60,6 +63,11 @@ ALL_PROTOCOLS = [*SECURE_PROTOCOLS, "non-secure", "serial"]
|
||||
DEFAULT_SECURE_PROTOCOL = "secure"
|
||||
DEFAULT_NON_SECURE_PROTOCOL = "non-secure"
|
||||
|
||||
PORT_PROTOCOL_MAP = {
|
||||
NON_SECURE_PORT: DEFAULT_NON_SECURE_PROTOCOL,
|
||||
SECURE_PORT: DEFAULT_SECURE_PROTOCOL,
|
||||
}
|
||||
|
||||
|
||||
async def validate_input(data: dict[str, str], mac: str | None) -> dict[str, str]:
|
||||
"""Validate the user input allows us to connect.
|
||||
@@ -81,10 +89,11 @@ async def validate_input(data: dict[str, str], mac: str | None) -> dict[str, str
|
||||
)
|
||||
elk.connect()
|
||||
|
||||
if not await async_wait_for_elk_to_sync(
|
||||
elk, LOGIN_TIMEOUT, VALIDATE_TIMEOUT, bool(userid)
|
||||
):
|
||||
raise InvalidAuth
|
||||
try:
|
||||
if not await async_wait_for_elk_to_sync(elk, LOGIN_TIMEOUT, VALIDATE_TIMEOUT):
|
||||
raise InvalidAuth
|
||||
finally:
|
||||
elk.disconnect()
|
||||
|
||||
short_mac = _short_mac(mac) if mac else None
|
||||
if prefix and prefix != short_mac:
|
||||
@@ -96,6 +105,13 @@ async def validate_input(data: dict[str, str], mac: str | None) -> dict[str, str
|
||||
return {"title": device_name, CONF_HOST: url, CONF_PREFIX: slugify(prefix)}
|
||||
|
||||
|
||||
def _address_from_discovery(device: ElkSystem):
|
||||
"""Append the port only if its non-standard."""
|
||||
if device.port in STANDARD_PORTS:
|
||||
return device.ip_address
|
||||
return f"{device.ip_address}:{device.port}"
|
||||
|
||||
|
||||
def _make_url_from_data(data):
|
||||
if host := data.get(CONF_HOST):
|
||||
return host
|
||||
@@ -108,7 +124,7 @@ def _make_url_from_data(data):
|
||||
def _placeholders_from_device(device: ElkSystem) -> dict[str, str]:
|
||||
return {
|
||||
"mac_address": _short_mac(device.mac_address),
|
||||
"host": f"{device.ip_address}:{device.port}",
|
||||
"host": _address_from_discovery(device),
|
||||
}
|
||||
|
||||
|
||||
@@ -165,6 +181,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
for progress in self._async_in_progress():
|
||||
if progress.get("context", {}).get(CONF_HOST) == host:
|
||||
return self.async_abort(reason="already_in_progress")
|
||||
# Handled ignored case since _async_current_entries
|
||||
# is called with include_ignore=False
|
||||
self._abort_if_unique_id_configured()
|
||||
if not device.port:
|
||||
if discovered_device := await async_discover_device(self.hass, host):
|
||||
self._discovered_device = discovered_device
|
||||
@@ -227,7 +246,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
try:
|
||||
info = await validate_input(user_input, self.unique_id)
|
||||
except asyncio.TimeoutError:
|
||||
return {CONF_HOST: "cannot_connect"}, None
|
||||
return {"base": "cannot_connect"}, None
|
||||
except InvalidAuth:
|
||||
return {CONF_PASSWORD: "invalid_auth"}, None
|
||||
except Exception: # pylint: disable=broad-except
|
||||
@@ -254,26 +273,26 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
device = self._discovered_device
|
||||
assert device is not None
|
||||
if user_input is not None:
|
||||
user_input[CONF_ADDRESS] = f"{device.ip_address}:{device.port}"
|
||||
user_input[CONF_ADDRESS] = _address_from_discovery(device)
|
||||
if self._async_current_entries():
|
||||
user_input[CONF_PREFIX] = _short_mac(device.mac_address)
|
||||
else:
|
||||
user_input[CONF_PREFIX] = ""
|
||||
if device.port != SECURE_PORT:
|
||||
user_input[CONF_PROTOCOL] = DEFAULT_NON_SECURE_PROTOCOL
|
||||
errors, result = await self._async_create_or_error(user_input, False)
|
||||
if not errors:
|
||||
return result
|
||||
|
||||
base_schmea = BASE_SCHEMA.copy()
|
||||
if device.port == SECURE_PORT:
|
||||
base_schmea[
|
||||
vol.Required(CONF_PROTOCOL, default=DEFAULT_SECURE_PROTOCOL)
|
||||
] = vol.In(SECURE_PROTOCOLS)
|
||||
|
||||
default_proto = PORT_PROTOCOL_MAP.get(device.port, DEFAULT_SECURE_PROTOCOL)
|
||||
return self.async_show_form(
|
||||
step_id="discovered_connection",
|
||||
data_schema=vol.Schema(base_schmea),
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
**BASE_SCHEMA,
|
||||
vol.Required(CONF_PROTOCOL, default=default_proto): vol.In(
|
||||
ALL_PROTOCOLS
|
||||
),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders=_placeholders_from_device(device),
|
||||
)
|
||||
@@ -287,9 +306,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if device := await async_discover_device(
|
||||
self.hass, user_input[CONF_ADDRESS]
|
||||
):
|
||||
await self.async_set_unique_id(dr.format_mac(device.mac_address))
|
||||
await self.async_set_unique_id(
|
||||
dr.format_mac(device.mac_address), raise_on_progress=False
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
user_input[CONF_ADDRESS] = f"{device.ip_address}:{device.port}"
|
||||
# Ignore the port from discovery since its always going to be
|
||||
# 2601 if secure is turned on even though they may want insecure
|
||||
user_input[CONF_ADDRESS] = device.ip_address
|
||||
errors, result = await self._async_create_or_error(user_input, False)
|
||||
if not errors:
|
||||
return result
|
||||
@@ -324,10 +347,15 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if is_ip_address(host) and (
|
||||
device := await async_discover_device(self.hass, host)
|
||||
):
|
||||
await self.async_set_unique_id(dr.format_mac(device.mac_address))
|
||||
await self.async_set_unique_id(
|
||||
dr.format_mac(device.mac_address), raise_on_progress=False
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return (await self._async_create_or_error(user_input, True))[1]
|
||||
errors, result = await self._async_create_or_error(user_input, True)
|
||||
if errors:
|
||||
return self.async_abort(reason=list(errors.values())[0])
|
||||
return result
|
||||
|
||||
def _url_already_configured(self, url):
|
||||
"""See if we already have a elkm1 matching user input configured."""
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"already_configured": "An ElkM1 with this prefix is already configured",
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
"address_already_configured": "An ElkM1 with this address is already configured",
|
||||
"already_configured": "An ElkM1 with this prefix is already configured",
|
||||
"already_in_progress": "Configuration flow is already in progress",
|
||||
"cannot_connect": "Failed to connect"
|
||||
"cannot_connect": "Failed to connect",
|
||||
"invalid_auth": "Invalid authentication",
|
||||
"unknown": "Unexpected error"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
@@ -37,13 +39,7 @@
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"address": "The IP address or domain or serial port if connecting via serial.",
|
||||
"device": "Device",
|
||||
"password": "Password",
|
||||
"prefix": "A unique prefix (leave blank if you only have one ElkM1).",
|
||||
"protocol": "Protocol",
|
||||
"temperature_unit": "The temperature unit ElkM1 uses.",
|
||||
"username": "Username"
|
||||
"device": "Device"
|
||||
},
|
||||
"description": "Choose a discovered system or 'Manual Entry' if no devices have been discovered.",
|
||||
"title": "Connect to Elk-M1 Control"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "emulated_kasa",
|
||||
"name": "Emulated Kasa",
|
||||
"documentation": "https://www.home-assistant.io/integrations/emulated_kasa",
|
||||
"requirements": ["sense_energy==0.10.2"],
|
||||
"requirements": ["sense_energy==0.10.4"],
|
||||
"codeowners": ["@kbickar"],
|
||||
"quality_scale": "internal",
|
||||
"iot_class": "local_push",
|
||||
|
||||
@@ -16,6 +16,7 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.util.network import is_ipv4_address
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
@@ -86,6 +87,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self, discovery_info: zeroconf.ZeroconfServiceInfo
|
||||
) -> FlowResult:
|
||||
"""Handle a flow initialized by zeroconf discovery."""
|
||||
if not is_ipv4_address(discovery_info.host):
|
||||
return self.async_abort(reason="not_ipv4_address")
|
||||
serial = discovery_info.properties["serialnum"]
|
||||
await self.async_set_unique_id(serial)
|
||||
self.ip_address = discovery_info.host
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Device is already configured",
|
||||
"reauth_successful": "Re-authentication was successful"
|
||||
"reauth_successful": "Re-authentication was successful",
|
||||
"not_ipv4_address": "Only IPv4 addresess are supported"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
|
||||
@@ -33,6 +33,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
except FRITZ_EXCEPTIONS as ex:
|
||||
raise ConfigEntryNotReady from ex
|
||||
|
||||
if (
|
||||
"X_AVM-DE_UPnP1" in avm_wrapper.connection.services
|
||||
and not (await avm_wrapper.async_get_upnp_configuration())["NewEnable"]
|
||||
):
|
||||
raise ConfigEntryAuthFailed("Missing UPnP configuration")
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][entry.entry_id] = avm_wrapper
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""AVM FRITZ!Box connectivity sensor."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
@@ -14,8 +15,8 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .common import AvmWrapper, FritzBoxBaseEntity
|
||||
from .const import DOMAIN, MeshRoles
|
||||
from .common import AvmWrapper, ConnectionInfo, FritzBoxBaseEntity
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -24,7 +25,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class FritzBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describes Fritz sensor entity."""
|
||||
|
||||
exclude_mesh_role: MeshRoles = MeshRoles.SLAVE
|
||||
is_suitable: Callable[[ConnectionInfo], bool] = lambda info: info.wan_enabled
|
||||
|
||||
|
||||
SENSOR_TYPES: tuple[FritzBinarySensorEntityDescription, ...] = (
|
||||
@@ -45,7 +46,7 @@ SENSOR_TYPES: tuple[FritzBinarySensorEntityDescription, ...] = (
|
||||
name="Firmware Update",
|
||||
device_class=BinarySensorDeviceClass.UPDATE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
exclude_mesh_role=MeshRoles.NONE,
|
||||
is_suitable=lambda info: True,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -57,10 +58,12 @@ async def async_setup_entry(
|
||||
_LOGGER.debug("Setting up FRITZ!Box binary sensors")
|
||||
avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
connection_info = await avm_wrapper.async_get_connection_info()
|
||||
|
||||
entities = [
|
||||
FritzBoxBinarySensor(avm_wrapper, entry.title, description)
|
||||
for description in SENSOR_TYPES
|
||||
if (description.exclude_mesh_role != avm_wrapper.mesh_role)
|
||||
if description.is_suitable(connection_info)
|
||||
]
|
||||
|
||||
async_add_entities(entities, True)
|
||||
|
||||
@@ -392,6 +392,8 @@ class FritzBoxTools(update_coordinator.DataUpdateCoordinator):
|
||||
)
|
||||
self.mesh_role = MeshRoles.NONE
|
||||
for mac, info in hosts.items():
|
||||
if info.ip_address:
|
||||
info.wan_access = self._get_wan_access(info.ip_address)
|
||||
if self.manage_device_info(info, mac, consider_home):
|
||||
new_device = True
|
||||
self.send_signal_device_update(new_device)
|
||||
@@ -630,6 +632,11 @@ class AvmWrapper(FritzBoxTools):
|
||||
)
|
||||
return {}
|
||||
|
||||
async def async_get_upnp_configuration(self) -> dict[str, Any]:
|
||||
"""Call X_AVM-DE_UPnP service."""
|
||||
|
||||
return await self.hass.async_add_executor_job(self.get_upnp_configuration)
|
||||
|
||||
async def async_get_wan_link_properties(self) -> dict[str, Any]:
|
||||
"""Call WANCommonInterfaceConfig service."""
|
||||
|
||||
@@ -637,6 +644,22 @@ class AvmWrapper(FritzBoxTools):
|
||||
partial(self.get_wan_link_properties)
|
||||
)
|
||||
|
||||
async def async_get_connection_info(self) -> ConnectionInfo:
|
||||
"""Return ConnectionInfo data."""
|
||||
|
||||
link_properties = await self.async_get_wan_link_properties()
|
||||
connection_info = ConnectionInfo(
|
||||
connection=link_properties.get("NewWANAccessType", "").lower(),
|
||||
mesh_role=self.mesh_role,
|
||||
wan_enabled=self.device_is_router,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"ConnectionInfo for FritzBox %s: %s",
|
||||
self.host,
|
||||
connection_info,
|
||||
)
|
||||
return connection_info
|
||||
|
||||
async def async_get_port_mapping(self, con_type: str, index: int) -> dict[str, Any]:
|
||||
"""Call GetGenericPortMappingEntry action."""
|
||||
|
||||
@@ -698,6 +721,11 @@ class AvmWrapper(FritzBoxTools):
|
||||
partial(self.set_allow_wan_access, ip_address, turn_on)
|
||||
)
|
||||
|
||||
def get_upnp_configuration(self) -> dict[str, Any]:
|
||||
"""Call X_AVM-DE_UPnP service."""
|
||||
|
||||
return self._service_call_action("X_AVM-DE_UPnP", "1", "GetInfo")
|
||||
|
||||
def get_ontel_num_deflections(self) -> dict[str, Any]:
|
||||
"""Call GetNumberOfDeflections action from X_AVM-DE_OnTel service."""
|
||||
|
||||
@@ -960,3 +988,12 @@ class FritzBoxBaseEntity:
|
||||
name=self._device_name,
|
||||
sw_version=self._avm_wrapper.current_firmware,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConnectionInfo:
|
||||
"""Fritz sensor connection information class."""
|
||||
|
||||
connection: str
|
||||
mesh_role: MeshRoles
|
||||
wan_enabled: bool
|
||||
|
||||
@@ -29,6 +29,7 @@ from .const import (
|
||||
ERROR_AUTH_INVALID,
|
||||
ERROR_CANNOT_CONNECT,
|
||||
ERROR_UNKNOWN,
|
||||
ERROR_UPNP_NOT_CONFIGURED,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -79,6 +80,12 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
return ERROR_UNKNOWN
|
||||
|
||||
if (
|
||||
"X_AVM-DE_UPnP1" in self.avm_wrapper.connection.services
|
||||
and not (await self.avm_wrapper.async_get_upnp_configuration())["NewEnable"]
|
||||
):
|
||||
return ERROR_UPNP_NOT_CONFIGURED
|
||||
|
||||
return None
|
||||
|
||||
async def async_check_configured_entry(self) -> ConfigEntry | None:
|
||||
|
||||
@@ -46,6 +46,7 @@ DEFAULT_USERNAME = ""
|
||||
|
||||
ERROR_AUTH_INVALID = "invalid_auth"
|
||||
ERROR_CANNOT_CONNECT = "cannot_connect"
|
||||
ERROR_UPNP_NOT_CONFIGURED = "upnp_not_configured"
|
||||
ERROR_UNKNOWN = "unknown_error"
|
||||
|
||||
FRITZ_SERVICES = "fritz_services"
|
||||
@@ -56,6 +57,7 @@ SERVICE_SET_GUEST_WIFI_PW = "set_guest_wifi_password"
|
||||
|
||||
SWITCH_TYPE_DEFLECTION = "CallDeflection"
|
||||
SWITCH_TYPE_PORTFORWARD = "PortForward"
|
||||
SWITCH_TYPE_PROFILE = "Profile"
|
||||
SWITCH_TYPE_WIFINETWORK = "WiFiNetwork"
|
||||
|
||||
UPTIME_DEVIATION = 5
|
||||
|
||||
@@ -22,6 +22,9 @@ async def async_get_config_entry_diagnostics(
|
||||
"entry": async_redact_data(entry.as_dict(), TO_REDACT),
|
||||
"device_info": {
|
||||
"model": avm_wrapper.model,
|
||||
"unique_id": avm_wrapper.unique_id.replace(
|
||||
avm_wrapper.unique_id[6:11], "XX:XX"
|
||||
),
|
||||
"current_firmware": avm_wrapper.current_firmware,
|
||||
"latest_firmware": avm_wrapper.latest_firmware,
|
||||
"update_available": avm_wrapper.update_available,
|
||||
|
||||
@@ -28,8 +28,8 @@ from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .common import AvmWrapper, FritzBoxBaseEntity
|
||||
from .const import DOMAIN, DSL_CONNECTION, UPTIME_DEVIATION, MeshRoles
|
||||
from .common import AvmWrapper, ConnectionInfo, FritzBoxBaseEntity
|
||||
from .const import DOMAIN, DSL_CONNECTION, UPTIME_DEVIATION
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -134,15 +134,6 @@ def _retrieve_link_attenuation_received_state(
|
||||
return status.attenuation[1] / 10 # type: ignore[no-any-return]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConnectionInfo:
|
||||
"""Fritz sensor connection information class."""
|
||||
|
||||
connection: str
|
||||
mesh_role: MeshRoles
|
||||
wan_enabled: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class FritzRequireKeysMixin:
|
||||
"""Fritz sensor data class."""
|
||||
@@ -283,18 +274,7 @@ async def async_setup_entry(
|
||||
_LOGGER.debug("Setting up FRITZ!Box sensors")
|
||||
avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
link_properties = await avm_wrapper.async_get_wan_link_properties()
|
||||
connection_info = ConnectionInfo(
|
||||
connection=link_properties.get("NewWANAccessType", "").lower(),
|
||||
mesh_role=avm_wrapper.mesh_role,
|
||||
wan_enabled=avm_wrapper.device_is_router,
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"ConnectionInfo for FritzBox %s: %s",
|
||||
avm_wrapper.host,
|
||||
connection_info,
|
||||
)
|
||||
connection_info = await avm_wrapper.async_get_connection_info()
|
||||
|
||||
entities = [
|
||||
FritzBoxSensor(avm_wrapper, entry.title, description)
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"upnp_not_configured": "Missing UPnP settings on device.",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
|
||||
@@ -30,6 +30,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
SWITCH_TYPE_DEFLECTION,
|
||||
SWITCH_TYPE_PORTFORWARD,
|
||||
SWITCH_TYPE_PROFILE,
|
||||
SWITCH_TYPE_WIFINETWORK,
|
||||
WIFI_STANDARD,
|
||||
MeshRoles,
|
||||
@@ -185,6 +186,7 @@ def profile_entities_list(
|
||||
data_fritz: FritzData,
|
||||
) -> list[FritzBoxProfileSwitch]:
|
||||
"""Add new tracker entities from the AVM device."""
|
||||
_LOGGER.debug("Setting up %s switches", SWITCH_TYPE_PROFILE)
|
||||
|
||||
new_profiles: list[FritzBoxProfileSwitch] = []
|
||||
|
||||
@@ -198,11 +200,15 @@ def profile_entities_list(
|
||||
if device_filter_out_from_trackers(
|
||||
mac, device, data_fritz.profile_switches.values()
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"Skipping profile switch creation for device %s", device.hostname
|
||||
)
|
||||
continue
|
||||
|
||||
new_profiles.append(FritzBoxProfileSwitch(avm_wrapper, device))
|
||||
data_fritz.profile_switches[avm_wrapper.unique_id].add(mac)
|
||||
|
||||
_LOGGER.debug("Creating %s profile switches", len(new_profiles))
|
||||
return new_profiles
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"already_configured": "Device is already configured",
|
||||
"already_in_progress": "Configuration flow is already in progress",
|
||||
"cannot_connect": "Failed to connect",
|
||||
"invalid_auth": "Invalid authentication"
|
||||
"invalid_auth": "Invalid authentication",
|
||||
"upnp_not_configured": "Missing UPnP settings on device."
|
||||
},
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
@@ -51,4 +52,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Home Assistant Frontend",
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"requirements": [
|
||||
"home-assistant-frontend==20220301.0"
|
||||
"home-assistant-frontend==20220301.2"
|
||||
],
|
||||
"dependencies": [
|
||||
"api",
|
||||
@@ -13,7 +13,8 @@
|
||||
"diagnostics",
|
||||
"http",
|
||||
"lovelace",
|
||||
"onboarding", "search",
|
||||
"onboarding",
|
||||
"search",
|
||||
"system_log",
|
||||
"websocket_api"
|
||||
],
|
||||
|
||||
@@ -59,11 +59,12 @@ SERVICE_SET = "set"
|
||||
SERVICE_REMOVE = "remove"
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.LIGHT,
|
||||
Platform.COVER,
|
||||
Platform.NOTIFY,
|
||||
Platform.FAN,
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.COVER,
|
||||
Platform.FAN,
|
||||
Platform.LIGHT,
|
||||
Platform.MEDIA_PLAYER,
|
||||
Platform.NOTIFY,
|
||||
]
|
||||
|
||||
REG_KEY = f"{DOMAIN}_registry"
|
||||
|
||||
@@ -221,12 +221,9 @@ class GrowattData:
|
||||
# Create datetime from the latest entry
|
||||
date_now = dt.now().date()
|
||||
last_updated_time = dt.parse_time(str(sorted_keys[-1]))
|
||||
combined_timestamp = datetime.datetime.combine(
|
||||
date_now, last_updated_time
|
||||
mix_detail["lastdataupdate"] = datetime.datetime.combine(
|
||||
date_now, last_updated_time, dt.DEFAULT_TIME_ZONE
|
||||
)
|
||||
# Convert datetime to UTC
|
||||
combined_timestamp_utc = dt.as_utc(combined_timestamp)
|
||||
mix_detail["lastdataupdate"] = combined_timestamp_utc.isoformat()
|
||||
|
||||
# Dashboard data is largely inaccurate for mix system but it is the only call with the ability to return the combined
|
||||
# imported from grid value that is the combination of charging AND load consumption
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/home_connect",
|
||||
"dependencies": ["http"],
|
||||
"codeowners": ["@DavidMStraub"],
|
||||
"requirements": ["homeconnect==0.6.3"],
|
||||
"requirements": ["homeconnect==0.7.0"],
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["homeconnect"]
|
||||
|
||||
@@ -274,7 +274,7 @@ class HomeAccessory(Accessory):
|
||||
if self.config.get(ATTR_SW_VERSION) is not None:
|
||||
sw_version = format_version(self.config[ATTR_SW_VERSION])
|
||||
if sw_version is None:
|
||||
sw_version = __version__
|
||||
sw_version = format_version(__version__)
|
||||
hw_version = None
|
||||
if self.config.get(ATTR_HW_VERSION) is not None:
|
||||
hw_version = format_version(self.config[ATTR_HW_VERSION])
|
||||
@@ -289,7 +289,9 @@ class HomeAccessory(Accessory):
|
||||
serv_info = self.get_service(SERV_ACCESSORY_INFO)
|
||||
char = self.driver.loader.get_char(CHAR_HARDWARE_REVISION)
|
||||
serv_info.add_characteristic(char)
|
||||
serv_info.configure_char(CHAR_HARDWARE_REVISION, value=hw_version)
|
||||
serv_info.configure_char(
|
||||
CHAR_HARDWARE_REVISION, value=hw_version[:MAX_VERSION_LENGTH]
|
||||
)
|
||||
self.iid_manager.assign(char)
|
||||
char.broker = self
|
||||
|
||||
@@ -532,7 +534,7 @@ class HomeBridge(Bridge):
|
||||
"""Initialize a Bridge object."""
|
||||
super().__init__(driver, name)
|
||||
self.set_info_service(
|
||||
firmware_revision=__version__,
|
||||
firmware_revision=format_version(__version__),
|
||||
manufacturer=MANUFACTURER,
|
||||
model=BRIDGE_MODEL,
|
||||
serial_number=BRIDGE_SERIAL_NUMBER,
|
||||
|
||||
@@ -285,20 +285,19 @@ class Thermostat(HomeAccessory):
|
||||
CHAR_CURRENT_HUMIDITY, value=50
|
||||
)
|
||||
|
||||
fan_modes = self.fan_modes = {
|
||||
fan_mode.lower(): fan_mode
|
||||
for fan_mode in attributes.get(ATTR_FAN_MODES, [])
|
||||
}
|
||||
fan_modes = {}
|
||||
self.ordered_fan_speeds = []
|
||||
if (
|
||||
features & SUPPORT_FAN_MODE
|
||||
and fan_modes
|
||||
and PRE_DEFINED_FAN_MODES.intersection(fan_modes)
|
||||
):
|
||||
self.ordered_fan_speeds = [
|
||||
speed for speed in ORDERED_FAN_SPEEDS if speed in fan_modes
|
||||
]
|
||||
self.fan_chars.append(CHAR_ROTATION_SPEED)
|
||||
|
||||
if features & SUPPORT_FAN_MODE:
|
||||
fan_modes = {
|
||||
fan_mode.lower(): fan_mode
|
||||
for fan_mode in attributes.get(ATTR_FAN_MODES) or []
|
||||
}
|
||||
if fan_modes and PRE_DEFINED_FAN_MODES.intersection(fan_modes):
|
||||
self.ordered_fan_speeds = [
|
||||
speed for speed in ORDERED_FAN_SPEEDS if speed in fan_modes
|
||||
]
|
||||
self.fan_chars.append(CHAR_ROTATION_SPEED)
|
||||
|
||||
if FAN_AUTO in fan_modes and (FAN_ON in fan_modes or self.ordered_fan_speeds):
|
||||
self.fan_chars.append(CHAR_TARGET_FAN_STATE)
|
||||
|
||||
@@ -100,6 +100,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
NUMBERS_ONLY_RE = re.compile(r"[^\d.]+")
|
||||
VERSION_RE = re.compile(r"([0-9]+)(\.[0-9]+)?(\.[0-9]+)?")
|
||||
MAX_VERSION_PART = 2**32 - 1
|
||||
|
||||
|
||||
MAX_PORT = 65535
|
||||
@@ -363,7 +364,15 @@ def convert_to_float(state):
|
||||
return None
|
||||
|
||||
|
||||
def cleanup_name_for_homekit(name: str | None) -> str | 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.
|
||||
@@ -371,7 +380,7 @@ def cleanup_name_for_homekit(name: str | None) -> str | None:
|
||||
# UNICODE_EMOJI is also not allowed but that
|
||||
# likely isn't a problem
|
||||
if name is None:
|
||||
return None
|
||||
return "None" # None crashes apple watches
|
||||
return name.translate(HOMEKIT_CHAR_TRANSLATIONS)[:MAX_NAME_LENGTH]
|
||||
|
||||
|
||||
@@ -420,13 +429,23 @@ def get_aid_storage_fullpath_for_entry_id(hass: HomeAssistant, entry_id: str):
|
||||
)
|
||||
|
||||
|
||||
def _format_version_part(version_part: str) -> str:
|
||||
return str(max(0, min(MAX_VERSION_PART, coerce_int(version_part))))
|
||||
|
||||
|
||||
def format_version(version):
|
||||
"""Extract the version string in a format homekit can consume."""
|
||||
split_ver = str(version).replace("-", ".")
|
||||
split_ver = str(version).replace("-", ".").replace(" ", ".")
|
||||
num_only = NUMBERS_ONLY_RE.sub("", split_ver)
|
||||
if match := VERSION_RE.search(num_only):
|
||||
return match.group(0)
|
||||
return None
|
||||
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):
|
||||
"""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):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "HomeKit Controller",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
|
||||
"requirements": ["aiohomekit==0.7.15"],
|
||||
"requirements": ["aiohomekit==0.7.16"],
|
||||
"zeroconf": ["_hap._tcp.local."],
|
||||
"after_dependencies": ["zeroconf"],
|
||||
"codeowners": ["@Jc2k", "@bdraco"],
|
||||
|
||||
@@ -6,6 +6,7 @@ from collections.abc import Callable
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import client_exceptions
|
||||
from aiohue import HueBridgeV1, HueBridgeV2, LinkButtonNotPressed, Unauthorized
|
||||
from aiohue.errors import AiohueException, BridgeBusy
|
||||
@@ -14,7 +15,7 @@ import async_timeout
|
||||
from homeassistant import core
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY, CONF_HOST, Platform
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
|
||||
from .const import CONF_API_VERSION, DOMAIN
|
||||
@@ -116,22 +117,23 @@ class HueBridge:
|
||||
self.authorized = True
|
||||
return True
|
||||
|
||||
async def async_request_call(
|
||||
self, task: Callable, *args, allowed_errors: list[str] | None = None, **kwargs
|
||||
) -> Any:
|
||||
"""Send request to the Hue bridge, optionally omitting error(s)."""
|
||||
async def async_request_call(self, task: Callable, *args, **kwargs) -> Any:
|
||||
"""Send request to the Hue bridge."""
|
||||
try:
|
||||
return await task(*args, **kwargs)
|
||||
except AiohueException as err:
|
||||
# The (new) Hue api can be a bit fanatic with throwing errors
|
||||
# some of which we accept in certain conditions
|
||||
# handle that here. Note that these errors are strings and do not have
|
||||
# an identifier or something.
|
||||
if allowed_errors is not None and str(err) in allowed_errors:
|
||||
# The (new) Hue api can be a bit fanatic with throwing errors so
|
||||
# we have some logic to treat some responses as warning only.
|
||||
msg = f"Request failed: {err}"
|
||||
if "may not have effect" in str(err):
|
||||
# log only
|
||||
self.logger.debug("Ignored error/warning from Hue API: %s", str(err))
|
||||
self.logger.debug(msg)
|
||||
return None
|
||||
raise err
|
||||
raise HomeAssistantError(msg) from err
|
||||
except aiohttp.ClientError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Request failed due connection error: {err}"
|
||||
) from err
|
||||
|
||||
async def async_reset(self) -> bool:
|
||||
"""Reset this bridge to default state.
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import aiohttp
|
||||
from aiohue import LinkButtonNotPressed, create_app_key
|
||||
from aiohue.discovery import DiscoveredHueBridge, discover_bridge, discover_nupnp
|
||||
from aiohue.util import normalize_bridge_id
|
||||
@@ -70,9 +71,12 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self, host: str, bridge_id: str | None = None
|
||||
) -> DiscoveredHueBridge:
|
||||
"""Return a DiscoveredHueBridge object."""
|
||||
bridge = await discover_bridge(
|
||||
host, websession=aiohttp_client.async_get_clientsession(self.hass)
|
||||
)
|
||||
try:
|
||||
bridge = await discover_bridge(
|
||||
host, websession=aiohttp_client.async_get_clientsession(self.hass)
|
||||
)
|
||||
except aiohttp.ClientError:
|
||||
return None
|
||||
if bridge_id is not None:
|
||||
bridge_id = normalize_bridge_id(bridge_id)
|
||||
assert bridge_id == bridge.id
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Philips Hue",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/hue",
|
||||
"requirements": ["aiohue==4.3.0"],
|
||||
"requirements": ["aiohue==4.4.1"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Royal Philips Electronics",
|
||||
|
||||
@@ -37,13 +37,6 @@ from .helpers import (
|
||||
normalize_hue_transition,
|
||||
)
|
||||
|
||||
ALLOWED_ERRORS = [
|
||||
"device (groupedLight) has communication issues, command (on) may not have effect",
|
||||
'device (groupedLight) is "soft off", command (on) may not have effect',
|
||||
"device (light) has communication issues, command (on) may not have effect",
|
||||
'device (light) is "soft off", command (on) may not have effect',
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -175,10 +168,7 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
|
||||
and flash is None
|
||||
):
|
||||
await self.bridge.async_request_call(
|
||||
self.controller.set_state,
|
||||
id=self.resource.id,
|
||||
on=True,
|
||||
allowed_errors=ALLOWED_ERRORS,
|
||||
self.controller.set_state, id=self.resource.id, on=True
|
||||
)
|
||||
return
|
||||
|
||||
@@ -194,7 +184,6 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
|
||||
color_xy=xy_color if light.supports_color else None,
|
||||
color_temp=color_temp if light.supports_color_temperature else None,
|
||||
transition_time=transition,
|
||||
allowed_errors=ALLOWED_ERRORS,
|
||||
)
|
||||
for light in self.controller.get_lights(self.resource.id)
|
||||
]
|
||||
@@ -214,10 +203,7 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
|
||||
# To set other features, you'll have to control the attached lights
|
||||
if transition is None:
|
||||
await self.bridge.async_request_call(
|
||||
self.controller.set_state,
|
||||
id=self.resource.id,
|
||||
on=False,
|
||||
allowed_errors=ALLOWED_ERRORS,
|
||||
self.controller.set_state, id=self.resource.id, on=False
|
||||
)
|
||||
return
|
||||
|
||||
@@ -229,7 +215,6 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
|
||||
light.id,
|
||||
on=False,
|
||||
transition_time=transition,
|
||||
allowed_errors=ALLOWED_ERRORS,
|
||||
)
|
||||
for light in self.controller.get_lights(self.resource.id)
|
||||
]
|
||||
|
||||
@@ -36,11 +36,6 @@ from .helpers import (
|
||||
normalize_hue_transition,
|
||||
)
|
||||
|
||||
ALLOWED_ERRORS = [
|
||||
"device (light) has communication issues, command (on) may not have effect",
|
||||
'device (light) is "soft off", command (on) may not have effect',
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -178,7 +173,6 @@ class HueLight(HueBaseEntity, LightEntity):
|
||||
color_xy=xy_color,
|
||||
color_temp=color_temp,
|
||||
transition_time=transition,
|
||||
allowed_errors=ALLOWED_ERRORS,
|
||||
)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
@@ -198,7 +192,6 @@ class HueLight(HueBaseEntity, LightEntity):
|
||||
id=self.resource.id,
|
||||
on=False,
|
||||
transition_time=transition,
|
||||
allowed_errors=ALLOWED_ERRORS,
|
||||
)
|
||||
|
||||
async def async_set_flash(self, flash: str) -> None:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Insteon",
|
||||
"documentation": "https://www.home-assistant.io/integrations/insteon",
|
||||
"requirements": [
|
||||
"pyinsteon==1.0.16"
|
||||
"pyinsteon==1.0.13"
|
||||
],
|
||||
"codeowners": [
|
||||
"@teharris1"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "isy994",
|
||||
"name": "Universal Devices ISY994",
|
||||
"documentation": "https://www.home-assistant.io/integrations/isy994",
|
||||
"requirements": ["pyisy==3.0.1"],
|
||||
"requirements": ["pyisy==3.0.5"],
|
||||
"codeowners": ["@bdraco", "@shbatm"],
|
||||
"config_flow": true,
|
||||
"ssdp": [
|
||||
|
||||
@@ -717,6 +717,8 @@ class KodiEntity(MediaPlayerEntity):
|
||||
await self._kodi.play_channel(int(media_id))
|
||||
elif media_type_lower == MEDIA_TYPE_PLAYLIST:
|
||||
await self._kodi.play_playlist(int(media_id))
|
||||
elif media_type_lower == "file":
|
||||
await self._kodi.play_file(media_id)
|
||||
elif media_type_lower == "directory":
|
||||
await self._kodi.play_directory(media_id)
|
||||
elif media_type_lower in [
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "LIFX",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/lifx",
|
||||
"requirements": ["aiolifx==0.7.0", "aiolifx_effects==0.2.2"],
|
||||
"requirements": ["aiolifx==0.7.1", "aiolifx_effects==0.2.2"],
|
||||
"homekit": {
|
||||
"models": ["LIFX"]
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Support for Honeywell Lyric climate platform."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from time import localtime, strftime, time
|
||||
|
||||
@@ -22,6 +23,7 @@ from homeassistant.components.climate.const import (
|
||||
HVAC_MODE_OFF,
|
||||
SUPPORT_PRESET_MODE,
|
||||
SUPPORT_TARGET_TEMPERATURE,
|
||||
SUPPORT_TARGET_TEMPERATURE_RANGE,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_TEMPERATURE
|
||||
@@ -45,7 +47,11 @@ from .const import (
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE
|
||||
# Only LCC models support presets
|
||||
SUPPORT_FLAGS_LCC = (
|
||||
SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE_RANGE
|
||||
)
|
||||
SUPPORT_FLAGS_TCC = SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_TEMPERATURE_RANGE
|
||||
|
||||
LYRIC_HVAC_ACTION_OFF = "EquipmentOff"
|
||||
LYRIC_HVAC_ACTION_HEAT = "Heat"
|
||||
@@ -166,7 +172,11 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
|
||||
@property
|
||||
def supported_features(self) -> int:
|
||||
"""Return the list of supported features."""
|
||||
return SUPPORT_FLAGS
|
||||
if self.device.changeableValues.thermostatSetpointStatus:
|
||||
support_flags = SUPPORT_FLAGS_LCC
|
||||
else:
|
||||
support_flags = SUPPORT_FLAGS_TCC
|
||||
return support_flags
|
||||
|
||||
@property
|
||||
def temperature_unit(self) -> str:
|
||||
@@ -200,25 +210,28 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the temperature we try to reach."""
|
||||
device = self.device
|
||||
if not device.hasDualSetpointStatus:
|
||||
if (
|
||||
not device.changeableValues.autoChangeoverActive
|
||||
and HVAC_MODES[device.changeableValues.mode] != HVAC_MODE_OFF
|
||||
):
|
||||
if self.hvac_mode == HVAC_MODE_COOL:
|
||||
return device.changeableValues.coolSetpoint
|
||||
return device.changeableValues.heatSetpoint
|
||||
return None
|
||||
|
||||
@property
|
||||
def target_temperature_low(self) -> float | None:
|
||||
"""Return the upper bound temperature we try to reach."""
|
||||
def target_temperature_high(self) -> float | None:
|
||||
"""Return the highbound target temperature we try to reach."""
|
||||
device = self.device
|
||||
if device.hasDualSetpointStatus:
|
||||
if device.changeableValues.autoChangeoverActive:
|
||||
return device.changeableValues.coolSetpoint
|
||||
return None
|
||||
|
||||
@property
|
||||
def target_temperature_high(self) -> float | None:
|
||||
"""Return the upper bound temperature we try to reach."""
|
||||
def target_temperature_low(self) -> float | None:
|
||||
"""Return the lowbound target temperature we try to reach."""
|
||||
device = self.device
|
||||
if device.hasDualSetpointStatus:
|
||||
if device.changeableValues.autoChangeoverActive:
|
||||
return device.changeableValues.heatSetpoint
|
||||
return None
|
||||
|
||||
@@ -256,11 +269,11 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
|
||||
|
||||
async def async_set_temperature(self, **kwargs) -> None:
|
||||
"""Set new target temperature."""
|
||||
device = self.device
|
||||
target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW)
|
||||
target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH)
|
||||
|
||||
device = self.device
|
||||
if device.hasDualSetpointStatus:
|
||||
if device.changeableValues.autoChangeoverActive:
|
||||
if target_temp_low is None or target_temp_high is None:
|
||||
raise HomeAssistantError(
|
||||
"Could not find target_temp_low and/or target_temp_high in arguments"
|
||||
@@ -270,11 +283,13 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
|
||||
await self._update_thermostat(
|
||||
self.location,
|
||||
device,
|
||||
coolSetpoint=target_temp_low,
|
||||
heatSetpoint=target_temp_high,
|
||||
coolSetpoint=target_temp_high,
|
||||
heatSetpoint=target_temp_low,
|
||||
mode=HVAC_MODES[device.changeableValues.heatCoolMode],
|
||||
)
|
||||
except LYRIC_EXCEPTIONS as exception:
|
||||
_LOGGER.error(exception)
|
||||
await self.coordinator.async_refresh()
|
||||
else:
|
||||
temp = kwargs.get(ATTR_TEMPERATURE)
|
||||
_LOGGER.debug("Set temperature: %s", temp)
|
||||
@@ -289,15 +304,58 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
|
||||
)
|
||||
except LYRIC_EXCEPTIONS as exception:
|
||||
_LOGGER.error(exception)
|
||||
await self.coordinator.async_refresh()
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: str) -> None:
|
||||
"""Set hvac mode."""
|
||||
_LOGGER.debug("Set hvac mode: %s", hvac_mode)
|
||||
_LOGGER.debug("HVAC mode: %s", hvac_mode)
|
||||
try:
|
||||
await self._update_thermostat(
|
||||
self.location, self.device, mode=LYRIC_HVAC_MODES[hvac_mode]
|
||||
)
|
||||
if LYRIC_HVAC_MODES[hvac_mode] == LYRIC_HVAC_MODE_HEAT_COOL:
|
||||
# If the system is off, turn it to Heat first then to Auto, otherwise it turns to
|
||||
# Auto briefly and then reverts to Off (perhaps related to heatCoolMode). This is the
|
||||
# behavior that happens with the native app as well, so likely a bug in the api itself
|
||||
|
||||
if HVAC_MODES[self.device.changeableValues.mode] == HVAC_MODE_OFF:
|
||||
_LOGGER.debug(
|
||||
"HVAC mode passed to lyric: %s",
|
||||
HVAC_MODES[LYRIC_HVAC_MODE_COOL],
|
||||
)
|
||||
await self._update_thermostat(
|
||||
self.location,
|
||||
self.device,
|
||||
mode=HVAC_MODES[LYRIC_HVAC_MODE_HEAT],
|
||||
autoChangeoverActive=False,
|
||||
)
|
||||
# Sleep 3 seconds before proceeding
|
||||
await asyncio.sleep(3)
|
||||
_LOGGER.debug(
|
||||
"HVAC mode passed to lyric: %s",
|
||||
HVAC_MODES[LYRIC_HVAC_MODE_HEAT],
|
||||
)
|
||||
await self._update_thermostat(
|
||||
self.location,
|
||||
self.device,
|
||||
mode=HVAC_MODES[LYRIC_HVAC_MODE_HEAT],
|
||||
autoChangeoverActive=True,
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"HVAC mode passed to lyric: %s",
|
||||
HVAC_MODES[self.device.changeableValues.mode],
|
||||
)
|
||||
await self._update_thermostat(
|
||||
self.location, self.device, autoChangeoverActive=True
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"HVAC mode passed to lyric: %s", LYRIC_HVAC_MODES[hvac_mode]
|
||||
)
|
||||
await self._update_thermostat(
|
||||
self.location,
|
||||
self.device,
|
||||
mode=LYRIC_HVAC_MODES[hvac_mode],
|
||||
autoChangeoverActive=False,
|
||||
)
|
||||
except LYRIC_EXCEPTIONS as exception:
|
||||
_LOGGER.error(exception)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
@@ -243,7 +243,10 @@ class MatrixBot:
|
||||
room.update_aliases()
|
||||
self._aliases_fetched_for.add(room.room_id)
|
||||
|
||||
if room_id_or_alias in room.aliases:
|
||||
if (
|
||||
room_id_or_alias in room.aliases
|
||||
or room_id_or_alias == room.canonical_alias
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"Already in room %s (known as %s)", room.room_id, room_id_or_alias
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "mediaroom",
|
||||
"name": "Mediaroom",
|
||||
"documentation": "https://www.home-assistant.io/integrations/mediaroom",
|
||||
"requirements": ["pymediaroom==0.6.4.1"],
|
||||
"requirements": ["pymediaroom==0.6.5.4"],
|
||||
"codeowners": ["@dgomes"],
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pymediaroom"]
|
||||
|
||||
@@ -13,7 +13,7 @@ import logging
|
||||
from operator import attrgetter
|
||||
import ssl
|
||||
import time
|
||||
from typing import Any, Union, cast
|
||||
from typing import TYPE_CHECKING, Any, Union, cast
|
||||
import uuid
|
||||
|
||||
import attr
|
||||
@@ -75,11 +75,16 @@ from .const import (
|
||||
ATTR_TOPIC,
|
||||
CONF_BIRTH_MESSAGE,
|
||||
CONF_BROKER,
|
||||
CONF_CERTIFICATE,
|
||||
CONF_CLIENT_CERT,
|
||||
CONF_CLIENT_KEY,
|
||||
CONF_COMMAND_TOPIC,
|
||||
CONF_ENCODING,
|
||||
CONF_QOS,
|
||||
CONF_RETAIN,
|
||||
CONF_STATE_TOPIC,
|
||||
CONF_TLS_INSECURE,
|
||||
CONF_TLS_VERSION,
|
||||
CONF_TOPIC,
|
||||
CONF_WILL_MESSAGE,
|
||||
DATA_MQTT_CONFIG,
|
||||
@@ -94,6 +99,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
MQTT_CONNECTED,
|
||||
MQTT_DISCONNECTED,
|
||||
PROTOCOL_31,
|
||||
PROTOCOL_311,
|
||||
)
|
||||
from .discovery import LAST_DISCOVERY
|
||||
@@ -107,6 +113,11 @@ from .models import (
|
||||
)
|
||||
from .util import _VALID_QOS_SCHEMA, valid_publish_topic, valid_subscribe_topic
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Only import for paho-mqtt type checking here, imports are done locally
|
||||
# because integrations should be able to optionally rely on MQTT.
|
||||
import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_SENTINEL = object()
|
||||
@@ -118,19 +129,19 @@ SERVICE_DUMP = "dump"
|
||||
|
||||
CONF_DISCOVERY_PREFIX = "discovery_prefix"
|
||||
CONF_KEEPALIVE = "keepalive"
|
||||
CONF_CERTIFICATE = "certificate"
|
||||
CONF_CLIENT_KEY = "client_key"
|
||||
CONF_CLIENT_CERT = "client_cert"
|
||||
CONF_TLS_INSECURE = "tls_insecure"
|
||||
CONF_TLS_VERSION = "tls_version"
|
||||
|
||||
PROTOCOL_31 = "3.1"
|
||||
|
||||
DEFAULT_PORT = 1883
|
||||
DEFAULT_KEEPALIVE = 60
|
||||
DEFAULT_PROTOCOL = PROTOCOL_311
|
||||
DEFAULT_TLS_PROTOCOL = "auto"
|
||||
|
||||
DEFAULT_VALUES = {
|
||||
CONF_PORT: DEFAULT_PORT,
|
||||
CONF_WILL_MESSAGE: DEFAULT_WILL,
|
||||
CONF_BIRTH_MESSAGE: DEFAULT_BIRTH,
|
||||
CONF_DISCOVERY: DEFAULT_DISCOVERY,
|
||||
}
|
||||
|
||||
ATTR_TOPIC_TEMPLATE = "topic_template"
|
||||
ATTR_PAYLOAD_TEMPLATE = "payload_template"
|
||||
|
||||
@@ -186,7 +197,7 @@ CONFIG_SCHEMA_BASE = vol.Schema(
|
||||
vol.Coerce(int), vol.Range(min=15)
|
||||
),
|
||||
vol.Optional(CONF_BROKER): cv.string,
|
||||
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
vol.Optional(CONF_PORT): cv.port,
|
||||
vol.Optional(CONF_USERNAME): cv.string,
|
||||
vol.Optional(CONF_PASSWORD): cv.string,
|
||||
vol.Optional(CONF_CERTIFICATE): vol.Any("auto", cv.isfile),
|
||||
@@ -203,9 +214,9 @@ CONFIG_SCHEMA_BASE = vol.Schema(
|
||||
vol.Optional(CONF_PROTOCOL, default=DEFAULT_PROTOCOL): vol.All(
|
||||
cv.string, vol.In([PROTOCOL_31, PROTOCOL_311])
|
||||
),
|
||||
vol.Optional(CONF_WILL_MESSAGE, default=DEFAULT_WILL): MQTT_WILL_BIRTH_SCHEMA,
|
||||
vol.Optional(CONF_BIRTH_MESSAGE, default=DEFAULT_BIRTH): MQTT_WILL_BIRTH_SCHEMA,
|
||||
vol.Optional(CONF_DISCOVERY, default=DEFAULT_DISCOVERY): cv.boolean,
|
||||
vol.Optional(CONF_WILL_MESSAGE): MQTT_WILL_BIRTH_SCHEMA,
|
||||
vol.Optional(CONF_BIRTH_MESSAGE): MQTT_WILL_BIRTH_SCHEMA,
|
||||
vol.Optional(CONF_DISCOVERY): cv.boolean,
|
||||
# discovery_prefix must be a valid publish topic because if no
|
||||
# state topic is specified, it will be created with the given prefix.
|
||||
vol.Optional(
|
||||
@@ -609,6 +620,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
def _merge_config(entry, conf):
|
||||
"""Merge configuration.yaml config with config entry."""
|
||||
# Base config on default values
|
||||
conf = {**DEFAULT_VALUES, **conf}
|
||||
return {**conf, **entry.data}
|
||||
|
||||
|
||||
@@ -628,6 +641,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
override,
|
||||
)
|
||||
|
||||
# Merge the configuration values from configuration.yaml
|
||||
conf = _merge_config(entry, conf)
|
||||
|
||||
hass.data[DATA_MQTT] = MQTT(
|
||||
@@ -757,6 +771,58 @@ class Subscription:
|
||||
encoding: str | None = attr.ib(default="utf-8")
|
||||
|
||||
|
||||
class MqttClientSetup:
|
||||
"""Helper class to setup the paho mqtt client from config."""
|
||||
|
||||
def __init__(self, config: ConfigType) -> None:
|
||||
"""Initialize the MQTT client setup helper."""
|
||||
|
||||
# We don't import on the top because some integrations
|
||||
# should be able to optionally rely on MQTT.
|
||||
import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel
|
||||
|
||||
if config[CONF_PROTOCOL] == PROTOCOL_31:
|
||||
proto = mqtt.MQTTv31
|
||||
else:
|
||||
proto = mqtt.MQTTv311
|
||||
|
||||
if (client_id := config.get(CONF_CLIENT_ID)) is None:
|
||||
# PAHO MQTT relies on the MQTT server to generate random client IDs.
|
||||
# However, that feature is not mandatory so we generate our own.
|
||||
client_id = mqtt.base62(uuid.uuid4().int, padding=22)
|
||||
self._client = mqtt.Client(client_id, protocol=proto)
|
||||
|
||||
# Enable logging
|
||||
self._client.enable_logger()
|
||||
|
||||
username = config.get(CONF_USERNAME)
|
||||
password = config.get(CONF_PASSWORD)
|
||||
if username is not None:
|
||||
self._client.username_pw_set(username, password)
|
||||
|
||||
if (certificate := config.get(CONF_CERTIFICATE)) == "auto":
|
||||
certificate = certifi.where()
|
||||
|
||||
client_key = config.get(CONF_CLIENT_KEY)
|
||||
client_cert = config.get(CONF_CLIENT_CERT)
|
||||
tls_insecure = config.get(CONF_TLS_INSECURE)
|
||||
if certificate is not None:
|
||||
self._client.tls_set(
|
||||
certificate,
|
||||
certfile=client_cert,
|
||||
keyfile=client_key,
|
||||
tls_version=ssl.PROTOCOL_TLS,
|
||||
)
|
||||
|
||||
if tls_insecure is not None:
|
||||
self._client.tls_insecure_set(tls_insecure)
|
||||
|
||||
@property
|
||||
def client(self) -> mqtt.Client:
|
||||
"""Return the paho MQTT client."""
|
||||
return self._client
|
||||
|
||||
|
||||
class MQTT:
|
||||
"""Home Assistant MQTT client."""
|
||||
|
||||
@@ -821,46 +887,7 @@ class MQTT:
|
||||
|
||||
def init_client(self):
|
||||
"""Initialize paho client."""
|
||||
# We don't import on the top because some integrations
|
||||
# should be able to optionally rely on MQTT.
|
||||
import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel
|
||||
|
||||
if self.conf[CONF_PROTOCOL] == PROTOCOL_31:
|
||||
proto: int = mqtt.MQTTv31
|
||||
else:
|
||||
proto = mqtt.MQTTv311
|
||||
|
||||
if (client_id := self.conf.get(CONF_CLIENT_ID)) is None:
|
||||
# PAHO MQTT relies on the MQTT server to generate random client IDs.
|
||||
# However, that feature is not mandatory so we generate our own.
|
||||
client_id = mqtt.base62(uuid.uuid4().int, padding=22)
|
||||
self._mqttc = mqtt.Client(client_id, protocol=proto)
|
||||
|
||||
# Enable logging
|
||||
self._mqttc.enable_logger()
|
||||
|
||||
username = self.conf.get(CONF_USERNAME)
|
||||
password = self.conf.get(CONF_PASSWORD)
|
||||
if username is not None:
|
||||
self._mqttc.username_pw_set(username, password)
|
||||
|
||||
if (certificate := self.conf.get(CONF_CERTIFICATE)) == "auto":
|
||||
certificate = certifi.where()
|
||||
|
||||
client_key = self.conf.get(CONF_CLIENT_KEY)
|
||||
client_cert = self.conf.get(CONF_CLIENT_CERT)
|
||||
tls_insecure = self.conf.get(CONF_TLS_INSECURE)
|
||||
if certificate is not None:
|
||||
self._mqttc.tls_set(
|
||||
certificate,
|
||||
certfile=client_cert,
|
||||
keyfile=client_key,
|
||||
tls_version=ssl.PROTOCOL_TLS,
|
||||
)
|
||||
|
||||
if tls_insecure is not None:
|
||||
self._mqttc.tls_insecure_set(tls_insecure)
|
||||
|
||||
self._mqttc = MqttClientSetup(self.conf).client
|
||||
self._mqttc.on_connect = self._mqtt_on_connect
|
||||
self._mqttc.on_disconnect = self._mqtt_on_disconnect
|
||||
self._mqttc.on_message = self._mqtt_on_message
|
||||
|
||||
@@ -271,7 +271,7 @@ _PLATFORM_SCHEMA_BASE = SCHEMA_BASE.extend(
|
||||
vol.Optional(CONF_HOLD_COMMAND_TOPIC): mqtt.valid_publish_topic,
|
||||
vol.Optional(CONF_HOLD_STATE_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_HOLD_STATE_TOPIC): mqtt.valid_subscribe_topic,
|
||||
vol.Optional(CONF_HOLD_LIST, default=list): cv.ensure_list,
|
||||
vol.Optional(CONF_HOLD_LIST): cv.ensure_list,
|
||||
vol.Optional(CONF_MODE_COMMAND_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic,
|
||||
vol.Optional(
|
||||
@@ -298,7 +298,7 @@ _PLATFORM_SCHEMA_BASE = SCHEMA_BASE.extend(
|
||||
),
|
||||
vol.Optional(CONF_RETAIN, default=mqtt.DEFAULT_RETAIN): cv.boolean,
|
||||
# CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9
|
||||
vol.Optional(CONF_SEND_IF_OFF, default=True): cv.boolean,
|
||||
vol.Optional(CONF_SEND_IF_OFF): cv.boolean,
|
||||
vol.Optional(CONF_ACTION_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_ACTION_TOPIC): mqtt.valid_subscribe_topic,
|
||||
# CONF_PRESET_MODE_COMMAND_TOPIC and CONF_PRESET_MODES_LIST must be used together
|
||||
@@ -431,6 +431,12 @@ class MqttClimate(MqttEntity, ClimateEntity):
|
||||
self._feature_preset_mode = False
|
||||
self._optimistic_preset_mode = None
|
||||
|
||||
# CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9
|
||||
self._send_if_off = True
|
||||
# AWAY and HOLD mode topics and templates are deprecated,
|
||||
# support will be removed with release 2022.9
|
||||
self._hold_list = []
|
||||
|
||||
MqttEntity.__init__(self, hass, config, config_entry, discovery_data)
|
||||
|
||||
@staticmethod
|
||||
@@ -499,6 +505,15 @@ class MqttClimate(MqttEntity, ClimateEntity):
|
||||
|
||||
self._command_templates = command_templates
|
||||
|
||||
# CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9
|
||||
if CONF_SEND_IF_OFF in config:
|
||||
self._send_if_off = config[CONF_SEND_IF_OFF]
|
||||
|
||||
# AWAY and HOLD mode topics and templates are deprecated,
|
||||
# support will be removed with release 2022.9
|
||||
if CONF_HOLD_LIST in config:
|
||||
self._hold_list = config[CONF_HOLD_LIST]
|
||||
|
||||
def _prepare_subscribe_topics(self): # noqa: C901
|
||||
"""(Re)Subscribe to topics."""
|
||||
topics = {}
|
||||
@@ -806,7 +821,9 @@ class MqttClimate(MqttEntity, ClimateEntity):
|
||||
):
|
||||
presets.append(PRESET_AWAY)
|
||||
|
||||
presets.extend(self._config[CONF_HOLD_LIST])
|
||||
# AWAY and HOLD mode topics and templates are deprecated,
|
||||
# support will be removed with release 2022.9
|
||||
presets.extend(self._hold_list)
|
||||
|
||||
if presets:
|
||||
presets.insert(0, PRESET_NONE)
|
||||
@@ -847,10 +864,7 @@ class MqttClimate(MqttEntity, ClimateEntity):
|
||||
setattr(self, attr, temp)
|
||||
|
||||
# CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9
|
||||
if (
|
||||
self._config[CONF_SEND_IF_OFF]
|
||||
or self._current_operation != HVAC_MODE_OFF
|
||||
):
|
||||
if self._send_if_off or self._current_operation != HVAC_MODE_OFF:
|
||||
payload = self._command_templates[cmnd_template](temp)
|
||||
await self._publish(cmnd_topic, payload)
|
||||
|
||||
@@ -890,7 +904,7 @@ class MqttClimate(MqttEntity, ClimateEntity):
|
||||
async def async_set_swing_mode(self, swing_mode):
|
||||
"""Set new swing mode."""
|
||||
# CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9
|
||||
if self._config[CONF_SEND_IF_OFF] or self._current_operation != HVAC_MODE_OFF:
|
||||
if self._send_if_off or self._current_operation != HVAC_MODE_OFF:
|
||||
payload = self._command_templates[CONF_SWING_MODE_COMMAND_TEMPLATE](
|
||||
swing_mode
|
||||
)
|
||||
@@ -903,7 +917,7 @@ class MqttClimate(MqttEntity, ClimateEntity):
|
||||
async def async_set_fan_mode(self, fan_mode):
|
||||
"""Set new target temperature."""
|
||||
# CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9
|
||||
if self._config[CONF_SEND_IF_OFF] or self._current_operation != HVAC_MODE_OFF:
|
||||
if self._send_if_off or self._current_operation != HVAC_MODE_OFF:
|
||||
payload = self._command_templates[CONF_FAN_MODE_COMMAND_TEMPLATE](fan_mode)
|
||||
await self._publish(CONF_FAN_MODE_COMMAND_TOPIC, payload)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from . import MqttClientSetup
|
||||
from .const import (
|
||||
ATTR_PAYLOAD,
|
||||
ATTR_QOS,
|
||||
@@ -62,6 +63,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
can_connect = await self.hass.async_add_executor_job(
|
||||
try_connection,
|
||||
self.hass,
|
||||
user_input[CONF_BROKER],
|
||||
user_input[CONF_PORT],
|
||||
user_input.get(CONF_USERNAME),
|
||||
@@ -102,6 +104,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
data = self._hassio_discovery
|
||||
can_connect = await self.hass.async_add_executor_job(
|
||||
try_connection,
|
||||
self.hass,
|
||||
data[CONF_HOST],
|
||||
data[CONF_PORT],
|
||||
data.get(CONF_USERNAME),
|
||||
@@ -152,6 +155,7 @@ class MQTTOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
if user_input is not None:
|
||||
can_connect = await self.hass.async_add_executor_job(
|
||||
try_connection,
|
||||
self.hass,
|
||||
user_input[CONF_BROKER],
|
||||
user_input[CONF_PORT],
|
||||
user_input.get(CONF_USERNAME),
|
||||
@@ -313,19 +317,22 @@ class MQTTOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
)
|
||||
|
||||
|
||||
def try_connection(broker, port, username, password, protocol="3.1"):
|
||||
def try_connection(hass, broker, port, username, password, protocol="3.1"):
|
||||
"""Test if we can connect to an MQTT broker."""
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
import paho.mqtt.client as mqtt
|
||||
# We don't import on the top because some integrations
|
||||
# should be able to optionally rely on MQTT.
|
||||
import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel
|
||||
|
||||
if protocol == "3.1":
|
||||
proto = mqtt.MQTTv31
|
||||
else:
|
||||
proto = mqtt.MQTTv311
|
||||
|
||||
client = mqtt.Client(protocol=proto)
|
||||
if username and password:
|
||||
client.username_pw_set(username, password)
|
||||
# Get the config from configuration.yaml
|
||||
yaml_config = hass.data.get(DATA_MQTT_CONFIG, {})
|
||||
entry_config = {
|
||||
CONF_BROKER: broker,
|
||||
CONF_PORT: port,
|
||||
CONF_USERNAME: username,
|
||||
CONF_PASSWORD: password,
|
||||
CONF_PROTOCOL: protocol,
|
||||
}
|
||||
client = MqttClientSetup({**yaml_config, **entry_config}).client
|
||||
|
||||
result = queue.Queue(maxsize=1)
|
||||
|
||||
|
||||
@@ -22,6 +22,12 @@ CONF_STATE_VALUE_TEMPLATE = "state_value_template"
|
||||
CONF_TOPIC = "topic"
|
||||
CONF_WILL_MESSAGE = "will_message"
|
||||
|
||||
CONF_CERTIFICATE = "certificate"
|
||||
CONF_CLIENT_KEY = "client_key"
|
||||
CONF_CLIENT_CERT = "client_cert"
|
||||
CONF_TLS_INSECURE = "tls_insecure"
|
||||
CONF_TLS_VERSION = "tls_version"
|
||||
|
||||
DATA_MQTT_CONFIG = "mqtt_config"
|
||||
DATA_MQTT_RELOAD_NEEDED = "mqtt_reload_needed"
|
||||
|
||||
@@ -56,4 +62,5 @@ MQTT_DISCONNECTED = "mqtt_disconnected"
|
||||
PAYLOAD_EMPTY_JSON = "{}"
|
||||
PAYLOAD_NONE = "None"
|
||||
|
||||
PROTOCOL_31 = "3.1"
|
||||
PROTOCOL_311 = "3.1.1"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "obihai",
|
||||
"name": "Obihai",
|
||||
"documentation": "https://www.home-assistant.io/integrations/obihai",
|
||||
"requirements": ["pyobihai==1.3.1"],
|
||||
"requirements": ["pyobihai==1.3.2"],
|
||||
"codeowners": ["@dshokouhi"],
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyobihai"]
|
||||
|
||||
@@ -43,7 +43,7 @@ async def async_setup_platform(
|
||||
station_id = config[CONF_STATION_ID]
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
osm_api = OpenSenseMapData(OpenSenseMap(station_id, hass.loop, session))
|
||||
osm_api = OpenSenseMapData(OpenSenseMap(station_id, session))
|
||||
|
||||
await osm_api.async_update()
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "opensensemap",
|
||||
"name": "openSenseMap",
|
||||
"documentation": "https://www.home-assistant.io/integrations/opensensemap",
|
||||
"requirements": ["opensensemap-api==0.1.5"],
|
||||
"requirements": ["opensensemap-api==0.2.0"],
|
||||
"codeowners": [],
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["opensensemap_api"]
|
||||
|
||||
@@ -97,6 +97,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
token_saver=token_saver,
|
||||
)
|
||||
try:
|
||||
# pylint: disable-next=fixme
|
||||
# TODO Remove authlib constraint when refactoring this code
|
||||
await session.ensure_active_token()
|
||||
except ConnectTimeout as err:
|
||||
_LOGGER.debug("Connection Timeout")
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Radio Browser",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/radio",
|
||||
"requirements": ["radios==0.1.0"],
|
||||
"requirements": ["radios==0.1.1"],
|
||||
"codeowners": ["@frenck"],
|
||||
"iot_class": "cloud_polling"
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/renault",
|
||||
"requirements": [
|
||||
"renault-api==0.1.9"
|
||||
"renault-api==0.1.10"
|
||||
],
|
||||
"codeowners": [
|
||||
"@epenet"
|
||||
|
||||
@@ -104,7 +104,7 @@ class RenaultVehicleProxy:
|
||||
coordinator = self.coordinators[key]
|
||||
if coordinator.not_supported:
|
||||
# Remove endpoint as it is not supported for this vehicle.
|
||||
LOGGER.warning(
|
||||
LOGGER.info(
|
||||
"Ignoring endpoint %s as it is not supported for this vehicle: %s",
|
||||
coordinator.name,
|
||||
coordinator.last_exception,
|
||||
@@ -112,7 +112,7 @@ class RenaultVehicleProxy:
|
||||
del self.coordinators[key]
|
||||
elif coordinator.access_denied:
|
||||
# Remove endpoint as it is denied for this vehicle.
|
||||
LOGGER.warning(
|
||||
LOGGER.info(
|
||||
"Ignoring endpoint %s as it is denied for this vehicle: %s",
|
||||
coordinator.name,
|
||||
coordinator.last_exception,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "rfxtrx",
|
||||
"name": "RFXCOM RFXtrx",
|
||||
"documentation": "https://www.home-assistant.io/integrations/rfxtrx",
|
||||
"requirements": ["pyRFXtrx==0.27.1"],
|
||||
"requirements": ["pyRFXtrx==0.28.0"],
|
||||
"codeowners": ["@danielhiversen", "@elupus", "@RobBie1221"],
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push",
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
"""Support for Roku."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from functools import wraps
|
||||
import logging
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from rokuecp import RokuConnectionError, RokuError
|
||||
from typing_extensions import Concatenate, ParamSpec
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -16,7 +8,6 @@ from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import RokuDataUpdateCoordinator
|
||||
from .entity import RokuEntity
|
||||
|
||||
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
|
||||
|
||||
@@ -27,10 +18,6 @@ PLATFORMS = [
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
]
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_T = TypeVar("_T", bound="RokuEntity")
|
||||
_P = ParamSpec("_P")
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
@@ -53,22 +40,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
return unload_ok
|
||||
|
||||
|
||||
def roku_exception_handler(
|
||||
func: Callable[Concatenate[_T, _P], Awaitable[None]] # type: ignore[misc]
|
||||
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: # type: ignore[misc]
|
||||
"""Decorate Roku calls to handle Roku exceptions."""
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
|
||||
try:
|
||||
await func(self, *args, **kwargs)
|
||||
except RokuConnectionError as error:
|
||||
if self.available:
|
||||
_LOGGER.error("Error communicating with API: %s", error)
|
||||
except RokuError as error:
|
||||
if self.available:
|
||||
_LOGGER.error("Invalid response from API: %s", error)
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
"""Helpers for Roku."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from functools import wraps
|
||||
import logging
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from rokuecp import RokuConnectionError, RokuConnectionTimeoutError, RokuError
|
||||
from typing_extensions import Concatenate, ParamSpec
|
||||
|
||||
from .entity import RokuEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_T = TypeVar("_T", bound=RokuEntity)
|
||||
_P = ParamSpec("_P")
|
||||
|
||||
|
||||
def format_channel_name(channel_number: str, channel_name: str | None = None) -> str:
|
||||
"""Format a Roku Channel name."""
|
||||
@@ -8,3 +23,28 @@ def format_channel_name(channel_number: str, channel_name: str | None = None) ->
|
||||
return f"{channel_name} ({channel_number})"
|
||||
|
||||
return channel_number
|
||||
|
||||
|
||||
def roku_exception_handler(ignore_timeout: bool = False) -> Callable[..., Callable]:
|
||||
"""Decorate Roku calls to handle Roku exceptions."""
|
||||
|
||||
def decorator(
|
||||
func: Callable[Concatenate[_T, _P], Awaitable[None]], # type: ignore[misc]
|
||||
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: # type: ignore[misc]
|
||||
@wraps(func)
|
||||
async def wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
|
||||
try:
|
||||
await func(self, *args, **kwargs)
|
||||
except RokuConnectionTimeoutError as error:
|
||||
if not ignore_timeout and self.available:
|
||||
_LOGGER.error("Error communicating with API: %s", error)
|
||||
except RokuConnectionError as error:
|
||||
if self.available:
|
||||
_LOGGER.error("Error communicating with API: %s", error)
|
||||
except RokuError as error:
|
||||
if self.available:
|
||||
_LOGGER.error("Invalid response from API: %s", error)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "roku",
|
||||
"name": "Roku",
|
||||
"documentation": "https://www.home-assistant.io/integrations/roku",
|
||||
"requirements": ["rokuecp==0.14.1"],
|
||||
"requirements": ["rokuecp==0.15.0"],
|
||||
"homekit": {
|
||||
"models": ["3810X", "4660X", "7820X", "C105X", "C135X"]
|
||||
},
|
||||
|
||||
@@ -51,7 +51,6 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_platform
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import roku_exception_handler
|
||||
from .browse_media import async_browse_media
|
||||
from .const import (
|
||||
ATTR_ARTIST_NAME,
|
||||
@@ -65,7 +64,7 @@ from .const import (
|
||||
)
|
||||
from .coordinator import RokuDataUpdateCoordinator
|
||||
from .entity import RokuEntity
|
||||
from .helpers import format_channel_name
|
||||
from .helpers import format_channel_name, roku_exception_handler
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -289,7 +288,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity):
|
||||
app.name for app in self.coordinator.data.apps if app.name is not None
|
||||
)
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def search(self, keyword: str) -> None:
|
||||
"""Emulate opening the search screen and entering the search keyword."""
|
||||
await self.coordinator.roku.search(keyword)
|
||||
@@ -321,68 +320,68 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity):
|
||||
media_content_type,
|
||||
)
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn on the Roku."""
|
||||
await self.coordinator.roku.remote("poweron")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler(ignore_timeout=True)
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn off the Roku."""
|
||||
await self.coordinator.roku.remote("poweroff")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_media_pause(self) -> None:
|
||||
"""Send pause command."""
|
||||
if self.state not in (STATE_STANDBY, STATE_PAUSED):
|
||||
await self.coordinator.roku.remote("play")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_media_play(self) -> None:
|
||||
"""Send play command."""
|
||||
if self.state not in (STATE_STANDBY, STATE_PLAYING):
|
||||
await self.coordinator.roku.remote("play")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_media_play_pause(self) -> None:
|
||||
"""Send play/pause command."""
|
||||
if self.state != STATE_STANDBY:
|
||||
await self.coordinator.roku.remote("play")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_media_previous_track(self) -> None:
|
||||
"""Send previous track command."""
|
||||
await self.coordinator.roku.remote("reverse")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_media_next_track(self) -> None:
|
||||
"""Send next track command."""
|
||||
await self.coordinator.roku.remote("forward")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
"""Mute the volume."""
|
||||
await self.coordinator.roku.remote("volume_mute")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_volume_up(self) -> None:
|
||||
"""Volume up media player."""
|
||||
await self.coordinator.roku.remote("volume_up")
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_volume_down(self) -> None:
|
||||
"""Volume down media player."""
|
||||
await self.coordinator.roku.remote("volume_down")
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_play_media(
|
||||
self, media_type: str, media_id: str, **kwargs: Any
|
||||
) -> None:
|
||||
@@ -487,7 +486,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity):
|
||||
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_select_source(self, source: str) -> None:
|
||||
"""Select input source."""
|
||||
if source == "Home":
|
||||
|
||||
@@ -9,10 +9,10 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import roku_exception_handler
|
||||
from .const import DOMAIN
|
||||
from .coordinator import RokuDataUpdateCoordinator
|
||||
from .entity import RokuEntity
|
||||
from .helpers import roku_exception_handler
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -44,19 +44,19 @@ class RokuRemote(RokuEntity, RemoteEntity):
|
||||
"""Return true if device is on."""
|
||||
return not self.coordinator.data.state.standby
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the device on."""
|
||||
await self.coordinator.roku.remote("poweron")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler(ignore_timeout=True)
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the device off."""
|
||||
await self.coordinator.roku.remote("poweroff")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None:
|
||||
"""Send a command to one device."""
|
||||
num_repeats = kwargs[ATTR_NUM_REPEATS]
|
||||
|
||||
@@ -12,11 +12,10 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import roku_exception_handler
|
||||
from .const import DOMAIN
|
||||
from .coordinator import RokuDataUpdateCoordinator
|
||||
from .entity import RokuEntity
|
||||
from .helpers import format_channel_name
|
||||
from .helpers import format_channel_name, roku_exception_handler
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -163,7 +162,7 @@ class RokuSelectEntity(RokuEntity, SelectEntity):
|
||||
"""Return a set of selectable options."""
|
||||
return self.entity_description.options_fn(self.coordinator.data)
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Set the option."""
|
||||
await self.entity_description.set_fn(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "sabnzbd",
|
||||
"name": "SABnzbd",
|
||||
"documentation": "https://www.home-assistant.io/integrations/sabnzbd",
|
||||
"requirements": ["pysabnzbd==1.1.0"],
|
||||
"requirements": ["pysabnzbd==1.1.1"],
|
||||
"dependencies": ["configurator"],
|
||||
"after_dependencies": ["discovery"],
|
||||
"codeowners": [],
|
||||
|
||||
@@ -10,7 +10,7 @@ from samsungctl import Remote
|
||||
from samsungctl.exceptions import AccessDenied, ConnectionClosed, UnhandledResponse
|
||||
from samsungtvws import SamsungTVWS
|
||||
from samsungtvws.exceptions import ConnectionFailure, HttpApiError
|
||||
from websocket import WebSocketException
|
||||
from websocket import WebSocketException, WebSocketTimeoutException
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
@@ -318,9 +318,10 @@ class SamsungTVWSBridge(SamsungTVBridge):
|
||||
|
||||
def _get_app_list(self) -> dict[str, str] | None:
|
||||
"""Get installed app list."""
|
||||
if self._app_list is None:
|
||||
if remote := self._get_remote():
|
||||
if self._app_list is None and (remote := self._get_remote()):
|
||||
with contextlib.suppress(TypeError, WebSocketTimeoutException):
|
||||
raw_app_list: list[dict[str, str]] = remote.app_list()
|
||||
LOGGER.debug("Received app list: %s", raw_app_list)
|
||||
self._app_list = {
|
||||
app["name"]: app["appId"]
|
||||
for app in sorted(raw_app_list, key=lambda app: app["name"])
|
||||
|
||||
@@ -10,7 +10,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components.light import ATTR_TRANSITION
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PLATFORM, SERVICE_TURN_ON
|
||||
from homeassistant.const import CONF_PLATFORM, SERVICE_TURN_ON, STATE_UNAVAILABLE
|
||||
from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
@@ -117,7 +117,11 @@ class Scene(RestoreEntity):
|
||||
"""Call when the scene is added to hass."""
|
||||
await super().async_internal_added_to_hass()
|
||||
state = await self.async_get_last_state()
|
||||
if state is not None and state.state is not None:
|
||||
if (
|
||||
state is not None
|
||||
and state.state is not None
|
||||
and state.state != STATE_UNAVAILABLE
|
||||
):
|
||||
self.__last_activated = state.state
|
||||
|
||||
def activate(self, **kwargs: Any) -> None:
|
||||
|
||||
@@ -79,7 +79,7 @@ class ScreenlogicConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult:
|
||||
"""Handle dhcp discovery."""
|
||||
mac = _extract_mac_from_name(discovery_info.hostname)
|
||||
mac = format_mac(discovery_info.macaddress)
|
||||
await self.async_set_unique_id(mac)
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_IP_ADDRESS: discovery_info.ip}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"dhcp": [
|
||||
{"registered_devices": true},
|
||||
{
|
||||
"hostname": "pentair: *",
|
||||
"hostname": "pentair*",
|
||||
"macaddress": "00C033*"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -175,7 +175,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Call a service to reload scripts."""
|
||||
if (conf := await component.async_prepare_reload()) is None:
|
||||
return
|
||||
|
||||
async_get_blueprints(hass).async_reset_cache()
|
||||
await _async_process_config(hass, conf, component)
|
||||
|
||||
async def turn_on_service(service: ServiceCall) -> None:
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_PASSWORD, CONF_TIMEOUT
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import ACTIVE_UPDATE_RATE, DEFAULT_TIMEOUT, DOMAIN, SENSE_TIMEOUT_EXCEPTIONS
|
||||
from .const import ACTIVE_UPDATE_RATE, DEFAULT_TIMEOUT, DOMAIN, SENSE_CONNECT_EXCEPTIONS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -76,7 +76,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
await self.validate_input(user_input)
|
||||
except SenseMFARequiredException:
|
||||
return await self.async_step_validation()
|
||||
except SENSE_TIMEOUT_EXCEPTIONS:
|
||||
except SENSE_CONNECT_EXCEPTIONS:
|
||||
errors["base"] = "cannot_connect"
|
||||
except SenseAuthenticationException:
|
||||
errors["base"] = "invalid_auth"
|
||||
@@ -93,7 +93,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if user_input:
|
||||
try:
|
||||
await self._gateway.validate_mfa(user_input[CONF_CODE])
|
||||
except SENSE_TIMEOUT_EXCEPTIONS:
|
||||
except SENSE_CONNECT_EXCEPTIONS:
|
||||
errors["base"] = "cannot_connect"
|
||||
except SenseAuthenticationException:
|
||||
errors["base"] = "invalid_auth"
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
import asyncio
|
||||
import socket
|
||||
|
||||
from sense_energy import SenseAPITimeoutException
|
||||
from sense_energy.sense_exceptions import SenseWebsocketException
|
||||
from sense_energy import (
|
||||
SenseAPIException,
|
||||
SenseAPITimeoutException,
|
||||
SenseWebsocketException,
|
||||
)
|
||||
|
||||
DOMAIN = "sense"
|
||||
DEFAULT_TIMEOUT = 10
|
||||
@@ -40,6 +43,11 @@ ICON = "mdi:flash"
|
||||
|
||||
SENSE_TIMEOUT_EXCEPTIONS = (asyncio.TimeoutError, SenseAPITimeoutException)
|
||||
SENSE_EXCEPTIONS = (socket.gaierror, SenseWebsocketException)
|
||||
SENSE_CONNECT_EXCEPTIONS = (
|
||||
asyncio.TimeoutError,
|
||||
SenseAPITimeoutException,
|
||||
SenseAPIException,
|
||||
)
|
||||
|
||||
MDI_ICONS = {
|
||||
"ac": "air-conditioner",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "sense",
|
||||
"name": "Sense",
|
||||
"documentation": "https://www.home-assistant.io/integrations/sense",
|
||||
"requirements": ["sense_energy==0.10.2"],
|
||||
"requirements": ["sense_energy==0.10.4"],
|
||||
"codeowners": ["@kbickar"],
|
||||
"config_flow": true,
|
||||
"dhcp": [
|
||||
|
||||
@@ -15,6 +15,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
|
||||
|
||||
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER, TIMEOUT
|
||||
|
||||
MAX_POSSIBLE_STEP = 1000
|
||||
|
||||
|
||||
class SensiboDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
"""A Sensibo Data Update Coordinator."""
|
||||
@@ -74,7 +76,11 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
.get("values", [0, 1])
|
||||
)
|
||||
if temperatures_list:
|
||||
temperature_step = temperatures_list[1] - temperatures_list[0]
|
||||
diff = MAX_POSSIBLE_STEP
|
||||
for i in range(len(temperatures_list) - 1):
|
||||
if temperatures_list[i + 1] - temperatures_list[i] < diff:
|
||||
diff = temperatures_list[i + 1] - temperatures_list[i]
|
||||
temperature_step = diff
|
||||
|
||||
active_features = list(ac_states)
|
||||
full_features = set()
|
||||
|
||||
@@ -317,4 +317,14 @@ class BlockSleepingClimate(
|
||||
|
||||
if self.device_block and self.block:
|
||||
_LOGGER.debug("Entity %s attached to blocks", self.name)
|
||||
|
||||
assert self.block.channel
|
||||
|
||||
self._preset_modes = [
|
||||
PRESET_NONE,
|
||||
*self.wrapper.device.settings["thermostats"][int(self.block.channel)][
|
||||
"schedule_profile_names"
|
||||
],
|
||||
]
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -336,7 +336,7 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity):
|
||||
ATTR_RGBW_COLOR
|
||||
]
|
||||
|
||||
if ATTR_EFFECT in kwargs:
|
||||
if ATTR_EFFECT in kwargs and ATTR_COLOR_TEMP not in kwargs:
|
||||
# Color effect change - used only in color mode, switch device mode to color
|
||||
set_mode = "color"
|
||||
if self.wrapper.model == "SHBLB-1":
|
||||
|
||||
@@ -174,6 +174,7 @@ SENSORS: Final = {
|
||||
value=lambda value: round(value / 1000, 2),
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
available=lambda block: cast(int, block.energy) != -1,
|
||||
),
|
||||
("emeter", "energyReturned"): BlockSensorDescription(
|
||||
key="emeter|energyReturned",
|
||||
@@ -182,6 +183,7 @@ SENSORS: Final = {
|
||||
value=lambda value: round(value / 1000, 2),
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
available=lambda block: cast(int, block.energyReturned) != -1,
|
||||
),
|
||||
("light", "energy"): BlockSensorDescription(
|
||||
key="light|energy",
|
||||
|
||||
@@ -9,22 +9,13 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_API_KEY, CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from .const import CONF_SITE_ID, DEFAULT_NAME, DOMAIN
|
||||
|
||||
|
||||
@callback
|
||||
def solaredge_entries(hass: HomeAssistant):
|
||||
"""Return the site_ids for the domain."""
|
||||
return {
|
||||
(entry.data[CONF_SITE_ID])
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
}
|
||||
|
||||
|
||||
class SolarEdgeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow."""
|
||||
|
||||
@@ -34,9 +25,18 @@ class SolarEdgeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Initialize the config flow."""
|
||||
self._errors = {}
|
||||
|
||||
@callback
|
||||
def _async_current_site_ids(self) -> set[str]:
|
||||
"""Return the site_ids for the domain."""
|
||||
return {
|
||||
entry.data[CONF_SITE_ID]
|
||||
for entry in self._async_current_entries(include_ignore=False)
|
||||
if CONF_SITE_ID in entry.data
|
||||
}
|
||||
|
||||
def _site_in_configuration_exists(self, site_id: str) -> bool:
|
||||
"""Return True if site_id exists in configuration."""
|
||||
return site_id in solaredge_entries(self.hass)
|
||||
return site_id in self._async_current_site_ids()
|
||||
|
||||
def _check_site(self, site_id: str, api_key: str) -> bool:
|
||||
"""Check if we can connect to the soleredge api service."""
|
||||
|
||||
@@ -79,6 +79,7 @@ class SomfyShade(RestoreEntity, CoverEntity):
|
||||
self._attr_unique_id = target_id
|
||||
self._attr_name = name
|
||||
self._reverse = reverse
|
||||
self._attr_is_closed = None
|
||||
self._attr_device_class = device_class
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._target_id)},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Sonos",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/sonos",
|
||||
"requirements": ["soco==0.26.3"],
|
||||
"requirements": ["soco==0.26.4"],
|
||||
"dependencies": ["ssdp"],
|
||||
"after_dependencies": ["plex", "spotify", "zeroconf", "media_source"],
|
||||
"zeroconf": ["_sonos._tcp.local."],
|
||||
|
||||
@@ -63,6 +63,7 @@ from .media import SonosMedia
|
||||
from .statistics import ActivityStatistics, EventStatistics
|
||||
|
||||
NEVER_TIME = -1200.0
|
||||
RESUB_COOLDOWN_SECONDS = 10.0
|
||||
EVENT_CHARGING = {
|
||||
"CHARGING": True,
|
||||
"NOT_CHARGING": False,
|
||||
@@ -126,6 +127,7 @@ class SonosSpeaker:
|
||||
self._last_event_cache: dict[str, Any] = {}
|
||||
self.activity_stats: ActivityStatistics = ActivityStatistics(self.zone_name)
|
||||
self.event_stats: EventStatistics = EventStatistics(self.zone_name)
|
||||
self._resub_cooldown_expires_at: float | None = None
|
||||
|
||||
# Scheduled callback handles
|
||||
self._poll_timer: Callable | None = None
|
||||
@@ -502,6 +504,16 @@ class SonosSpeaker:
|
||||
@callback
|
||||
def speaker_activity(self, source):
|
||||
"""Track the last activity on this speaker, set availability and resubscribe."""
|
||||
if self._resub_cooldown_expires_at:
|
||||
if time.monotonic() < self._resub_cooldown_expires_at:
|
||||
_LOGGER.debug(
|
||||
"Activity on %s from %s while in cooldown, ignoring",
|
||||
self.zone_name,
|
||||
source,
|
||||
)
|
||||
return
|
||||
self._resub_cooldown_expires_at = None
|
||||
|
||||
_LOGGER.debug("Activity on %s from %s", self.zone_name, source)
|
||||
self._last_activity = time.monotonic()
|
||||
self.activity_stats.activity(source, self._last_activity)
|
||||
@@ -542,6 +554,10 @@ class SonosSpeaker:
|
||||
if not self.available:
|
||||
return
|
||||
|
||||
if self._resub_cooldown_expires_at is None and not self.hass.is_stopping:
|
||||
self._resub_cooldown_expires_at = time.monotonic() + RESUB_COOLDOWN_SECONDS
|
||||
_LOGGER.debug("Starting resubscription cooldown for %s", self.zone_name)
|
||||
|
||||
self.available = False
|
||||
self.async_write_entity_states()
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ class SQLSensor(SensorEntity):
|
||||
else:
|
||||
self._attr_native_value = data
|
||||
|
||||
if not data:
|
||||
if data is None:
|
||||
_LOGGER.warning("%s returned no results", self._query)
|
||||
|
||||
sess.close()
|
||||
|
||||
@@ -101,6 +101,7 @@ class Sun(Entity):
|
||||
self.rising = self.phase = None
|
||||
self._next_change = None
|
||||
|
||||
@callback
|
||||
def update_location(_event):
|
||||
location, elevation = get_astral_location(self.hass)
|
||||
if location == self.location:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "synology_dsm",
|
||||
"name": "Synology DSM",
|
||||
"documentation": "https://www.home-assistant.io/integrations/synology_dsm",
|
||||
"requirements": ["py-synologydsm-api==1.0.6"],
|
||||
"requirements": ["py-synologydsm-api==1.0.7"],
|
||||
"codeowners": ["@hacf-fr", "@Quentame", "@mib1185"],
|
||||
"config_flow": true,
|
||||
"ssdp": [
|
||||
|
||||
@@ -277,8 +277,6 @@ class TemplateFan(TemplateEntity, FanEntity):
|
||||
"""Turn off the fan."""
|
||||
await self._off_script.async_run(context=self._context)
|
||||
self._state = STATE_OFF
|
||||
self._percentage = 0
|
||||
self._preset_mode = None
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the percentage speed of the fan."""
|
||||
|
||||
@@ -88,7 +88,10 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity):
|
||||
|
||||
# Handle turning to temp mode
|
||||
if ATTR_COLOR_TEMP in kwargs:
|
||||
color_tmp = mired_to_kelvin(int(kwargs[ATTR_COLOR_TEMP]))
|
||||
# Handle temp conversion mireds -> kelvin being slightly outside of valid range
|
||||
kelvin = mired_to_kelvin(int(kwargs[ATTR_COLOR_TEMP]))
|
||||
kelvin_range = self.device.valid_temperature_range
|
||||
color_tmp = max(kelvin_range.min, min(kelvin_range.max, kelvin))
|
||||
_LOGGER.debug("Changing color temp to %s", color_tmp)
|
||||
await self.device.set_color_temp(
|
||||
color_tmp, brightness=brightness, transition=transition
|
||||
|
||||
@@ -78,4 +78,4 @@ class VelbusCover(VelbusEntity, CoverEntity):
|
||||
|
||||
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover to a specific position."""
|
||||
self._channel.set_position(100 - kwargs[ATTR_POSITION])
|
||||
await self._channel.set_position(100 - kwargs[ATTR_POSITION])
|
||||
|
||||
@@ -14,7 +14,6 @@ from miio import (
|
||||
AirHumidifierMiot,
|
||||
AirHumidifierMjjsq,
|
||||
AirPurifier,
|
||||
AirPurifierMB4,
|
||||
AirPurifierMiot,
|
||||
CleaningDetails,
|
||||
CleaningSummary,
|
||||
@@ -23,10 +22,8 @@ from miio import (
|
||||
DNDStatus,
|
||||
Fan,
|
||||
Fan1C,
|
||||
FanMiot,
|
||||
FanP5,
|
||||
FanP9,
|
||||
FanP10,
|
||||
FanP11,
|
||||
FanZA5,
|
||||
RoborockVacuum,
|
||||
Timer,
|
||||
@@ -52,7 +49,6 @@ from .const import (
|
||||
KEY_DEVICE,
|
||||
MODEL_AIRFRESH_A1,
|
||||
MODEL_AIRFRESH_T2017,
|
||||
MODEL_AIRPURIFIER_3C,
|
||||
MODEL_FAN_1C,
|
||||
MODEL_FAN_P5,
|
||||
MODEL_FAN_P9,
|
||||
@@ -111,10 +107,10 @@ AIR_MONITOR_PLATFORMS = [Platform.AIR_QUALITY, Platform.SENSOR]
|
||||
|
||||
MODEL_TO_CLASS_MAP = {
|
||||
MODEL_FAN_1C: Fan1C,
|
||||
MODEL_FAN_P10: FanP10,
|
||||
MODEL_FAN_P11: FanP11,
|
||||
MODEL_FAN_P9: FanMiot,
|
||||
MODEL_FAN_P10: FanMiot,
|
||||
MODEL_FAN_P11: FanMiot,
|
||||
MODEL_FAN_P5: FanP5,
|
||||
MODEL_FAN_P9: FanP9,
|
||||
MODEL_FAN_ZA5: FanZA5,
|
||||
}
|
||||
|
||||
@@ -314,8 +310,6 @@ async def async_create_miio_device_and_coordinator(
|
||||
device = AirHumidifier(host, token, model=model)
|
||||
migrate = True
|
||||
# Airpurifiers and Airfresh
|
||||
elif model == MODEL_AIRPURIFIER_3C:
|
||||
device = AirPurifierMB4(host, token)
|
||||
elif model in MODELS_PURIFIER_MIOT:
|
||||
device = AirPurifierMiot(host, token)
|
||||
elif model.startswith("zhimi.airpurifier."):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Xiaomi Miio",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/xiaomi_miio",
|
||||
"requirements": ["construct==2.10.56", "micloud==0.5", "python-miio==0.5.10"],
|
||||
"requirements": ["construct==2.10.56", "micloud==0.5", "python-miio==0.5.11"],
|
||||
"codeowners": ["@rytilahti", "@syssi", "@starkillerOG", "@bieniu"],
|
||||
"zeroconf": ["_miio._udp.local."],
|
||||
"iot_class": "local_polling",
|
||||
|
||||
@@ -51,7 +51,7 @@ DEFAULT_URL = "ws://localhost:3000"
|
||||
TITLE = "Z-Wave JS"
|
||||
|
||||
ADDON_SETUP_TIMEOUT = 5
|
||||
ADDON_SETUP_TIMEOUT_ROUNDS = 4
|
||||
ADDON_SETUP_TIMEOUT_ROUNDS = 40
|
||||
CONF_EMULATE_HARDWARE = "emulate_hardware"
|
||||
CONF_LOG_LEVEL = "log_level"
|
||||
SERVER_VERSION_TIMEOUT = 10
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Z-Wave JS",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/zwave_js",
|
||||
"requirements": ["zwave-js-server-python==0.35.1"],
|
||||
"requirements": ["zwave-js-server-python==0.35.2"],
|
||||
"codeowners": ["@home-assistant/z-wave"],
|
||||
"dependencies": ["usb", "http", "websocket_api"],
|
||||
"iot_class": "local_push",
|
||||
|
||||
@@ -7,7 +7,7 @@ from .backports.enum import StrEnum
|
||||
|
||||
MAJOR_VERSION: Final = 2022
|
||||
MINOR_VERSION: Final = 3
|
||||
PATCH_VERSION: Final = "0"
|
||||
PATCH_VERSION: Final = "8"
|
||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0)
|
||||
|
||||
@@ -81,7 +81,7 @@ DHCP: list[dict[str, str | bool]] = [
|
||||
{'domain': 'samsungtv', 'macaddress': '4844F7*'},
|
||||
{'domain': 'samsungtv', 'macaddress': '8CEA48*'},
|
||||
{'domain': 'screenlogic', 'registered_devices': True},
|
||||
{'domain': 'screenlogic', 'hostname': 'pentair: *', 'macaddress': '00C033*'},
|
||||
{'domain': 'screenlogic', 'hostname': 'pentair*', 'macaddress': '00C033*'},
|
||||
{'domain': 'sense', 'hostname': 'sense-*', 'macaddress': '009D6B*'},
|
||||
{'domain': 'sense', 'hostname': 'sense-*', 'macaddress': 'DCEFCA*'},
|
||||
{'domain': 'sense', 'hostname': 'sense-*', 'macaddress': 'A4D578*'},
|
||||
|
||||
@@ -6,6 +6,7 @@ import asyncio
|
||||
from collections.abc import Awaitable, Iterable, Mapping, MutableMapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum, auto
|
||||
import functools as ft
|
||||
import logging
|
||||
import math
|
||||
@@ -207,6 +208,19 @@ class EntityCategory(StrEnum):
|
||||
SYSTEM = "system"
|
||||
|
||||
|
||||
class EntityPlatformState(Enum):
|
||||
"""The platform state of an entity."""
|
||||
|
||||
# Not Added: Not yet added to a platform, polling updates are written to the state machine
|
||||
NOT_ADDED = auto()
|
||||
|
||||
# Added: Added to a platform, polling updates are written to the state machine
|
||||
ADDED = auto()
|
||||
|
||||
# Removed: Removed from a platform, polling updates are not written to the state machine
|
||||
REMOVED = auto()
|
||||
|
||||
|
||||
def convert_to_entity_category(
|
||||
value: EntityCategory | str | None, raise_report: bool = True
|
||||
) -> EntityCategory | None:
|
||||
@@ -294,7 +308,7 @@ class Entity(ABC):
|
||||
_context_set: datetime | None = None
|
||||
|
||||
# If entity is added to an entity platform
|
||||
_added = False
|
||||
_platform_state = EntityPlatformState.NOT_ADDED
|
||||
|
||||
# Entity Properties
|
||||
_attr_assumed_state: bool = False
|
||||
@@ -553,6 +567,10 @@ class Entity(ABC):
|
||||
@callback
|
||||
def _async_write_ha_state(self) -> None:
|
||||
"""Write the state to the state machine."""
|
||||
if self._platform_state == EntityPlatformState.REMOVED:
|
||||
# Polling returned after the entity has already been removed
|
||||
return
|
||||
|
||||
if self.registry_entry and self.registry_entry.disabled_by:
|
||||
if not self._disabled_reported:
|
||||
self._disabled_reported = True
|
||||
@@ -758,7 +776,7 @@ class Entity(ABC):
|
||||
parallel_updates: asyncio.Semaphore | None,
|
||||
) -> None:
|
||||
"""Start adding an entity to a platform."""
|
||||
if self._added:
|
||||
if self._platform_state == EntityPlatformState.ADDED:
|
||||
raise HomeAssistantError(
|
||||
f"Entity {self.entity_id} cannot be added a second time to an entity platform"
|
||||
)
|
||||
@@ -766,7 +784,7 @@ class Entity(ABC):
|
||||
self.hass = hass
|
||||
self.platform = platform
|
||||
self.parallel_updates = parallel_updates
|
||||
self._added = True
|
||||
self._platform_state = EntityPlatformState.ADDED
|
||||
|
||||
@callback
|
||||
def add_to_platform_abort(self) -> None:
|
||||
@@ -774,7 +792,7 @@ class Entity(ABC):
|
||||
self.hass = None # type: ignore[assignment]
|
||||
self.platform = None
|
||||
self.parallel_updates = None
|
||||
self._added = False
|
||||
self._platform_state = EntityPlatformState.NOT_ADDED
|
||||
|
||||
async def add_to_platform_finish(self) -> None:
|
||||
"""Finish adding an entity to a platform."""
|
||||
@@ -792,12 +810,12 @@ class Entity(ABC):
|
||||
If the entity doesn't have a non disabled entry in the entity registry,
|
||||
or if force_remove=True, its state will be removed.
|
||||
"""
|
||||
if self.platform and not self._added:
|
||||
if self.platform and self._platform_state != EntityPlatformState.ADDED:
|
||||
raise HomeAssistantError(
|
||||
f"Entity {self.entity_id} async_remove called twice"
|
||||
)
|
||||
|
||||
self._added = False
|
||||
self._platform_state = EntityPlatformState.REMOVED
|
||||
|
||||
if self._on_remove is not None:
|
||||
while self._on_remove:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user