forked from home-assistant/core
Compare commits
82 Commits
2022.8.0b3
...
2022.8.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfb2867e8d | ||
|
|
c9581f6a2e | ||
|
|
e96903fddf | ||
|
|
5026bff426 | ||
|
|
4b63aa7f15 | ||
|
|
1c2dd78e4c | ||
|
|
9cf11cf6ed | ||
|
|
8971a2073e | ||
|
|
bfa64d2e01 | ||
|
|
9c21d56539 | ||
|
|
8bfc352524 | ||
|
|
0e7bf35e4a | ||
|
|
1dd701a89a | ||
|
|
d266b1ced6 | ||
|
|
6727dab330 | ||
|
|
42509056bd | ||
|
|
a370e4f4b0 | ||
|
|
a17e99f714 | ||
|
|
db227a888d | ||
|
|
1808dd3d84 | ||
|
|
31fed328ce | ||
|
|
1a030f118a | ||
|
|
a4049e93d8 | ||
|
|
854ca853dc | ||
|
|
2710e4b5ec | ||
|
|
450af52bac | ||
|
|
60da54558e | ||
|
|
11319defae | ||
|
|
6340da72a5 | ||
|
|
5c9d557b10 | ||
|
|
d2955a48b0 | ||
|
|
d2b98fa285 | ||
|
|
8ef3ca2daf | ||
|
|
80a053a4cd | ||
|
|
81ee24738b | ||
|
|
29f6d7818a | ||
|
|
bc1e371cae | ||
|
|
42a1f6ca20 | ||
|
|
d85129c527 | ||
|
|
ad14b5f3d7 | ||
|
|
51a6899a60 | ||
|
|
d2dc83c4c7 | ||
|
|
d7a418a219 | ||
|
|
a78da6a000 | ||
|
|
690f051a87 | ||
|
|
c22cb13bd0 | ||
|
|
213812f087 | ||
|
|
19b0961084 | ||
|
|
e073f6b439 | ||
|
|
c4906414ea | ||
|
|
cc9a130f58 | ||
|
|
c90a223cb6 | ||
|
|
2eddbf2381 | ||
|
|
654e26052b | ||
|
|
676664022d | ||
|
|
ed57951571 | ||
|
|
b9ee81dfc3 | ||
|
|
da00f5ba1e | ||
|
|
30cd087f6f | ||
|
|
66afd1e696 | ||
|
|
23488f392b | ||
|
|
7140a9d025 | ||
|
|
4f671bccbc | ||
|
|
6b588d41ff | ||
|
|
b962a6e767 | ||
|
|
a332eb154c | ||
|
|
75747ce319 | ||
|
|
c6038380d6 | ||
|
|
990975e908 | ||
|
|
2a58bf06c1 | ||
|
|
5ab549653b | ||
|
|
ffd2813150 | ||
|
|
ebf91fe46b | ||
|
|
e330147751 | ||
|
|
26a3621bb3 | ||
|
|
58265664d1 | ||
|
|
d205fb5064 | ||
|
|
38ae2f4e9e | ||
|
|
d84bc20a58 | ||
|
|
a3276e00b9 | ||
|
|
bdb627539e | ||
|
|
240890e496 |
@@ -388,6 +388,7 @@ omit =
|
||||
homeassistant/components/flume/__init__.py
|
||||
homeassistant/components/flume/sensor.py
|
||||
homeassistant/components/flunearyou/__init__.py
|
||||
homeassistant/components/flunearyou/repairs.py
|
||||
homeassistant/components/flunearyou/sensor.py
|
||||
homeassistant/components/folder/sensor.py
|
||||
homeassistant/components/folder_watcher/*
|
||||
|
||||
@@ -128,6 +128,7 @@ homeassistant.components.homekit.util
|
||||
homeassistant.components.homekit_controller
|
||||
homeassistant.components.homekit_controller.alarm_control_panel
|
||||
homeassistant.components.homekit_controller.button
|
||||
homeassistant.components.homekit_controller.config_flow
|
||||
homeassistant.components.homekit_controller.const
|
||||
homeassistant.components.homekit_controller.lock
|
||||
homeassistant.components.homekit_controller.select
|
||||
|
||||
@@ -1044,8 +1044,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/switch/ @home-assistant/core
|
||||
/homeassistant/components/switch_as_x/ @home-assistant/core
|
||||
/tests/components/switch_as_x/ @home-assistant/core
|
||||
/homeassistant/components/switchbot/ @bdraco @danielhiversen @RenierM26 @murtas
|
||||
/tests/components/switchbot/ @bdraco @danielhiversen @RenierM26 @murtas
|
||||
/homeassistant/components/switchbot/ @bdraco @danielhiversen @RenierM26 @murtas @Eloston
|
||||
/tests/components/switchbot/ @bdraco @danielhiversen @RenierM26 @murtas @Eloston
|
||||
/homeassistant/components/switcher_kis/ @tomerfi @thecode
|
||||
/tests/components/switcher_kis/ @tomerfi @thecode
|
||||
/homeassistant/components/switchmate/ @danielhiversen @qiz-li
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "aladdin_connect",
|
||||
"name": "Aladdin Connect",
|
||||
"documentation": "https://www.home-assistant.io/integrations/aladdin_connect",
|
||||
"requirements": ["AIOAladdinConnect==0.1.31"],
|
||||
"requirements": ["AIOAladdinConnect==0.1.41"],
|
||||
"codeowners": ["@mkmer"],
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aladdin_connect"],
|
||||
|
||||
@@ -68,16 +68,19 @@ API_TEMP_UNITS = {TEMP_FAHRENHEIT: "FAHRENHEIT", TEMP_CELSIUS: "CELSIUS"}
|
||||
# back to HA state.
|
||||
API_THERMOSTAT_MODES = OrderedDict(
|
||||
[
|
||||
(climate.HVAC_MODE_HEAT, "HEAT"),
|
||||
(climate.HVAC_MODE_COOL, "COOL"),
|
||||
(climate.HVAC_MODE_HEAT_COOL, "AUTO"),
|
||||
(climate.HVAC_MODE_AUTO, "AUTO"),
|
||||
(climate.HVAC_MODE_OFF, "OFF"),
|
||||
(climate.HVAC_MODE_FAN_ONLY, "OFF"),
|
||||
(climate.HVAC_MODE_DRY, "CUSTOM"),
|
||||
(climate.HVACMode.HEAT, "HEAT"),
|
||||
(climate.HVACMode.COOL, "COOL"),
|
||||
(climate.HVACMode.HEAT_COOL, "AUTO"),
|
||||
(climate.HVACMode.AUTO, "AUTO"),
|
||||
(climate.HVACMode.OFF, "OFF"),
|
||||
(climate.HVACMode.FAN_ONLY, "CUSTOM"),
|
||||
(climate.HVACMode.DRY, "CUSTOM"),
|
||||
]
|
||||
)
|
||||
API_THERMOSTAT_MODES_CUSTOM = {climate.HVAC_MODE_DRY: "DEHUMIDIFY"}
|
||||
API_THERMOSTAT_MODES_CUSTOM = {
|
||||
climate.HVACMode.DRY: "DEHUMIDIFY",
|
||||
climate.HVACMode.FAN_ONLY: "FAN",
|
||||
}
|
||||
API_THERMOSTAT_PRESETS = {climate.PRESET_ECO: "ECO"}
|
||||
|
||||
# AlexaModeController does not like a single mode for the fan preset, we add PRESET_MODE_NA if a fan has only one preset_mode
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Apple TV",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/apple_tv",
|
||||
"requirements": ["pyatv==0.10.2"],
|
||||
"requirements": ["pyatv==0.10.3"],
|
||||
"dependencies": ["zeroconf"],
|
||||
"zeroconf": [
|
||||
"_mediaremotetv._tcp.local.",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "azure_service_bus",
|
||||
"name": "Azure Service Bus",
|
||||
"documentation": "https://www.home-assistant.io/integrations/azure_service_bus",
|
||||
"requirements": ["azure-servicebus==0.50.3"],
|
||||
"requirements": ["azure-servicebus==7.8.0"],
|
||||
"codeowners": ["@hfurubotten"],
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["azure"]
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from azure.servicebus.aio import Message, ServiceBusClient
|
||||
from azure.servicebus.common.errors import (
|
||||
MessageSendFailed,
|
||||
from azure.servicebus import ServiceBusMessage
|
||||
from azure.servicebus.aio import ServiceBusClient
|
||||
from azure.servicebus.exceptions import (
|
||||
MessagingEntityNotFoundError,
|
||||
ServiceBusConnectionError,
|
||||
ServiceBusResourceNotFound,
|
||||
ServiceBusError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -60,10 +61,10 @@ def get_service(hass, config, discovery_info=None):
|
||||
|
||||
try:
|
||||
if queue_name:
|
||||
client = servicebus.get_queue(queue_name)
|
||||
client = servicebus.get_queue_sender(queue_name)
|
||||
else:
|
||||
client = servicebus.get_topic(topic_name)
|
||||
except (ServiceBusConnectionError, ServiceBusResourceNotFound) as err:
|
||||
client = servicebus.get_topic_sender(topic_name)
|
||||
except (ServiceBusConnectionError, MessagingEntityNotFoundError) as err:
|
||||
_LOGGER.error(
|
||||
"Connection error while creating client for queue/topic '%s'. %s",
|
||||
queue_name or topic_name,
|
||||
@@ -93,11 +94,12 @@ class ServiceBusNotificationService(BaseNotificationService):
|
||||
if data := kwargs.get(ATTR_DATA):
|
||||
dto.update(data)
|
||||
|
||||
queue_message = Message(json.dumps(dto))
|
||||
queue_message.properties.content_type = CONTENT_TYPE_JSON
|
||||
queue_message = ServiceBusMessage(
|
||||
json.dumps(dto), content_type=CONTENT_TYPE_JSON
|
||||
)
|
||||
try:
|
||||
await self._client.send(queue_message)
|
||||
except MessageSendFailed as err:
|
||||
await self._client.send_messages(queue_message)
|
||||
except ServiceBusError as err:
|
||||
_LOGGER.error(
|
||||
"Could not send service bus notification to %s. %s",
|
||||
self._client.name,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Big Ass Fans",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/baf",
|
||||
"requirements": ["aiobafi6==0.7.0"],
|
||||
"requirements": ["aiobafi6==0.7.2"],
|
||||
"codeowners": ["@bdraco", "@jfroy"],
|
||||
"iot_class": "local_push",
|
||||
"zeroconf": [
|
||||
|
||||
@@ -8,12 +8,12 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
import logging
|
||||
from typing import Final
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
import async_timeout
|
||||
from bleak import BleakError
|
||||
from bleak.backends.device import BLEDevice
|
||||
from bleak.backends.scanner import AdvertisementData
|
||||
from dbus_next import InvalidMessageError
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
@@ -27,8 +27,8 @@ from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import discovery_flow
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import async_get_bluetooth
|
||||
from homeassistant.util.package import is_docker_env
|
||||
|
||||
from . import models
|
||||
from .const import CONF_ADAPTER, DEFAULT_ADAPTERS, DOMAIN
|
||||
@@ -42,14 +42,25 @@ from .models import HaBleakScanner, HaBleakScannerWrapper
|
||||
from .usage import install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher
|
||||
from .util import async_get_bluetooth_adapters
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bleak.backends.device import BLEDevice
|
||||
from bleak.backends.scanner import AdvertisementData
|
||||
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
UNAVAILABLE_TRACK_SECONDS: Final = 60 * 5
|
||||
START_TIMEOUT = 15
|
||||
START_TIMEOUT = 9
|
||||
|
||||
SOURCE_LOCAL: Final = "local"
|
||||
|
||||
SCANNER_WATCHDOG_TIMEOUT: Final = 60 * 5
|
||||
SCANNER_WATCHDOG_INTERVAL: Final = timedelta(seconds=SCANNER_WATCHDOG_TIMEOUT)
|
||||
MONOTONIC_TIME = time.monotonic
|
||||
|
||||
|
||||
@dataclass
|
||||
class BluetoothServiceInfoBleak(BluetoothServiceInfo):
|
||||
@@ -182,7 +193,7 @@ async def async_process_advertisements(
|
||||
def _async_discovered_device(
|
||||
service_info: BluetoothServiceInfoBleak, change: BluetoothChange
|
||||
) -> None:
|
||||
if callback(service_info):
|
||||
if not done.done() and callback(service_info):
|
||||
done.set_result(service_info)
|
||||
|
||||
unload = async_register_callback(hass, _async_discovered_device, match_dict, mode)
|
||||
@@ -246,9 +257,10 @@ async def async_setup_entry(
|
||||
) -> bool:
|
||||
"""Set up the bluetooth integration from a config entry."""
|
||||
manager: BluetoothManager = hass.data[DOMAIN]
|
||||
await manager.async_start(
|
||||
BluetoothScanningMode.ACTIVE, entry.options.get(CONF_ADAPTER)
|
||||
)
|
||||
async with manager.start_stop_lock:
|
||||
await manager.async_start(
|
||||
BluetoothScanningMode.ACTIVE, entry.options.get(CONF_ADAPTER)
|
||||
)
|
||||
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
||||
return True
|
||||
|
||||
@@ -257,8 +269,6 @@ async def _async_update_listener(
|
||||
hass: HomeAssistant, entry: config_entries.ConfigEntry
|
||||
) -> None:
|
||||
"""Handle options update."""
|
||||
manager: BluetoothManager = hass.data[DOMAIN]
|
||||
manager.async_start_reload()
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
@@ -267,7 +277,9 @@ async def async_unload_entry(
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
manager: BluetoothManager = hass.data[DOMAIN]
|
||||
await manager.async_stop()
|
||||
async with manager.start_stop_lock:
|
||||
manager.async_start_reload()
|
||||
await manager.async_stop()
|
||||
return True
|
||||
|
||||
|
||||
@@ -283,13 +295,19 @@ class BluetoothManager:
|
||||
self.hass = hass
|
||||
self._integration_matcher = integration_matcher
|
||||
self.scanner: HaBleakScanner | None = None
|
||||
self.start_stop_lock = asyncio.Lock()
|
||||
self._cancel_device_detected: CALLBACK_TYPE | None = None
|
||||
self._cancel_unavailable_tracking: CALLBACK_TYPE | None = None
|
||||
self._cancel_stop: CALLBACK_TYPE | None = None
|
||||
self._cancel_watchdog: CALLBACK_TYPE | None = None
|
||||
self._unavailable_callbacks: dict[str, list[Callable[[str], None]]] = {}
|
||||
self._callbacks: list[
|
||||
tuple[BluetoothCallback, BluetoothCallbackMatcher | None]
|
||||
] = []
|
||||
self._last_detection = 0.0
|
||||
self._reloading = False
|
||||
self._adapter: str | None = None
|
||||
self._scanning_mode = BluetoothScanningMode.ACTIVE
|
||||
|
||||
@hass_callback
|
||||
def async_setup(self) -> None:
|
||||
@@ -311,6 +329,8 @@ class BluetoothManager:
|
||||
) -> None:
|
||||
"""Set up BT Discovery."""
|
||||
assert self.scanner is not None
|
||||
self._adapter = adapter
|
||||
self._scanning_mode = scanning_mode
|
||||
if self._reloading:
|
||||
# On reload, we need to reset the scanner instance
|
||||
# since the devices in its history may not be reachable
|
||||
@@ -337,16 +357,70 @@ class BluetoothManager:
|
||||
try:
|
||||
async with async_timeout.timeout(START_TIMEOUT):
|
||||
await self.scanner.start() # type: ignore[no-untyped-call]
|
||||
except InvalidMessageError as ex:
|
||||
self._cancel_device_detected()
|
||||
_LOGGER.debug("Invalid DBus message received: %s", ex, exc_info=True)
|
||||
raise ConfigEntryNotReady(
|
||||
f"Invalid DBus message received: {ex}; try restarting `dbus`"
|
||||
) from ex
|
||||
except BrokenPipeError as ex:
|
||||
self._cancel_device_detected()
|
||||
_LOGGER.debug("DBus connection broken: %s", ex, exc_info=True)
|
||||
if is_docker_env():
|
||||
raise ConfigEntryNotReady(
|
||||
f"DBus connection broken: {ex}; try restarting `bluetooth`, `dbus`, and finally the docker container"
|
||||
) from ex
|
||||
raise ConfigEntryNotReady(
|
||||
f"DBus connection broken: {ex}; try restarting `bluetooth` and `dbus`"
|
||||
) from ex
|
||||
except FileNotFoundError as ex:
|
||||
self._cancel_device_detected()
|
||||
_LOGGER.debug(
|
||||
"FileNotFoundError while starting bluetooth: %s", ex, exc_info=True
|
||||
)
|
||||
if is_docker_env():
|
||||
raise ConfigEntryNotReady(
|
||||
f"DBus service not found; docker config may be missing `-v /run/dbus:/run/dbus:ro`: {ex}"
|
||||
) from ex
|
||||
raise ConfigEntryNotReady(
|
||||
f"DBus service not found; make sure the DBus socket is available to Home Assistant: {ex}"
|
||||
) from ex
|
||||
except asyncio.TimeoutError as ex:
|
||||
self._cancel_device_detected()
|
||||
raise ConfigEntryNotReady(
|
||||
f"Timed out starting Bluetooth after {START_TIMEOUT} seconds"
|
||||
) from ex
|
||||
except (FileNotFoundError, BleakError) as ex:
|
||||
except BleakError as ex:
|
||||
self._cancel_device_detected()
|
||||
_LOGGER.debug("BleakError while starting bluetooth: %s", ex, exc_info=True)
|
||||
raise ConfigEntryNotReady(f"Failed to start Bluetooth: {ex}") from ex
|
||||
self.async_setup_unavailable_tracking()
|
||||
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop)
|
||||
self._async_setup_scanner_watchdog()
|
||||
self._cancel_stop = self.hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_STOP, self._async_hass_stopping
|
||||
)
|
||||
|
||||
@hass_callback
|
||||
def _async_setup_scanner_watchdog(self) -> None:
|
||||
"""If Dbus gets restarted or updated, we need to restart the scanner."""
|
||||
self._last_detection = MONOTONIC_TIME()
|
||||
self._cancel_watchdog = async_track_time_interval(
|
||||
self.hass, self._async_scanner_watchdog, SCANNER_WATCHDOG_INTERVAL
|
||||
)
|
||||
|
||||
async def _async_scanner_watchdog(self, now: datetime) -> None:
|
||||
"""Check if the scanner is running."""
|
||||
time_since_last_detection = MONOTONIC_TIME() - self._last_detection
|
||||
if time_since_last_detection < SCANNER_WATCHDOG_TIMEOUT:
|
||||
return
|
||||
_LOGGER.info(
|
||||
"Bluetooth scanner has gone quiet for %s, restarting",
|
||||
SCANNER_WATCHDOG_INTERVAL,
|
||||
)
|
||||
async with self.start_stop_lock:
|
||||
self.async_start_reload()
|
||||
await self.async_stop()
|
||||
await self.async_start(self._scanning_mode, self._adapter)
|
||||
|
||||
@hass_callback
|
||||
def async_setup_unavailable_tracking(self) -> None:
|
||||
@@ -381,6 +455,7 @@ class BluetoothManager:
|
||||
self, device: BLEDevice, advertisement_data: AdvertisementData
|
||||
) -> None:
|
||||
"""Handle a detected device."""
|
||||
self._last_detection = MONOTONIC_TIME()
|
||||
matched_domains = self._integration_matcher.match_domains(
|
||||
device, advertisement_data
|
||||
)
|
||||
@@ -493,14 +568,26 @@ class BluetoothManager:
|
||||
for device_adv in self.scanner.history.values()
|
||||
]
|
||||
|
||||
async def async_stop(self, event: Event | None = None) -> None:
|
||||
async def _async_hass_stopping(self, event: Event) -> None:
|
||||
"""Stop the Bluetooth integration at shutdown."""
|
||||
self._cancel_stop = None
|
||||
await self.async_stop()
|
||||
|
||||
async def async_stop(self) -> None:
|
||||
"""Stop bluetooth discovery."""
|
||||
_LOGGER.debug("Stopping bluetooth discovery")
|
||||
if self._cancel_watchdog:
|
||||
self._cancel_watchdog()
|
||||
self._cancel_watchdog = None
|
||||
if self._cancel_device_detected:
|
||||
self._cancel_device_detected()
|
||||
self._cancel_device_detected = None
|
||||
if self._cancel_unavailable_tracking:
|
||||
self._cancel_unavailable_tracking()
|
||||
self._cancel_unavailable_tracking = None
|
||||
if self._cancel_stop:
|
||||
self._cancel_stop()
|
||||
self._cancel_stop = None
|
||||
if self.scanner:
|
||||
try:
|
||||
await self.scanner.stop() # type: ignore[no-untyped-call]
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
"""Config flow to configure the Bluetooth integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import onboarding
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from .const import CONF_ADAPTER, DEFAULT_NAME, DOMAIN
|
||||
from .util import async_get_bluetooth_adapters
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
|
||||
class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow for Bluetooth."""
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/bluetooth",
|
||||
"dependencies": ["websocket_api"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["bleak==0.15.0", "bluetooth-adapters==0.1.2"],
|
||||
"requirements": ["bleak==0.15.1", "bluetooth-adapters==0.1.3"],
|
||||
"codeowners": ["@bdraco"],
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push"
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
"""The bluetooth integration matchers."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
import fnmatch
|
||||
from typing import Final, TypedDict
|
||||
from typing import TYPE_CHECKING, Final, TypedDict
|
||||
|
||||
from bleak.backends.device import BLEDevice
|
||||
from bleak.backends.scanner import AdvertisementData
|
||||
from lru import LRU # pylint: disable=no-name-in-module
|
||||
|
||||
from homeassistant.loader import BluetoothMatcher, BluetoothMatcherOptional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
from bleak.backends.device import BLEDevice
|
||||
from bleak.backends.scanner import AdvertisementData
|
||||
|
||||
|
||||
MAX_REMEMBER_ADDRESSES: Final = 2048
|
||||
|
||||
|
||||
|
||||
@@ -4,10 +4,9 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import Any, Final
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from bleak import BleakScanner
|
||||
from bleak.backends.device import BLEDevice
|
||||
from bleak.backends.scanner import (
|
||||
AdvertisementData,
|
||||
AdvertisementDataCallback,
|
||||
@@ -16,6 +15,10 @@ from bleak.backends.scanner import (
|
||||
|
||||
from homeassistant.core import CALLBACK_TYPE, callback as hass_callback
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bleak.backends.device import BLEDevice
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
FILTER_UUIDS: Final = "UUIDs"
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
"""Passive update coordinator for the Bluetooth integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Generator
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak
|
||||
from .update_coordinator import BasePassiveBluetoothCoordinator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Generator
|
||||
import logging
|
||||
|
||||
from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak
|
||||
|
||||
|
||||
class PassiveBluetoothDataUpdateCoordinator(BasePassiveBluetoothCoordinator):
|
||||
"""Class to manage passive bluetooth advertisements.
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
"""Passive update processors for the Bluetooth integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
import dataclasses
|
||||
import logging
|
||||
from typing import Any, Generic, TypeVar
|
||||
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
||||
|
||||
from homeassistant.const import ATTR_IDENTIFIERS, ATTR_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak
|
||||
from .const import DOMAIN
|
||||
from .update_coordinator import BasePassiveBluetoothCoordinator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Mapping
|
||||
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class PassiveBluetoothEntityKey:
|
||||
|
||||
@@ -26,11 +26,13 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import BroadlinkDevice
|
||||
from .const import DOMAIN
|
||||
from .entity import BroadlinkEntity
|
||||
from .helpers import data_packet, import_device, mac_address
|
||||
@@ -80,8 +82,18 @@ async def async_setup_platform(
|
||||
host = config.get(CONF_HOST)
|
||||
|
||||
if switches := config.get(CONF_SWITCHES):
|
||||
platform_data = hass.data[DOMAIN].platforms.setdefault(Platform.SWITCH, {})
|
||||
platform_data.setdefault(mac_addr, []).extend(switches)
|
||||
platform_data = hass.data[DOMAIN].platforms.get(Platform.SWITCH, {})
|
||||
async_add_entities_config_entry: AddEntitiesCallback
|
||||
device: BroadlinkDevice
|
||||
async_add_entities_config_entry, device = platform_data.get(
|
||||
mac_addr, (None, None)
|
||||
)
|
||||
if not async_add_entities_config_entry:
|
||||
raise PlatformNotReady
|
||||
|
||||
async_add_entities_config_entry(
|
||||
BroadlinkRMSwitch(device, config) for config in switches
|
||||
)
|
||||
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
@@ -104,12 +116,8 @@ async def async_setup_entry(
|
||||
switches: list[BroadlinkSwitch] = []
|
||||
|
||||
if device.api.type in {"RM4MINI", "RM4PRO", "RMMINI", "RMMINIB", "RMPRO"}:
|
||||
platform_data = hass.data[DOMAIN].platforms.get(Platform.SWITCH, {})
|
||||
user_defined_switches = platform_data.get(device.api.mac, {})
|
||||
switches.extend(
|
||||
BroadlinkRMSwitch(device, config) for config in user_defined_switches
|
||||
)
|
||||
|
||||
platform_data = hass.data[DOMAIN].platforms.setdefault(Platform.SWITCH, {})
|
||||
platform_data[device.api.mac] = async_add_entities, device
|
||||
elif device.api.type == "SP1":
|
||||
switches.append(BroadlinkSP1Switch(device))
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import os
|
||||
from random import SystemRandom
|
||||
from typing import Final, Optional, cast, final
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp import hdrs, web
|
||||
import async_timeout
|
||||
import attr
|
||||
import voluptuous as vol
|
||||
@@ -715,8 +715,11 @@ class CameraView(HomeAssistantView):
|
||||
)
|
||||
|
||||
if not authenticated:
|
||||
if request[KEY_AUTHENTICATED]:
|
||||
# Attempt with invalid bearer token, raise unauthorized
|
||||
# so ban middleware can handle it.
|
||||
if hdrs.AUTHORIZATION in request.headers:
|
||||
raise web.HTTPUnauthorized()
|
||||
# Invalid sigAuth or camera access token
|
||||
raise web.HTTPForbidden()
|
||||
|
||||
if not camera.is_on:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "deCONZ",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/deconz",
|
||||
"requirements": ["pydeconz==100"],
|
||||
"requirements": ["pydeconz==102"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Royal Philips Electronics",
|
||||
|
||||
@@ -40,7 +40,11 @@ GAS_USAGE_DEVICE_CLASSES = (
|
||||
sensor.SensorDeviceClass.GAS,
|
||||
)
|
||||
GAS_USAGE_UNITS = {
|
||||
sensor.SensorDeviceClass.ENERGY: (ENERGY_WATT_HOUR, ENERGY_KILO_WATT_HOUR),
|
||||
sensor.SensorDeviceClass.ENERGY: (
|
||||
ENERGY_WATT_HOUR,
|
||||
ENERGY_KILO_WATT_HOUR,
|
||||
ENERGY_MEGA_WATT_HOUR,
|
||||
),
|
||||
sensor.SensorDeviceClass.GAS: (VOLUME_CUBIC_METERS, VOLUME_CUBIC_FEET),
|
||||
}
|
||||
GAS_PRICE_UNITS = tuple(
|
||||
|
||||
@@ -5,12 +5,14 @@ from enocean.utils import combine_hex
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity
|
||||
from homeassistant.const import CONF_ID, CONF_NAME
|
||||
from homeassistant.const import CONF_ID, CONF_NAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .device import EnOceanEntity
|
||||
|
||||
CONF_CHANNEL = "channel"
|
||||
@@ -25,10 +27,40 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
)
|
||||
|
||||
|
||||
def setup_platform(
|
||||
def generate_unique_id(dev_id: list[int], channel: int) -> str:
|
||||
"""Generate a valid unique id."""
|
||||
return f"{combine_hex(dev_id)}-{channel}"
|
||||
|
||||
|
||||
def _migrate_to_new_unique_id(hass: HomeAssistant, dev_id, channel) -> None:
|
||||
"""Migrate old unique ids to new unique ids."""
|
||||
old_unique_id = f"{combine_hex(dev_id)}"
|
||||
|
||||
ent_reg = entity_registry.async_get(hass)
|
||||
entity_id = ent_reg.async_get_entity_id(Platform.SWITCH, DOMAIN, old_unique_id)
|
||||
|
||||
if entity_id is not None:
|
||||
new_unique_id = generate_unique_id(dev_id, channel)
|
||||
try:
|
||||
ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id)
|
||||
except ValueError:
|
||||
LOGGER.warning(
|
||||
"Skip migration of id [%s] to [%s] because it already exists",
|
||||
old_unique_id,
|
||||
new_unique_id,
|
||||
)
|
||||
else:
|
||||
LOGGER.debug(
|
||||
"Migrating unique_id from [%s] to [%s]",
|
||||
old_unique_id,
|
||||
new_unique_id,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
add_entities: AddEntitiesCallback,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the EnOcean switch platform."""
|
||||
@@ -36,7 +68,8 @@ def setup_platform(
|
||||
dev_id = config.get(CONF_ID)
|
||||
dev_name = config.get(CONF_NAME)
|
||||
|
||||
add_entities([EnOceanSwitch(dev_id, dev_name, channel)])
|
||||
_migrate_to_new_unique_id(hass, dev_id, channel)
|
||||
async_add_entities([EnOceanSwitch(dev_id, dev_name, channel)])
|
||||
|
||||
|
||||
class EnOceanSwitch(EnOceanEntity, SwitchEntity):
|
||||
@@ -49,7 +82,7 @@ class EnOceanSwitch(EnOceanEntity, SwitchEntity):
|
||||
self._on_state = False
|
||||
self._on_state2 = False
|
||||
self.channel = channel
|
||||
self._attr_unique_id = f"{combine_hex(dev_id)}"
|
||||
self._attr_unique_id = generate_unique_id(dev_id, channel)
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "entur_public_transport",
|
||||
"name": "Entur",
|
||||
"documentation": "https://www.home-assistant.io/integrations/entur_public_transport",
|
||||
"requirements": ["enturclient==0.2.3"],
|
||||
"requirements": ["enturclient==0.2.4"],
|
||||
"codeowners": ["@hfurubotten"],
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["enturclient"]
|
||||
|
||||
@@ -5,14 +5,20 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bleak import BleakScanner
|
||||
from fjaraskupan import Device, State, device_filter
|
||||
from fjaraskupan import Device, State
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothCallbackMatcher,
|
||||
BluetoothChange,
|
||||
BluetoothScanningMode,
|
||||
BluetoothServiceInfoBleak,
|
||||
async_address_present,
|
||||
async_register_callback,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_connect,
|
||||
async_dispatcher_send,
|
||||
@@ -23,11 +29,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
|
||||
|
||||
from .const import DISPATCH_DETECTION, DOMAIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bleak.backends.device import BLEDevice
|
||||
from bleak.backends.scanner import AdvertisementData
|
||||
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.FAN,
|
||||
@@ -70,16 +71,18 @@ class Coordinator(DataUpdateCoordinator[State]):
|
||||
async def _async_update_data(self) -> State:
|
||||
"""Handle an explicit update request."""
|
||||
if self._refresh_was_scheduled:
|
||||
raise UpdateFailed("No data received within schedule.")
|
||||
if async_address_present(self.hass, self.device.address):
|
||||
return self.device.state
|
||||
raise UpdateFailed(
|
||||
"No data received within schedule, and device is no longer present"
|
||||
)
|
||||
|
||||
await self.device.update()
|
||||
return self.device.state
|
||||
|
||||
def detection_callback(
|
||||
self, ble_device: BLEDevice, advertisement_data: AdvertisementData
|
||||
) -> None:
|
||||
def detection_callback(self, service_info: BluetoothServiceInfoBleak) -> None:
|
||||
"""Handle a new announcement of data."""
|
||||
self.device.detection_callback(ble_device, advertisement_data)
|
||||
self.device.detection_callback(service_info.device, service_info.advertisement)
|
||||
self.async_set_updated_data(self.device.state)
|
||||
|
||||
|
||||
@@ -87,59 +90,52 @@ class Coordinator(DataUpdateCoordinator[State]):
|
||||
class EntryState:
|
||||
"""Store state of config entry."""
|
||||
|
||||
scanner: BleakScanner
|
||||
coordinators: dict[str, Coordinator]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Fjäråskupan from a config entry."""
|
||||
|
||||
scanner = BleakScanner(filters={"DuplicateData": True})
|
||||
|
||||
state = EntryState(scanner, {})
|
||||
state = EntryState({})
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][entry.entry_id] = state
|
||||
|
||||
async def detection_callback(
|
||||
ble_device: BLEDevice, advertisement_data: AdvertisementData
|
||||
def detection_callback(
|
||||
service_info: BluetoothServiceInfoBleak, change: BluetoothChange
|
||||
) -> None:
|
||||
if data := state.coordinators.get(ble_device.address):
|
||||
_LOGGER.debug(
|
||||
"Update: %s %s - %s", ble_device.name, ble_device, advertisement_data
|
||||
)
|
||||
|
||||
data.detection_callback(ble_device, advertisement_data)
|
||||
if change != BluetoothChange.ADVERTISEMENT:
|
||||
return
|
||||
if data := state.coordinators.get(service_info.address):
|
||||
_LOGGER.debug("Update: %s", service_info)
|
||||
data.detection_callback(service_info)
|
||||
else:
|
||||
if not device_filter(ble_device, advertisement_data):
|
||||
return
|
||||
_LOGGER.debug("Detected: %s", service_info)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Detected: %s %s - %s", ble_device.name, ble_device, advertisement_data
|
||||
)
|
||||
|
||||
device = Device(ble_device)
|
||||
device = Device(service_info.device)
|
||||
device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, ble_device.address)},
|
||||
identifiers={(DOMAIN, service_info.address)},
|
||||
manufacturer="Fjäråskupan",
|
||||
name="Fjäråskupan",
|
||||
)
|
||||
|
||||
coordinator: Coordinator = Coordinator(hass, device, device_info)
|
||||
coordinator.detection_callback(ble_device, advertisement_data)
|
||||
coordinator.detection_callback(service_info)
|
||||
|
||||
state.coordinators[ble_device.address] = coordinator
|
||||
state.coordinators[service_info.address] = coordinator
|
||||
async_dispatcher_send(
|
||||
hass, f"{DISPATCH_DETECTION}.{entry.entry_id}", coordinator
|
||||
)
|
||||
|
||||
scanner.register_detection_callback(detection_callback)
|
||||
await scanner.start()
|
||||
|
||||
async def on_hass_stop(event: Event) -> None:
|
||||
await scanner.stop()
|
||||
|
||||
entry.async_on_unload(
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop)
|
||||
async_register_callback(
|
||||
hass,
|
||||
detection_callback,
|
||||
BluetoothCallbackMatcher(
|
||||
manufacturer_id=20296,
|
||||
manufacturer_data_start=[79, 68, 70, 74, 65, 82],
|
||||
),
|
||||
BluetoothScanningMode.ACTIVE,
|
||||
)
|
||||
)
|
||||
|
||||
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
|
||||
@@ -177,7 +173,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
entry_state: EntryState = hass.data[DOMAIN].pop(entry.entry_id)
|
||||
await entry_state.scanner.stop()
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
|
||||
@@ -1,42 +1,25 @@
|
||||
"""Config flow for Fjäråskupan integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import async_timeout
|
||||
from bleak import BleakScanner
|
||||
from bleak.backends.device import BLEDevice
|
||||
from bleak.backends.scanner import AdvertisementData
|
||||
from fjaraskupan import device_filter
|
||||
|
||||
from homeassistant.components.bluetooth import async_discovered_service_info
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.config_entry_flow import register_discovery_flow
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
CONST_WAIT_TIME = 5.0
|
||||
|
||||
|
||||
async def _async_has_devices(hass: HomeAssistant) -> bool:
|
||||
"""Return if there are devices that can be discovered."""
|
||||
|
||||
event = asyncio.Event()
|
||||
service_infos = async_discovered_service_info(hass)
|
||||
|
||||
def detection(device: BLEDevice, advertisement_data: AdvertisementData):
|
||||
if device_filter(device, advertisement_data):
|
||||
event.set()
|
||||
for service_info in service_infos:
|
||||
if device_filter(service_info.device, service_info.advertisement):
|
||||
return True
|
||||
|
||||
async with BleakScanner(
|
||||
detection_callback=detection,
|
||||
filters={"DuplicateData": True},
|
||||
):
|
||||
try:
|
||||
async with async_timeout.timeout(CONST_WAIT_TIME):
|
||||
await event.wait()
|
||||
except asyncio.TimeoutError:
|
||||
return False
|
||||
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
register_discovery_flow(DOMAIN, "Fjäråskupan", _async_has_devices)
|
||||
|
||||
@@ -6,5 +6,12 @@
|
||||
"requirements": ["fjaraskupan==1.0.2"],
|
||||
"codeowners": ["@elupus"],
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bleak", "fjaraskupan"]
|
||||
"loggers": ["bleak", "fjaraskupan"],
|
||||
"dependencies": ["bluetooth"],
|
||||
"bluetooth": [
|
||||
{
|
||||
"manufacturer_id": 20296,
|
||||
"manufacturer_data_start": [79, 68, 70, 74, 65, 82]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Any
|
||||
from pyflunearyou import Client
|
||||
from pyflunearyou.errors import FluNearYouError
|
||||
|
||||
from homeassistant.components.repairs import IssueSeverity, async_create_issue
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -26,6 +27,15 @@ PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Flu Near You as config entry."""
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"integration_removal",
|
||||
is_fixable=True,
|
||||
severity=IssueSeverity.ERROR,
|
||||
translation_key="integration_removal",
|
||||
)
|
||||
|
||||
websession = aiohttp_client.async_get_clientsession(hass)
|
||||
client = Client(session=websession)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"name": "Flu Near You",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/flunearyou",
|
||||
"dependencies": ["repairs"],
|
||||
"requirements": ["pyflunearyou==2.0.2"],
|
||||
"codeowners": ["@bachya"],
|
||||
"iot_class": "cloud_polling",
|
||||
|
||||
42
homeassistant/components/flunearyou/repairs.py
Normal file
42
homeassistant/components/flunearyou/repairs.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Repairs platform for the Flu Near You integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class FluNearYouFixFlow(RepairsFlow):
|
||||
"""Handler for an issue fixing flow."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
if user_input is not None:
|
||||
removal_tasks = [
|
||||
self.hass.config_entries.async_remove(entry.entry_id)
|
||||
for entry in self.hass.config_entries.async_entries(DOMAIN)
|
||||
]
|
||||
await asyncio.gather(*removal_tasks)
|
||||
return self.async_create_entry(title="Fixed issue", data={})
|
||||
return self.async_show_form(step_id="confirm", data_schema=vol.Schema({}))
|
||||
|
||||
|
||||
async def async_create_fix_flow(
|
||||
hass: HomeAssistant, issue_id: str
|
||||
) -> FluNearYouFixFlow:
|
||||
"""Create flow."""
|
||||
return FluNearYouFixFlow()
|
||||
@@ -16,5 +16,18 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"integration_removal": {
|
||||
"title": "Flu Near You is no longer available",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm": {
|
||||
"title": "Remove Flu Near You",
|
||||
"description": "The external data source powering the Flu Near You integration is no longer available; thus, the integration no longer works.\n\nPress SUBMIT to remove Flu Near You from your Home Assistant instance."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,18 @@
|
||||
"title": "Configure Flu Near You"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"integration_removal": {
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "The data source that powered the Flu Near You integration is no longer available. Press SUBMIT to remove all configured instances of the integration from Home Assistant.",
|
||||
"title": "Remove Flu Near You"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Flu Near You is no longer available"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,27 +105,33 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
assert mac_address is not None
|
||||
mac = dr.format_mac(mac_address)
|
||||
await self.async_set_unique_id(mac)
|
||||
for entry in self._async_current_entries(include_ignore=False):
|
||||
if entry.data[CONF_HOST] == device[ATTR_IPADDR] or (
|
||||
entry.unique_id
|
||||
and ":" in entry.unique_id
|
||||
and mac_matches_by_one(entry.unique_id, mac)
|
||||
for entry in self._async_current_entries(include_ignore=True):
|
||||
if not (
|
||||
entry.data.get(CONF_HOST) == device[ATTR_IPADDR]
|
||||
or (
|
||||
entry.unique_id
|
||||
and ":" in entry.unique_id
|
||||
and mac_matches_by_one(entry.unique_id, mac)
|
||||
)
|
||||
):
|
||||
if (
|
||||
async_update_entry_from_discovery(
|
||||
self.hass, entry, device, None, allow_update_mac
|
||||
)
|
||||
or entry.state == config_entries.ConfigEntryState.SETUP_RETRY
|
||||
):
|
||||
self.hass.async_create_task(
|
||||
self.hass.config_entries.async_reload(entry.entry_id)
|
||||
)
|
||||
else:
|
||||
async_dispatcher_send(
|
||||
self.hass,
|
||||
FLUX_LED_DISCOVERY_SIGNAL.format(entry_id=entry.entry_id),
|
||||
)
|
||||
continue
|
||||
if entry.source == config_entries.SOURCE_IGNORE:
|
||||
raise AbortFlow("already_configured")
|
||||
if (
|
||||
async_update_entry_from_discovery(
|
||||
self.hass, entry, device, None, allow_update_mac
|
||||
)
|
||||
or entry.state == config_entries.ConfigEntryState.SETUP_RETRY
|
||||
):
|
||||
self.hass.async_create_task(
|
||||
self.hass.config_entries.async_reload(entry.entry_id)
|
||||
)
|
||||
else:
|
||||
async_dispatcher_send(
|
||||
self.hass,
|
||||
FLUX_LED_DISCOVERY_SIGNAL.format(entry_id=entry.entry_id),
|
||||
)
|
||||
raise AbortFlow("already_configured")
|
||||
|
||||
async def _async_handle_discovery(self) -> FlowResult:
|
||||
"""Handle any discovery."""
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "frontend",
|
||||
"name": "Home Assistant Frontend",
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"requirements": ["home-assistant-frontend==20220728.0"],
|
||||
"requirements": ["home-assistant-frontend==20220802.0"],
|
||||
"dependencies": [
|
||||
"api",
|
||||
"auth",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"service_uuid": "00008251-0000-1000-8000-00805f9b34fb"
|
||||
}
|
||||
],
|
||||
"requirements": ["govee-ble==0.12.4"],
|
||||
"requirements": ["govee-ble==0.12.6"],
|
||||
"dependencies": ["bluetooth"],
|
||||
"codeowners": ["@bdraco"],
|
||||
"iot_class": "local_push"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from homeassistant.components.network import async_get_ipv4_broadcast_addresses
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -32,7 +33,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
async def _async_scan_update(_=None):
|
||||
await gree_discovery.discovery.scan()
|
||||
bcast_addr = list(await async_get_ipv4_broadcast_addresses(hass))
|
||||
await gree_discovery.discovery.scan(0, bcast_ifaces=bcast_addr)
|
||||
|
||||
_LOGGER.debug("Scanning network for Gree devices")
|
||||
await _async_scan_update()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Config flow for Gree."""
|
||||
from greeclimate.discovery import Discovery
|
||||
|
||||
from homeassistant.components.network import async_get_ipv4_broadcast_addresses
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_entry_flow
|
||||
|
||||
@@ -10,7 +11,10 @@ from .const import DISCOVERY_TIMEOUT, DOMAIN
|
||||
async def _async_has_devices(hass: HomeAssistant) -> bool:
|
||||
"""Return if there are devices that can be discovered."""
|
||||
gree_discovery = Discovery(DISCOVERY_TIMEOUT)
|
||||
devices = await gree_discovery.scan(wait_for=DISCOVERY_TIMEOUT)
|
||||
bcast_addr = list(await async_get_ipv4_broadcast_addresses(hass))
|
||||
devices = await gree_discovery.scan(
|
||||
wait_for=DISCOVERY_TIMEOUT, bcast_ifaces=bcast_addr
|
||||
)
|
||||
return len(devices) > 0
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"name": "Gree Climate",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/gree",
|
||||
"requirements": ["greeclimate==1.2.0"],
|
||||
"requirements": ["greeclimate==1.3.0"],
|
||||
"dependencies": ["network"],
|
||||
"codeowners": ["@cmroche"],
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["greeclimate"]
|
||||
|
||||
@@ -137,6 +137,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# so we use a lock to ensure that only one API request is reaching it at a time:
|
||||
api_lock = asyncio.Lock()
|
||||
|
||||
async def async_init_coordinator(
|
||||
coordinator: GuardianDataUpdateCoordinator,
|
||||
) -> None:
|
||||
"""Initialize a GuardianDataUpdateCoordinator."""
|
||||
await coordinator.async_initialize()
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
# Set up GuardianDataUpdateCoordinators for the valve controller:
|
||||
valve_controller_coordinators: dict[str, GuardianDataUpdateCoordinator] = {}
|
||||
init_valve_controller_tasks = []
|
||||
@@ -151,13 +158,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
api
|
||||
] = GuardianDataUpdateCoordinator(
|
||||
hass,
|
||||
entry=entry,
|
||||
client=client,
|
||||
api_name=api,
|
||||
api_coro=api_coro,
|
||||
api_lock=api_lock,
|
||||
valve_controller_uid=entry.data[CONF_UID],
|
||||
)
|
||||
init_valve_controller_tasks.append(coordinator.async_refresh())
|
||||
init_valve_controller_tasks.append(async_init_coordinator(coordinator))
|
||||
|
||||
await asyncio.gather(*init_valve_controller_tasks)
|
||||
|
||||
@@ -352,6 +360,7 @@ class PairedSensorManager:
|
||||
|
||||
coordinator = self.coordinators[uid] = GuardianDataUpdateCoordinator(
|
||||
self._hass,
|
||||
entry=self._entry,
|
||||
client=self._client,
|
||||
api_name=f"{API_SENSOR_PAIRED_SENSOR_STATUS}_{uid}",
|
||||
api_coro=lambda: cast(
|
||||
@@ -422,7 +431,7 @@ class GuardianEntity(CoordinatorEntity[GuardianDataUpdateCoordinator]):
|
||||
|
||||
@callback
|
||||
def _async_update_from_latest_data(self) -> None:
|
||||
"""Update the entity.
|
||||
"""Update the entity's underlying data.
|
||||
|
||||
This should be extended by Guardian platforms.
|
||||
"""
|
||||
|
||||
@@ -137,7 +137,7 @@ class PairedSensorBinarySensor(PairedSensorEntity, BinarySensorEntity):
|
||||
|
||||
@callback
|
||||
def _async_update_from_latest_data(self) -> None:
|
||||
"""Update the entity."""
|
||||
"""Update the entity's underlying data."""
|
||||
if self.entity_description.key == SENSOR_KIND_LEAK_DETECTED:
|
||||
self._attr_is_on = self.coordinator.data["wet"]
|
||||
elif self.entity_description.key == SENSOR_KIND_MOVED:
|
||||
|
||||
@@ -15,6 +15,7 @@ from homeassistant.components.button import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
@@ -111,3 +112,5 @@ class GuardianButton(ValveControllerEntity, ButtonEntity):
|
||||
raise HomeAssistantError(
|
||||
f'Error while pressing button "{self.entity_id}": {err}'
|
||||
) from err
|
||||
|
||||
async_dispatcher_send(self.hass, self.coordinator.signal_reboot_requested)
|
||||
|
||||
@@ -128,7 +128,7 @@ class PairedSensorSensor(PairedSensorEntity, SensorEntity):
|
||||
|
||||
@callback
|
||||
def _async_update_from_latest_data(self) -> None:
|
||||
"""Update the entity."""
|
||||
"""Update the entity's underlying data."""
|
||||
if self.entity_description.key == SENSOR_KIND_BATTERY:
|
||||
self._attr_native_value = self.coordinator.data["battery"]
|
||||
elif self.entity_description.key == SENSOR_KIND_TEMPERATURE:
|
||||
@@ -142,7 +142,7 @@ class ValveControllerSensor(ValveControllerEntity, SensorEntity):
|
||||
|
||||
@callback
|
||||
def _async_update_from_latest_data(self) -> None:
|
||||
"""Update the entity."""
|
||||
"""Update the entity's underlying data."""
|
||||
if self.entity_description.key == SENSOR_KIND_TEMPERATURE:
|
||||
self._attr_native_value = self.coordinator.data["temperature"]
|
||||
elif self.entity_description.key == SENSOR_KIND_UPTIME:
|
||||
|
||||
@@ -9,21 +9,28 @@ from typing import Any, cast
|
||||
from aioguardian import Client
|
||||
from aioguardian.errors import GuardianError
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import LOGGER
|
||||
|
||||
DEFAULT_UPDATE_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
SIGNAL_REBOOT_REQUESTED = "guardian_reboot_requested_{0}"
|
||||
|
||||
|
||||
class GuardianDataUpdateCoordinator(DataUpdateCoordinator[dict]):
|
||||
"""Define an extended DataUpdateCoordinator with some Guardian goodies."""
|
||||
|
||||
config_entry: ConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
entry: ConfigEntry,
|
||||
client: Client,
|
||||
api_name: str,
|
||||
api_coro: Callable[..., Awaitable],
|
||||
@@ -41,6 +48,12 @@ class GuardianDataUpdateCoordinator(DataUpdateCoordinator[dict]):
|
||||
self._api_coro = api_coro
|
||||
self._api_lock = api_lock
|
||||
self._client = client
|
||||
self._signal_handler_unsubs: list[Callable[..., None]] = []
|
||||
|
||||
self.config_entry = entry
|
||||
self.signal_reboot_requested = SIGNAL_REBOOT_REQUESTED.format(
|
||||
self.config_entry.entry_id
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Execute a "locked" API request against the valve controller."""
|
||||
@@ -50,3 +63,26 @@ class GuardianDataUpdateCoordinator(DataUpdateCoordinator[dict]):
|
||||
except GuardianError as err:
|
||||
raise UpdateFailed(err) from err
|
||||
return cast(dict[str, Any], resp["data"])
|
||||
|
||||
async def async_initialize(self) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
|
||||
@callback
|
||||
def async_reboot_requested() -> None:
|
||||
"""Respond to a reboot request."""
|
||||
self.last_update_success = False
|
||||
self.async_update_listeners()
|
||||
|
||||
self._signal_handler_unsubs.append(
|
||||
async_dispatcher_connect(
|
||||
self.hass, self.signal_reboot_requested, async_reboot_requested
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_teardown() -> None:
|
||||
"""Tear the coordinator down appropriately."""
|
||||
for unsub in self._signal_handler_unsubs:
|
||||
unsub()
|
||||
|
||||
self.config_entry.async_on_unload(async_teardown)
|
||||
|
||||
@@ -223,12 +223,24 @@ HARDWARE_INTEGRATIONS = {
|
||||
async def async_get_addon_info(hass: HomeAssistant, slug: str) -> dict:
|
||||
"""Return add-on info.
|
||||
|
||||
The add-on must be installed.
|
||||
The caller of the function should handle HassioAPIError.
|
||||
"""
|
||||
hassio = hass.data[DOMAIN]
|
||||
return await hassio.get_addon_info(slug)
|
||||
|
||||
|
||||
@api_data
|
||||
async def async_get_addon_store_info(hass: HomeAssistant, slug: str) -> dict:
|
||||
"""Return add-on store info.
|
||||
|
||||
The caller of the function should handle HassioAPIError.
|
||||
"""
|
||||
hassio: HassIO = hass.data[DOMAIN]
|
||||
command = f"/store/addons/{slug}"
|
||||
return await hassio.send_command(command, method="get")
|
||||
|
||||
|
||||
@bind_hass
|
||||
async def async_update_diagnostics(hass: HomeAssistant, diagnostics: bool) -> dict:
|
||||
"""Update Supervisor diagnostics toggle.
|
||||
|
||||
@@ -117,24 +117,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up Home Connect component."""
|
||||
hass.data[DOMAIN] = {}
|
||||
|
||||
if DOMAIN not in config:
|
||||
return True
|
||||
|
||||
await async_import_client_credential(
|
||||
hass,
|
||||
DOMAIN,
|
||||
ClientCredential(
|
||||
config[DOMAIN][CONF_CLIENT_ID],
|
||||
config[DOMAIN][CONF_CLIENT_SECRET],
|
||||
),
|
||||
)
|
||||
_LOGGER.warning(
|
||||
"Configuration of Home Connect integration in YAML is deprecated and "
|
||||
"will be removed in a future release; Your existing OAuth "
|
||||
"Application Credentials have been imported into the UI "
|
||||
"automatically and can be safely removed from your "
|
||||
"configuration.yaml file"
|
||||
)
|
||||
if DOMAIN in config:
|
||||
await async_import_client_credential(
|
||||
hass,
|
||||
DOMAIN,
|
||||
ClientCredential(
|
||||
config[DOMAIN][CONF_CLIENT_ID],
|
||||
config[DOMAIN][CONF_CLIENT_SECRET],
|
||||
),
|
||||
)
|
||||
_LOGGER.warning(
|
||||
"Configuration of Home Connect integration in YAML is deprecated and "
|
||||
"will be removed in a future release; Your existing OAuth "
|
||||
"Application Credentials have been imported into the UI "
|
||||
"automatically and can be safely removed from your "
|
||||
"configuration.yaml file"
|
||||
)
|
||||
|
||||
async def _async_service_program(call, method):
|
||||
"""Execute calls to services taking a program."""
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.components.repairs.models import IssueSeverity
|
||||
from homeassistant.const import __version__
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.start import async_at_start
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.util.yaml import parse_yaml
|
||||
@@ -100,7 +101,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
coordinator = AlertUpdateCoordinator(hass)
|
||||
coordinator.async_add_listener(async_schedule_update_alerts)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
async def initial_refresh(hass: HomeAssistant) -> None:
|
||||
await coordinator.async_refresh()
|
||||
|
||||
async_at_start(hass, initial_refresh)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"domain": "homeassistant_alerts",
|
||||
"name": "Home Assistant alerts",
|
||||
"name": "Home Assistant Alerts",
|
||||
"config_flow": false,
|
||||
"documentation": "https://www.home-assistant.io/integrations/homeassistant_alerts",
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
|
||||
@@ -31,7 +31,7 @@ from homeassistant.helpers.typing import ConfigType
|
||||
from .config_flow import normalize_hkid
|
||||
from .connection import HKDevice, valid_serial_number
|
||||
from .const import ENTITY_MAP, KNOWN_DEVICES, TRIGGERS
|
||||
from .storage import async_get_entity_storage
|
||||
from .storage import EntityMapStorage, async_get_entity_storage
|
||||
from .utils import async_get_controller, folded_name
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -269,7 +269,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
hkid = entry.data["AccessoryPairingID"]
|
||||
|
||||
if hkid in hass.data[KNOWN_DEVICES]:
|
||||
connection = hass.data[KNOWN_DEVICES][hkid]
|
||||
connection: HKDevice = hass.data[KNOWN_DEVICES][hkid]
|
||||
await connection.async_unload()
|
||||
|
||||
return True
|
||||
@@ -280,7 +280,8 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
hkid = entry.data["AccessoryPairingID"]
|
||||
|
||||
# Remove cached type data from .storage/homekit_controller-entity-map
|
||||
hass.data[ENTITY_MAP].async_delete_map(hkid)
|
||||
entity_map_storage: EntityMapStorage = hass.data[ENTITY_MAP]
|
||||
entity_map_storage.async_delete_map(hkid)
|
||||
|
||||
controller = await async_get_controller(hass)
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"""Config flow to configure homekit_controller."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import aiohomekit
|
||||
from aiohomekit import Controller, const as aiohomekit_const
|
||||
from aiohomekit.controller.abstract import AbstractDiscovery, AbstractPairing
|
||||
from aiohomekit.controller.abstract import (
|
||||
AbstractDiscovery,
|
||||
AbstractPairing,
|
||||
FinishPairing,
|
||||
)
|
||||
from aiohomekit.exceptions import AuthenticationError
|
||||
from aiohomekit.model.categories import Categories
|
||||
from aiohomekit.model.status_flags import StatusFlags
|
||||
@@ -17,7 +20,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components import zeroconf
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import AbortFlow, FlowResult
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
@@ -78,7 +81,9 @@ def formatted_category(category: Categories) -> str:
|
||||
|
||||
|
||||
@callback
|
||||
def find_existing_host(hass, serial: str) -> config_entries.ConfigEntry | None:
|
||||
def find_existing_host(
|
||||
hass: HomeAssistant, serial: str
|
||||
) -> config_entries.ConfigEntry | None:
|
||||
"""Return a set of the configured hosts."""
|
||||
for entry in hass.config_entries.async_entries(DOMAIN):
|
||||
if entry.data.get("AccessoryPairingID") == serial:
|
||||
@@ -115,15 +120,17 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self.category: Categories | None = None
|
||||
self.devices: dict[str, AbstractDiscovery] = {}
|
||||
self.controller: Controller | None = None
|
||||
self.finish_pairing: Awaitable[AbstractPairing] | None = None
|
||||
self.finish_pairing: FinishPairing | None = None
|
||||
|
||||
async def _async_setup_controller(self):
|
||||
async def _async_setup_controller(self) -> None:
|
||||
"""Create the controller."""
|
||||
self.controller = await async_get_controller(self.hass)
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle a flow start."""
|
||||
errors = {}
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
key = user_input["device"]
|
||||
@@ -142,6 +149,8 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if self.controller is None:
|
||||
await self._async_setup_controller()
|
||||
|
||||
assert self.controller
|
||||
|
||||
self.devices = {}
|
||||
|
||||
async for discovery in self.controller.async_discover():
|
||||
@@ -167,7 +176,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
),
|
||||
)
|
||||
|
||||
async def async_step_unignore(self, user_input):
|
||||
async def async_step_unignore(self, user_input: dict[str, Any]) -> FlowResult:
|
||||
"""Rediscover a previously ignored discover."""
|
||||
unique_id = user_input["unique_id"]
|
||||
await self.async_set_unique_id(unique_id)
|
||||
@@ -175,19 +184,21 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if self.controller is None:
|
||||
await self._async_setup_controller()
|
||||
|
||||
assert self.controller
|
||||
|
||||
try:
|
||||
discovery = await self.controller.async_find(unique_id)
|
||||
except aiohomekit.AccessoryNotFoundError:
|
||||
return self.async_abort(reason="accessory_not_found_error")
|
||||
|
||||
self.name = discovery.description.name
|
||||
self.model = discovery.description.model
|
||||
self.model = getattr(discovery.description, "model", BLE_DEFAULT_NAME)
|
||||
self.category = discovery.description.category
|
||||
self.hkid = discovery.description.id
|
||||
|
||||
return self._async_step_pair_show_form()
|
||||
|
||||
async def _hkid_is_homekit(self, hkid):
|
||||
async def _hkid_is_homekit(self, hkid: str) -> bool:
|
||||
"""Determine if the device is a homekit bridge or accessory."""
|
||||
dev_reg = dr.async_get(self.hass)
|
||||
device = dev_reg.async_get_device(
|
||||
@@ -410,7 +421,9 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
return self._async_step_pair_show_form()
|
||||
|
||||
async def async_step_pair(self, pair_info=None):
|
||||
async def async_step_pair(
|
||||
self, pair_info: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Pair with a new HomeKit accessory."""
|
||||
# If async_step_pair is called with no pairing code then we do the M1
|
||||
# phase of pairing. If this is successful the device enters pairing
|
||||
@@ -428,11 +441,16 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
# callable. We call the callable with the pin that the user has typed
|
||||
# in.
|
||||
|
||||
# Should never call this step without setting self.hkid
|
||||
assert self.hkid
|
||||
|
||||
errors = {}
|
||||
|
||||
if self.controller is None:
|
||||
await self._async_setup_controller()
|
||||
|
||||
assert self.controller
|
||||
|
||||
if pair_info and self.finish_pairing:
|
||||
self.context["pairing"] = True
|
||||
code = pair_info["pairing_code"]
|
||||
@@ -507,21 +525,27 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
return self._async_step_pair_show_form(errors)
|
||||
|
||||
async def async_step_busy_error(self, user_input=None):
|
||||
async def async_step_busy_error(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Retry pairing after the accessory is busy."""
|
||||
if user_input is not None:
|
||||
return await self.async_step_pair()
|
||||
|
||||
return self.async_show_form(step_id="busy_error")
|
||||
|
||||
async def async_step_max_tries_error(self, user_input=None):
|
||||
async def async_step_max_tries_error(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Retry pairing after the accessory has reached max tries."""
|
||||
if user_input is not None:
|
||||
return await self.async_step_pair()
|
||||
|
||||
return self.async_show_form(step_id="max_tries_error")
|
||||
|
||||
async def async_step_protocol_error(self, user_input=None):
|
||||
async def async_step_protocol_error(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Retry pairing after the accessory has a protocol error."""
|
||||
if user_input is not None:
|
||||
return await self.async_step_pair()
|
||||
@@ -529,7 +553,11 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(step_id="protocol_error")
|
||||
|
||||
@callback
|
||||
def _async_step_pair_show_form(self, errors=None):
|
||||
def _async_step_pair_show_form(
|
||||
self, errors: dict[str, str] | None = None
|
||||
) -> FlowResult:
|
||||
assert self.category
|
||||
|
||||
placeholders = self.context["title_placeholders"] = {
|
||||
"name": self.name,
|
||||
"category": formatted_category(self.category),
|
||||
@@ -569,7 +597,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
entity_storage = await async_get_entity_storage(self.hass)
|
||||
assert self.unique_id is not None
|
||||
entity_storage.async_create_or_update_map(
|
||||
self.unique_id,
|
||||
pairing.id,
|
||||
accessories_state.config_num,
|
||||
accessories_state.accessories.serialize(),
|
||||
)
|
||||
|
||||
@@ -107,9 +107,9 @@ class HomeKitLight(HomeKitEntity, LightEntity):
|
||||
return ColorMode.ONOFF
|
||||
|
||||
@property
|
||||
def supported_color_modes(self) -> set[ColorMode | str] | None:
|
||||
def supported_color_modes(self) -> set[ColorMode]:
|
||||
"""Flag supported color modes."""
|
||||
color_modes: set[ColorMode | str] = set()
|
||||
color_modes: set[ColorMode] = set()
|
||||
|
||||
if self.service.has(CharacteristicsTypes.HUE) or self.service.has(
|
||||
CharacteristicsTypes.SATURATION
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "HomeKit Controller",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
|
||||
"requirements": ["aiohomekit==1.2.2"],
|
||||
"requirements": ["aiohomekit==1.2.5"],
|
||||
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."],
|
||||
"bluetooth": [{ "manufacturer_id": 76, "manufacturer_data_start": [6] }],
|
||||
"dependencies": ["bluetooth", "zeroconf"],
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -12,6 +13,7 @@ from .const import DOMAIN, ENTITY_MAP
|
||||
ENTITY_MAP_STORAGE_KEY = f"{DOMAIN}-entity-map"
|
||||
ENTITY_MAP_STORAGE_VERSION = 1
|
||||
ENTITY_MAP_SAVE_DELAY = 10
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Pairing(TypedDict):
|
||||
@@ -68,6 +70,7 @@ class EntityMapStorage:
|
||||
self, homekit_id: str, config_num: int, accessories: list[Any]
|
||||
) -> Pairing:
|
||||
"""Create a new pairing cache."""
|
||||
_LOGGER.debug("Creating or updating entity map for %s", homekit_id)
|
||||
data = Pairing(config_num=config_num, accessories=accessories)
|
||||
self.storage_data[homekit_id] = data
|
||||
self._async_schedule_save()
|
||||
@@ -76,11 +79,17 @@ class EntityMapStorage:
|
||||
@callback
|
||||
def async_delete_map(self, homekit_id: str) -> None:
|
||||
"""Delete pairing cache."""
|
||||
if homekit_id not in self.storage_data:
|
||||
return
|
||||
|
||||
self.storage_data.pop(homekit_id)
|
||||
self._async_schedule_save()
|
||||
removed_one = False
|
||||
# Previously there was a bug where a lowercase homekit_id was stored
|
||||
# in the storage. We need to account for that.
|
||||
for hkid in (homekit_id, homekit_id.lower()):
|
||||
if hkid not in self.storage_data:
|
||||
continue
|
||||
_LOGGER.debug("Deleting entity map for %s", hkid)
|
||||
self.storage_data.pop(hkid)
|
||||
removed_one = True
|
||||
if removed_one:
|
||||
self._async_schedule_save()
|
||||
|
||||
@callback
|
||||
def _async_schedule_save(self) -> None:
|
||||
|
||||
@@ -223,6 +223,7 @@ class IntegrationSensor(RestoreEntity, SensorEntity):
|
||||
== SensorDeviceClass.POWER
|
||||
):
|
||||
self._attr_device_class = SensorDeviceClass.ENERGY
|
||||
self._attr_icon = None
|
||||
update_state = True
|
||||
|
||||
if update_state:
|
||||
|
||||
@@ -47,7 +47,6 @@ class LGDevice(MediaPlayerEntity):
|
||||
self._port = port
|
||||
self._attr_unique_id = unique_id
|
||||
|
||||
self._name = None
|
||||
self._volume = 0
|
||||
self._volume_min = 0
|
||||
self._volume_max = 0
|
||||
@@ -94,8 +93,6 @@ class LGDevice(MediaPlayerEntity):
|
||||
elif response["msg"] == "SPK_LIST_VIEW_INFO":
|
||||
if "i_vol" in data:
|
||||
self._volume = data["i_vol"]
|
||||
if "s_user_name" in data:
|
||||
self._name = data["s_user_name"]
|
||||
if "i_vol_min" in data:
|
||||
self._volume_min = data["i_vol_min"]
|
||||
if "i_vol_max" in data:
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"abort": {
|
||||
"existing_instance_updated": "Updated existing configuration.",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Service is already configured",
|
||||
"existing_instance_updated": "Updated existing configuration."
|
||||
"already_configured": "Device is already configured"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect"
|
||||
|
||||
@@ -91,9 +91,11 @@ class Life360Data:
|
||||
members: dict[str, Life360Member] = field(init=False, default_factory=dict)
|
||||
|
||||
|
||||
class Life360DataUpdateCoordinator(DataUpdateCoordinator):
|
||||
class Life360DataUpdateCoordinator(DataUpdateCoordinator[Life360Data]):
|
||||
"""Life360 data update coordinator."""
|
||||
|
||||
config_entry: ConfigEntry
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Initialize data update coordinator."""
|
||||
super().__init__(
|
||||
|
||||
@@ -11,10 +11,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_BATTERY_CHARGING
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import (
|
||||
ATTR_ADDRESS,
|
||||
@@ -31,6 +28,7 @@ from .const import (
|
||||
LOGGER,
|
||||
SHOW_DRIVING,
|
||||
)
|
||||
from .coordinator import Life360DataUpdateCoordinator, Life360Member
|
||||
|
||||
_LOC_ATTRS = (
|
||||
"address",
|
||||
@@ -95,23 +93,27 @@ async def async_setup_entry(
|
||||
entry.async_on_unload(coordinator.async_add_listener(process_data))
|
||||
|
||||
|
||||
class Life360DeviceTracker(CoordinatorEntity, TrackerEntity):
|
||||
class Life360DeviceTracker(
|
||||
CoordinatorEntity[Life360DataUpdateCoordinator], TrackerEntity
|
||||
):
|
||||
"""Life360 Device Tracker."""
|
||||
|
||||
_attr_attribution = ATTRIBUTION
|
||||
_attr_unique_id: str
|
||||
|
||||
def __init__(self, coordinator: DataUpdateCoordinator, member_id: str) -> None:
|
||||
def __init__(
|
||||
self, coordinator: Life360DataUpdateCoordinator, member_id: str
|
||||
) -> None:
|
||||
"""Initialize Life360 Entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = member_id
|
||||
|
||||
self._data = coordinator.data.members[self.unique_id]
|
||||
self._data: Life360Member | None = coordinator.data.members[member_id]
|
||||
self._prev_data = self._data
|
||||
|
||||
self._attr_name = self._data.name
|
||||
self._attr_entity_picture = self._data.entity_picture
|
||||
|
||||
self._prev_data = self._data
|
||||
|
||||
@property
|
||||
def _options(self) -> Mapping[str, Any]:
|
||||
"""Shortcut to config entry options."""
|
||||
@@ -120,16 +122,15 @@ class Life360DeviceTracker(CoordinatorEntity, TrackerEntity):
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
# Get a shortcut to this member's data. Can't guarantee it's the same dict every
|
||||
# update, or that there is even data for this member every update, so need to
|
||||
# update shortcut each time.
|
||||
self._data = self.coordinator.data.members.get(self.unique_id)
|
||||
|
||||
# Get a shortcut to this Member's data. This needs to be updated each time since
|
||||
# coordinator provides a new Life360Member object each time, and it's possible
|
||||
# that there is no data for this Member on some updates.
|
||||
if self.available:
|
||||
# If nothing important has changed, then skip the update altogether.
|
||||
if self._data == self._prev_data:
|
||||
return
|
||||
self._data = self.coordinator.data.members.get(self._attr_unique_id)
|
||||
else:
|
||||
self._data = None
|
||||
|
||||
if self._data:
|
||||
# Check if we should effectively throw out new location data.
|
||||
last_seen = self._data.last_seen
|
||||
prev_seen = self._prev_data.last_seen
|
||||
@@ -168,27 +169,21 @@ class Life360DeviceTracker(CoordinatorEntity, TrackerEntity):
|
||||
"""Return True if state updates should be forced."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
# Guard against member not being in last update for some reason.
|
||||
return super().available and self._data is not None
|
||||
|
||||
@property
|
||||
def entity_picture(self) -> str | None:
|
||||
"""Return the entity picture to use in the frontend, if any."""
|
||||
if self.available:
|
||||
if self._data:
|
||||
self._attr_entity_picture = self._data.entity_picture
|
||||
return super().entity_picture
|
||||
|
||||
# All of the following will only be called if self.available is True.
|
||||
|
||||
@property
|
||||
def battery_level(self) -> int | None:
|
||||
"""Return the battery level of the device.
|
||||
|
||||
Percentage from 0-100.
|
||||
"""
|
||||
if not self._data:
|
||||
return None
|
||||
return self._data.battery_level
|
||||
|
||||
@property
|
||||
@@ -202,11 +197,15 @@ class Life360DeviceTracker(CoordinatorEntity, TrackerEntity):
|
||||
|
||||
Value in meters.
|
||||
"""
|
||||
if not self._data:
|
||||
return 0
|
||||
return self._data.gps_accuracy
|
||||
|
||||
@property
|
||||
def driving(self) -> bool:
|
||||
"""Return if driving."""
|
||||
if not self._data:
|
||||
return False
|
||||
if (driving_speed := self._options.get(CONF_DRIVING_SPEED)) is not None:
|
||||
if self._data.speed >= driving_speed:
|
||||
return True
|
||||
@@ -222,23 +221,38 @@ class Life360DeviceTracker(CoordinatorEntity, TrackerEntity):
|
||||
@property
|
||||
def latitude(self) -> float | None:
|
||||
"""Return latitude value of the device."""
|
||||
if not self._data:
|
||||
return None
|
||||
return self._data.latitude
|
||||
|
||||
@property
|
||||
def longitude(self) -> float | None:
|
||||
"""Return longitude value of the device."""
|
||||
if not self._data:
|
||||
return None
|
||||
return self._data.longitude
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> Mapping[str, Any] | None:
|
||||
"""Return entity specific state attributes."""
|
||||
attrs = {}
|
||||
attrs[ATTR_ADDRESS] = self._data.address
|
||||
attrs[ATTR_AT_LOC_SINCE] = self._data.at_loc_since
|
||||
attrs[ATTR_BATTERY_CHARGING] = self._data.battery_charging
|
||||
attrs[ATTR_DRIVING] = self.driving
|
||||
attrs[ATTR_LAST_SEEN] = self._data.last_seen
|
||||
attrs[ATTR_PLACE] = self._data.place
|
||||
attrs[ATTR_SPEED] = self._data.speed
|
||||
attrs[ATTR_WIFI_ON] = self._data.wifi_on
|
||||
return attrs
|
||||
if not self._data:
|
||||
return {
|
||||
ATTR_ADDRESS: None,
|
||||
ATTR_AT_LOC_SINCE: None,
|
||||
ATTR_BATTERY_CHARGING: None,
|
||||
ATTR_DRIVING: None,
|
||||
ATTR_LAST_SEEN: None,
|
||||
ATTR_PLACE: None,
|
||||
ATTR_SPEED: None,
|
||||
ATTR_WIFI_ON: None,
|
||||
}
|
||||
return {
|
||||
ATTR_ADDRESS: self._data.address,
|
||||
ATTR_AT_LOC_SINCE: self._data.at_loc_since,
|
||||
ATTR_BATTERY_CHARGING: self._data.battery_charging,
|
||||
ATTR_DRIVING: self.driving,
|
||||
ATTR_LAST_SEEN: self._data.last_seen,
|
||||
ATTR_PLACE: self._data.place,
|
||||
ATTR_SPEED: self._data.speed,
|
||||
ATTR_WIFI_ON: self._data.wifi_on,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"domain": "luci",
|
||||
"name": "OpenWRT (luci)",
|
||||
"name": "OpenWrt (luci)",
|
||||
"documentation": "https://www.home-assistant.io/integrations/luci",
|
||||
"requirements": ["openwrt-luci-rpc==1.1.11"],
|
||||
"codeowners": ["@mzdrale"],
|
||||
|
||||
@@ -43,14 +43,18 @@ class MeaterSensorEntityDescription(
|
||||
|
||||
def _elapsed_time_to_timestamp(probe: MeaterProbe) -> datetime | None:
|
||||
"""Convert elapsed time to timestamp."""
|
||||
if not probe.cook:
|
||||
if not probe.cook or not hasattr(probe.cook, "time_elapsed"):
|
||||
return None
|
||||
return dt_util.utcnow() - timedelta(seconds=probe.cook.time_elapsed)
|
||||
|
||||
|
||||
def _remaining_time_to_timestamp(probe: MeaterProbe) -> datetime | None:
|
||||
"""Convert remaining time to timestamp."""
|
||||
if not probe.cook or probe.cook.time_remaining < 0:
|
||||
if (
|
||||
not probe.cook
|
||||
or not hasattr(probe.cook, "time_remaining")
|
||||
or probe.cook.time_remaining < 0
|
||||
):
|
||||
return None
|
||||
return dt_util.utcnow() + timedelta(seconds=probe.cook.time_remaining)
|
||||
|
||||
@@ -99,7 +103,9 @@ SENSOR_TYPES = (
|
||||
native_unit_of_measurement=TEMP_CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
available=lambda probe: probe is not None and probe.cook is not None,
|
||||
value=lambda probe: probe.cook.target_temperature if probe.cook else None,
|
||||
value=lambda probe: probe.cook.target_temperature
|
||||
if probe.cook and hasattr(probe.cook, "target_temperature")
|
||||
else None,
|
||||
),
|
||||
# Peak temperature
|
||||
MeaterSensorEntityDescription(
|
||||
@@ -109,7 +115,9 @@ SENSOR_TYPES = (
|
||||
native_unit_of_measurement=TEMP_CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
available=lambda probe: probe is not None and probe.cook is not None,
|
||||
value=lambda probe: probe.cook.peak_temperature if probe.cook else None,
|
||||
value=lambda probe: probe.cook.peak_temperature
|
||||
if probe.cook and hasattr(probe.cook, "peak_temperature")
|
||||
else None,
|
||||
),
|
||||
# Remaining time in seconds. When unknown/calculating default is used. Default: -1
|
||||
# Exposed as a TIMESTAMP sensor where the timestamp is current time + remaining time.
|
||||
|
||||
@@ -116,7 +116,7 @@ class MikrotikDataUpdateCoordinatorTracker(
|
||||
return self.device.mac
|
||||
|
||||
@property
|
||||
def ip_address(self) -> str:
|
||||
def ip_address(self) -> str | None:
|
||||
"""Return the mac address of the client."""
|
||||
return self.device.ip_address
|
||||
|
||||
|
||||
@@ -60,9 +60,9 @@ class Device:
|
||||
return self._params.get("host-name", self.mac)
|
||||
|
||||
@property
|
||||
def ip_address(self) -> str:
|
||||
def ip_address(self) -> str | None:
|
||||
"""Return device primary ip address."""
|
||||
return self._params["address"]
|
||||
return self._params.get("address")
|
||||
|
||||
@property
|
||||
def mac(self) -> str:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"issues": {
|
||||
"replaced": {
|
||||
"title": "The Xiaomi Mijia BLE Temperature and Humidity Sensor integration has been replaced",
|
||||
"description": "The Xiaomi Mijia BLE Temperature and Humidity Sensor integration stopped working in Home Assistant 2022.7 and was replaced by the Xiaomi BLE integration in the 2022.8 release.\n\nThere is no migration path possible, therefore, you have to add your Xiaomi Mijia BLE device using the new integration manually.\n\nYour existing Xiaomi Mijia BLE Temperature and Humidity sensor YAML configuration is no longer used by Home Assistant. Remove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
|
||||
"description": "The Xiaomi Mijia BLE Temperature and Humidity Sensor integration stopped working in Home Assistant 2022.7 and was replaced by the Xiaomi BLE integration in the 2022.8 release.\n\nThere is no migration path possible, therefore, you have to add your Xiaomi Mijia BLE device using the new integration manually.\n\nYour existing Xiaomi Mijia BLE Temperature and Humidity Sensor YAML configuration is no longer used by Home Assistant. Remove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"issues": {
|
||||
"replaced": {
|
||||
"description": "The Xiaomi Mijia BLE Temperature and Humidity Sensor integration stopped working in Home Assistant 2022.7 and was replaced by the Xiaomi BLE integration in the 2022.8 release.\n\nThere is no migration path possible, therefore, you have to add your Xiaomi Mijia BLE device using the new integration manually.\n\nYour existing Xiaomi Mijia BLE Temperature and Humidity sensor YAML configuration is no longer used by Home Assistant. Remove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue.",
|
||||
"description": "The Xiaomi Mijia BLE Temperature and Humidity Sensor integration stopped working in Home Assistant 2022.7 and was replaced by the Xiaomi BLE integration in the 2022.8 release.\n\nThere is no migration path possible, therefore, you have to add your Xiaomi Mijia BLE device using the new integration manually.\n\nYour existing Xiaomi Mijia BLE Temperature and Humidity Sensor YAML configuration is no longer used by Home Assistant. Remove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue.",
|
||||
"title": "The Xiaomi Mijia BLE Temperature and Humidity Sensor integration has been replaced"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,11 @@ from homeassistant.components.application_credentials import (
|
||||
from homeassistant.components.camera import Image, img_util
|
||||
from homeassistant.components.http.const import KEY_HASS_USER
|
||||
from homeassistant.components.http.view import HomeAssistantView
|
||||
from homeassistant.components.repairs import (
|
||||
IssueSeverity,
|
||||
async_create_issue,
|
||||
async_delete_issue,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_BINARY_SENSORS,
|
||||
@@ -187,6 +192,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
entry, unique_id=entry.data[CONF_PROJECT_ID]
|
||||
)
|
||||
|
||||
async_delete_issue(hass, DOMAIN, "removed_app_auth")
|
||||
|
||||
subscriber = await api.new_subscriber(hass, entry)
|
||||
if not subscriber:
|
||||
return False
|
||||
@@ -255,6 +262,18 @@ async def async_import_config(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
if entry.data["auth_implementation"] == INSTALLED_AUTH_DOMAIN:
|
||||
# App Auth credentials have been deprecated and must be re-created
|
||||
# by the user in the config flow
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"removed_app_auth",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.ERROR,
|
||||
translation_key="removed_app_auth",
|
||||
translation_placeholders={
|
||||
"more_info_url": "https://www.home-assistant.io/more-info/nest-auth-deprecation",
|
||||
"documentation_url": "https://www.home-assistant.io/integrations/nest/",
|
||||
},
|
||||
)
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Google has deprecated App Auth credentials, and the integration "
|
||||
"must be reconfigured in the UI to restore access to Nest Devices."
|
||||
@@ -271,12 +290,14 @@ async def async_import_config(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
WEB_AUTH_DOMAIN,
|
||||
)
|
||||
|
||||
_LOGGER.warning(
|
||||
"Configuration of Nest integration in YAML is deprecated and "
|
||||
"will be removed in a future release; Your existing configuration "
|
||||
"(including OAuth Application Credentials) has been imported into "
|
||||
"the UI automatically and can be safely removed from your "
|
||||
"configuration.yaml file"
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"deprecated_yaml",
|
||||
breaks_in_ha_version="2022.10.0",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_yaml",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "nest",
|
||||
"name": "Nest",
|
||||
"config_flow": true,
|
||||
"dependencies": ["ffmpeg", "http", "application_credentials"],
|
||||
"dependencies": ["ffmpeg", "http", "application_credentials", "repairs"],
|
||||
"after_dependencies": ["media_source"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/nest",
|
||||
"requirements": ["python-nest==4.2.0", "google-nest-sdm==2.0.0"],
|
||||
|
||||
@@ -88,5 +88,15 @@
|
||||
"camera_sound": "Sound detected",
|
||||
"doorbell_chime": "Doorbell pressed"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml": {
|
||||
"title": "The Nest YAML configuration is being removed",
|
||||
"description": "Configuring Nest in configuration.yaml is being removed in Home Assistant 2022.10.\n\nYour existing OAuth Application Credentials and access settings have been imported into the UI automatically. Remove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
|
||||
},
|
||||
"removed_app_auth": {
|
||||
"title": "Nest Authentication Credentials must be updated",
|
||||
"description": "To improve security and reduce phishing risk Google has deprecated the authentication method used by Home Assistant.\n\n**This requires action by you to resolve** ([more info]({more_info_url}))\n\n1. Visit the integrations page\n1. Click Reconfigure on the Nest integration.\n1. Home Assistant will walk you through the steps to upgrade to Web Authentication.\n\nSee the Nest [integration instructions]({documentation_url}) for troubleshooting information."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"missing_configuration": "The component is not configured. Please follow the documentation.",
|
||||
"no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})",
|
||||
"reauth_successful": "Re-authentication was successful",
|
||||
"single_instance_allowed": "Already configured. Only a single configuration possible.",
|
||||
"unknown_authorize_url_generation": "Unknown error generating an authorize URL."
|
||||
},
|
||||
"create_entry": {
|
||||
@@ -26,13 +25,6 @@
|
||||
"wrong_project_id": "Please enter a valid Cloud Project ID (was same as Device Access Project ID)"
|
||||
},
|
||||
"step": {
|
||||
"auth": {
|
||||
"data": {
|
||||
"code": "Access Token"
|
||||
},
|
||||
"description": "To link your Google account, [authorize your account]({url}).\n\nAfter authorization, copy-paste the provided Auth Token code below.",
|
||||
"title": "Link Google Account"
|
||||
},
|
||||
"auth_upgrade": {
|
||||
"description": "App Auth has been deprecated by Google to improve security, and you need to take action by creating new application credentials.\n\nOpen the [documentation]({more_info_url}) to follow along as the next steps will guide you through the steps you need to take to restore access to your Nest devices.",
|
||||
"title": "Nest: App Auth Deprecation"
|
||||
@@ -96,5 +88,15 @@
|
||||
"camera_sound": "Sound detected",
|
||||
"doorbell_chime": "Doorbell pressed"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml": {
|
||||
"description": "Configuring Nest in configuration.yaml is being removed in Home Assistant 2022.10.\n\nYour existing OAuth Application Credentials and access settings have been imported into the UI automatically. Remove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue.",
|
||||
"title": "The Nest YAML configuration is being removed"
|
||||
},
|
||||
"removed_app_auth": {
|
||||
"description": "To improve security and reduce phishing risk Google has deprecated the authentication method used by Home Assistant.\n\n**This requires action by you to resolve** ([more info]({more_info_url}))\n\n1. Visit the integrations page\n1. Click Reconfigure on the Nest integration.\n1. Home Assistant will walk you through the steps to upgrade to Web Authentication.\n\nSee the Nest [integration instructions]({documentation_url}) for troubleshooting information.",
|
||||
"title": "Nest Authentication Credentials must be updated"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "NextDNS",
|
||||
"documentation": "https://www.home-assistant.io/integrations/nextdns",
|
||||
"codeowners": ["@bieniu"],
|
||||
"requirements": ["nextdns==1.0.1"],
|
||||
"requirements": ["nextdns==1.0.2"],
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["nextdns"]
|
||||
|
||||
@@ -135,7 +135,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
icon="mdi:dns",
|
||||
name="TCP Queries",
|
||||
name="TCP queries",
|
||||
native_unit_of_measurement="queries",
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
value=lambda data: data.tcp_queries,
|
||||
@@ -190,7 +190,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
icon="mdi:dns",
|
||||
name="TCP Queries Ratio",
|
||||
name="TCP queries ratio",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value=lambda data: data.tcp_queries_ratio,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Support for OpenTherm Gateway devices."""
|
||||
import asyncio
|
||||
from datetime import date, datetime
|
||||
import logging
|
||||
|
||||
import pyotgw
|
||||
import pyotgw.vars as gw_vars
|
||||
from serial import SerialException
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
@@ -23,6 +25,7 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
@@ -37,6 +40,7 @@ from .const import (
|
||||
CONF_PRECISION,
|
||||
CONF_READ_PRECISION,
|
||||
CONF_SET_PRECISION,
|
||||
CONNECTION_TIMEOUT,
|
||||
DATA_GATEWAYS,
|
||||
DATA_OPENTHERM_GW,
|
||||
DOMAIN,
|
||||
@@ -107,8 +111,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
|
||||
|
||||
config_entry.add_update_listener(options_updated)
|
||||
|
||||
# Schedule directly on the loop to avoid blocking HA startup.
|
||||
hass.loop.create_task(gateway.connect_and_subscribe())
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
gateway.connect_and_subscribe(),
|
||||
timeout=CONNECTION_TIMEOUT,
|
||||
)
|
||||
except (asyncio.TimeoutError, ConnectionError, SerialException) as ex:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Could not connect to gateway at {gateway.device_path}: {ex}"
|
||||
) from ex
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
|
||||
@@ -428,6 +439,9 @@ class OpenThermGatewayDevice:
|
||||
async def connect_and_subscribe(self):
|
||||
"""Connect to serial device and subscribe report handler."""
|
||||
self.status = await self.gateway.connect(self.device_path)
|
||||
if not self.status:
|
||||
await self.cleanup()
|
||||
raise ConnectionError
|
||||
version_string = self.status[gw_vars.OTGW].get(gw_vars.OTGW_ABOUT)
|
||||
self.gw_version = version_string[18:] if version_string else None
|
||||
_LOGGER.debug(
|
||||
|
||||
@@ -26,6 +26,7 @@ from .const import (
|
||||
CONF_READ_PRECISION,
|
||||
CONF_SET_PRECISION,
|
||||
CONF_TEMPORARY_OVRD_MODE,
|
||||
CONNECTION_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
@@ -62,15 +63,21 @@ class OpenThermGwConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
otgw = pyotgw.OpenThermGateway()
|
||||
status = await otgw.connect(device)
|
||||
await otgw.disconnect()
|
||||
if not status:
|
||||
raise ConnectionError
|
||||
return status[gw_vars.OTGW].get(gw_vars.OTGW_ABOUT)
|
||||
|
||||
try:
|
||||
res = await asyncio.wait_for(test_connection(), timeout=10)
|
||||
except (asyncio.TimeoutError, SerialException):
|
||||
await asyncio.wait_for(
|
||||
test_connection(),
|
||||
timeout=CONNECTION_TIMEOUT,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return self._show_form({"base": "timeout_connect"})
|
||||
except (ConnectionError, SerialException):
|
||||
return self._show_form({"base": "cannot_connect"})
|
||||
|
||||
if res:
|
||||
return self._create_entry(gw_id, name, device)
|
||||
return self._create_entry(gw_id, name, device)
|
||||
|
||||
return self._show_form()
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ CONF_READ_PRECISION = "read_precision"
|
||||
CONF_SET_PRECISION = "set_precision"
|
||||
CONF_TEMPORARY_OVRD_MODE = "temporary_override_mode"
|
||||
|
||||
CONNECTION_TIMEOUT = 10
|
||||
|
||||
DATA_GATEWAYS = "gateways"
|
||||
DATA_OPENTHERM_GW = "opentherm_gw"
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "opentherm_gw",
|
||||
"name": "OpenTherm Gateway",
|
||||
"documentation": "https://www.home-assistant.io/integrations/opentherm_gw",
|
||||
"requirements": ["pyotgw==2.0.1"],
|
||||
"requirements": ["pyotgw==2.0.2"],
|
||||
"codeowners": ["@mvn23"],
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push",
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"error": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"id_exists": "Gateway id already exists",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"domain": "overkiz",
|
||||
"name": "Overkiz (by Somfy)",
|
||||
"name": "Overkiz",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/overkiz",
|
||||
"requirements": ["pyoverkiz==1.4.2"],
|
||||
|
||||
@@ -38,15 +38,22 @@ LOGGER = logging.getLogger(__name__)
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Philips TV from a config entry."""
|
||||
|
||||
system: SystemType | None = entry.data.get(CONF_SYSTEM)
|
||||
tvapi = PhilipsTV(
|
||||
entry.data[CONF_HOST],
|
||||
entry.data[CONF_API_VERSION],
|
||||
username=entry.data.get(CONF_USERNAME),
|
||||
password=entry.data.get(CONF_PASSWORD),
|
||||
system=system,
|
||||
)
|
||||
coordinator = PhilipsTVDataUpdateCoordinator(hass, tvapi, entry.options)
|
||||
|
||||
await coordinator.async_refresh()
|
||||
|
||||
if (actual_system := tvapi.system) and actual_system != system:
|
||||
data = {**entry.data, CONF_SYSTEM: actual_system}
|
||||
hass.config_entries.async_update_entry(entry, data=data)
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Risco",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/risco",
|
||||
"requirements": ["pyrisco==0.5.0"],
|
||||
"requirements": ["pyrisco==0.5.2"],
|
||||
"codeowners": ["@OnFreund"],
|
||||
"quality_scale": "platinum",
|
||||
"iot_class": "cloud_polling",
|
||||
|
||||
@@ -13,6 +13,7 @@ from homeassistant.components.binary_sensor import (
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -35,6 +36,7 @@ class RaspberryChargerBinarySensor(BinarySensorEntity):
|
||||
"""Binary sensor representing the rpi power status."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.PROBLEM
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_icon = "mdi:raspberry-pi"
|
||||
_attr_name = "RPi Power status"
|
||||
_attr_unique_id = "rpi_power" # only one sensor possible
|
||||
|
||||
@@ -81,17 +81,34 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
description_placeholders={CONF_URL: self._oauth_values.auth_url},
|
||||
)
|
||||
|
||||
auth_code = user_input[CONF_AUTH_CODE]
|
||||
|
||||
if auth_code.startswith("="):
|
||||
# Sometimes, users may include the "=" from the URL query param; in that
|
||||
# case, strip it off and proceed:
|
||||
LOGGER.debug('Stripping "=" from the start of the authorization code')
|
||||
auth_code = auth_code[1:]
|
||||
|
||||
if len(auth_code) != 45:
|
||||
# SimpliSafe authorization codes are 45 characters in length; if the user
|
||||
# provides something different, stop them here:
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_SCHEMA,
|
||||
errors={CONF_AUTH_CODE: "invalid_auth_code_length"},
|
||||
description_placeholders={CONF_URL: self._oauth_values.auth_url},
|
||||
)
|
||||
|
||||
errors = {}
|
||||
session = aiohttp_client.async_get_clientsession(self.hass)
|
||||
|
||||
try:
|
||||
simplisafe = await API.async_from_auth(
|
||||
user_input[CONF_AUTH_CODE],
|
||||
auth_code,
|
||||
self._oauth_values.code_verifier,
|
||||
session=session,
|
||||
)
|
||||
except InvalidCredentialsError:
|
||||
errors = {"base": "invalid_auth"}
|
||||
errors = {CONF_AUTH_CODE: "invalid_auth"}
|
||||
except SimplipyError as err:
|
||||
LOGGER.error("Unknown error while logging into SimpliSafe: %s", err)
|
||||
errors = {"base": "unknown"}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "SimpliSafe authenticates users via its web app. Due to technical limitations, there is a manual step at the end of this process; please ensure that you read the [documentation](http://home-assistant.io/integrations/simplisafe#getting-an-authorization-code) before starting.\n\nWhen you are ready, click [here]({url}) to open the SimpliSafe web app and input your credentials. When the process is complete, return here and input the authorization code from the SimpliSafe web app URL.",
|
||||
"description": "SimpliSafe authenticates users via its web app. Due to technical limitations, there is a manual step at the end of this process; please ensure that you read the [documentation](http://home-assistant.io/integrations/simplisafe#getting-an-authorization-code) before starting.\n\nWhen you are ready, click [here]({url}) to open the SimpliSafe web app and input your credentials. If you've already logged into SimpliSafe in your browser, you may want to open a new tab, then copy/paste the above URL into that tab.\n\nWhen the process is complete, return here and input the authorization code from the `com.simplisafe.mobile` URL.",
|
||||
"data": {
|
||||
"auth_code": "Authorization Code"
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
"error": {
|
||||
"identifier_exists": "Account already registered",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"invalid_auth_code_length": "SimpliSafe authorization codes are 45 characters in length",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
|
||||
@@ -2,39 +2,21 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "This SimpliSafe account is already in use.",
|
||||
"email_2fa_timed_out": "Timed out while waiting for email-based two-factor authentication.",
|
||||
"reauth_successful": "Re-authentication was successful",
|
||||
"wrong_account": "The user credentials provided do not match this SimpliSafe account."
|
||||
},
|
||||
"error": {
|
||||
"identifier_exists": "Account already registered",
|
||||
"invalid_auth": "Invalid authentication",
|
||||
"invalid_auth_code_length": "SimpliSafe authorization codes are 45 characters in length",
|
||||
"unknown": "Unexpected error"
|
||||
},
|
||||
"progress": {
|
||||
"email_2fa": "Check your email for a verification link from Simplisafe."
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "Password"
|
||||
},
|
||||
"description": "Please re-enter the password for {username}.",
|
||||
"title": "Reauthenticate Integration"
|
||||
},
|
||||
"sms_2fa": {
|
||||
"data": {
|
||||
"code": "Code"
|
||||
},
|
||||
"description": "Input the two-factor authentication code sent to you via SMS."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"auth_code": "Authorization Code",
|
||||
"password": "Password",
|
||||
"username": "Username"
|
||||
"auth_code": "Authorization Code"
|
||||
},
|
||||
"description": "SimpliSafe authenticates users via its web app. Due to technical limitations, there is a manual step at the end of this process; please ensure that you read the [documentation](http://home-assistant.io/integrations/simplisafe#getting-an-authorization-code) before starting.\n\nWhen you are ready, click [here]({url}) to open the SimpliSafe web app and input your credentials. When the process is complete, return here and input the authorization code from the SimpliSafe web app URL."
|
||||
"description": "SimpliSafe authenticates users via its web app. Due to technical limitations, there is a manual step at the end of this process; please ensure that you read the [documentation](http://home-assistant.io/integrations/simplisafe#getting-an-authorization-code) before starting.\n\nWhen you are ready, click [here]({url}) to open the SimpliSafe web app and input your credentials. If you've already logged into SimpliSafe in your browser, you may want to open a new tab, then copy/paste the above URL into that tab.\n\nWhen the process is complete, return here and input the authorization code from the `com.simplisafe.mobile` URL."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -72,7 +72,7 @@ FRIENDLY_NAMES = {
|
||||
ATTR_MUSIC_PLAYBACK_FULL_VOLUME: "Surround music full volume",
|
||||
ATTR_NIGHT_SOUND: "Night sound",
|
||||
ATTR_SPEECH_ENHANCEMENT: "Speech enhancement",
|
||||
ATTR_STATUS_LIGHT: "Status Light",
|
||||
ATTR_STATUS_LIGHT: "Status light",
|
||||
ATTR_SUB_ENABLED: "Subwoofer enabled",
|
||||
ATTR_SURROUND_ENABLED: "Surround enabled",
|
||||
ATTR_TOUCH_CONTROLS: "Touch controls",
|
||||
|
||||
@@ -22,6 +22,7 @@ from .const import (
|
||||
ATTR_CONTACT,
|
||||
ATTR_CURTAIN,
|
||||
ATTR_HYGROMETER,
|
||||
ATTR_PLUG,
|
||||
CONF_RETRY_COUNT,
|
||||
DEFAULT_RETRY_COUNT,
|
||||
DOMAIN,
|
||||
@@ -30,6 +31,7 @@ from .coordinator import SwitchbotDataUpdateCoordinator
|
||||
|
||||
PLATFORMS_BY_TYPE = {
|
||||
ATTR_BOT: [Platform.SWITCH, Platform.SENSOR],
|
||||
ATTR_PLUG: [Platform.SWITCH, Platform.SENSOR],
|
||||
ATTR_CURTAIN: [Platform.COVER, Platform.BINARY_SENSOR, Platform.SENSOR],
|
||||
ATTR_HYGROMETER: [Platform.SENSOR],
|
||||
ATTR_CONTACT: [Platform.BINARY_SENSOR, Platform.SENSOR],
|
||||
@@ -37,6 +39,7 @@ PLATFORMS_BY_TYPE = {
|
||||
CLASS_BY_DEVICE = {
|
||||
ATTR_CURTAIN: switchbot.SwitchbotCurtain,
|
||||
ATTR_BOT: switchbot.Switchbot,
|
||||
ATTR_PLUG: switchbot.SwitchbotPlugMini,
|
||||
}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -7,12 +7,14 @@ ATTR_BOT = "bot"
|
||||
ATTR_CURTAIN = "curtain"
|
||||
ATTR_HYGROMETER = "hygrometer"
|
||||
ATTR_CONTACT = "contact"
|
||||
ATTR_PLUG = "plug"
|
||||
DEFAULT_NAME = "Switchbot"
|
||||
SUPPORTED_MODEL_TYPES = {
|
||||
"WoHand": ATTR_BOT,
|
||||
"WoCurtain": ATTR_CURTAIN,
|
||||
"WoSensorTH": ATTR_HYGROMETER,
|
||||
"WoContact": ATTR_CONTACT,
|
||||
"WoPlug": ATTR_PLUG,
|
||||
}
|
||||
|
||||
# Config Defaults
|
||||
|
||||
@@ -80,9 +80,12 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity):
|
||||
if not last_state or ATTR_CURRENT_POSITION not in last_state.attributes:
|
||||
return
|
||||
|
||||
self._attr_current_cover_position = last_state.attributes[ATTR_CURRENT_POSITION]
|
||||
self._last_run_success = last_state.attributes["last_run_success"]
|
||||
self._attr_is_closed = last_state.attributes[ATTR_CURRENT_POSITION] <= 20
|
||||
self._attr_current_cover_position = last_state.attributes.get(
|
||||
ATTR_CURRENT_POSITION
|
||||
)
|
||||
self._last_run_success = last_state.attributes.get("last_run_success")
|
||||
if self._attr_current_cover_position is not None:
|
||||
self._attr_is_closed = self._attr_current_cover_position <= 20
|
||||
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Open the curtain."""
|
||||
|
||||
@@ -2,10 +2,16 @@
|
||||
"domain": "switchbot",
|
||||
"name": "SwitchBot",
|
||||
"documentation": "https://www.home-assistant.io/integrations/switchbot",
|
||||
"requirements": ["PySwitchbot==0.15.2"],
|
||||
"requirements": ["PySwitchbot==0.18.4"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth"],
|
||||
"codeowners": ["@bdraco", "@danielhiversen", "@RenierM26", "@murtas"],
|
||||
"codeowners": [
|
||||
"@bdraco",
|
||||
"@danielhiversen",
|
||||
"@RenierM26",
|
||||
"@murtas",
|
||||
"@Eloston"
|
||||
],
|
||||
"bluetooth": [
|
||||
{
|
||||
"service_data_uuid": "0000fd3d-0000-1000-8000-00805f9b34fb"
|
||||
|
||||
@@ -33,6 +33,13 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = {
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"wifi_rssi": SensorEntityDescription(
|
||||
key="wifi_rssi",
|
||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"battery": SensorEntityDescription(
|
||||
key="battery",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
@@ -98,7 +105,7 @@ class SwitchBotSensor(SwitchbotEntity, SensorEntity):
|
||||
super().__init__(coordinator, unique_id, address, name=switchbot_name)
|
||||
self._sensor = sensor
|
||||
self._attr_unique_id = f"{unique_id}-{sensor}"
|
||||
self._attr_name = f"{switchbot_name} {sensor.title()}"
|
||||
self._attr_name = f"{switchbot_name} {sensor.replace('_', ' ').title()}"
|
||||
self.entity_description = SENSOR_TYPES[sensor]
|
||||
|
||||
@property
|
||||
|
||||
@@ -33,7 +33,7 @@ async def async_setup_entry(
|
||||
assert unique_id is not None
|
||||
async_add_entities(
|
||||
[
|
||||
SwitchBotBotEntity(
|
||||
SwitchBotSwitch(
|
||||
coordinator,
|
||||
unique_id,
|
||||
entry.data[CONF_ADDRESS],
|
||||
@@ -44,8 +44,8 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class SwitchBotBotEntity(SwitchbotEntity, SwitchEntity, RestoreEntity):
|
||||
"""Representation of a Switchbot."""
|
||||
class SwitchBotSwitch(SwitchbotEntity, SwitchEntity, RestoreEntity):
|
||||
"""Representation of a Switchbot switch."""
|
||||
|
||||
_attr_device_class = SwitchDeviceClass.SWITCH
|
||||
|
||||
@@ -69,7 +69,7 @@ class SwitchBotBotEntity(SwitchbotEntity, SwitchEntity, RestoreEntity):
|
||||
if not (last_state := await self.async_get_last_state()):
|
||||
return
|
||||
self._attr_is_on = last_state.state == STATE_ON
|
||||
self._last_run_success = last_state.attributes["last_run_success"]
|
||||
self._last_run_success = last_state.attributes.get("last_run_success")
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn device on."""
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Offer webhook triggered automation rules."""
|
||||
from functools import partial
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aiohttp import hdrs
|
||||
import voluptuous as vol
|
||||
@@ -13,7 +15,7 @@ from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import async_register, async_unregister
|
||||
from . import DOMAIN, async_register, async_unregister
|
||||
|
||||
# mypy: allow-untyped-defs
|
||||
|
||||
@@ -26,20 +28,35 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
|
||||
}
|
||||
)
|
||||
|
||||
WEBHOOK_TRIGGERS = f"{DOMAIN}_triggers"
|
||||
|
||||
async def _handle_webhook(job, trigger_data, hass, webhook_id, request):
|
||||
|
||||
@dataclass
|
||||
class TriggerInstance:
|
||||
"""Attached trigger settings."""
|
||||
|
||||
automation_info: AutomationTriggerInfo
|
||||
job: HassJob
|
||||
|
||||
|
||||
async def _handle_webhook(hass, webhook_id, request):
|
||||
"""Handle incoming webhook."""
|
||||
result = {"platform": "webhook", "webhook_id": webhook_id}
|
||||
base_result = {"platform": "webhook", "webhook_id": webhook_id}
|
||||
|
||||
if "json" in request.headers.get(hdrs.CONTENT_TYPE, ""):
|
||||
result["json"] = await request.json()
|
||||
base_result["json"] = await request.json()
|
||||
else:
|
||||
result["data"] = await request.post()
|
||||
base_result["data"] = await request.post()
|
||||
|
||||
result["query"] = request.query
|
||||
result["description"] = "webhook"
|
||||
result.update(**trigger_data)
|
||||
hass.async_run_hass_job(job, {"trigger": result})
|
||||
base_result["query"] = request.query
|
||||
base_result["description"] = "webhook"
|
||||
|
||||
triggers: dict[str, list[TriggerInstance]] = hass.data.setdefault(
|
||||
WEBHOOK_TRIGGERS, {}
|
||||
)
|
||||
for trigger in triggers[webhook_id]:
|
||||
result = {**base_result, **trigger.automation_info["trigger_data"]}
|
||||
hass.async_run_hass_job(trigger.job, {"trigger": result})
|
||||
|
||||
|
||||
async def async_attach_trigger(
|
||||
@@ -49,20 +66,32 @@ async def async_attach_trigger(
|
||||
automation_info: AutomationTriggerInfo,
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Trigger based on incoming webhooks."""
|
||||
trigger_data = automation_info["trigger_data"]
|
||||
webhook_id: str = config[CONF_WEBHOOK_ID]
|
||||
job = HassJob(action)
|
||||
async_register(
|
||||
hass,
|
||||
automation_info["domain"],
|
||||
automation_info["name"],
|
||||
webhook_id,
|
||||
partial(_handle_webhook, job, trigger_data),
|
||||
|
||||
triggers: dict[str, list[TriggerInstance]] = hass.data.setdefault(
|
||||
WEBHOOK_TRIGGERS, {}
|
||||
)
|
||||
|
||||
if webhook_id not in triggers:
|
||||
async_register(
|
||||
hass,
|
||||
automation_info["domain"],
|
||||
automation_info["name"],
|
||||
webhook_id,
|
||||
_handle_webhook,
|
||||
)
|
||||
triggers[webhook_id] = []
|
||||
|
||||
trigger_instance = TriggerInstance(automation_info, job)
|
||||
triggers[webhook_id].append(trigger_instance)
|
||||
|
||||
@callback
|
||||
def unregister():
|
||||
"""Unregister webhook."""
|
||||
async_unregister(hass, webhook_id)
|
||||
triggers[webhook_id].remove(trigger_instance)
|
||||
if not triggers[webhook_id]:
|
||||
async_unregister(hass, webhook_id)
|
||||
triggers.pop(webhook_id)
|
||||
|
||||
return unregister
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
import dataclasses
|
||||
from typing import Any
|
||||
|
||||
@@ -12,7 +13,7 @@ from xiaomi_ble.parser import EncryptionScheme
|
||||
from homeassistant.components import onboarding
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothScanningMode,
|
||||
BluetoothServiceInfoBleak,
|
||||
BluetoothServiceInfo,
|
||||
async_discovered_service_info,
|
||||
async_process_advertisements,
|
||||
)
|
||||
@@ -31,11 +32,11 @@ class Discovery:
|
||||
"""A discovered bluetooth device."""
|
||||
|
||||
title: str
|
||||
discovery_info: BluetoothServiceInfoBleak
|
||||
discovery_info: BluetoothServiceInfo
|
||||
device: DeviceData
|
||||
|
||||
|
||||
def _title(discovery_info: BluetoothServiceInfoBleak, device: DeviceData) -> str:
|
||||
def _title(discovery_info: BluetoothServiceInfo, device: DeviceData) -> str:
|
||||
return device.title or device.get_device_name() or discovery_info.name
|
||||
|
||||
|
||||
@@ -46,19 +47,19 @@ class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._discovery_info: BluetoothServiceInfoBleak | None = None
|
||||
self._discovery_info: BluetoothServiceInfo | None = None
|
||||
self._discovered_device: DeviceData | None = None
|
||||
self._discovered_devices: dict[str, Discovery] = {}
|
||||
|
||||
async def _async_wait_for_full_advertisement(
|
||||
self, discovery_info: BluetoothServiceInfoBleak, device: DeviceData
|
||||
) -> BluetoothServiceInfoBleak:
|
||||
self, discovery_info: BluetoothServiceInfo, device: DeviceData
|
||||
) -> BluetoothServiceInfo:
|
||||
"""Sometimes first advertisement we receive is blank or incomplete. Wait until we get a useful one."""
|
||||
if not device.pending:
|
||||
return discovery_info
|
||||
|
||||
def _process_more_advertisements(
|
||||
service_info: BluetoothServiceInfoBleak,
|
||||
service_info: BluetoothServiceInfo,
|
||||
) -> bool:
|
||||
device.update(service_info)
|
||||
return not device.pending
|
||||
@@ -72,7 +73,7 @@ class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
async def async_step_bluetooth(
|
||||
self, discovery_info: BluetoothServiceInfoBleak
|
||||
self, discovery_info: BluetoothServiceInfo
|
||||
) -> FlowResult:
|
||||
"""Handle the bluetooth discovery step."""
|
||||
await self.async_set_unique_id(discovery_info.address)
|
||||
@@ -81,20 +82,21 @@ class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if not device.supported(discovery_info):
|
||||
return self.async_abort(reason="not_supported")
|
||||
|
||||
title = _title(discovery_info, device)
|
||||
self.context["title_placeholders"] = {"name": title}
|
||||
|
||||
self._discovered_device = device
|
||||
|
||||
# Wait until we have received enough information about this device to detect its encryption type
|
||||
try:
|
||||
discovery_info = await self._async_wait_for_full_advertisement(
|
||||
self._discovery_info = await self._async_wait_for_full_advertisement(
|
||||
discovery_info, device
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
# If we don't see a valid packet within the timeout then this device is not supported.
|
||||
return self.async_abort(reason="not_supported")
|
||||
|
||||
self._discovery_info = discovery_info
|
||||
self._discovered_device = device
|
||||
|
||||
title = _title(discovery_info, device)
|
||||
self.context["title_placeholders"] = {"name": title}
|
||||
# This device might have a really long advertising interval
|
||||
# So create a config entry for it, and if we discover it has encryption later
|
||||
# We can do a reauth
|
||||
return await self.async_step_confirm_slow()
|
||||
|
||||
if device.encryption_scheme == EncryptionScheme.MIBEACON_LEGACY:
|
||||
return await self.async_step_get_encryption_key_legacy()
|
||||
@@ -107,6 +109,8 @@ class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> FlowResult:
|
||||
"""Enter a legacy bindkey for a v2/v3 MiBeacon device."""
|
||||
assert self._discovery_info
|
||||
assert self._discovered_device
|
||||
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
@@ -115,18 +119,15 @@ class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if len(bindkey) != 24:
|
||||
errors["bindkey"] = "expected_24_characters"
|
||||
else:
|
||||
device = DeviceData(bindkey=bytes.fromhex(bindkey))
|
||||
self._discovered_device.bindkey = bytes.fromhex(bindkey)
|
||||
|
||||
# If we got this far we already know supported will
|
||||
# return true so we don't bother checking that again
|
||||
# We just want to retry the decryption
|
||||
device.supported(self._discovery_info)
|
||||
self._discovered_device.supported(self._discovery_info)
|
||||
|
||||
if device.bindkey_verified:
|
||||
return self.async_create_entry(
|
||||
title=self.context["title_placeholders"]["name"],
|
||||
data={"bindkey": bindkey},
|
||||
)
|
||||
if self._discovered_device.bindkey_verified:
|
||||
return self._async_get_or_create_entry(bindkey)
|
||||
|
||||
errors["bindkey"] = "decryption_failed"
|
||||
|
||||
@@ -142,6 +143,7 @@ class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> FlowResult:
|
||||
"""Enter a bindkey for a v4/v5 MiBeacon device."""
|
||||
assert self._discovery_info
|
||||
assert self._discovered_device
|
||||
|
||||
errors = {}
|
||||
|
||||
@@ -151,18 +153,15 @@ class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if len(bindkey) != 32:
|
||||
errors["bindkey"] = "expected_32_characters"
|
||||
else:
|
||||
device = DeviceData(bindkey=bytes.fromhex(bindkey))
|
||||
self._discovered_device.bindkey = bytes.fromhex(bindkey)
|
||||
|
||||
# If we got this far we already know supported will
|
||||
# return true so we don't bother checking that again
|
||||
# We just want to retry the decryption
|
||||
device.supported(self._discovery_info)
|
||||
self._discovered_device.supported(self._discovery_info)
|
||||
|
||||
if device.bindkey_verified:
|
||||
return self.async_create_entry(
|
||||
title=self.context["title_placeholders"]["name"],
|
||||
data={"bindkey": bindkey},
|
||||
)
|
||||
if self._discovered_device.bindkey_verified:
|
||||
return self._async_get_or_create_entry(bindkey)
|
||||
|
||||
errors["bindkey"] = "decryption_failed"
|
||||
|
||||
@@ -178,10 +177,7 @@ class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> FlowResult:
|
||||
"""Confirm discovery."""
|
||||
if user_input is not None or not onboarding.async_is_onboarded(self.hass):
|
||||
return self.async_create_entry(
|
||||
title=self.context["title_placeholders"]["name"],
|
||||
data={},
|
||||
)
|
||||
return self._async_get_or_create_entry()
|
||||
|
||||
self._set_confirm_only()
|
||||
return self.async_show_form(
|
||||
@@ -189,6 +185,19 @@ class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
description_placeholders=self.context["title_placeholders"],
|
||||
)
|
||||
|
||||
async def async_step_confirm_slow(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Ack that device is slow."""
|
||||
if user_input is not None:
|
||||
return self._async_get_or_create_entry()
|
||||
|
||||
self._set_confirm_only()
|
||||
return self.async_show_form(
|
||||
step_id="confirm_slow",
|
||||
description_placeholders=self.context["title_placeholders"],
|
||||
)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
@@ -198,24 +207,28 @@ class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
await self.async_set_unique_id(address, raise_on_progress=False)
|
||||
discovery = self._discovered_devices[address]
|
||||
|
||||
self.context["title_placeholders"] = {"name": discovery.title}
|
||||
|
||||
# Wait until we have received enough information about this device to detect its encryption type
|
||||
try:
|
||||
self._discovery_info = await self._async_wait_for_full_advertisement(
|
||||
discovery.discovery_info, discovery.device
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
# If we don't see a valid packet within the timeout then this device is not supported.
|
||||
return self.async_abort(reason="not_supported")
|
||||
# This device might have a really long advertising interval
|
||||
# So create a config entry for it, and if we discover it has encryption later
|
||||
# We can do a reauth
|
||||
return await self.async_step_confirm_slow()
|
||||
|
||||
self._discovered_device = discovery.device
|
||||
|
||||
if discovery.device.encryption_scheme == EncryptionScheme.MIBEACON_LEGACY:
|
||||
self.context["title_placeholders"] = {"name": discovery.title}
|
||||
return await self.async_step_get_encryption_key_legacy()
|
||||
|
||||
if discovery.device.encryption_scheme == EncryptionScheme.MIBEACON_4_5:
|
||||
self.context["title_placeholders"] = {"name": discovery.title}
|
||||
return await self.async_step_get_encryption_key_4_5()
|
||||
|
||||
return self.async_create_entry(title=discovery.title, data={})
|
||||
return self._async_get_or_create_entry()
|
||||
|
||||
current_addresses = self._async_current_ids()
|
||||
for discovery_info in async_discovered_service_info(self.hass):
|
||||
@@ -241,3 +254,46 @@ class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
step_id="user",
|
||||
data_schema=vol.Schema({vol.Required(CONF_ADDRESS): vol.In(titles)}),
|
||||
)
|
||||
|
||||
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
|
||||
"""Handle a flow initialized by a reauth event."""
|
||||
entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
|
||||
assert entry is not None
|
||||
|
||||
device: DeviceData = entry_data["device"]
|
||||
self._discovered_device = device
|
||||
|
||||
self._discovery_info = device.last_service_info
|
||||
|
||||
if device.encryption_scheme == EncryptionScheme.MIBEACON_LEGACY:
|
||||
return await self.async_step_get_encryption_key_legacy()
|
||||
|
||||
if device.encryption_scheme == EncryptionScheme.MIBEACON_4_5:
|
||||
return await self.async_step_get_encryption_key_4_5()
|
||||
|
||||
# Otherwise there wasn't actually encryption so abort
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
def _async_get_or_create_entry(self, bindkey=None):
|
||||
data = {}
|
||||
|
||||
if bindkey:
|
||||
data["bindkey"] = bindkey
|
||||
|
||||
if entry_id := self.context.get("entry_id"):
|
||||
entry = self.hass.config_entries.async_get_entry(entry_id)
|
||||
assert entry is not None
|
||||
|
||||
self.hass.config_entries.async_update_entry(entry, data=data)
|
||||
|
||||
# Reload the config entry to notify of updated config
|
||||
self.hass.async_create_task(
|
||||
self.hass.config_entries.async_reload(entry.entry_id)
|
||||
)
|
||||
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
return self.async_create_entry(
|
||||
title=self.context["title_placeholders"]["name"],
|
||||
data=data,
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"service_data_uuid": "0000fe95-0000-1000-8000-00805f9b34fb"
|
||||
}
|
||||
],
|
||||
"requirements": ["xiaomi-ble==0.6.2"],
|
||||
"requirements": ["xiaomi-ble==0.6.4"],
|
||||
"dependencies": ["bluetooth"],
|
||||
"codeowners": ["@Jc2k", "@Ernst79"],
|
||||
"iot_class": "local_push"
|
||||
|
||||
@@ -11,8 +11,10 @@ from xiaomi_ble import (
|
||||
Units,
|
||||
XiaomiBluetoothDeviceData,
|
||||
)
|
||||
from xiaomi_ble.parser import EncryptionScheme
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.bluetooth import BluetoothServiceInfoBleak
|
||||
from homeassistant.components.bluetooth.passive_update_processor import (
|
||||
PassiveBluetoothDataProcessor,
|
||||
PassiveBluetoothDataUpdate,
|
||||
@@ -163,6 +165,27 @@ def sensor_update_to_bluetooth_data_update(
|
||||
)
|
||||
|
||||
|
||||
def process_service_info(
|
||||
hass: HomeAssistant,
|
||||
entry: config_entries.ConfigEntry,
|
||||
data: XiaomiBluetoothDeviceData,
|
||||
service_info: BluetoothServiceInfoBleak,
|
||||
) -> PassiveBluetoothDataUpdate:
|
||||
"""Process a BluetoothServiceInfoBleak, running side effects and returning sensor data."""
|
||||
update = data.update(service_info)
|
||||
|
||||
# If device isn't pending we know it has seen at least one broadcast with a payload
|
||||
# If that payload was encrypted and the bindkey was not verified then we need to reauth
|
||||
if (
|
||||
not data.pending
|
||||
and data.encryption_scheme != EncryptionScheme.NONE
|
||||
and not data.bindkey_verified
|
||||
):
|
||||
entry.async_start_reauth(hass, data={"device": data})
|
||||
|
||||
return sensor_update_to_bluetooth_data_update(update)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: config_entries.ConfigEntry,
|
||||
@@ -177,9 +200,7 @@ async def async_setup_entry(
|
||||
kwargs["bindkey"] = bytes.fromhex(bindkey)
|
||||
data = XiaomiBluetoothDeviceData(**kwargs)
|
||||
processor = PassiveBluetoothDataProcessor(
|
||||
lambda service_info: sensor_update_to_bluetooth_data_update(
|
||||
data.update(service_info)
|
||||
)
|
||||
lambda service_info: process_service_info(hass, entry, data, service_info)
|
||||
)
|
||||
entry.async_on_unload(
|
||||
processor.async_add_entities_listener(
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
"bluetooth_confirm": {
|
||||
"description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
|
||||
},
|
||||
"confirm_slow": {
|
||||
"description": "There hasn't been a broadcast from this device in the last minute so we aren't sure if this device uses encryption or not. This may be because the device uses a slow broadcast interval. Confirm to add this device anyway, then the next time a broadcast is received you will be prompted to enter its bindkey if it's needed."
|
||||
},
|
||||
"get_encryption_key_legacy": {
|
||||
"description": "The sensor data broadcast by the sensor is encrypted. In order to decrypt it we need a 24 character hexadecimal bindkey.",
|
||||
"data": {
|
||||
@@ -24,13 +27,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"error": {
|
||||
"decryption_failed": "The provided bindkey did not work, sensor data could not be decrypted. Please check it and try again.",
|
||||
"expected_24_characters": "Expected a 24 character hexadecimal bindkey.",
|
||||
"expected_32_characters": "Expected a 32 character hexadecimal bindkey."
|
||||
},
|
||||
"abort": {
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,22 @@
|
||||
"abort": {
|
||||
"already_configured": "Device is already configured",
|
||||
"already_in_progress": "Configuration flow is already in progress",
|
||||
"no_devices_found": "No devices found on the network",
|
||||
"reauth_successful": "Re-authentication was successful"
|
||||
},
|
||||
"error": {
|
||||
"decryption_failed": "The provided bindkey did not work, sensor data could not be decrypted. Please check it and try again.",
|
||||
"expected_24_characters": "Expected a 24 character hexadecimal bindkey.",
|
||||
"expected_32_characters": "Expected a 32 character hexadecimal bindkey.",
|
||||
"no_devices_found": "No devices found on the network"
|
||||
"expected_32_characters": "Expected a 32 character hexadecimal bindkey."
|
||||
},
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
"bluetooth_confirm": {
|
||||
"description": "Do you want to setup {name}?"
|
||||
},
|
||||
"confirm_slow": {
|
||||
"description": "There hasn't been a broadcast from this device in the last minute so we aren't sure if this device uses encryption or not. This may be because the device uses a slow broadcast interval. Confirm to add this device anyway, then the next time a broadcast is received you will be prompted to enter its bindkey if it's needed."
|
||||
},
|
||||
"get_encryption_key_4_5": {
|
||||
"data": {
|
||||
"bindkey": "Bindkey"
|
||||
|
||||
@@ -35,6 +35,7 @@ from .core.const import (
|
||||
DOMAIN,
|
||||
PLATFORMS,
|
||||
SIGNAL_ADD_ENTITIES,
|
||||
ZHA_DEVICES_LOADED_EVENT,
|
||||
RadioType,
|
||||
)
|
||||
from .core.discovery import GROUP_PROBE
|
||||
@@ -75,7 +76,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up ZHA from config."""
|
||||
hass.data[DATA_ZHA] = {}
|
||||
hass.data[DATA_ZHA] = {ZHA_DEVICES_LOADED_EVENT: asyncio.Event()}
|
||||
|
||||
if DOMAIN in config:
|
||||
conf = config[DOMAIN]
|
||||
@@ -109,6 +110,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
|
||||
|
||||
zha_gateway = ZHAGateway(hass, config, config_entry)
|
||||
await zha_gateway.async_initialize()
|
||||
hass.data[DATA_ZHA][ZHA_DEVICES_LOADED_EVENT].set()
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
device_registry.async_get_or_create(
|
||||
@@ -141,6 +143,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
"""Unload ZHA config entry."""
|
||||
zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
|
||||
await zha_gateway.shutdown()
|
||||
hass.data[DATA_ZHA][ZHA_DEVICES_LOADED_EVENT].clear()
|
||||
|
||||
GROUP_PROBE.cleanup()
|
||||
api.async_unload_api(hass)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Lighting channels module for Zigbee Home Automation."""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
from functools import cached_property
|
||||
|
||||
from zigpy.zcl.clusters import lighting
|
||||
|
||||
@@ -46,17 +46,8 @@ class ColorChannel(ZigbeeChannel):
|
||||
"color_loop_active": False,
|
||||
}
|
||||
|
||||
@property
|
||||
def color_capabilities(self) -> int:
|
||||
"""Return color capabilities of the light."""
|
||||
with suppress(KeyError):
|
||||
return self.cluster["color_capabilities"]
|
||||
if self.cluster.get("color_temperature") is not None:
|
||||
return self.CAPABILITIES_COLOR_XY | self.CAPABILITIES_COLOR_TEMP
|
||||
return self.CAPABILITIES_COLOR_XY
|
||||
|
||||
@property
|
||||
def zcl_color_capabilities(self) -> lighting.Color.ColorCapabilities:
|
||||
@cached_property
|
||||
def color_capabilities(self) -> lighting.Color.ColorCapabilities:
|
||||
"""Return ZCL color capabilities of the light."""
|
||||
color_capabilities = self.cluster.get("color_capabilities")
|
||||
if color_capabilities is None:
|
||||
@@ -117,43 +108,41 @@ class ColorChannel(ZigbeeChannel):
|
||||
def hs_supported(self) -> bool:
|
||||
"""Return True if the channel supports hue and saturation."""
|
||||
return (
|
||||
self.zcl_color_capabilities is not None
|
||||
self.color_capabilities is not None
|
||||
and lighting.Color.ColorCapabilities.Hue_and_saturation
|
||||
in self.zcl_color_capabilities
|
||||
in self.color_capabilities
|
||||
)
|
||||
|
||||
@property
|
||||
def enhanced_hue_supported(self) -> bool:
|
||||
"""Return True if the channel supports enhanced hue and saturation."""
|
||||
return (
|
||||
self.zcl_color_capabilities is not None
|
||||
and lighting.Color.ColorCapabilities.Enhanced_hue
|
||||
in self.zcl_color_capabilities
|
||||
self.color_capabilities is not None
|
||||
and lighting.Color.ColorCapabilities.Enhanced_hue in self.color_capabilities
|
||||
)
|
||||
|
||||
@property
|
||||
def xy_supported(self) -> bool:
|
||||
"""Return True if the channel supports xy."""
|
||||
return (
|
||||
self.zcl_color_capabilities is not None
|
||||
self.color_capabilities is not None
|
||||
and lighting.Color.ColorCapabilities.XY_attributes
|
||||
in self.zcl_color_capabilities
|
||||
in self.color_capabilities
|
||||
)
|
||||
|
||||
@property
|
||||
def color_temp_supported(self) -> bool:
|
||||
"""Return True if the channel supports color temperature."""
|
||||
return (
|
||||
self.zcl_color_capabilities is not None
|
||||
self.color_capabilities is not None
|
||||
and lighting.Color.ColorCapabilities.Color_temperature
|
||||
in self.zcl_color_capabilities
|
||||
)
|
||||
in self.color_capabilities
|
||||
) or self.color_temperature is not None
|
||||
|
||||
@property
|
||||
def color_loop_supported(self) -> bool:
|
||||
"""Return True if the channel supports color loop."""
|
||||
return (
|
||||
self.zcl_color_capabilities is not None
|
||||
and lighting.Color.ColorCapabilities.Color_loop
|
||||
in self.zcl_color_capabilities
|
||||
self.color_capabilities is not None
|
||||
and lighting.Color.ColorCapabilities.Color_loop in self.color_capabilities
|
||||
)
|
||||
|
||||
@@ -394,6 +394,7 @@ ZHA_GW_MSG_GROUP_REMOVED = "group_removed"
|
||||
ZHA_GW_MSG_LOG_ENTRY = "log_entry"
|
||||
ZHA_GW_MSG_LOG_OUTPUT = "log_output"
|
||||
ZHA_GW_MSG_RAW_INIT = "raw_device_initialized"
|
||||
ZHA_DEVICES_LOADED_EVENT = "zha_devices_loaded_event"
|
||||
|
||||
EFFECT_BLINK = 0x00
|
||||
EFFECT_BREATHE = 0x01
|
||||
|
||||
@@ -142,6 +142,7 @@ class ZHAGateway:
|
||||
self._log_relay_handler = LogRelayHandler(hass, self)
|
||||
self.config_entry = config_entry
|
||||
self._unsubs: list[Callable[[], None]] = []
|
||||
self.initialized: bool = False
|
||||
|
||||
async def async_initialize(self) -> None:
|
||||
"""Initialize controller and connect radio."""
|
||||
@@ -183,6 +184,7 @@ class ZHAGateway:
|
||||
self._hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str(self.coordinator_ieee)
|
||||
self.async_load_devices()
|
||||
self.async_load_groups()
|
||||
self.initialized = True
|
||||
|
||||
@callback
|
||||
def async_load_devices(self) -> None:
|
||||
@@ -217,7 +219,7 @@ class ZHAGateway:
|
||||
async def async_initialize_devices_and_entities(self) -> None:
|
||||
"""Initialize devices and load entities."""
|
||||
|
||||
_LOGGER.debug("Loading all devices")
|
||||
_LOGGER.debug("Initializing all devices from Zigpy cache")
|
||||
await asyncio.gather(
|
||||
*(dev.async_initialize(from_cache=True) for dev in self.devices.values())
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ import zigpy.zdo.types as zdo_types
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, State, callback
|
||||
from homeassistant.exceptions import IntegrationError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .const import (
|
||||
@@ -42,6 +43,7 @@ if TYPE_CHECKING:
|
||||
from .gateway import ZHAGateway
|
||||
|
||||
_T = TypeVar("_T")
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -170,10 +172,22 @@ def async_get_zha_device(hass: HomeAssistant, device_id: str) -> ZHADevice:
|
||||
device_registry = dr.async_get(hass)
|
||||
registry_device = device_registry.async_get(device_id)
|
||||
if not registry_device:
|
||||
_LOGGER.error("Device id `%s` not found in registry", device_id)
|
||||
raise KeyError(f"Device id `{device_id}` not found in registry.")
|
||||
zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
|
||||
ieee_address = list(list(registry_device.identifiers)[0])[1]
|
||||
ieee = zigpy.types.EUI64.convert(ieee_address)
|
||||
if not zha_gateway.initialized:
|
||||
_LOGGER.error("Attempting to get a ZHA device when ZHA is not initialized")
|
||||
raise IntegrationError("ZHA is not initialized yet")
|
||||
try:
|
||||
ieee_address = list(list(registry_device.identifiers)[0])[1]
|
||||
ieee = zigpy.types.EUI64.convert(ieee_address)
|
||||
except (IndexError, ValueError) as ex:
|
||||
_LOGGER.error(
|
||||
"Unable to determine device IEEE for device with device id `%s`", device_id
|
||||
)
|
||||
raise KeyError(
|
||||
f"Unable to determine device IEEE for device with device id `{device_id}`."
|
||||
) from ex
|
||||
return zha_gateway.devices[ieee]
|
||||
|
||||
|
||||
|
||||
@@ -13,11 +13,11 @@ from homeassistant.components.device_automation.exceptions import (
|
||||
from homeassistant.components.homeassistant.triggers import event as event_trigger
|
||||
from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.exceptions import HomeAssistantError, IntegrationError
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import DOMAIN
|
||||
from .core.const import ZHA_EVENT
|
||||
from . import DOMAIN as ZHA_DOMAIN
|
||||
from .core.const import DATA_ZHA, ZHA_DEVICES_LOADED_EVENT, ZHA_EVENT
|
||||
from .core.helpers import async_get_zha_device
|
||||
|
||||
CONF_SUBTYPE = "subtype"
|
||||
@@ -35,11 +35,12 @@ async def async_validate_trigger_config(
|
||||
"""Validate config."""
|
||||
config = TRIGGER_SCHEMA(config)
|
||||
|
||||
if "zha" in hass.config.components:
|
||||
if ZHA_DOMAIN in hass.config.components:
|
||||
await hass.data[DATA_ZHA][ZHA_DEVICES_LOADED_EVENT].wait()
|
||||
trigger = (config[CONF_TYPE], config[CONF_SUBTYPE])
|
||||
try:
|
||||
zha_device = async_get_zha_device(hass, config[CONF_DEVICE_ID])
|
||||
except (KeyError, AttributeError) as err:
|
||||
except (KeyError, AttributeError, IntegrationError) as err:
|
||||
raise InvalidDeviceAutomationConfig from err
|
||||
if (
|
||||
zha_device.device_automation_triggers is None
|
||||
@@ -100,7 +101,7 @@ async def async_get_triggers(
|
||||
triggers.append(
|
||||
{
|
||||
CONF_DEVICE_ID: device_id,
|
||||
CONF_DOMAIN: DOMAIN,
|
||||
CONF_DOMAIN: ZHA_DOMAIN,
|
||||
CONF_PLATFORM: DEVICE,
|
||||
CONF_TYPE: trigger,
|
||||
CONF_SUBTYPE: subtype,
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/zha",
|
||||
"requirements": [
|
||||
"bellows==0.31.2",
|
||||
"bellows==0.31.3",
|
||||
"pyserial==3.5",
|
||||
"pyserial-asyncio==0.6",
|
||||
"zha-quirks==0.0.78",
|
||||
"zigpy-deconz==0.18.0",
|
||||
"zigpy==0.48.0",
|
||||
"zigpy==0.49.0",
|
||||
"zigpy-xbee==0.15.0",
|
||||
"zigpy-zigate==0.9.0",
|
||||
"zigpy-zigate==0.9.1",
|
||||
"zigpy-znp==0.8.1"
|
||||
],
|
||||
"usb": [
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user