mirror of
https://github.com/home-assistant/core.git
synced 2025-07-24 05:37:44 +00:00
2023.1.5 (#86058)
This commit is contained in:
commit
c5fb3e7fab
@ -1293,8 +1293,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/weather/ @home-assistant/core
|
||||
/homeassistant/components/webhook/ @home-assistant/core
|
||||
/tests/components/webhook/ @home-assistant/core
|
||||
/homeassistant/components/webostv/ @bendavid @thecode
|
||||
/tests/components/webostv/ @bendavid @thecode
|
||||
/homeassistant/components/webostv/ @thecode
|
||||
/tests/components/webostv/ @thecode
|
||||
/homeassistant/components/websocket_api/ @home-assistant/core
|
||||
/tests/components/websocket_api/ @home-assistant/core
|
||||
/homeassistant/components/wemo/ @esev
|
||||
|
@ -1,6 +1,4 @@
|
||||
"""application_credentials platform for Google Assistant SDK."""
|
||||
import oauth2client
|
||||
|
||||
from homeassistant.components.application_credentials import AuthorizationServer
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
@ -8,8 +6,8 @@ from homeassistant.core import HomeAssistant
|
||||
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
|
||||
"""Return authorization server."""
|
||||
return AuthorizationServer(
|
||||
oauth2client.GOOGLE_AUTH_URI,
|
||||
oauth2client.GOOGLE_TOKEN_URI,
|
||||
"https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"https://oauth2.googleapis.com/token",
|
||||
)
|
||||
|
||||
|
||||
|
@ -16,7 +16,7 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon
|
||||
get_zigbee_socket,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import DOMAIN
|
||||
@ -25,12 +25,10 @@ from .util import get_usb_service_info
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _multi_pan_addon_info(
|
||||
hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> AddonInfo | None:
|
||||
"""Return AddonInfo if the multi-PAN addon is enabled for our SkyConnect."""
|
||||
async def _wait_multi_pan_addon(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Wait for multi-PAN info to be available."""
|
||||
if not is_hassio(hass):
|
||||
return None
|
||||
return
|
||||
|
||||
addon_manager: AddonManager = get_addon_manager(hass)
|
||||
try:
|
||||
@ -50,7 +48,18 @@ async def _multi_pan_addon_info(
|
||||
)
|
||||
raise ConfigEntryNotReady
|
||||
|
||||
if addon_info.state == AddonState.NOT_INSTALLED:
|
||||
|
||||
async def _multi_pan_addon_info(
|
||||
hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> AddonInfo | None:
|
||||
"""Return AddonInfo if the multi-PAN addon is enabled for our SkyConnect."""
|
||||
if not is_hassio(hass):
|
||||
return None
|
||||
|
||||
addon_manager: AddonManager = get_addon_manager(hass)
|
||||
addon_info: AddonInfo = await addon_manager.async_get_addon_info()
|
||||
|
||||
if addon_info.state != AddonState.RUNNING:
|
||||
return None
|
||||
|
||||
usb_dev = entry.data["device"]
|
||||
@ -62,8 +71,8 @@ async def _multi_pan_addon_info(
|
||||
return addon_info
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a Home Assistant Sky Connect config entry."""
|
||||
async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Finish Home Assistant Sky Connect config entry setup."""
|
||||
matcher = usb.USBCallbackMatcher(
|
||||
domain=DOMAIN,
|
||||
vid=entry.data["vid"].upper(),
|
||||
@ -74,8 +83,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
)
|
||||
|
||||
if not usb.async_is_plugged_in(hass, matcher):
|
||||
# The USB dongle is not plugged in
|
||||
raise ConfigEntryNotReady
|
||||
# The USB dongle is not plugged in, remove the config entry
|
||||
hass.async_create_task(hass.config_entries.async_remove(entry.entry_id))
|
||||
return
|
||||
|
||||
addon_info = await _multi_pan_addon_info(hass, entry)
|
||||
|
||||
@ -86,7 +96,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
context={"source": "usb"},
|
||||
data=usb_info,
|
||||
)
|
||||
return True
|
||||
return
|
||||
|
||||
hw_discovery_data = {
|
||||
"name": "Sky Connect Multi-PAN",
|
||||
@ -101,6 +111,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
data=hw_discovery_data,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a Home Assistant Sky Connect config entry."""
|
||||
|
||||
await _wait_multi_pan_addon(hass, entry)
|
||||
|
||||
@callback
|
||||
def async_usb_scan_done() -> None:
|
||||
"""Handle usb discovery started."""
|
||||
hass.async_create_task(_async_usb_scan_done(hass, entry))
|
||||
|
||||
unsub_usb = usb.async_register_initial_scan_callback(hass, async_usb_scan_done)
|
||||
entry.async_on_unload(unsub_usb)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
@ -3,7 +3,7 @@
|
||||
"name": "HomeKit Controller",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
|
||||
"requirements": ["aiohomekit==2.4.3"],
|
||||
"requirements": ["aiohomekit==2.4.4"],
|
||||
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."],
|
||||
"bluetooth": [{ "manufacturer_id": 76, "manufacturer_data_start": [6] }],
|
||||
"dependencies": ["bluetooth", "zeroconf"],
|
||||
|
@ -1,7 +1,9 @@
|
||||
"""The Media Source implementation for the Jellyfin integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from jellyfin_apiclient_python.api import jellyfin_url
|
||||
@ -41,6 +43,8 @@ from .const import (
|
||||
)
|
||||
from .models import JellyfinData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
|
||||
"""Set up Jellyfin media source."""
|
||||
@ -75,6 +79,9 @@ class JellyfinSource(MediaSource):
|
||||
stream_url = self._get_stream_url(media_item)
|
||||
mime_type = _media_mime_type(media_item)
|
||||
|
||||
# Media Sources without a mime type have been filtered out during library creation
|
||||
assert mime_type is not None
|
||||
|
||||
return PlayMedia(stream_url, mime_type)
|
||||
|
||||
async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource:
|
||||
@ -240,7 +247,11 @@ class JellyfinSource(MediaSource):
|
||||
k.get(ITEM_KEY_INDEX_NUMBER, None),
|
||||
),
|
||||
)
|
||||
return [self._build_track(track) for track in tracks]
|
||||
return [
|
||||
self._build_track(track)
|
||||
for track in tracks
|
||||
if _media_mime_type(track) is not None
|
||||
]
|
||||
|
||||
def _build_track(self, track: dict[str, Any]) -> BrowseMediaSource:
|
||||
"""Return a single track as a browsable media source."""
|
||||
@ -289,7 +300,11 @@ class JellyfinSource(MediaSource):
|
||||
"""Return all movies in the movie library."""
|
||||
movies = await self._get_children(library_id, ITEM_TYPE_MOVIE)
|
||||
movies = sorted(movies, key=lambda k: k[ITEM_KEY_NAME]) # type: ignore[no-any-return]
|
||||
return [self._build_movie(movie) for movie in movies]
|
||||
return [
|
||||
self._build_movie(movie)
|
||||
for movie in movies
|
||||
if _media_mime_type(movie) is not None
|
||||
]
|
||||
|
||||
def _build_movie(self, movie: dict[str, Any]) -> BrowseMediaSource:
|
||||
"""Return a single movie as a browsable media source."""
|
||||
@ -349,20 +364,24 @@ class JellyfinSource(MediaSource):
|
||||
raise BrowseError(f"Unsupported media type {media_type}")
|
||||
|
||||
|
||||
def _media_mime_type(media_item: dict[str, Any]) -> str:
|
||||
def _media_mime_type(media_item: dict[str, Any]) -> str | None:
|
||||
"""Return the mime type of a media item."""
|
||||
if not media_item.get(ITEM_KEY_MEDIA_SOURCES):
|
||||
raise BrowseError("Unable to determine mime type for item without media source")
|
||||
_LOGGER.debug("Unable to determine mime type for item without media source")
|
||||
return None
|
||||
|
||||
media_source = media_item[ITEM_KEY_MEDIA_SOURCES][0]
|
||||
|
||||
if MEDIA_SOURCE_KEY_PATH not in media_source:
|
||||
raise BrowseError("Unable to determine mime type for media source without path")
|
||||
_LOGGER.debug("Unable to determine mime type for media source without path")
|
||||
return None
|
||||
|
||||
path = media_source[MEDIA_SOURCE_KEY_PATH]
|
||||
mime_type, _ = mimetypes.guess_type(path)
|
||||
|
||||
if mime_type is None:
|
||||
raise BrowseError(f"Unable to determine mime type for path {path}")
|
||||
_LOGGER.debug(
|
||||
"Unable to determine mime type for path %s", os.path.basename(path)
|
||||
)
|
||||
|
||||
return mime_type
|
||||
|
@ -211,6 +211,7 @@ class NestFlowHandler(
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult:
|
||||
"""Complete OAuth setup and finish pubsub or finish."""
|
||||
_LOGGER.debug("Finishing post-oauth configuration")
|
||||
assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API"
|
||||
self._data.update(data)
|
||||
if self.source == SOURCE_REAUTH:
|
||||
@ -459,6 +460,7 @@ class NestFlowHandler(
|
||||
|
||||
async def async_step_finish(self, data: dict[str, Any] | None = None) -> FlowResult:
|
||||
"""Create an entry for the SDM flow."""
|
||||
_LOGGER.debug("Creating/updating configuration entry")
|
||||
assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API"
|
||||
# Update existing config entry when in the reauth flow.
|
||||
if entry := self._async_reauth_entry():
|
||||
|
@ -5,7 +5,7 @@
|
||||
"dependencies": ["ffmpeg", "http", "application_credentials"],
|
||||
"after_dependencies": ["media_source"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/nest",
|
||||
"requirements": ["python-nest==4.2.0", "google-nest-sdm==2.1.0"],
|
||||
"requirements": ["python-nest==4.2.0", "google-nest-sdm==2.2.2"],
|
||||
"codeowners": ["@allenporter"],
|
||||
"quality_scale": "platinum",
|
||||
"dhcp": [
|
||||
|
@ -17,7 +17,8 @@ from homeassistant.const import (
|
||||
CONF_VERIFY_SSL,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
@ -64,6 +65,13 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.UPDATE,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Pi-hole integration."""
|
||||
@ -103,11 +111,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
location = entry.data[CONF_LOCATION]
|
||||
api_key = entry.data.get(CONF_API_KEY)
|
||||
|
||||
# For backward compatibility
|
||||
if CONF_STATISTICS_ONLY not in entry.data:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, data={**entry.data, CONF_STATISTICS_ONLY: not api_key}
|
||||
)
|
||||
# remove obsolet CONF_STATISTICS_ONLY from entry.data
|
||||
if CONF_STATISTICS_ONLY in entry.data:
|
||||
entry_data = entry.data.copy()
|
||||
entry_data.pop(CONF_STATISTICS_ONLY)
|
||||
hass.config_entries.async_update_entry(entry, data=entry_data)
|
||||
|
||||
# start reauth to force api key is present
|
||||
if CONF_API_KEY not in entry.data:
|
||||
raise ConfigEntryAuthFailed
|
||||
|
||||
_LOGGER.debug("Setting up %s integration with host %s", DOMAIN, host)
|
||||
|
||||
@ -125,8 +137,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
try:
|
||||
await api.get_data()
|
||||
await api.get_versions()
|
||||
_LOGGER.debug("async_update_data() api.data: %s", api.data)
|
||||
except HoleError as err:
|
||||
raise UpdateFailed(f"Failed to communicate with API: {err}") from err
|
||||
if not isinstance(api.data, dict):
|
||||
raise ConfigEntryAuthFailed
|
||||
|
||||
coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
@ -142,30 +157,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, _async_platforms(entry))
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload Pi-hole entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(
|
||||
entry, _async_platforms(entry)
|
||||
)
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
return unload_ok
|
||||
|
||||
|
||||
@callback
|
||||
def _async_platforms(entry: ConfigEntry) -> list[Platform]:
|
||||
"""Return platforms to be loaded / unloaded."""
|
||||
platforms = [Platform.BINARY_SENSOR, Platform.UPDATE, Platform.SENSOR]
|
||||
if not entry.data[CONF_STATISTICS_ONLY]:
|
||||
platforms.append(Platform.SWITCH)
|
||||
return platforms
|
||||
|
||||
|
||||
class PiHoleEntity(CoordinatorEntity):
|
||||
"""Representation of a Pi-hole entity."""
|
||||
|
||||
|
@ -15,8 +15,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from . import PiHoleEntity
|
||||
from .const import (
|
||||
BINARY_SENSOR_TYPES,
|
||||
BINARY_SENSOR_TYPES_STATISTICS_ONLY,
|
||||
CONF_STATISTICS_ONLY,
|
||||
DATA_KEY_API,
|
||||
DATA_KEY_COORDINATOR,
|
||||
DOMAIN as PIHOLE_DOMAIN,
|
||||
@ -42,18 +40,6 @@ async def async_setup_entry(
|
||||
for description in BINARY_SENSOR_TYPES
|
||||
]
|
||||
|
||||
if entry.data[CONF_STATISTICS_ONLY]:
|
||||
binary_sensors += [
|
||||
PiHoleBinarySensor(
|
||||
hole_data[DATA_KEY_API],
|
||||
hole_data[DATA_KEY_COORDINATOR],
|
||||
name,
|
||||
entry.entry_id,
|
||||
description,
|
||||
)
|
||||
for description in BINARY_SENSOR_TYPES_STATISTICS_ONLY
|
||||
]
|
||||
|
||||
async_add_entities(binary_sensors, True)
|
||||
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
"""Config flow to configure the Pi-hole integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@ -26,7 +27,6 @@ from .const import (
|
||||
DEFAULT_LOCATION,
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_SSL,
|
||||
DEFAULT_STATISTICS_ONLY,
|
||||
DEFAULT_VERIFY_SSL,
|
||||
DOMAIN,
|
||||
)
|
||||
@ -47,65 +47,29 @@ class PiHoleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle a flow initiated by the user."""
|
||||
return await self.async_step_init(user_input)
|
||||
|
||||
async def async_step_import(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle a flow initiated by import."""
|
||||
return await self.async_step_init(user_input, is_import=True)
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None, is_import: bool = False
|
||||
) -> FlowResult:
|
||||
"""Handle init step of a flow."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
host = (
|
||||
user_input[CONF_HOST]
|
||||
if is_import
|
||||
else f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}"
|
||||
)
|
||||
name = user_input[CONF_NAME]
|
||||
location = user_input[CONF_LOCATION]
|
||||
tls = user_input[CONF_SSL]
|
||||
verify_tls = user_input[CONF_VERIFY_SSL]
|
||||
endpoint = f"{host}/{location}"
|
||||
self._config = {
|
||||
CONF_HOST: f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}",
|
||||
CONF_NAME: user_input[CONF_NAME],
|
||||
CONF_LOCATION: user_input[CONF_LOCATION],
|
||||
CONF_SSL: user_input[CONF_SSL],
|
||||
CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL],
|
||||
CONF_API_KEY: user_input[CONF_API_KEY],
|
||||
}
|
||||
|
||||
if await self._async_endpoint_existed(endpoint):
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
try:
|
||||
await self._async_try_connect(host, location, tls, verify_tls)
|
||||
except HoleError as ex:
|
||||
_LOGGER.debug("Connection failed: %s", ex)
|
||||
if is_import:
|
||||
_LOGGER.error("Failed to import: %s", ex)
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
self._config = {
|
||||
CONF_HOST: host,
|
||||
CONF_NAME: name,
|
||||
CONF_LOCATION: location,
|
||||
CONF_SSL: tls,
|
||||
CONF_VERIFY_SSL: verify_tls,
|
||||
self._async_abort_entries_match(
|
||||
{
|
||||
CONF_HOST: f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}",
|
||||
CONF_LOCATION: user_input[CONF_LOCATION],
|
||||
}
|
||||
if is_import:
|
||||
api_key = user_input.get(CONF_API_KEY)
|
||||
return self.async_create_entry(
|
||||
title=name,
|
||||
data={
|
||||
**self._config,
|
||||
CONF_STATISTICS_ONLY: api_key is None,
|
||||
CONF_API_KEY: api_key,
|
||||
},
|
||||
)
|
||||
self._config[CONF_STATISTICS_ONLY] = user_input[CONF_STATISTICS_ONLY]
|
||||
if self._config[CONF_STATISTICS_ONLY]:
|
||||
return self.async_create_entry(title=name, data=self._config)
|
||||
return await self.async_step_api_key()
|
||||
)
|
||||
|
||||
if not (errors := await self._async_try_connect()):
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_NAME], data=self._config
|
||||
)
|
||||
|
||||
user_input = user_input or {}
|
||||
return self.async_show_form(
|
||||
@ -116,6 +80,7 @@ class PiHoleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
vol.Required(
|
||||
CONF_PORT, default=user_input.get(CONF_PORT, 80)
|
||||
): vol.Coerce(int),
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
vol.Required(
|
||||
CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME)
|
||||
): str,
|
||||
@ -123,12 +88,6 @@ class PiHoleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
CONF_LOCATION,
|
||||
default=user_input.get(CONF_LOCATION, DEFAULT_LOCATION),
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_STATISTICS_ONLY,
|
||||
default=user_input.get(
|
||||
CONF_STATISTICS_ONLY, DEFAULT_STATISTICS_ONLY
|
||||
),
|
||||
): bool,
|
||||
vol.Required(
|
||||
CONF_SSL,
|
||||
default=user_input.get(CONF_SSL, DEFAULT_SSL),
|
||||
@ -142,24 +101,94 @@ class PiHoleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_api_key(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult:
|
||||
"""Handle a flow initiated by import."""
|
||||
|
||||
host = user_input[CONF_HOST]
|
||||
name = user_input[CONF_NAME]
|
||||
location = user_input[CONF_LOCATION]
|
||||
tls = user_input[CONF_SSL]
|
||||
verify_tls = user_input[CONF_VERIFY_SSL]
|
||||
endpoint = f"{host}/{location}"
|
||||
|
||||
if await self._async_endpoint_existed(endpoint):
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
try:
|
||||
await self._async_try_connect_legacy(host, location, tls, verify_tls)
|
||||
except HoleError as ex:
|
||||
_LOGGER.debug("Connection failed: %s", ex)
|
||||
_LOGGER.error("Failed to import: %s", ex)
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
self._config = {
|
||||
CONF_HOST: host,
|
||||
CONF_NAME: name,
|
||||
CONF_LOCATION: location,
|
||||
CONF_SSL: tls,
|
||||
CONF_VERIFY_SSL: verify_tls,
|
||||
}
|
||||
api_key = user_input.get(CONF_API_KEY)
|
||||
return self.async_create_entry(
|
||||
title=name,
|
||||
data={
|
||||
**self._config,
|
||||
CONF_STATISTICS_ONLY: api_key is None,
|
||||
CONF_API_KEY: api_key,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
|
||||
"""Perform reauth upon an API authentication error."""
|
||||
self._config = dict(entry_data)
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self,
|
||||
user_input: dict[str, Any] | None = None,
|
||||
) -> FlowResult:
|
||||
"""Handle step to setup API key."""
|
||||
"""Perform reauth confirm upon an API authentication error."""
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title=self._config[CONF_NAME],
|
||||
data={
|
||||
**self._config,
|
||||
CONF_API_KEY: user_input.get(CONF_API_KEY, ""),
|
||||
},
|
||||
)
|
||||
self._config = {**self._config, CONF_API_KEY: user_input[CONF_API_KEY]}
|
||||
if not (errors := await self._async_try_connect()):
|
||||
entry = self.hass.config_entries.async_get_entry(
|
||||
self.context["entry_id"]
|
||||
)
|
||||
assert entry
|
||||
self.hass.config_entries.async_update_entry(entry, data=self._config)
|
||||
self.hass.async_create_task(
|
||||
self.hass.config_entries.async_reload(self.context["entry_id"])
|
||||
)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="api_key",
|
||||
data_schema=vol.Schema({vol.Optional(CONF_API_KEY): str}),
|
||||
step_id="reauth_confirm",
|
||||
description_placeholders={
|
||||
CONF_HOST: self._config[CONF_HOST],
|
||||
CONF_LOCATION: self._config[CONF_LOCATION],
|
||||
},
|
||||
data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def _async_try_connect(self) -> dict[str, str]:
|
||||
session = async_get_clientsession(self.hass, self._config[CONF_VERIFY_SSL])
|
||||
pi_hole = Hole(
|
||||
self._config[CONF_HOST],
|
||||
session,
|
||||
location=self._config[CONF_LOCATION],
|
||||
tls=self._config[CONF_SSL],
|
||||
api_token=self._config[CONF_API_KEY],
|
||||
)
|
||||
try:
|
||||
await pi_hole.get_data()
|
||||
except HoleError as ex:
|
||||
_LOGGER.debug("Connection failed: %s", ex)
|
||||
return {"base": "cannot_connect"}
|
||||
if not isinstance(pi_hole.data, dict):
|
||||
return {CONF_API_KEY: "invalid_auth"}
|
||||
return {}
|
||||
|
||||
async def _async_endpoint_existed(self, endpoint: str) -> bool:
|
||||
existing_endpoints = [
|
||||
f"{entry.data.get(CONF_HOST)}/{entry.data.get(CONF_LOCATION)}"
|
||||
@ -167,7 +196,7 @@ class PiHoleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
]
|
||||
return endpoint in existing_endpoints
|
||||
|
||||
async def _async_try_connect(
|
||||
async def _async_try_connect_legacy(
|
||||
self, host: str, location: str, tls: bool, verify_tls: bool
|
||||
) -> None:
|
||||
session = async_get_clientsession(self.hass, verify_tls)
|
||||
|
@ -154,9 +154,6 @@ BINARY_SENSOR_TYPES: tuple[PiHoleBinarySensorEntityDescription, ...] = (
|
||||
},
|
||||
state_value=lambda api: bool(api.versions["FTL_update"]),
|
||||
),
|
||||
)
|
||||
|
||||
BINARY_SENSOR_TYPES_STATISTICS_ONLY: tuple[PiHoleBinarySensorEntityDescription, ...] = (
|
||||
PiHoleBinarySensorEntityDescription(
|
||||
key="status",
|
||||
name="Status",
|
||||
|
@ -8,28 +8,25 @@
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"location": "[%key:common::config_flow::data::location%]",
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"statistics_only": "Statistics Only",
|
||||
"ssl": "[%key:common::config_flow::data::ssl%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
}
|
||||
},
|
||||
"api_key": {
|
||||
"reauth_confirm": {
|
||||
"title": "PI-Hole [%key:common::config_flow::title::reauth%]",
|
||||
"description": "Please enter a new api key for PI-Hole at {host}/{location}",
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml": {
|
||||
"title": "The PI-Hole YAML configuration is being removed",
|
||||
"description": "Configuring PI-Hole using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the PI-Hole YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,16 +1,20 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Service is already configured"
|
||||
"already_configured": "Service is already configured",
|
||||
"reauth_successful": "Re-authentication was successful"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect"
|
||||
"cannot_connect": "Failed to connect",
|
||||
"invalid_auth": "Invalid authentication"
|
||||
},
|
||||
"step": {
|
||||
"api_key": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"api_key": "API Key"
|
||||
}
|
||||
},
|
||||
"description": "Please enter a new api key for PI-Hole at {host}/{location}",
|
||||
"title": "PI-Hole Reauthenticate Integration"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
@ -20,16 +24,9 @@
|
||||
"name": "Name",
|
||||
"port": "Port",
|
||||
"ssl": "Uses an SSL certificate",
|
||||
"statistics_only": "Statistics Only",
|
||||
"verify_ssl": "Verify SSL certificate"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml": {
|
||||
"description": "Configuring PI-Hole using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the PI-Hole YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue.",
|
||||
"title": "The PI-Hole YAML configuration is being removed"
|
||||
}
|
||||
}
|
||||
}
|
@ -14,10 +14,11 @@ from reolink_aio.exceptions import ApiError, InvalidContentTypeError
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import DOMAIN
|
||||
from .exceptions import UserNotAdmin
|
||||
from .host import ReolinkHost
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@ -40,16 +41,20 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
|
||||
|
||||
try:
|
||||
if not await host.async_init():
|
||||
await host.stop()
|
||||
raise ConfigEntryNotReady(
|
||||
f"Error while trying to setup {host.api.host}:{host.api.port}: "
|
||||
"failed to obtain data from device."
|
||||
)
|
||||
except UserNotAdmin as err:
|
||||
raise ConfigEntryAuthFailed(err) from UserNotAdmin
|
||||
except (
|
||||
ClientConnectorError,
|
||||
asyncio.TimeoutError,
|
||||
ApiError,
|
||||
InvalidContentTypeError,
|
||||
) as err:
|
||||
await host.stop()
|
||||
raise ConfigEntryNotReady(
|
||||
f'Error while trying to setup {host.api.host}:{host.api.port}: "{str(err)}".'
|
||||
) from err
|
||||
|
@ -1,19 +1,21 @@
|
||||
"""Config flow for the Reolink camera component."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from reolink_aio.exceptions import ApiError, CredentialsInvalidError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries, core, exceptions
|
||||
from homeassistant import config_entries, exceptions
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DEFAULT_PROTOCOL, DOMAIN
|
||||
from .exceptions import UserNotAdmin
|
||||
from .host import ReolinkHost
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@ -53,6 +55,13 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize."""
|
||||
self._host: str | None = None
|
||||
self._username: str = "admin"
|
||||
self._password: str | None = None
|
||||
self._reauth: bool = False
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
@ -61,16 +70,37 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Options callback for Reolink."""
|
||||
return ReolinkOptionsFlowHandler(config_entry)
|
||||
|
||||
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
|
||||
"""Perform reauth upon an authentication error or no admin privileges."""
|
||||
self._host = entry_data[CONF_HOST]
|
||||
self._username = entry_data[CONF_USERNAME]
|
||||
self._password = entry_data[CONF_PASSWORD]
|
||||
self._reauth = True
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Dialog that informs the user that reauth is required."""
|
||||
if user_input is not None:
|
||||
return await self.async_step_user()
|
||||
return self.async_show_form(step_id="reauth_confirm")
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors = {}
|
||||
placeholders = {}
|
||||
placeholders = {"error": ""}
|
||||
|
||||
if user_input is not None:
|
||||
host = ReolinkHost(self.hass, user_input, DEFAULT_OPTIONS)
|
||||
try:
|
||||
host = await async_obtain_host_settings(self.hass, user_input)
|
||||
await async_obtain_host_settings(host)
|
||||
except UserNotAdmin:
|
||||
errors[CONF_USERNAME] = "not_admin"
|
||||
placeholders["username"] = host.api.username
|
||||
placeholders["userlevel"] = host.api.user_level
|
||||
except CannotConnect:
|
||||
errors[CONF_HOST] = "cannot_connect"
|
||||
except CredentialsInvalidError:
|
||||
@ -87,7 +117,17 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
user_input[CONF_PORT] = host.api.port
|
||||
user_input[CONF_USE_HTTPS] = host.api.use_https
|
||||
|
||||
await self.async_set_unique_id(host.unique_id, raise_on_progress=False)
|
||||
existing_entry = await self.async_set_unique_id(
|
||||
host.unique_id, raise_on_progress=False
|
||||
)
|
||||
if existing_entry and self._reauth:
|
||||
if self.hass.config_entries.async_update_entry(
|
||||
existing_entry, data=user_input
|
||||
):
|
||||
await self.hass.config_entries.async_reload(
|
||||
existing_entry.entry_id
|
||||
)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
self._abort_if_unique_id_configured(updates=user_input)
|
||||
|
||||
return self.async_create_entry(
|
||||
@ -98,9 +138,9 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME, default="admin"): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_USERNAME, default=self._username): str,
|
||||
vol.Required(CONF_PASSWORD, default=self._password): str,
|
||||
vol.Required(CONF_HOST, default=self._host): str,
|
||||
}
|
||||
)
|
||||
if errors:
|
||||
@ -119,20 +159,14 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
|
||||
async def async_obtain_host_settings(
|
||||
hass: core.HomeAssistant, user_input: dict
|
||||
) -> ReolinkHost:
|
||||
async def async_obtain_host_settings(host: ReolinkHost) -> None:
|
||||
"""Initialize the Reolink host and get the host information."""
|
||||
host = ReolinkHost(hass, user_input, DEFAULT_OPTIONS)
|
||||
|
||||
try:
|
||||
if not await host.async_init():
|
||||
raise CannotConnect
|
||||
finally:
|
||||
await host.stop()
|
||||
|
||||
return host
|
||||
|
||||
|
||||
class CannotConnect(exceptions.HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
6
homeassistant/components/reolink/exceptions.py
Normal file
6
homeassistant/components/reolink/exceptions.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""Exceptions for the Reolink Camera integration."""
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
|
||||
class UserNotAdmin(HomeAssistantError):
|
||||
"""Raised when user is not admin."""
|
@ -19,6 +19,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
|
||||
from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DEFAULT_TIMEOUT
|
||||
from .exceptions import UserNotAdmin
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -68,6 +69,12 @@ class ReolinkHost:
|
||||
if self._api.mac_address is None:
|
||||
return False
|
||||
|
||||
if not self._api.is_admin:
|
||||
await self.stop()
|
||||
raise UserNotAdmin(
|
||||
f"User '{self._api.username}' has authorization level '{self._api.user_level}', only admin users can change camera settings"
|
||||
)
|
||||
|
||||
enable_onvif = None
|
||||
enable_rtmp = None
|
||||
enable_rtsp = None
|
||||
|
@ -3,7 +3,7 @@
|
||||
"name": "Reolink IP NVR/camera",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/reolink",
|
||||
"requirements": ["reolink-aio==0.1.3"],
|
||||
"requirements": ["reolink-aio==0.2.1"],
|
||||
"codeowners": ["@starkillerOG"],
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["reolink-aio"]
|
||||
|
@ -2,6 +2,7 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "{error}",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
@ -9,16 +10,22 @@
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "[%key:common::config_flow::title::reauth%]",
|
||||
"description": "The Reolink integration needs to re-authenticate your connection details"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"api_error": "API error occurred: {error}",
|
||||
"api_error": "API error occurred",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]: {error}"
|
||||
"not_admin": "User needs to be admin, user ''{username}'' has authorisation level ''{userlevel}''",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
@ -1,15 +1,21 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Device is already configured"
|
||||
"already_configured": "Device is already configured",
|
||||
"reauth_successful": "Re-authentication was successful"
|
||||
},
|
||||
"error": {
|
||||
"api_error": "API error occurred: {error}",
|
||||
"api_error": "API error occurred",
|
||||
"cannot_connect": "Failed to connect",
|
||||
"invalid_auth": "Invalid authentication",
|
||||
"unknown": "Unexpected error: {error}"
|
||||
"not_admin": "User needs to be admin, user ''{username}'' has authorisation level ''{userlevel}''",
|
||||
"unknown": "Unexpected error"
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"description": "The Reolink integration needs to re-authenticate your connection details",
|
||||
"title": "Reauthenticate Integration"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "Host",
|
||||
@ -17,7 +23,8 @@
|
||||
"port": "Port",
|
||||
"use_https": "Enable HTTPS",
|
||||
"username": "Username"
|
||||
}
|
||||
},
|
||||
"description": "{error}"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -217,7 +217,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Abort and reconnect soon if the device with the mac address is already configured."""
|
||||
if (
|
||||
current_entry := await self.async_set_unique_id(mac)
|
||||
) and current_entry.data[CONF_HOST] == host:
|
||||
) and current_entry.data.get(CONF_HOST) == host:
|
||||
await async_reconnect_soon(self.hass, current_entry)
|
||||
if host == INTERNAL_WIFI_AP_IP:
|
||||
# If the device is broadcasting the internal wifi ap ip
|
||||
|
@ -2,7 +2,7 @@
|
||||
"domain": "switchbot",
|
||||
"name": "SwitchBot",
|
||||
"documentation": "https://www.home-assistant.io/integrations/switchbot",
|
||||
"requirements": ["PySwitchbot==0.36.3"],
|
||||
"requirements": ["PySwitchbot==0.36.4"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth"],
|
||||
"codeowners": [
|
||||
|
@ -3,7 +3,7 @@
|
||||
"domain": "tibber",
|
||||
"name": "Tibber",
|
||||
"documentation": "https://www.home-assistant.io/integrations/tibber",
|
||||
"requirements": ["pyTibber==0.26.8"],
|
||||
"requirements": ["pyTibber==0.26.9"],
|
||||
"codeowners": ["@danielhiversen"],
|
||||
"quality_scale": "silver",
|
||||
"config_flow": true,
|
||||
|
@ -61,6 +61,18 @@ def async_register_scan_request_callback(
|
||||
return discovery.async_register_scan_request_callback(callback)
|
||||
|
||||
|
||||
@hass_callback
|
||||
def async_register_initial_scan_callback(
|
||||
hass: HomeAssistant, callback: CALLBACK_TYPE
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Register to receive a callback when the initial USB scan is done.
|
||||
|
||||
If the initial scan is already done, the callback is called immediately.
|
||||
"""
|
||||
discovery: USBDiscovery = hass.data[DOMAIN]
|
||||
return discovery.async_register_initial_scan_callback(callback)
|
||||
|
||||
|
||||
@hass_callback
|
||||
def async_is_plugged_in(hass: HomeAssistant, matcher: USBCallbackMatcher) -> bool:
|
||||
"""Return True is a USB device is present."""
|
||||
@ -186,6 +198,8 @@ class USBDiscovery:
|
||||
self.observer_active = False
|
||||
self._request_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None
|
||||
self._request_callbacks: list[CALLBACK_TYPE] = []
|
||||
self.initial_scan_done = False
|
||||
self._initial_scan_callbacks: list[CALLBACK_TYPE] = []
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Set up USB Discovery."""
|
||||
@ -249,7 +263,7 @@ class USBDiscovery:
|
||||
self,
|
||||
_callback: CALLBACK_TYPE,
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Register a callback."""
|
||||
"""Register a scan request callback."""
|
||||
self._request_callbacks.append(_callback)
|
||||
|
||||
@hass_callback
|
||||
@ -258,6 +272,26 @@ class USBDiscovery:
|
||||
|
||||
return _async_remove_callback
|
||||
|
||||
@hass_callback
|
||||
def async_register_initial_scan_callback(
|
||||
self,
|
||||
callback: CALLBACK_TYPE,
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Register an initial scan callback."""
|
||||
if self.initial_scan_done:
|
||||
callback()
|
||||
return lambda: None
|
||||
|
||||
self._initial_scan_callbacks.append(callback)
|
||||
|
||||
@hass_callback
|
||||
def _async_remove_callback() -> None:
|
||||
if callback not in self._initial_scan_callbacks:
|
||||
return
|
||||
self._initial_scan_callbacks.remove(callback)
|
||||
|
||||
return _async_remove_callback
|
||||
|
||||
@hass_callback
|
||||
def _async_process_discovered_usb_device(self, device: USBDevice) -> None:
|
||||
"""Process a USB discovery."""
|
||||
@ -307,6 +341,12 @@ class USBDiscovery:
|
||||
async def _async_scan_serial(self) -> None:
|
||||
"""Scan serial ports."""
|
||||
self._async_process_ports(await self.hass.async_add_executor_job(comports))
|
||||
if self.initial_scan_done:
|
||||
return
|
||||
|
||||
self.initial_scan_done = True
|
||||
while self._initial_scan_callbacks:
|
||||
self._initial_scan_callbacks.pop()()
|
||||
|
||||
async def _async_scan(self) -> None:
|
||||
"""Scan for USB devices and notify callbacks to scan as well."""
|
||||
|
@ -24,7 +24,6 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.config_validation import PLATFORM_SCHEMA
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
@ -53,7 +52,6 @@ KEY_TO_ATTR = {
|
||||
ATTRIBUTION = "Data provided by the World Air Quality Index project"
|
||||
|
||||
ATTR_ICON = "mdi:cloud"
|
||||
ATTR_UNIT = "AQI"
|
||||
|
||||
CONF_LOCATIONS = "locations"
|
||||
CONF_STATIONS = "stations"
|
||||
@ -62,7 +60,7 @@ SCAN_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
TIMEOUT = 10
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_STATIONS): cv.ensure_list,
|
||||
vol.Required(CONF_TOKEN): cv.string,
|
||||
@ -110,7 +108,6 @@ class WaqiSensor(SensorEntity):
|
||||
"""Implementation of a WAQI sensor."""
|
||||
|
||||
_attr_icon = ATTR_ICON
|
||||
_attr_native_unit_of_measurement = ATTR_UNIT
|
||||
_attr_device_class = SensorDeviceClass.AQI
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
|
@ -116,7 +116,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
host = urlparse(discovery_info.ssdp_location).hostname
|
||||
assert host
|
||||
self._host = host
|
||||
self._name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME]
|
||||
self._name = discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, DEFAULT_NAME)
|
||||
|
||||
uuid = discovery_info.upnp[ssdp.ATTR_UPNP_UDN]
|
||||
assert uuid
|
||||
|
@ -3,8 +3,8 @@
|
||||
"name": "LG webOS Smart TV",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/webostv",
|
||||
"requirements": ["aiowebostv==0.3.0"],
|
||||
"codeowners": ["@bendavid", "@thecode"],
|
||||
"requirements": ["aiowebostv==0.3.2"],
|
||||
"codeowners": ["@thecode"],
|
||||
"ssdp": [{ "st": "urn:lge-com:service:webos-second-screen:1" }],
|
||||
"quality_scale": "platinum",
|
||||
"iot_class": "local_push",
|
||||
|
@ -1,14 +1,18 @@
|
||||
"""Support for interface with an LG webOS Smart TV."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from contextlib import suppress
|
||||
from datetime import timedelta
|
||||
from functools import wraps
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
from ssl import SSLContext
|
||||
from typing import Any, TypeVar, cast
|
||||
|
||||
from aiowebostv import WebOsClient, WebOsTvPairError
|
||||
import async_timeout
|
||||
from typing_extensions import Concatenate, ParamSpec
|
||||
|
||||
from homeassistant import util
|
||||
@ -28,6 +32,7 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
@ -466,3 +471,25 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
|
||||
async def async_command(self, command: str, **kwargs: Any) -> None:
|
||||
"""Send a command."""
|
||||
await self._client.request(command, payload=kwargs.get(ATTR_PAYLOAD))
|
||||
|
||||
async def _async_fetch_image(self, url: str) -> tuple[bytes | None, str | None]:
|
||||
"""Retrieve an image.
|
||||
|
||||
webOS uses self-signed certificates, thus we need to use an empty
|
||||
SSLContext to bypass validation errors if url starts with https.
|
||||
"""
|
||||
content = None
|
||||
ssl_context = None
|
||||
if url.startswith("https"):
|
||||
ssl_context = SSLContext()
|
||||
|
||||
websession = async_get_clientsession(self.hass)
|
||||
with suppress(asyncio.TimeoutError), async_timeout.timeout(10):
|
||||
response = await websession.get(url, ssl=ssl_context)
|
||||
if response.status == HTTPStatus.OK:
|
||||
content = await response.read()
|
||||
|
||||
if content is None:
|
||||
_LOGGER.warning("Error retrieving proxied image from %s", url)
|
||||
|
||||
return content, None
|
||||
|
@ -8,7 +8,7 @@ from .backports.enum import StrEnum
|
||||
APPLICATION_NAME: Final = "HomeAssistant"
|
||||
MAJOR_VERSION: Final = 2023
|
||||
MINOR_VERSION: Final = 1
|
||||
PATCH_VERSION: Final = "4"
|
||||
PATCH_VERSION: Final = "5"
|
||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0)
|
||||
|
@ -41,6 +41,9 @@ MY_AUTH_CALLBACK_PATH = "https://my.home-assistant.io/redirect/oauth"
|
||||
|
||||
CLOCK_OUT_OF_SYNC_MAX_SEC = 20
|
||||
|
||||
OAUTH_AUTHORIZE_URL_TIMEOUT_SEC = 30
|
||||
OAUTH_TOKEN_TIMEOUT_SEC = 30
|
||||
|
||||
|
||||
class AbstractOAuth2Implementation(ABC):
|
||||
"""Base class to abstract OAuth2 authentication."""
|
||||
@ -194,6 +197,7 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation):
|
||||
if self.client_secret is not None:
|
||||
data["client_secret"] = self.client_secret
|
||||
|
||||
_LOGGER.debug("Sending token request to %s", self.token_url)
|
||||
resp = await session.post(self.token_url, data=data)
|
||||
if resp.status >= 400 and _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
body = await resp.text()
|
||||
@ -283,9 +287,10 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta):
|
||||
return self.async_external_step_done(next_step_id=next_step)
|
||||
|
||||
try:
|
||||
async with async_timeout.timeout(10):
|
||||
async with async_timeout.timeout(OAUTH_AUTHORIZE_URL_TIMEOUT_SEC):
|
||||
url = await self.async_generate_authorize_url()
|
||||
except asyncio.TimeoutError:
|
||||
except asyncio.TimeoutError as err:
|
||||
_LOGGER.error("Timeout generating authorize url: %s", err)
|
||||
return self.async_abort(reason="authorize_url_timeout")
|
||||
except NoURLAvailableError:
|
||||
return self.async_abort(
|
||||
@ -303,7 +308,17 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Create config entry from external data."""
|
||||
token = await self.flow_impl.async_resolve_external_data(self.external_data)
|
||||
_LOGGER.debug("Creating config entry from external data")
|
||||
|
||||
try:
|
||||
async with async_timeout.timeout(OAUTH_TOKEN_TIMEOUT_SEC):
|
||||
token = await self.flow_impl.async_resolve_external_data(
|
||||
self.external_data
|
||||
)
|
||||
except asyncio.TimeoutError as err:
|
||||
_LOGGER.error("Timeout resolving OAuth token: %s", err)
|
||||
return self.async_abort(reason="oauth2_timeout")
|
||||
|
||||
# Force int for non-compliant oauth2 providers
|
||||
try:
|
||||
token["expires_in"] = int(token["expires_in"])
|
||||
@ -436,7 +451,7 @@ class OAuth2AuthorizeCallbackView(http.HomeAssistantView):
|
||||
await hass.config_entries.flow.async_configure(
|
||||
flow_id=state["flow_id"], user_input=user_input
|
||||
)
|
||||
|
||||
_LOGGER.debug("Resumed OAuth configuration flow")
|
||||
return web.Response(
|
||||
headers={"content-type": "text/html"},
|
||||
text="<script>window.close()</script>",
|
||||
|
@ -71,6 +71,7 @@
|
||||
"no_devices_found": "No devices found on the network",
|
||||
"webhook_not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive webhook messages.",
|
||||
"oauth2_error": "Received invalid token data.",
|
||||
"oauth2_timeout": "Timeout resolving OAuth token.",
|
||||
"oauth2_missing_configuration": "The component is not configured. Please follow the documentation.",
|
||||
"oauth2_missing_credentials": "The integration requires application credentials.",
|
||||
"oauth2_authorize_url_timeout": "Timeout generating authorize URL.",
|
||||
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "homeassistant"
|
||||
version = "2023.1.4"
|
||||
version = "2023.1.5"
|
||||
license = {text = "Apache-2.0"}
|
||||
description = "Open-source home automation platform running on Python 3."
|
||||
readme = "README.rst"
|
||||
|
@ -40,7 +40,7 @@ PyRMVtransport==0.3.3
|
||||
PySocks==1.7.1
|
||||
|
||||
# homeassistant.components.switchbot
|
||||
PySwitchbot==0.36.3
|
||||
PySwitchbot==0.36.4
|
||||
|
||||
# homeassistant.components.transport_nsw
|
||||
PyTransportNSW==0.1.1
|
||||
@ -177,7 +177,7 @@ aioguardian==2022.07.0
|
||||
aioharmony==0.2.9
|
||||
|
||||
# homeassistant.components.homekit_controller
|
||||
aiohomekit==2.4.3
|
||||
aiohomekit==2.4.4
|
||||
|
||||
# homeassistant.components.emulated_hue
|
||||
# homeassistant.components.http
|
||||
@ -297,7 +297,7 @@ aiovlc==0.1.0
|
||||
aiowatttime==0.1.1
|
||||
|
||||
# homeassistant.components.webostv
|
||||
aiowebostv==0.3.0
|
||||
aiowebostv==0.3.2
|
||||
|
||||
# homeassistant.components.yandex_transport
|
||||
aioymaps==1.2.2
|
||||
@ -794,7 +794,7 @@ google-cloud-pubsub==2.13.11
|
||||
google-cloud-texttospeech==2.12.3
|
||||
|
||||
# homeassistant.components.nest
|
||||
google-nest-sdm==2.1.0
|
||||
google-nest-sdm==2.2.2
|
||||
|
||||
# homeassistant.components.google_travel_time
|
||||
googlemaps==2.5.1
|
||||
@ -1439,7 +1439,7 @@ pyRFXtrx==0.30.0
|
||||
pySwitchmate==0.5.1
|
||||
|
||||
# homeassistant.components.tibber
|
||||
pyTibber==0.26.8
|
||||
pyTibber==0.26.9
|
||||
|
||||
# homeassistant.components.dlink
|
||||
pyW215==0.7.0
|
||||
@ -2190,7 +2190,7 @@ regenmaschine==2022.11.0
|
||||
renault-api==0.1.11
|
||||
|
||||
# homeassistant.components.reolink
|
||||
reolink-aio==0.1.3
|
||||
reolink-aio==0.2.1
|
||||
|
||||
# homeassistant.components.python_script
|
||||
restrictedpython==5.2
|
||||
|
@ -36,7 +36,7 @@ PyRMVtransport==0.3.3
|
||||
PySocks==1.7.1
|
||||
|
||||
# homeassistant.components.switchbot
|
||||
PySwitchbot==0.36.3
|
||||
PySwitchbot==0.36.4
|
||||
|
||||
# homeassistant.components.transport_nsw
|
||||
PyTransportNSW==0.1.1
|
||||
@ -161,7 +161,7 @@ aioguardian==2022.07.0
|
||||
aioharmony==0.2.9
|
||||
|
||||
# homeassistant.components.homekit_controller
|
||||
aiohomekit==2.4.3
|
||||
aiohomekit==2.4.4
|
||||
|
||||
# homeassistant.components.emulated_hue
|
||||
# homeassistant.components.http
|
||||
@ -272,7 +272,7 @@ aiovlc==0.1.0
|
||||
aiowatttime==0.1.1
|
||||
|
||||
# homeassistant.components.webostv
|
||||
aiowebostv==0.3.0
|
||||
aiowebostv==0.3.2
|
||||
|
||||
# homeassistant.components.yandex_transport
|
||||
aioymaps==1.2.2
|
||||
@ -601,7 +601,7 @@ goodwe==0.2.18
|
||||
google-cloud-pubsub==2.13.11
|
||||
|
||||
# homeassistant.components.nest
|
||||
google-nest-sdm==2.1.0
|
||||
google-nest-sdm==2.2.2
|
||||
|
||||
# homeassistant.components.google_travel_time
|
||||
googlemaps==2.5.1
|
||||
@ -1039,7 +1039,7 @@ pyMetno==0.9.0
|
||||
pyRFXtrx==0.30.0
|
||||
|
||||
# homeassistant.components.tibber
|
||||
pyTibber==0.26.8
|
||||
pyTibber==0.26.9
|
||||
|
||||
# homeassistant.components.nextbus
|
||||
py_nextbusnext==0.1.5
|
||||
@ -1529,7 +1529,7 @@ regenmaschine==2022.11.0
|
||||
renault-api==0.1.11
|
||||
|
||||
# homeassistant.components.reolink
|
||||
reolink-aio==0.1.3
|
||||
reolink-aio==0.2.1
|
||||
|
||||
# homeassistant.components.python_script
|
||||
restrictedpython==5.2
|
||||
|
@ -1,8 +1,6 @@
|
||||
"""Test the Google Assistant SDK config flow."""
|
||||
from unittest.mock import patch
|
||||
|
||||
import oauth2client
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.google_assistant_sdk.const import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
@ -12,6 +10,8 @@ from .conftest import CLIENT_ID, ComponentSetup
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
GOOGLE_AUTH_URI = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
GOOGLE_TOKEN_URI = "https://oauth2.googleapis.com/token"
|
||||
TITLE = "Google Assistant SDK"
|
||||
|
||||
|
||||
@ -35,7 +35,7 @@ async def test_full_flow(
|
||||
)
|
||||
|
||||
assert result["url"] == (
|
||||
f"{oauth2client.GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}"
|
||||
f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}"
|
||||
"&redirect_uri=https://example.com/auth/external/callback"
|
||||
f"&state={state}&scope=https://www.googleapis.com/auth/assistant-sdk-prototype"
|
||||
"&access_type=offline&prompt=consent"
|
||||
@ -47,7 +47,7 @@ async def test_full_flow(
|
||||
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
||||
|
||||
aioclient_mock.post(
|
||||
oauth2client.GOOGLE_TOKEN_URI,
|
||||
GOOGLE_TOKEN_URI,
|
||||
json={
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"access_token": "mock-access-token",
|
||||
@ -112,7 +112,7 @@ async def test_reauth(
|
||||
},
|
||||
)
|
||||
assert result["url"] == (
|
||||
f"{oauth2client.GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}"
|
||||
f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}"
|
||||
"&redirect_uri=https://example.com/auth/external/callback"
|
||||
f"&state={state}&scope=https://www.googleapis.com/auth/assistant-sdk-prototype"
|
||||
"&access_type=offline&prompt=consent"
|
||||
@ -123,7 +123,7 @@ async def test_reauth(
|
||||
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
||||
|
||||
aioclient_mock.post(
|
||||
oauth2client.GOOGLE_TOKEN_URI,
|
||||
GOOGLE_TOKEN_URI,
|
||||
json={
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"access_token": "updated-access-token",
|
||||
@ -181,7 +181,7 @@ async def test_single_instance_allowed(
|
||||
)
|
||||
|
||||
assert result["url"] == (
|
||||
f"{oauth2client.GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}"
|
||||
f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}"
|
||||
"&redirect_uri=https://example.com/auth/external/callback"
|
||||
f"&state={state}&scope=https://www.googleapis.com/auth/assistant-sdk-prototype"
|
||||
"&access_type=offline&prompt=consent"
|
||||
@ -193,7 +193,7 @@ async def test_single_instance_allowed(
|
||||
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
||||
|
||||
aioclient_mock.post(
|
||||
oauth2client.GOOGLE_TOKEN_URI,
|
||||
GOOGLE_TOKEN_URI,
|
||||
json={
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"access_token": "mock-access-token",
|
||||
|
@ -2,9 +2,10 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.components.homeassistant_sky_connect.const import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import EVENT_HOMEASSISTANT_STARTED, HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry, MockModule, mock_integration
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
CONFIG_ENTRY_DATA = {
|
||||
"device": "bla_device",
|
||||
@ -29,7 +30,8 @@ async def test_hardware_info(
|
||||
hass: HomeAssistant, hass_ws_client, addon_store_info
|
||||
) -> None:
|
||||
"""Test we can get the board info."""
|
||||
mock_integration(hass, MockModule("usb"))
|
||||
assert await async_setup_component(hass, "usb", {})
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
|
||||
# Setup the config entry
|
||||
config_entry = MockConfigEntry(
|
||||
|
@ -9,7 +9,8 @@ from homeassistant.components import zha
|
||||
from homeassistant.components.hassio.handler import HassioAPIError
|
||||
from homeassistant.components.homeassistant_sky_connect.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import EVENT_HOMEASSISTANT_STARTED, HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
@ -55,6 +56,9 @@ async def test_setup_entry(
|
||||
num_flows,
|
||||
) -> None:
|
||||
"""Test setup of a config entry, including setup of zha."""
|
||||
assert await async_setup_component(hass, "usb", {})
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
|
||||
# Setup the config entry
|
||||
config_entry = MockConfigEntry(
|
||||
data=CONFIG_ENTRY_DATA,
|
||||
@ -100,6 +104,9 @@ async def test_setup_zha(
|
||||
mock_zha_config_flow_setup, hass: HomeAssistant, addon_store_info
|
||||
) -> None:
|
||||
"""Test zha gets the right config."""
|
||||
assert await async_setup_component(hass, "usb", {})
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
|
||||
# Setup the config entry
|
||||
config_entry = MockConfigEntry(
|
||||
data=CONFIG_ENTRY_DATA,
|
||||
@ -146,6 +153,9 @@ async def test_setup_zha_multipan(
|
||||
hass: HomeAssistant, addon_info, addon_running
|
||||
) -> None:
|
||||
"""Test zha gets the right config."""
|
||||
assert await async_setup_component(hass, "usb", {})
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
|
||||
addon_info.return_value["options"]["device"] = CONFIG_ENTRY_DATA["device"]
|
||||
|
||||
# Setup the config entry
|
||||
@ -197,6 +207,9 @@ async def test_setup_zha_multipan_other_device(
|
||||
mock_zha_config_flow_setup, hass: HomeAssistant, addon_info, addon_running
|
||||
) -> None:
|
||||
"""Test zha gets the right config."""
|
||||
assert await async_setup_component(hass, "usb", {})
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
|
||||
addon_info.return_value["options"]["device"] = "/dev/not_our_sky_connect"
|
||||
|
||||
# Setup the config entry
|
||||
@ -258,16 +271,23 @@ async def test_setup_entry_wait_usb(hass: HomeAssistant) -> None:
|
||||
"homeassistant.components.homeassistant_sky_connect.usb.async_is_plugged_in",
|
||||
return_value=False,
|
||||
) as mock_is_plugged_in:
|
||||
assert not await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||
# USB discovery starts, config entry should be removed
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
await hass.async_block_till_done()
|
||||
assert len(mock_is_plugged_in.mock_calls) == 1
|
||||
assert config_entry.state == ConfigEntryState.SETUP_RETRY
|
||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 0
|
||||
|
||||
|
||||
async def test_setup_entry_addon_info_fails(
|
||||
hass: HomeAssistant, addon_store_info
|
||||
) -> None:
|
||||
"""Test setup of a config entry when fetching addon info fails."""
|
||||
assert await async_setup_component(hass, "usb", {})
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
|
||||
addon_store_info.side_effect = HassioAPIError("Boom")
|
||||
|
||||
# Setup the config entry
|
||||
@ -296,6 +316,9 @@ async def test_setup_entry_addon_not_running(
|
||||
hass: HomeAssistant, addon_installed, start_addon
|
||||
) -> None:
|
||||
"""Test the addon is started if it is not running."""
|
||||
assert await async_setup_component(hass, "usb", {})
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
|
||||
# Setup the config entry
|
||||
config_entry = MockConfigEntry(
|
||||
data=CONFIG_ENTRY_DATA,
|
||||
|
@ -3,7 +3,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from hole.exceptions import HoleError
|
||||
|
||||
from homeassistant.components.pi_hole.const import CONF_STATISTICS_ONLY
|
||||
from homeassistant.components.pi_hole.const import (
|
||||
CONF_STATISTICS_ONLY,
|
||||
DEFAULT_LOCATION,
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_SSL,
|
||||
DEFAULT_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_HOST,
|
||||
@ -47,7 +53,16 @@ API_KEY = "apikey"
|
||||
SSL = False
|
||||
VERIFY_SSL = True
|
||||
|
||||
CONF_DATA = {
|
||||
CONFIG_DATA_DEFAULTS = {
|
||||
CONF_HOST: f"{HOST}:{PORT}",
|
||||
CONF_LOCATION: DEFAULT_LOCATION,
|
||||
CONF_NAME: DEFAULT_NAME,
|
||||
CONF_SSL: DEFAULT_SSL,
|
||||
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
|
||||
CONF_API_KEY: API_KEY,
|
||||
}
|
||||
|
||||
CONFIG_DATA = {
|
||||
CONF_HOST: f"{HOST}:{PORT}",
|
||||
CONF_LOCATION: LOCATION,
|
||||
CONF_NAME: NAME,
|
||||
@ -56,34 +71,35 @@ CONF_DATA = {
|
||||
CONF_VERIFY_SSL: VERIFY_SSL,
|
||||
}
|
||||
|
||||
CONF_CONFIG_FLOW_USER = {
|
||||
CONFIG_FLOW_USER = {
|
||||
CONF_HOST: HOST,
|
||||
CONF_PORT: PORT,
|
||||
CONF_API_KEY: API_KEY,
|
||||
CONF_LOCATION: LOCATION,
|
||||
CONF_NAME: NAME,
|
||||
CONF_STATISTICS_ONLY: False,
|
||||
CONF_SSL: SSL,
|
||||
CONF_VERIFY_SSL: VERIFY_SSL,
|
||||
}
|
||||
|
||||
CONF_CONFIG_FLOW_API_KEY = {
|
||||
CONFIG_FLOW_API_KEY = {
|
||||
CONF_API_KEY: API_KEY,
|
||||
}
|
||||
|
||||
CONF_CONFIG_ENTRY = {
|
||||
CONFIG_ENTRY = {
|
||||
CONF_HOST: f"{HOST}:{PORT}",
|
||||
CONF_LOCATION: LOCATION,
|
||||
CONF_NAME: NAME,
|
||||
CONF_STATISTICS_ONLY: False,
|
||||
CONF_API_KEY: API_KEY,
|
||||
CONF_SSL: SSL,
|
||||
CONF_VERIFY_SSL: VERIFY_SSL,
|
||||
}
|
||||
|
||||
CONFIG_ENTRY_IMPORTED = {**CONFIG_ENTRY, CONF_STATISTICS_ONLY: False}
|
||||
|
||||
SWITCH_ENTITY_ID = "switch.pi_hole"
|
||||
|
||||
|
||||
def _create_mocked_hole(raise_exception=False, has_versions=True):
|
||||
def _create_mocked_hole(raise_exception=False, has_versions=True, has_data=True):
|
||||
mocked_hole = MagicMock()
|
||||
type(mocked_hole).get_data = AsyncMock(
|
||||
side_effect=HoleError("") if raise_exception else None
|
||||
@ -93,7 +109,10 @@ def _create_mocked_hole(raise_exception=False, has_versions=True):
|
||||
)
|
||||
type(mocked_hole).enable = AsyncMock()
|
||||
type(mocked_hole).disable = AsyncMock()
|
||||
mocked_hole.data = ZERO_DATA
|
||||
if has_data:
|
||||
mocked_hole.data = ZERO_DATA
|
||||
else:
|
||||
mocked_hole.data = []
|
||||
if has_versions:
|
||||
mocked_hole.versions = SAMPLE_VERSIONS
|
||||
else:
|
||||
|
@ -2,28 +2,26 @@
|
||||
import logging
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.components.pi_hole.const import CONF_STATISTICS_ONLY, DOMAIN
|
||||
from homeassistant.components.pi_hole.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from . import (
|
||||
CONF_CONFIG_ENTRY,
|
||||
CONF_CONFIG_FLOW_API_KEY,
|
||||
CONF_CONFIG_FLOW_USER,
|
||||
CONF_DATA,
|
||||
CONFIG_DATA,
|
||||
CONFIG_DATA_DEFAULTS,
|
||||
CONFIG_ENTRY,
|
||||
CONFIG_ENTRY_IMPORTED,
|
||||
CONFIG_FLOW_USER,
|
||||
NAME,
|
||||
ZERO_DATA,
|
||||
_create_mocked_hole,
|
||||
_patch_config_flow_hole,
|
||||
_patch_init_hole,
|
||||
)
|
||||
|
||||
|
||||
def _flow_next(hass, flow_id):
|
||||
return next(
|
||||
flow
|
||||
for flow in hass.config_entries.flow.async_progress()
|
||||
if flow["flow_id"] == flow_id
|
||||
)
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
def _patch_setup():
|
||||
@ -33,41 +31,41 @@ def _patch_setup():
|
||||
)
|
||||
|
||||
|
||||
async def test_flow_import(hass, caplog):
|
||||
async def test_flow_import(hass: HomeAssistant, caplog):
|
||||
"""Test import flow."""
|
||||
mocked_hole = _create_mocked_hole()
|
||||
with _patch_config_flow_hole(mocked_hole), _patch_setup():
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_IMPORT}, data=CONF_DATA
|
||||
DOMAIN, context={"source": SOURCE_IMPORT}, data=CONFIG_DATA
|
||||
)
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == NAME
|
||||
assert result["data"] == CONF_CONFIG_ENTRY
|
||||
assert result["data"] == CONFIG_ENTRY_IMPORTED
|
||||
|
||||
# duplicated server
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_IMPORT}, data=CONF_DATA
|
||||
DOMAIN, context={"source": SOURCE_IMPORT}, data=CONFIG_DATA
|
||||
)
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_flow_import_invalid(hass, caplog):
|
||||
async def test_flow_import_invalid(hass: HomeAssistant, caplog):
|
||||
"""Test import flow with invalid server."""
|
||||
mocked_hole = _create_mocked_hole(True)
|
||||
with _patch_config_flow_hole(mocked_hole), _patch_setup():
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_IMPORT}, data=CONF_DATA
|
||||
DOMAIN, context={"source": SOURCE_IMPORT}, data=CONFIG_DATA
|
||||
)
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "cannot_connect"
|
||||
assert len([x for x in caplog.records if x.levelno == logging.ERROR]) == 1
|
||||
|
||||
|
||||
async def test_flow_user(hass):
|
||||
async def test_flow_user(hass: HomeAssistant):
|
||||
"""Test user initialized flow."""
|
||||
mocked_hole = _create_mocked_hole()
|
||||
with _patch_config_flow_hole(mocked_hole), _patch_setup():
|
||||
mocked_hole = _create_mocked_hole(has_data=False)
|
||||
with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
@ -75,69 +73,68 @@ async def test_flow_user(hass):
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {}
|
||||
_flow_next(hass, result["flow_id"])
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input=CONF_CONFIG_FLOW_USER,
|
||||
user_input=CONFIG_FLOW_USER,
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "api_key"
|
||||
assert result["errors"] is None
|
||||
_flow_next(hass, result["flow_id"])
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {CONF_API_KEY: "invalid_auth"}
|
||||
|
||||
mocked_hole.data = ZERO_DATA
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input=CONF_CONFIG_FLOW_API_KEY,
|
||||
user_input=CONFIG_FLOW_USER,
|
||||
)
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == NAME
|
||||
assert result["data"] == CONF_CONFIG_ENTRY
|
||||
assert result["data"] == CONFIG_ENTRY
|
||||
|
||||
# duplicated server
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
data=CONF_CONFIG_FLOW_USER,
|
||||
data=CONFIG_FLOW_USER,
|
||||
)
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_flow_statistics_only(hass):
|
||||
"""Test user initialized flow with statistics only."""
|
||||
mocked_hole = _create_mocked_hole()
|
||||
with _patch_config_flow_hole(mocked_hole), _patch_setup():
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {}
|
||||
_flow_next(hass, result["flow_id"])
|
||||
|
||||
user_input = {**CONF_CONFIG_FLOW_USER}
|
||||
user_input[CONF_STATISTICS_ONLY] = True
|
||||
config_entry_data = {**CONF_CONFIG_ENTRY}
|
||||
config_entry_data[CONF_STATISTICS_ONLY] = True
|
||||
config_entry_data.pop(CONF_API_KEY)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input=user_input,
|
||||
)
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == NAME
|
||||
assert result["data"] == config_entry_data
|
||||
|
||||
|
||||
async def test_flow_user_invalid(hass):
|
||||
"""Test user initialized flow with invalid server."""
|
||||
mocked_hole = _create_mocked_hole(True)
|
||||
with _patch_config_flow_hole(mocked_hole), _patch_setup():
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW_USER
|
||||
DOMAIN, context={"source": SOURCE_USER}, data=CONFIG_FLOW_USER
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
|
||||
async def test_flow_reauth(hass: HomeAssistant):
|
||||
"""Test reauth flow."""
|
||||
mocked_hole = _create_mocked_hole(has_data=False)
|
||||
entry = MockConfigEntry(domain=DOMAIN, data=CONFIG_DATA_DEFAULTS)
|
||||
entry.add_to_hass(hass)
|
||||
with _patch_init_hole(mocked_hole), _patch_config_flow_hole(mocked_hole):
|
||||
assert not await hass.config_entries.async_setup(entry.entry_id)
|
||||
|
||||
flows = hass.config_entries.flow.async_progress()
|
||||
|
||||
assert len(flows) == 1
|
||||
assert flows[0]["step_id"] == "reauth_confirm"
|
||||
assert flows[0]["context"]["entry_id"] == entry.entry_id
|
||||
|
||||
mocked_hole.data = ZERO_DATA
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
flows[0]["flow_id"],
|
||||
user_input={CONF_API_KEY: "newkey"},
|
||||
)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert entry.data[CONF_API_KEY] == "newkey"
|
||||
|
@ -7,27 +7,16 @@ from hole.exceptions import HoleError
|
||||
from homeassistant.components import pi_hole, switch
|
||||
from homeassistant.components.pi_hole.const import (
|
||||
CONF_STATISTICS_ONLY,
|
||||
DEFAULT_LOCATION,
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_SSL,
|
||||
DEFAULT_VERIFY_SSL,
|
||||
SERVICE_DISABLE,
|
||||
SERVICE_DISABLE_ATTR_DURATION,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
CONF_API_KEY,
|
||||
CONF_HOST,
|
||||
CONF_LOCATION,
|
||||
CONF_NAME,
|
||||
CONF_SSL,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY, CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from . import (
|
||||
CONF_CONFIG_ENTRY,
|
||||
CONF_DATA,
|
||||
CONFIG_DATA_DEFAULTS,
|
||||
SWITCH_ENTITY_ID,
|
||||
_create_mocked_hole,
|
||||
_patch_config_flow_hole,
|
||||
@ -37,7 +26,7 @@ from . import (
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_setup_minimal_config(hass):
|
||||
async def test_setup_minimal_config(hass: HomeAssistant):
|
||||
"""Tests component setup with minimal config."""
|
||||
mocked_hole = _create_mocked_hole()
|
||||
with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole):
|
||||
@ -88,7 +77,7 @@ async def test_setup_minimal_config(hass):
|
||||
assert state.state == "off"
|
||||
|
||||
|
||||
async def test_setup_name_config(hass):
|
||||
async def test_setup_name_config(hass: HomeAssistant):
|
||||
"""Tests component setup with a custom name."""
|
||||
mocked_hole = _create_mocked_hole()
|
||||
with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole):
|
||||
@ -106,7 +95,7 @@ async def test_setup_name_config(hass):
|
||||
)
|
||||
|
||||
|
||||
async def test_switch(hass, caplog):
|
||||
async def test_switch(hass: HomeAssistant, caplog):
|
||||
"""Test Pi-hole switch."""
|
||||
mocked_hole = _create_mocked_hole()
|
||||
with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole):
|
||||
@ -154,7 +143,7 @@ async def test_switch(hass, caplog):
|
||||
assert errors[-1].message == "Unable to disable Pi-hole: Error2"
|
||||
|
||||
|
||||
async def test_disable_service_call(hass):
|
||||
async def test_disable_service_call(hass: HomeAssistant):
|
||||
"""Test disable service call with no Pi-hole named."""
|
||||
mocked_hole = _create_mocked_hole()
|
||||
with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole):
|
||||
@ -180,21 +169,14 @@ async def test_disable_service_call(hass):
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mocked_hole.disable.assert_called_once_with(1)
|
||||
mocked_hole.disable.assert_called_with(1)
|
||||
|
||||
|
||||
async def test_unload(hass):
|
||||
async def test_unload(hass: HomeAssistant):
|
||||
"""Test unload entities."""
|
||||
entry = MockConfigEntry(
|
||||
domain=pi_hole.DOMAIN,
|
||||
data={
|
||||
CONF_NAME: DEFAULT_NAME,
|
||||
CONF_HOST: "pi.hole",
|
||||
CONF_LOCATION: DEFAULT_LOCATION,
|
||||
CONF_SSL: DEFAULT_SSL,
|
||||
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
|
||||
CONF_STATISTICS_ONLY: True,
|
||||
},
|
||||
data={**CONFIG_DATA_DEFAULTS, CONF_HOST: "pi.hole"},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
mocked_hole = _create_mocked_hole()
|
||||
@ -208,32 +190,25 @@ async def test_unload(hass):
|
||||
assert entry.entry_id not in hass.data[pi_hole.DOMAIN]
|
||||
|
||||
|
||||
async def test_migrate(hass):
|
||||
"""Test migrate from old config entry."""
|
||||
entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=CONF_DATA)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
async def test_remove_obsolete(hass: HomeAssistant):
|
||||
"""Test removing obsolete config entry parameters."""
|
||||
mocked_hole = _create_mocked_hole()
|
||||
with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.data == CONF_CONFIG_ENTRY
|
||||
|
||||
|
||||
async def test_migrate_statistics_only(hass):
|
||||
"""Test migrate from old config entry with statistics only."""
|
||||
conf_data = {**CONF_DATA}
|
||||
conf_data[CONF_API_KEY] = ""
|
||||
entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=conf_data)
|
||||
entry = MockConfigEntry(
|
||||
domain=pi_hole.DOMAIN, data={**CONFIG_DATA_DEFAULTS, CONF_STATISTICS_ONLY: True}
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
with _patch_init_hole(mocked_hole):
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
assert CONF_STATISTICS_ONLY not in entry.data
|
||||
|
||||
|
||||
async def test_missing_api_key(hass: HomeAssistant):
|
||||
"""Tests start reauth flow if api key is missing."""
|
||||
mocked_hole = _create_mocked_hole()
|
||||
with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
config_entry_data = {**CONF_CONFIG_ENTRY}
|
||||
config_entry_data[CONF_STATISTICS_ONLY] = True
|
||||
config_entry_data[CONF_API_KEY] = ""
|
||||
assert entry.data == config_entry_data
|
||||
data = CONFIG_DATA_DEFAULTS.copy()
|
||||
data.pop(CONF_API_KEY)
|
||||
entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=data)
|
||||
entry.add_to_hass(hass)
|
||||
with _patch_init_hole(mocked_hole):
|
||||
assert not await hass.config_entries.async_setup(entry.entry_id)
|
||||
assert entry.state == ConfigEntryState.SETUP_ERROR
|
||||
|
@ -24,7 +24,7 @@ TEST_NVR_NAME = "test_reolink_name"
|
||||
TEST_USE_HTTPS = True
|
||||
|
||||
|
||||
def get_mock_info(error=None, host_data_return=True):
|
||||
def get_mock_info(error=None, host_data_return=True, user_level="admin"):
|
||||
"""Return a mock gateway info instance."""
|
||||
host_mock = Mock()
|
||||
if error is None:
|
||||
@ -40,6 +40,8 @@ def get_mock_info(error=None, host_data_return=True):
|
||||
host_mock.nvr_name = TEST_NVR_NAME
|
||||
host_mock.port = TEST_PORT
|
||||
host_mock.use_https = TEST_USE_HTTPS
|
||||
host_mock.is_admin = user_level == "admin"
|
||||
host_mock.user_level = user_level
|
||||
return host_mock
|
||||
|
||||
|
||||
@ -110,7 +112,22 @@ async def test_config_flow_errors(hass):
|
||||
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"host": "cannot_connect"}
|
||||
assert result["errors"] == {CONF_HOST: "cannot_connect"}
|
||||
|
||||
host_mock = get_mock_info(user_level="guest")
|
||||
with patch("homeassistant.components.reolink.host.Host", return_value=host_mock):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_USERNAME: TEST_USERNAME,
|
||||
CONF_PASSWORD: TEST_PASSWORD,
|
||||
CONF_HOST: TEST_HOST,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {CONF_USERNAME: "not_admin"}
|
||||
|
||||
host_mock = get_mock_info(error=json.JSONDecodeError("test_error", "test", 1))
|
||||
with patch("homeassistant.components.reolink.host.Host", return_value=host_mock):
|
||||
@ -125,7 +142,7 @@ async def test_config_flow_errors(hass):
|
||||
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"host": "unknown"}
|
||||
assert result["errors"] == {CONF_HOST: "unknown"}
|
||||
|
||||
host_mock = get_mock_info(error=CredentialsInvalidError("Test error"))
|
||||
with patch("homeassistant.components.reolink.host.Host", return_value=host_mock):
|
||||
@ -140,7 +157,7 @@ async def test_config_flow_errors(hass):
|
||||
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"host": "invalid_auth"}
|
||||
assert result["errors"] == {CONF_HOST: "invalid_auth"}
|
||||
|
||||
host_mock = get_mock_info(error=ApiError("Test error"))
|
||||
with patch("homeassistant.components.reolink.host.Host", return_value=host_mock):
|
||||
@ -155,7 +172,7 @@ async def test_config_flow_errors(hass):
|
||||
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"host": "api_error"}
|
||||
assert result["errors"] == {CONF_HOST: "api_error"}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
@ -261,3 +278,64 @@ async def test_change_connection_settings(hass):
|
||||
assert config_entry.data[CONF_HOST] == TEST_HOST2
|
||||
assert config_entry.data[CONF_USERNAME] == TEST_USERNAME2
|
||||
assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD2
|
||||
|
||||
|
||||
async def test_reauth(hass):
|
||||
"""Test a reauth flow."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=const.DOMAIN,
|
||||
unique_id=format_mac(TEST_MAC),
|
||||
data={
|
||||
CONF_HOST: TEST_HOST,
|
||||
CONF_USERNAME: TEST_USERNAME,
|
||||
CONF_PASSWORD: TEST_PASSWORD,
|
||||
CONF_PORT: TEST_PORT,
|
||||
const.CONF_USE_HTTPS: TEST_USE_HTTPS,
|
||||
},
|
||||
options={
|
||||
const.CONF_PROTOCOL: const.DEFAULT_PROTOCOL,
|
||||
},
|
||||
title=TEST_NVR_NAME,
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
const.DOMAIN,
|
||||
context={
|
||||
"source": config_entries.SOURCE_REAUTH,
|
||||
"entry_id": config_entry.entry_id,
|
||||
"title_placeholders": {"name": TEST_NVR_NAME},
|
||||
"unique_id": format_mac(TEST_MAC),
|
||||
},
|
||||
data=config_entry.data,
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{},
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_HOST: TEST_HOST2,
|
||||
CONF_USERNAME: TEST_USERNAME2,
|
||||
CONF_PASSWORD: TEST_PASSWORD2,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] == "abort"
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert config_entry.data[CONF_HOST] == TEST_HOST2
|
||||
assert config_entry.data[CONF_USERNAME] == TEST_USERNAME2
|
||||
assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD2
|
||||
|
@ -706,6 +706,30 @@ async def test_zeroconf_already_configured(hass):
|
||||
assert entry.data["host"] == "1.1.1.1"
|
||||
|
||||
|
||||
async def test_zeroconf_ignored(hass):
|
||||
"""Test zeroconf when the device was previously ignored."""
|
||||
|
||||
entry = MockConfigEntry(
|
||||
domain="shelly",
|
||||
unique_id="test-mac",
|
||||
data={},
|
||||
source=config_entries.SOURCE_IGNORE,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"aioshelly.common.get_info",
|
||||
return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False},
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
data=DISCOVERY_INFO,
|
||||
context={"source": config_entries.SOURCE_ZEROCONF},
|
||||
)
|
||||
assert result["type"] == data_entry_flow.FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_zeroconf_with_wifi_ap_ip(hass):
|
||||
"""Test we ignore the Wi-FI AP IP."""
|
||||
|
||||
|
@ -936,3 +936,59 @@ async def test_web_socket_triggers_discovery_request_callbacks(hass, hass_ws_cli
|
||||
assert response["success"]
|
||||
await hass.async_block_till_done()
|
||||
assert len(mock_callback.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_initial_scan_callback(hass, hass_ws_client):
|
||||
"""Test it's possible to register a callback when the initial scan is done."""
|
||||
mock_callback_1 = Mock()
|
||||
mock_callback_2 = Mock()
|
||||
|
||||
with patch("pyudev.Context", side_effect=ImportError), patch(
|
||||
"homeassistant.components.usb.async_get_usb", return_value=[]
|
||||
), patch("homeassistant.components.usb.comports", return_value=[]), patch.object(
|
||||
hass.config_entries.flow, "async_init"
|
||||
):
|
||||
assert await async_setup_component(hass, "usb", {"usb": {}})
|
||||
cancel_1 = usb.async_register_initial_scan_callback(hass, mock_callback_1)
|
||||
assert len(mock_callback_1.mock_calls) == 0
|
||||
|
||||
await hass.async_block_till_done()
|
||||
assert len(mock_callback_1.mock_calls) == 0
|
||||
|
||||
# This triggers the initial scan
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
await hass.async_block_till_done()
|
||||
assert len(mock_callback_1.mock_calls) == 1
|
||||
|
||||
# A callback registered now should be called immediately. The old callback
|
||||
# should not be called again
|
||||
cancel_2 = usb.async_register_initial_scan_callback(hass, mock_callback_2)
|
||||
assert len(mock_callback_1.mock_calls) == 1
|
||||
assert len(mock_callback_2.mock_calls) == 1
|
||||
|
||||
# Calling the cancels should be allowed even if the callback has been called
|
||||
cancel_1()
|
||||
cancel_2()
|
||||
|
||||
|
||||
async def test_cancel_initial_scan_callback(hass, hass_ws_client):
|
||||
"""Test it's possible to cancel an initial scan callback."""
|
||||
mock_callback = Mock()
|
||||
|
||||
with patch("pyudev.Context", side_effect=ImportError), patch(
|
||||
"homeassistant.components.usb.async_get_usb", return_value=[]
|
||||
), patch("homeassistant.components.usb.comports", return_value=[]), patch.object(
|
||||
hass.config_entries.flow, "async_init"
|
||||
):
|
||||
assert await async_setup_component(hass, "usb", {"usb": {}})
|
||||
cancel = usb.async_register_initial_scan_callback(hass, mock_callback)
|
||||
assert len(mock_callback.mock_calls) == 0
|
||||
|
||||
await hass.async_block_till_done()
|
||||
assert len(mock_callback.mock_calls) == 0
|
||||
cancel()
|
||||
|
||||
# This triggers the initial scan
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
await hass.async_block_till_done()
|
||||
assert len(mock_callback.mock_calls) == 0
|
||||
|
@ -1,6 +1,7 @@
|
||||
"""The tests for the LG webOS media player platform."""
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
from http import HTTPStatus
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
@ -697,3 +698,68 @@ async def test_supported_features_ignore_cache(hass, client):
|
||||
attrs = hass.states.get(ENTITY_ID).attributes
|
||||
|
||||
assert attrs[ATTR_SUPPORTED_FEATURES] == supported
|
||||
|
||||
|
||||
async def test_get_image_http(
|
||||
hass, client, hass_client_no_auth, aioclient_mock, monkeypatch
|
||||
):
|
||||
"""Test get image via http."""
|
||||
url = "http://something/valid_icon"
|
||||
monkeypatch.setitem(client.apps[LIVE_TV_APP_ID], "icon", url)
|
||||
await setup_webostv(hass)
|
||||
await client.mock_state_update()
|
||||
|
||||
attrs = hass.states.get(ENTITY_ID).attributes
|
||||
assert "entity_picture_local" not in attrs
|
||||
|
||||
aioclient_mock.get(url, text="image")
|
||||
client = await hass_client_no_auth()
|
||||
|
||||
resp = await client.get(attrs["entity_picture"])
|
||||
content = await resp.read()
|
||||
|
||||
assert content == b"image"
|
||||
|
||||
|
||||
async def test_get_image_http_error(
|
||||
hass, client, hass_client_no_auth, aioclient_mock, caplog, monkeypatch
|
||||
):
|
||||
"""Test get image via http error."""
|
||||
url = "http://something/icon_error"
|
||||
monkeypatch.setitem(client.apps[LIVE_TV_APP_ID], "icon", url)
|
||||
await setup_webostv(hass)
|
||||
await client.mock_state_update()
|
||||
|
||||
attrs = hass.states.get(ENTITY_ID).attributes
|
||||
assert "entity_picture_local" not in attrs
|
||||
|
||||
aioclient_mock.get(url, exc=asyncio.TimeoutError())
|
||||
client = await hass_client_no_auth()
|
||||
|
||||
resp = await client.get(attrs["entity_picture"])
|
||||
content = await resp.read()
|
||||
|
||||
assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
assert f"Error retrieving proxied image from {url}" in caplog.text
|
||||
assert content == b""
|
||||
|
||||
|
||||
async def test_get_image_https(
|
||||
hass, client, hass_client_no_auth, aioclient_mock, monkeypatch
|
||||
):
|
||||
"""Test get image via http."""
|
||||
url = "https://something/valid_icon_https"
|
||||
monkeypatch.setitem(client.apps[LIVE_TV_APP_ID], "icon", url)
|
||||
await setup_webostv(hass)
|
||||
await client.mock_state_update()
|
||||
|
||||
attrs = hass.states.get(ENTITY_ID).attributes
|
||||
assert "entity_picture_local" not in attrs
|
||||
|
||||
aioclient_mock.get(url, text="https_image")
|
||||
client = await hass_client_no_auth()
|
||||
|
||||
resp = await client.get(attrs["entity_picture"])
|
||||
content = await resp.read()
|
||||
|
||||
assert content == b"https_image"
|
||||
|
@ -134,8 +134,9 @@ async def test_abort_if_authorization_timeout(
|
||||
flow = flow_handler()
|
||||
flow.hass = hass
|
||||
|
||||
with patch.object(
|
||||
local_impl, "async_generate_authorize_url", side_effect=asyncio.TimeoutError
|
||||
with patch(
|
||||
"homeassistant.helpers.config_entry_oauth2_flow.async_timeout.timeout",
|
||||
side_effect=asyncio.TimeoutError,
|
||||
):
|
||||
result = await flow.async_step_user()
|
||||
|
||||
@ -278,6 +279,62 @@ async def test_abort_if_oauth_rejected(
|
||||
assert result["description_placeholders"] == {"error": "access_denied"}
|
||||
|
||||
|
||||
async def test_abort_on_oauth_timeout_error(
|
||||
hass,
|
||||
flow_handler,
|
||||
local_impl,
|
||||
hass_client_no_auth,
|
||||
aioclient_mock,
|
||||
current_request_with_host,
|
||||
):
|
||||
"""Check timeout during oauth token exchange."""
|
||||
flow_handler.async_register_implementation(hass, local_impl)
|
||||
config_entry_oauth2_flow.async_register_implementation(
|
||||
hass, TEST_DOMAIN, MockOAuth2Implementation()
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
TEST_DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert result["step_id"] == "pick_implementation"
|
||||
|
||||
# Pick implementation
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={"implementation": TEST_DOMAIN}
|
||||
)
|
||||
|
||||
state = config_entry_oauth2_flow._encode_jwt(
|
||||
hass,
|
||||
{
|
||||
"flow_id": result["flow_id"],
|
||||
"redirect_uri": "https://example.com/auth/external/callback",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP
|
||||
assert result["url"] == (
|
||||
f"{AUTHORIZE_URL}?response_type=code&client_id={CLIENT_ID}"
|
||||
"&redirect_uri=https://example.com/auth/external/callback"
|
||||
f"&state={state}&scope=read+write"
|
||||
)
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
assert resp.status == 200
|
||||
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
||||
|
||||
with patch(
|
||||
"homeassistant.helpers.config_entry_oauth2_flow.async_timeout.timeout",
|
||||
side_effect=asyncio.TimeoutError,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
|
||||
assert result["type"] == data_entry_flow.FlowResultType.ABORT
|
||||
assert result["reason"] == "oauth2_timeout"
|
||||
|
||||
|
||||
async def test_step_discovery(hass, flow_handler, local_impl):
|
||||
"""Check flow triggers from discovery."""
|
||||
flow_handler.async_register_implementation(hass, local_impl)
|
||||
|
Loading…
x
Reference in New Issue
Block a user