This commit is contained in:
Franck Nijhof 2023-01-17 09:40:32 +01:00 committed by GitHub
commit c5fb3e7fab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 846 additions and 350 deletions

View File

@ -1293,8 +1293,8 @@ build.json @home-assistant/supervisor
/tests/components/weather/ @home-assistant/core /tests/components/weather/ @home-assistant/core
/homeassistant/components/webhook/ @home-assistant/core /homeassistant/components/webhook/ @home-assistant/core
/tests/components/webhook/ @home-assistant/core /tests/components/webhook/ @home-assistant/core
/homeassistant/components/webostv/ @bendavid @thecode /homeassistant/components/webostv/ @thecode
/tests/components/webostv/ @bendavid @thecode /tests/components/webostv/ @thecode
/homeassistant/components/websocket_api/ @home-assistant/core /homeassistant/components/websocket_api/ @home-assistant/core
/tests/components/websocket_api/ @home-assistant/core /tests/components/websocket_api/ @home-assistant/core
/homeassistant/components/wemo/ @esev /homeassistant/components/wemo/ @esev

View File

@ -1,6 +1,4 @@
"""application_credentials platform for Google Assistant SDK.""" """application_credentials platform for Google Assistant SDK."""
import oauth2client
from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -8,8 +6,8 @@ from homeassistant.core import HomeAssistant
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
"""Return authorization server.""" """Return authorization server."""
return AuthorizationServer( return AuthorizationServer(
oauth2client.GOOGLE_AUTH_URI, "https://accounts.google.com/o/oauth2/v2/auth",
oauth2client.GOOGLE_TOKEN_URI, "https://oauth2.googleapis.com/token",
) )

View File

@ -16,7 +16,7 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon
get_zigbee_socket, get_zigbee_socket,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN from .const import DOMAIN
@ -25,12 +25,10 @@ from .util import get_usb_service_info
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
async def _multi_pan_addon_info( async def _wait_multi_pan_addon(hass: HomeAssistant, entry: ConfigEntry) -> None:
hass: HomeAssistant, entry: ConfigEntry """Wait for multi-PAN info to be available."""
) -> AddonInfo | None:
"""Return AddonInfo if the multi-PAN addon is enabled for our SkyConnect."""
if not is_hassio(hass): if not is_hassio(hass):
return None return
addon_manager: AddonManager = get_addon_manager(hass) addon_manager: AddonManager = get_addon_manager(hass)
try: try:
@ -50,7 +48,18 @@ async def _multi_pan_addon_info(
) )
raise ConfigEntryNotReady 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 return None
usb_dev = entry.data["device"] usb_dev = entry.data["device"]
@ -62,8 +71,8 @@ async def _multi_pan_addon_info(
return addon_info return addon_info
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Set up a Home Assistant Sky Connect config entry.""" """Finish Home Assistant Sky Connect config entry setup."""
matcher = usb.USBCallbackMatcher( matcher = usb.USBCallbackMatcher(
domain=DOMAIN, domain=DOMAIN,
vid=entry.data["vid"].upper(), 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): if not usb.async_is_plugged_in(hass, matcher):
# The USB dongle is not plugged in # The USB dongle is not plugged in, remove the config entry
raise ConfigEntryNotReady hass.async_create_task(hass.config_entries.async_remove(entry.entry_id))
return
addon_info = await _multi_pan_addon_info(hass, entry) 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"}, context={"source": "usb"},
data=usb_info, data=usb_info,
) )
return True return
hw_discovery_data = { hw_discovery_data = {
"name": "Sky Connect Multi-PAN", "name": "Sky Connect Multi-PAN",
@ -101,6 +111,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
data=hw_discovery_data, 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 return True

View File

@ -3,7 +3,7 @@
"name": "HomeKit Controller", "name": "HomeKit Controller",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/homekit_controller", "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."], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."],
"bluetooth": [{ "manufacturer_id": 76, "manufacturer_data_start": [6] }], "bluetooth": [{ "manufacturer_id": 76, "manufacturer_data_start": [6] }],
"dependencies": ["bluetooth", "zeroconf"], "dependencies": ["bluetooth", "zeroconf"],

View File

@ -1,7 +1,9 @@
"""The Media Source implementation for the Jellyfin integration.""" """The Media Source implementation for the Jellyfin integration."""
from __future__ import annotations from __future__ import annotations
import logging
import mimetypes import mimetypes
import os
from typing import Any from typing import Any
from jellyfin_apiclient_python.api import jellyfin_url from jellyfin_apiclient_python.api import jellyfin_url
@ -41,6 +43,8 @@ from .const import (
) )
from .models import JellyfinData from .models import JellyfinData
_LOGGER = logging.getLogger(__name__)
async def async_get_media_source(hass: HomeAssistant) -> MediaSource: async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
"""Set up Jellyfin media source.""" """Set up Jellyfin media source."""
@ -75,6 +79,9 @@ class JellyfinSource(MediaSource):
stream_url = self._get_stream_url(media_item) stream_url = self._get_stream_url(media_item)
mime_type = _media_mime_type(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) return PlayMedia(stream_url, mime_type)
async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource: async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource:
@ -240,7 +247,11 @@ class JellyfinSource(MediaSource):
k.get(ITEM_KEY_INDEX_NUMBER, None), 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: def _build_track(self, track: dict[str, Any]) -> BrowseMediaSource:
"""Return a single track as a browsable media source.""" """Return a single track as a browsable media source."""
@ -289,7 +300,11 @@ class JellyfinSource(MediaSource):
"""Return all movies in the movie library.""" """Return all movies in the movie library."""
movies = await self._get_children(library_id, ITEM_TYPE_MOVIE) 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] 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: def _build_movie(self, movie: dict[str, Any]) -> BrowseMediaSource:
"""Return a single movie as a browsable media source.""" """Return a single movie as a browsable media source."""
@ -349,20 +364,24 @@ class JellyfinSource(MediaSource):
raise BrowseError(f"Unsupported media type {media_type}") 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.""" """Return the mime type of a media item."""
if not media_item.get(ITEM_KEY_MEDIA_SOURCES): 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] media_source = media_item[ITEM_KEY_MEDIA_SOURCES][0]
if MEDIA_SOURCE_KEY_PATH not in media_source: 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] path = media_source[MEDIA_SOURCE_KEY_PATH]
mime_type, _ = mimetypes.guess_type(path) mime_type, _ = mimetypes.guess_type(path)
if mime_type is None: 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 return mime_type

View File

@ -211,6 +211,7 @@ class NestFlowHandler(
async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult:
"""Complete OAuth setup and finish pubsub or finish.""" """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" assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API"
self._data.update(data) self._data.update(data)
if self.source == SOURCE_REAUTH: if self.source == SOURCE_REAUTH:
@ -459,6 +460,7 @@ class NestFlowHandler(
async def async_step_finish(self, data: dict[str, Any] | None = None) -> FlowResult: async def async_step_finish(self, data: dict[str, Any] | None = None) -> FlowResult:
"""Create an entry for the SDM flow.""" """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" assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API"
# Update existing config entry when in the reauth flow. # Update existing config entry when in the reauth flow.
if entry := self._async_reauth_entry(): if entry := self._async_reauth_entry():

View File

@ -5,7 +5,7 @@
"dependencies": ["ffmpeg", "http", "application_credentials"], "dependencies": ["ffmpeg", "http", "application_credentials"],
"after_dependencies": ["media_source"], "after_dependencies": ["media_source"],
"documentation": "https://www.home-assistant.io/integrations/nest", "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"], "codeowners": ["@allenporter"],
"quality_scale": "platinum", "quality_scale": "platinum",
"dhcp": [ "dhcp": [

View File

@ -17,7 +17,8 @@ from homeassistant.const import (
CONF_VERIFY_SSL, CONF_VERIFY_SSL,
Platform, 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 import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
@ -64,6 +65,13 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA, extra=vol.ALLOW_EXTRA,
) )
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.SENSOR,
Platform.SWITCH,
Platform.UPDATE,
]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Pi-hole integration.""" """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] location = entry.data[CONF_LOCATION]
api_key = entry.data.get(CONF_API_KEY) api_key = entry.data.get(CONF_API_KEY)
# For backward compatibility # remove obsolet CONF_STATISTICS_ONLY from entry.data
if CONF_STATISTICS_ONLY not in entry.data: if CONF_STATISTICS_ONLY in entry.data:
hass.config_entries.async_update_entry( entry_data = entry.data.copy()
entry, data={**entry.data, CONF_STATISTICS_ONLY: not api_key} 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) _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: try:
await api.get_data() await api.get_data()
await api.get_versions() await api.get_versions()
_LOGGER.debug("async_update_data() api.data: %s", api.data)
except HoleError as err: except HoleError as err:
raise UpdateFailed(f"Failed to communicate with API: {err}") from err raise UpdateFailed(f"Failed to communicate with API: {err}") from err
if not isinstance(api.data, dict):
raise ConfigEntryAuthFailed
coordinator = DataUpdateCoordinator( coordinator = DataUpdateCoordinator(
hass, hass,
@ -142,30 +157,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await coordinator.async_config_entry_first_refresh() 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 return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload Pi-hole entry.""" """Unload Pi-hole entry."""
unload_ok = await hass.config_entries.async_unload_platforms( unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
entry, _async_platforms(entry)
)
if unload_ok: if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id) hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok 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): class PiHoleEntity(CoordinatorEntity):
"""Representation of a Pi-hole entity.""" """Representation of a Pi-hole entity."""

View File

@ -15,8 +15,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from . import PiHoleEntity from . import PiHoleEntity
from .const import ( from .const import (
BINARY_SENSOR_TYPES, BINARY_SENSOR_TYPES,
BINARY_SENSOR_TYPES_STATISTICS_ONLY,
CONF_STATISTICS_ONLY,
DATA_KEY_API, DATA_KEY_API,
DATA_KEY_COORDINATOR, DATA_KEY_COORDINATOR,
DOMAIN as PIHOLE_DOMAIN, DOMAIN as PIHOLE_DOMAIN,
@ -42,18 +40,6 @@ async def async_setup_entry(
for description in BINARY_SENSOR_TYPES 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) async_add_entities(binary_sensors, True)

View File

@ -1,6 +1,7 @@
"""Config flow to configure the Pi-hole integration.""" """Config flow to configure the Pi-hole integration."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
import logging import logging
from typing import Any from typing import Any
@ -26,7 +27,6 @@ from .const import (
DEFAULT_LOCATION, DEFAULT_LOCATION,
DEFAULT_NAME, DEFAULT_NAME,
DEFAULT_SSL, DEFAULT_SSL,
DEFAULT_STATISTICS_ONLY,
DEFAULT_VERIFY_SSL, DEFAULT_VERIFY_SSL,
DOMAIN, DOMAIN,
) )
@ -47,65 +47,29 @@ class PiHoleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> FlowResult: ) -> FlowResult:
"""Handle a flow initiated by the user.""" """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 = {} errors = {}
if user_input is not None: if user_input is not None:
host = ( self._config = {
user_input[CONF_HOST] CONF_HOST: f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}",
if is_import CONF_NAME: user_input[CONF_NAME],
else f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" CONF_LOCATION: user_input[CONF_LOCATION],
) CONF_SSL: user_input[CONF_SSL],
name = user_input[CONF_NAME] CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL],
location = user_input[CONF_LOCATION] CONF_API_KEY: user_input[CONF_API_KEY],
tls = user_input[CONF_SSL] }
verify_tls = user_input[CONF_VERIFY_SSL]
endpoint = f"{host}/{location}"
if await self._async_endpoint_existed(endpoint): self._async_abort_entries_match(
return self.async_abort(reason="already_configured") {
CONF_HOST: f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}",
try: CONF_LOCATION: user_input[CONF_LOCATION],
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,
} }
if is_import: )
api_key = user_input.get(CONF_API_KEY)
return self.async_create_entry( if not (errors := await self._async_try_connect()):
title=name, return self.async_create_entry(
data={ title=user_input[CONF_NAME], data=self._config
**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()
user_input = user_input or {} user_input = user_input or {}
return self.async_show_form( return self.async_show_form(
@ -116,6 +80,7 @@ class PiHoleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
vol.Required( vol.Required(
CONF_PORT, default=user_input.get(CONF_PORT, 80) CONF_PORT, default=user_input.get(CONF_PORT, 80)
): vol.Coerce(int), ): vol.Coerce(int),
vol.Required(CONF_API_KEY): str,
vol.Required( vol.Required(
CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME) CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME)
): str, ): str,
@ -123,12 +88,6 @@ class PiHoleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
CONF_LOCATION, CONF_LOCATION,
default=user_input.get(CONF_LOCATION, DEFAULT_LOCATION), default=user_input.get(CONF_LOCATION, DEFAULT_LOCATION),
): str, ): str,
vol.Required(
CONF_STATISTICS_ONLY,
default=user_input.get(
CONF_STATISTICS_ONLY, DEFAULT_STATISTICS_ONLY
),
): bool,
vol.Required( vol.Required(
CONF_SSL, CONF_SSL,
default=user_input.get(CONF_SSL, DEFAULT_SSL), default=user_input.get(CONF_SSL, DEFAULT_SSL),
@ -142,24 +101,94 @@ class PiHoleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
errors=errors, errors=errors,
) )
async def async_step_api_key( async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult:
self, user_input: dict[str, Any] | None = None """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: ) -> FlowResult:
"""Handle step to setup API key.""" """Perform reauth confirm upon an API authentication error."""
errors = {}
if user_input is not None: if user_input is not None:
return self.async_create_entry( self._config = {**self._config, CONF_API_KEY: user_input[CONF_API_KEY]}
title=self._config[CONF_NAME], if not (errors := await self._async_try_connect()):
data={ entry = self.hass.config_entries.async_get_entry(
**self._config, self.context["entry_id"]
CONF_API_KEY: user_input.get(CONF_API_KEY, ""), )
}, 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( return self.async_show_form(
step_id="api_key", step_id="reauth_confirm",
data_schema=vol.Schema({vol.Optional(CONF_API_KEY): str}), 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: async def _async_endpoint_existed(self, endpoint: str) -> bool:
existing_endpoints = [ existing_endpoints = [
f"{entry.data.get(CONF_HOST)}/{entry.data.get(CONF_LOCATION)}" 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 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 self, host: str, location: str, tls: bool, verify_tls: bool
) -> None: ) -> None:
session = async_get_clientsession(self.hass, verify_tls) session = async_get_clientsession(self.hass, verify_tls)

View File

@ -154,9 +154,6 @@ BINARY_SENSOR_TYPES: tuple[PiHoleBinarySensorEntityDescription, ...] = (
}, },
state_value=lambda api: bool(api.versions["FTL_update"]), state_value=lambda api: bool(api.versions["FTL_update"]),
), ),
)
BINARY_SENSOR_TYPES_STATISTICS_ONLY: tuple[PiHoleBinarySensorEntityDescription, ...] = (
PiHoleBinarySensorEntityDescription( PiHoleBinarySensorEntityDescription(
key="status", key="status",
name="Status", name="Status",

View File

@ -8,28 +8,25 @@
"name": "[%key:common::config_flow::data::name%]", "name": "[%key:common::config_flow::data::name%]",
"location": "[%key:common::config_flow::data::location%]", "location": "[%key:common::config_flow::data::location%]",
"api_key": "[%key:common::config_flow::data::api_key%]", "api_key": "[%key:common::config_flow::data::api_key%]",
"statistics_only": "Statistics Only",
"ssl": "[%key:common::config_flow::data::ssl%]", "ssl": "[%key:common::config_flow::data::ssl%]",
"verify_ssl": "[%key:common::config_flow::data::verify_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": { "data": {
"api_key": "[%key:common::config_flow::data::api_key%]" "api_key": "[%key:common::config_flow::data::api_key%]"
} }
} }
}, },
"error": { "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": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]" "already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
} "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"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."
} }
} }
} }

View File

@ -1,16 +1,20 @@
{ {
"config": { "config": {
"abort": { "abort": {
"already_configured": "Service is already configured" "already_configured": "Service is already configured",
"reauth_successful": "Re-authentication was successful"
}, },
"error": { "error": {
"cannot_connect": "Failed to connect" "cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication"
}, },
"step": { "step": {
"api_key": { "reauth_confirm": {
"data": { "data": {
"api_key": "API Key" "api_key": "API Key"
} },
"description": "Please enter a new api key for PI-Hole at {host}/{location}",
"title": "PI-Hole Reauthenticate Integration"
}, },
"user": { "user": {
"data": { "data": {
@ -20,16 +24,9 @@
"name": "Name", "name": "Name",
"port": "Port", "port": "Port",
"ssl": "Uses an SSL certificate", "ssl": "Uses an SSL certificate",
"statistics_only": "Statistics Only",
"verify_ssl": "Verify SSL certificate" "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"
}
} }
} }

View File

@ -14,10 +14,11 @@ from reolink_aio.exceptions import ApiError, InvalidContentTypeError
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN from .const import DOMAIN
from .exceptions import UserNotAdmin
from .host import ReolinkHost from .host import ReolinkHost
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -40,16 +41,20 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
try: try:
if not await host.async_init(): if not await host.async_init():
await host.stop()
raise ConfigEntryNotReady( raise ConfigEntryNotReady(
f"Error while trying to setup {host.api.host}:{host.api.port}: " f"Error while trying to setup {host.api.host}:{host.api.port}: "
"failed to obtain data from device." "failed to obtain data from device."
) )
except UserNotAdmin as err:
raise ConfigEntryAuthFailed(err) from UserNotAdmin
except ( except (
ClientConnectorError, ClientConnectorError,
asyncio.TimeoutError, asyncio.TimeoutError,
ApiError, ApiError,
InvalidContentTypeError, InvalidContentTypeError,
) as err: ) as err:
await host.stop()
raise ConfigEntryNotReady( raise ConfigEntryNotReady(
f'Error while trying to setup {host.api.host}:{host.api.port}: "{str(err)}".' f'Error while trying to setup {host.api.host}:{host.api.port}: "{str(err)}".'
) from err ) from err

View File

@ -1,19 +1,21 @@
"""Config flow for the Reolink camera component.""" """Config flow for the Reolink camera component."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
import logging import logging
from typing import Any from typing import Any
from reolink_aio.exceptions import ApiError, CredentialsInvalidError from reolink_aio.exceptions import ApiError, CredentialsInvalidError
import voluptuous as vol 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.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DEFAULT_PROTOCOL, DOMAIN from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DEFAULT_PROTOCOL, DOMAIN
from .exceptions import UserNotAdmin
from .host import ReolinkHost from .host import ReolinkHost
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -53,6 +55,13 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1 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 @staticmethod
@callback @callback
def async_get_options_flow( def async_get_options_flow(
@ -61,16 +70,37 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Options callback for Reolink.""" """Options callback for Reolink."""
return ReolinkOptionsFlowHandler(config_entry) 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( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> FlowResult: ) -> FlowResult:
"""Handle the initial step.""" """Handle the initial step."""
errors = {} errors = {}
placeholders = {} placeholders = {"error": ""}
if user_input is not None: if user_input is not None:
host = ReolinkHost(self.hass, user_input, DEFAULT_OPTIONS)
try: 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: except CannotConnect:
errors[CONF_HOST] = "cannot_connect" errors[CONF_HOST] = "cannot_connect"
except CredentialsInvalidError: except CredentialsInvalidError:
@ -87,7 +117,17 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
user_input[CONF_PORT] = host.api.port user_input[CONF_PORT] = host.api.port
user_input[CONF_USE_HTTPS] = host.api.use_https 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) self._abort_if_unique_id_configured(updates=user_input)
return self.async_create_entry( return self.async_create_entry(
@ -98,9 +138,9 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
data_schema = vol.Schema( data_schema = vol.Schema(
{ {
vol.Required(CONF_USERNAME, default="admin"): str, vol.Required(CONF_USERNAME, default=self._username): str,
vol.Required(CONF_PASSWORD): str, vol.Required(CONF_PASSWORD, default=self._password): str,
vol.Required(CONF_HOST): str, vol.Required(CONF_HOST, default=self._host): str,
} }
) )
if errors: if errors:
@ -119,20 +159,14 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
) )
async def async_obtain_host_settings( async def async_obtain_host_settings(host: ReolinkHost) -> None:
hass: core.HomeAssistant, user_input: dict
) -> ReolinkHost:
"""Initialize the Reolink host and get the host information.""" """Initialize the Reolink host and get the host information."""
host = ReolinkHost(hass, user_input, DEFAULT_OPTIONS)
try: try:
if not await host.async_init(): if not await host.async_init():
raise CannotConnect raise CannotConnect
finally: finally:
await host.stop() await host.stop()
return host
class CannotConnect(exceptions.HomeAssistantError): class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect.""" """Error to indicate we cannot connect."""

View 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."""

View File

@ -19,6 +19,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.device_registry import format_mac
from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DEFAULT_TIMEOUT from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DEFAULT_TIMEOUT
from .exceptions import UserNotAdmin
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -68,6 +69,12 @@ class ReolinkHost:
if self._api.mac_address is None: if self._api.mac_address is None:
return False 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_onvif = None
enable_rtmp = None enable_rtmp = None
enable_rtsp = None enable_rtsp = None

View File

@ -3,7 +3,7 @@
"name": "Reolink IP NVR/camera", "name": "Reolink IP NVR/camera",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/reolink", "documentation": "https://www.home-assistant.io/integrations/reolink",
"requirements": ["reolink-aio==0.1.3"], "requirements": ["reolink-aio==0.2.1"],
"codeowners": ["@starkillerOG"], "codeowners": ["@starkillerOG"],
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["reolink-aio"] "loggers": ["reolink-aio"]

View File

@ -2,6 +2,7 @@
"config": { "config": {
"step": { "step": {
"user": { "user": {
"description": "{error}",
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]", "host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]", "port": "[%key:common::config_flow::data::port%]",
@ -9,16 +10,22 @@
"username": "[%key:common::config_flow::data::username%]", "username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]" "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": { "error": {
"api_error": "API error occurred: {error}", "api_error": "API error occurred",
"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%]", "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": { "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": { "options": {

View File

@ -1,15 +1,21 @@
{ {
"config": { "config": {
"abort": { "abort": {
"already_configured": "Device is already configured" "already_configured": "Device is already configured",
"reauth_successful": "Re-authentication was successful"
}, },
"error": { "error": {
"api_error": "API error occurred: {error}", "api_error": "API error occurred",
"cannot_connect": "Failed to connect", "cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication", "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": { "step": {
"reauth_confirm": {
"description": "The Reolink integration needs to re-authenticate your connection details",
"title": "Reauthenticate Integration"
},
"user": { "user": {
"data": { "data": {
"host": "Host", "host": "Host",
@ -17,7 +23,8 @@
"port": "Port", "port": "Port",
"use_https": "Enable HTTPS", "use_https": "Enable HTTPS",
"username": "Username" "username": "Username"
} },
"description": "{error}"
} }
} }
}, },

View File

@ -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.""" """Abort and reconnect soon if the device with the mac address is already configured."""
if ( if (
current_entry := await self.async_set_unique_id(mac) 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) await async_reconnect_soon(self.hass, current_entry)
if host == INTERNAL_WIFI_AP_IP: if host == INTERNAL_WIFI_AP_IP:
# If the device is broadcasting the internal wifi ap ip # If the device is broadcasting the internal wifi ap ip

View File

@ -2,7 +2,7 @@
"domain": "switchbot", "domain": "switchbot",
"name": "SwitchBot", "name": "SwitchBot",
"documentation": "https://www.home-assistant.io/integrations/switchbot", "documentation": "https://www.home-assistant.io/integrations/switchbot",
"requirements": ["PySwitchbot==0.36.3"], "requirements": ["PySwitchbot==0.36.4"],
"config_flow": true, "config_flow": true,
"dependencies": ["bluetooth"], "dependencies": ["bluetooth"],
"codeowners": [ "codeowners": [

View File

@ -3,7 +3,7 @@
"domain": "tibber", "domain": "tibber",
"name": "Tibber", "name": "Tibber",
"documentation": "https://www.home-assistant.io/integrations/tibber", "documentation": "https://www.home-assistant.io/integrations/tibber",
"requirements": ["pyTibber==0.26.8"], "requirements": ["pyTibber==0.26.9"],
"codeowners": ["@danielhiversen"], "codeowners": ["@danielhiversen"],
"quality_scale": "silver", "quality_scale": "silver",
"config_flow": true, "config_flow": true,

View File

@ -61,6 +61,18 @@ def async_register_scan_request_callback(
return discovery.async_register_scan_request_callback(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 @hass_callback
def async_is_plugged_in(hass: HomeAssistant, matcher: USBCallbackMatcher) -> bool: def async_is_plugged_in(hass: HomeAssistant, matcher: USBCallbackMatcher) -> bool:
"""Return True is a USB device is present.""" """Return True is a USB device is present."""
@ -186,6 +198,8 @@ class USBDiscovery:
self.observer_active = False self.observer_active = False
self._request_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None self._request_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None
self._request_callbacks: list[CALLBACK_TYPE] = [] self._request_callbacks: list[CALLBACK_TYPE] = []
self.initial_scan_done = False
self._initial_scan_callbacks: list[CALLBACK_TYPE] = []
async def async_setup(self) -> None: async def async_setup(self) -> None:
"""Set up USB Discovery.""" """Set up USB Discovery."""
@ -249,7 +263,7 @@ class USBDiscovery:
self, self,
_callback: CALLBACK_TYPE, _callback: CALLBACK_TYPE,
) -> CALLBACK_TYPE: ) -> CALLBACK_TYPE:
"""Register a callback.""" """Register a scan request callback."""
self._request_callbacks.append(_callback) self._request_callbacks.append(_callback)
@hass_callback @hass_callback
@ -258,6 +272,26 @@ class USBDiscovery:
return _async_remove_callback 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 @hass_callback
def _async_process_discovered_usb_device(self, device: USBDevice) -> None: def _async_process_discovered_usb_device(self, device: USBDevice) -> None:
"""Process a USB discovery.""" """Process a USB discovery."""
@ -307,6 +341,12 @@ class USBDiscovery:
async def _async_scan_serial(self) -> None: async def _async_scan_serial(self) -> None:
"""Scan serial ports.""" """Scan serial ports."""
self._async_process_ports(await self.hass.async_add_executor_job(comports)) 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: async def _async_scan(self) -> None:
"""Scan for USB devices and notify callbacks to scan as well.""" """Scan for USB devices and notify callbacks to scan as well."""

View File

@ -24,7 +24,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv 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.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@ -53,7 +52,6 @@ KEY_TO_ATTR = {
ATTRIBUTION = "Data provided by the World Air Quality Index project" ATTRIBUTION = "Data provided by the World Air Quality Index project"
ATTR_ICON = "mdi:cloud" ATTR_ICON = "mdi:cloud"
ATTR_UNIT = "AQI"
CONF_LOCATIONS = "locations" CONF_LOCATIONS = "locations"
CONF_STATIONS = "stations" CONF_STATIONS = "stations"
@ -62,7 +60,7 @@ SCAN_INTERVAL = timedelta(minutes=5)
TIMEOUT = 10 TIMEOUT = 10
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend(
{ {
vol.Optional(CONF_STATIONS): cv.ensure_list, vol.Optional(CONF_STATIONS): cv.ensure_list,
vol.Required(CONF_TOKEN): cv.string, vol.Required(CONF_TOKEN): cv.string,
@ -110,7 +108,6 @@ class WaqiSensor(SensorEntity):
"""Implementation of a WAQI sensor.""" """Implementation of a WAQI sensor."""
_attr_icon = ATTR_ICON _attr_icon = ATTR_ICON
_attr_native_unit_of_measurement = ATTR_UNIT
_attr_device_class = SensorDeviceClass.AQI _attr_device_class = SensorDeviceClass.AQI
_attr_state_class = SensorStateClass.MEASUREMENT _attr_state_class = SensorStateClass.MEASUREMENT

View File

@ -116,7 +116,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
host = urlparse(discovery_info.ssdp_location).hostname host = urlparse(discovery_info.ssdp_location).hostname
assert host assert host
self._host = 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] uuid = discovery_info.upnp[ssdp.ATTR_UPNP_UDN]
assert uuid assert uuid

View File

@ -3,8 +3,8 @@
"name": "LG webOS Smart TV", "name": "LG webOS Smart TV",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/webostv", "documentation": "https://www.home-assistant.io/integrations/webostv",
"requirements": ["aiowebostv==0.3.0"], "requirements": ["aiowebostv==0.3.2"],
"codeowners": ["@bendavid", "@thecode"], "codeowners": ["@thecode"],
"ssdp": [{ "st": "urn:lge-com:service:webos-second-screen:1" }], "ssdp": [{ "st": "urn:lge-com:service:webos-second-screen:1" }],
"quality_scale": "platinum", "quality_scale": "platinum",
"iot_class": "local_push", "iot_class": "local_push",

View File

@ -1,14 +1,18 @@
"""Support for interface with an LG webOS Smart TV.""" """Support for interface with an LG webOS Smart TV."""
from __future__ import annotations from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable, Coroutine from collections.abc import Awaitable, Callable, Coroutine
from contextlib import suppress from contextlib import suppress
from datetime import timedelta from datetime import timedelta
from functools import wraps from functools import wraps
from http import HTTPStatus
import logging import logging
from ssl import SSLContext
from typing import Any, TypeVar, cast from typing import Any, TypeVar, cast
from aiowebostv import WebOsClient, WebOsTvPairError from aiowebostv import WebOsClient, WebOsTvPairError
import async_timeout
from typing_extensions import Concatenate, ParamSpec from typing_extensions import Concatenate, ParamSpec
from homeassistant import util from homeassistant import util
@ -28,6 +32,7 @@ from homeassistant.const import (
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError 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.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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: async def async_command(self, command: str, **kwargs: Any) -> None:
"""Send a command.""" """Send a command."""
await self._client.request(command, payload=kwargs.get(ATTR_PAYLOAD)) 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

View File

@ -8,7 +8,7 @@ from .backports.enum import StrEnum
APPLICATION_NAME: Final = "HomeAssistant" APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2023 MAJOR_VERSION: Final = 2023
MINOR_VERSION: Final = 1 MINOR_VERSION: Final = 1
PATCH_VERSION: Final = "4" PATCH_VERSION: Final = "5"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0) REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0)

View File

@ -41,6 +41,9 @@ MY_AUTH_CALLBACK_PATH = "https://my.home-assistant.io/redirect/oauth"
CLOCK_OUT_OF_SYNC_MAX_SEC = 20 CLOCK_OUT_OF_SYNC_MAX_SEC = 20
OAUTH_AUTHORIZE_URL_TIMEOUT_SEC = 30
OAUTH_TOKEN_TIMEOUT_SEC = 30
class AbstractOAuth2Implementation(ABC): class AbstractOAuth2Implementation(ABC):
"""Base class to abstract OAuth2 authentication.""" """Base class to abstract OAuth2 authentication."""
@ -194,6 +197,7 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation):
if self.client_secret is not None: if self.client_secret is not None:
data["client_secret"] = self.client_secret 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) resp = await session.post(self.token_url, data=data)
if resp.status >= 400 and _LOGGER.isEnabledFor(logging.DEBUG): if resp.status >= 400 and _LOGGER.isEnabledFor(logging.DEBUG):
body = await resp.text() 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) return self.async_external_step_done(next_step_id=next_step)
try: try:
async with async_timeout.timeout(10): async with async_timeout.timeout(OAUTH_AUTHORIZE_URL_TIMEOUT_SEC):
url = await self.async_generate_authorize_url() 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") return self.async_abort(reason="authorize_url_timeout")
except NoURLAvailableError: except NoURLAvailableError:
return self.async_abort( return self.async_abort(
@ -303,7 +308,17 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta):
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> FlowResult: ) -> FlowResult:
"""Create config entry from external data.""" """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 # Force int for non-compliant oauth2 providers
try: try:
token["expires_in"] = int(token["expires_in"]) token["expires_in"] = int(token["expires_in"])
@ -436,7 +451,7 @@ class OAuth2AuthorizeCallbackView(http.HomeAssistantView):
await hass.config_entries.flow.async_configure( await hass.config_entries.flow.async_configure(
flow_id=state["flow_id"], user_input=user_input flow_id=state["flow_id"], user_input=user_input
) )
_LOGGER.debug("Resumed OAuth configuration flow")
return web.Response( return web.Response(
headers={"content-type": "text/html"}, headers={"content-type": "text/html"},
text="<script>window.close()</script>", text="<script>window.close()</script>",

View File

@ -71,6 +71,7 @@
"no_devices_found": "No devices found on the network", "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.", "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_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_configuration": "The component is not configured. Please follow the documentation.",
"oauth2_missing_credentials": "The integration requires application credentials.", "oauth2_missing_credentials": "The integration requires application credentials.",
"oauth2_authorize_url_timeout": "Timeout generating authorize URL.", "oauth2_authorize_url_timeout": "Timeout generating authorize URL.",

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "homeassistant" name = "homeassistant"
version = "2023.1.4" version = "2023.1.5"
license = {text = "Apache-2.0"} license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3." description = "Open-source home automation platform running on Python 3."
readme = "README.rst" readme = "README.rst"

View File

@ -40,7 +40,7 @@ PyRMVtransport==0.3.3
PySocks==1.7.1 PySocks==1.7.1
# homeassistant.components.switchbot # homeassistant.components.switchbot
PySwitchbot==0.36.3 PySwitchbot==0.36.4
# homeassistant.components.transport_nsw # homeassistant.components.transport_nsw
PyTransportNSW==0.1.1 PyTransportNSW==0.1.1
@ -177,7 +177,7 @@ aioguardian==2022.07.0
aioharmony==0.2.9 aioharmony==0.2.9
# homeassistant.components.homekit_controller # homeassistant.components.homekit_controller
aiohomekit==2.4.3 aiohomekit==2.4.4
# homeassistant.components.emulated_hue # homeassistant.components.emulated_hue
# homeassistant.components.http # homeassistant.components.http
@ -297,7 +297,7 @@ aiovlc==0.1.0
aiowatttime==0.1.1 aiowatttime==0.1.1
# homeassistant.components.webostv # homeassistant.components.webostv
aiowebostv==0.3.0 aiowebostv==0.3.2
# homeassistant.components.yandex_transport # homeassistant.components.yandex_transport
aioymaps==1.2.2 aioymaps==1.2.2
@ -794,7 +794,7 @@ google-cloud-pubsub==2.13.11
google-cloud-texttospeech==2.12.3 google-cloud-texttospeech==2.12.3
# homeassistant.components.nest # homeassistant.components.nest
google-nest-sdm==2.1.0 google-nest-sdm==2.2.2
# homeassistant.components.google_travel_time # homeassistant.components.google_travel_time
googlemaps==2.5.1 googlemaps==2.5.1
@ -1439,7 +1439,7 @@ pyRFXtrx==0.30.0
pySwitchmate==0.5.1 pySwitchmate==0.5.1
# homeassistant.components.tibber # homeassistant.components.tibber
pyTibber==0.26.8 pyTibber==0.26.9
# homeassistant.components.dlink # homeassistant.components.dlink
pyW215==0.7.0 pyW215==0.7.0
@ -2190,7 +2190,7 @@ regenmaschine==2022.11.0
renault-api==0.1.11 renault-api==0.1.11
# homeassistant.components.reolink # homeassistant.components.reolink
reolink-aio==0.1.3 reolink-aio==0.2.1
# homeassistant.components.python_script # homeassistant.components.python_script
restrictedpython==5.2 restrictedpython==5.2

View File

@ -36,7 +36,7 @@ PyRMVtransport==0.3.3
PySocks==1.7.1 PySocks==1.7.1
# homeassistant.components.switchbot # homeassistant.components.switchbot
PySwitchbot==0.36.3 PySwitchbot==0.36.4
# homeassistant.components.transport_nsw # homeassistant.components.transport_nsw
PyTransportNSW==0.1.1 PyTransportNSW==0.1.1
@ -161,7 +161,7 @@ aioguardian==2022.07.0
aioharmony==0.2.9 aioharmony==0.2.9
# homeassistant.components.homekit_controller # homeassistant.components.homekit_controller
aiohomekit==2.4.3 aiohomekit==2.4.4
# homeassistant.components.emulated_hue # homeassistant.components.emulated_hue
# homeassistant.components.http # homeassistant.components.http
@ -272,7 +272,7 @@ aiovlc==0.1.0
aiowatttime==0.1.1 aiowatttime==0.1.1
# homeassistant.components.webostv # homeassistant.components.webostv
aiowebostv==0.3.0 aiowebostv==0.3.2
# homeassistant.components.yandex_transport # homeassistant.components.yandex_transport
aioymaps==1.2.2 aioymaps==1.2.2
@ -601,7 +601,7 @@ goodwe==0.2.18
google-cloud-pubsub==2.13.11 google-cloud-pubsub==2.13.11
# homeassistant.components.nest # homeassistant.components.nest
google-nest-sdm==2.1.0 google-nest-sdm==2.2.2
# homeassistant.components.google_travel_time # homeassistant.components.google_travel_time
googlemaps==2.5.1 googlemaps==2.5.1
@ -1039,7 +1039,7 @@ pyMetno==0.9.0
pyRFXtrx==0.30.0 pyRFXtrx==0.30.0
# homeassistant.components.tibber # homeassistant.components.tibber
pyTibber==0.26.8 pyTibber==0.26.9
# homeassistant.components.nextbus # homeassistant.components.nextbus
py_nextbusnext==0.1.5 py_nextbusnext==0.1.5
@ -1529,7 +1529,7 @@ regenmaschine==2022.11.0
renault-api==0.1.11 renault-api==0.1.11
# homeassistant.components.reolink # homeassistant.components.reolink
reolink-aio==0.1.3 reolink-aio==0.2.1
# homeassistant.components.python_script # homeassistant.components.python_script
restrictedpython==5.2 restrictedpython==5.2

View File

@ -1,8 +1,6 @@
"""Test the Google Assistant SDK config flow.""" """Test the Google Assistant SDK config flow."""
from unittest.mock import patch from unittest.mock import patch
import oauth2client
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components.google_assistant_sdk.const import DOMAIN from homeassistant.components.google_assistant_sdk.const import DOMAIN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -12,6 +10,8 @@ from .conftest import CLIENT_ID, ComponentSetup
from tests.common import MockConfigEntry 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" TITLE = "Google Assistant SDK"
@ -35,7 +35,7 @@ async def test_full_flow(
) )
assert result["url"] == ( 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" "&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}&scope=https://www.googleapis.com/auth/assistant-sdk-prototype" f"&state={state}&scope=https://www.googleapis.com/auth/assistant-sdk-prototype"
"&access_type=offline&prompt=consent" "&access_type=offline&prompt=consent"
@ -47,7 +47,7 @@ async def test_full_flow(
assert resp.headers["content-type"] == "text/html; charset=utf-8" assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post( aioclient_mock.post(
oauth2client.GOOGLE_TOKEN_URI, GOOGLE_TOKEN_URI,
json={ json={
"refresh_token": "mock-refresh-token", "refresh_token": "mock-refresh-token",
"access_token": "mock-access-token", "access_token": "mock-access-token",
@ -112,7 +112,7 @@ async def test_reauth(
}, },
) )
assert result["url"] == ( 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" "&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}&scope=https://www.googleapis.com/auth/assistant-sdk-prototype" f"&state={state}&scope=https://www.googleapis.com/auth/assistant-sdk-prototype"
"&access_type=offline&prompt=consent" "&access_type=offline&prompt=consent"
@ -123,7 +123,7 @@ async def test_reauth(
assert resp.headers["content-type"] == "text/html; charset=utf-8" assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post( aioclient_mock.post(
oauth2client.GOOGLE_TOKEN_URI, GOOGLE_TOKEN_URI,
json={ json={
"refresh_token": "mock-refresh-token", "refresh_token": "mock-refresh-token",
"access_token": "updated-access-token", "access_token": "updated-access-token",
@ -181,7 +181,7 @@ async def test_single_instance_allowed(
) )
assert result["url"] == ( 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" "&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}&scope=https://www.googleapis.com/auth/assistant-sdk-prototype" f"&state={state}&scope=https://www.googleapis.com/auth/assistant-sdk-prototype"
"&access_type=offline&prompt=consent" "&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" assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post( aioclient_mock.post(
oauth2client.GOOGLE_TOKEN_URI, GOOGLE_TOKEN_URI,
json={ json={
"refresh_token": "mock-refresh-token", "refresh_token": "mock-refresh-token",
"access_token": "mock-access-token", "access_token": "mock-access-token",

View File

@ -2,9 +2,10 @@
from unittest.mock import patch from unittest.mock import patch
from homeassistant.components.homeassistant_sky_connect.const import DOMAIN 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 = { CONFIG_ENTRY_DATA = {
"device": "bla_device", "device": "bla_device",
@ -29,7 +30,8 @@ async def test_hardware_info(
hass: HomeAssistant, hass_ws_client, addon_store_info hass: HomeAssistant, hass_ws_client, addon_store_info
) -> None: ) -> None:
"""Test we can get the board info.""" """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 # Setup the config entry
config_entry = MockConfigEntry( config_entry = MockConfigEntry(

View File

@ -9,7 +9,8 @@ from homeassistant.components import zha
from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.hassio.handler import HassioAPIError
from homeassistant.components.homeassistant_sky_connect.const import DOMAIN from homeassistant.components.homeassistant_sky_connect.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState 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 from tests.common import MockConfigEntry
@ -55,6 +56,9 @@ async def test_setup_entry(
num_flows, num_flows,
) -> None: ) -> None:
"""Test setup of a config entry, including setup of zha.""" """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 # Setup the config entry
config_entry = MockConfigEntry( config_entry = MockConfigEntry(
data=CONFIG_ENTRY_DATA, data=CONFIG_ENTRY_DATA,
@ -100,6 +104,9 @@ async def test_setup_zha(
mock_zha_config_flow_setup, hass: HomeAssistant, addon_store_info mock_zha_config_flow_setup, hass: HomeAssistant, addon_store_info
) -> None: ) -> None:
"""Test zha gets the right config.""" """Test zha gets the right config."""
assert await async_setup_component(hass, "usb", {})
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
# Setup the config entry # Setup the config entry
config_entry = MockConfigEntry( config_entry = MockConfigEntry(
data=CONFIG_ENTRY_DATA, data=CONFIG_ENTRY_DATA,
@ -146,6 +153,9 @@ async def test_setup_zha_multipan(
hass: HomeAssistant, addon_info, addon_running hass: HomeAssistant, addon_info, addon_running
) -> None: ) -> None:
"""Test zha gets the right config.""" """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"] addon_info.return_value["options"]["device"] = CONFIG_ENTRY_DATA["device"]
# Setup the config entry # 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 mock_zha_config_flow_setup, hass: HomeAssistant, addon_info, addon_running
) -> None: ) -> None:
"""Test zha gets the right config.""" """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" addon_info.return_value["options"]["device"] = "/dev/not_our_sky_connect"
# Setup the config entry # 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", "homeassistant.components.homeassistant_sky_connect.usb.async_is_plugged_in",
return_value=False, return_value=False,
) as mock_is_plugged_in: ) 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() await hass.async_block_till_done()
assert len(mock_is_plugged_in.mock_calls) == 1 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( async def test_setup_entry_addon_info_fails(
hass: HomeAssistant, addon_store_info hass: HomeAssistant, addon_store_info
) -> None: ) -> None:
"""Test setup of a config entry when fetching addon info fails.""" """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") addon_store_info.side_effect = HassioAPIError("Boom")
# Setup the config entry # Setup the config entry
@ -296,6 +316,9 @@ async def test_setup_entry_addon_not_running(
hass: HomeAssistant, addon_installed, start_addon hass: HomeAssistant, addon_installed, start_addon
) -> None: ) -> None:
"""Test the addon is started if it is not running.""" """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 # Setup the config entry
config_entry = MockConfigEntry( config_entry = MockConfigEntry(
data=CONFIG_ENTRY_DATA, data=CONFIG_ENTRY_DATA,

View File

@ -3,7 +3,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
from hole.exceptions import HoleError 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 ( from homeassistant.const import (
CONF_API_KEY, CONF_API_KEY,
CONF_HOST, CONF_HOST,
@ -47,7 +53,16 @@ API_KEY = "apikey"
SSL = False SSL = False
VERIFY_SSL = True 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_HOST: f"{HOST}:{PORT}",
CONF_LOCATION: LOCATION, CONF_LOCATION: LOCATION,
CONF_NAME: NAME, CONF_NAME: NAME,
@ -56,34 +71,35 @@ CONF_DATA = {
CONF_VERIFY_SSL: VERIFY_SSL, CONF_VERIFY_SSL: VERIFY_SSL,
} }
CONF_CONFIG_FLOW_USER = { CONFIG_FLOW_USER = {
CONF_HOST: HOST, CONF_HOST: HOST,
CONF_PORT: PORT, CONF_PORT: PORT,
CONF_API_KEY: API_KEY,
CONF_LOCATION: LOCATION, CONF_LOCATION: LOCATION,
CONF_NAME: NAME, CONF_NAME: NAME,
CONF_STATISTICS_ONLY: False,
CONF_SSL: SSL, CONF_SSL: SSL,
CONF_VERIFY_SSL: VERIFY_SSL, CONF_VERIFY_SSL: VERIFY_SSL,
} }
CONF_CONFIG_FLOW_API_KEY = { CONFIG_FLOW_API_KEY = {
CONF_API_KEY: API_KEY, CONF_API_KEY: API_KEY,
} }
CONF_CONFIG_ENTRY = { CONFIG_ENTRY = {
CONF_HOST: f"{HOST}:{PORT}", CONF_HOST: f"{HOST}:{PORT}",
CONF_LOCATION: LOCATION, CONF_LOCATION: LOCATION,
CONF_NAME: NAME, CONF_NAME: NAME,
CONF_STATISTICS_ONLY: False,
CONF_API_KEY: API_KEY, CONF_API_KEY: API_KEY,
CONF_SSL: SSL, CONF_SSL: SSL,
CONF_VERIFY_SSL: VERIFY_SSL, CONF_VERIFY_SSL: VERIFY_SSL,
} }
CONFIG_ENTRY_IMPORTED = {**CONFIG_ENTRY, CONF_STATISTICS_ONLY: False}
SWITCH_ENTITY_ID = "switch.pi_hole" 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() mocked_hole = MagicMock()
type(mocked_hole).get_data = AsyncMock( type(mocked_hole).get_data = AsyncMock(
side_effect=HoleError("") if raise_exception else None 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).enable = AsyncMock()
type(mocked_hole).disable = 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: if has_versions:
mocked_hole.versions = SAMPLE_VERSIONS mocked_hole.versions = SAMPLE_VERSIONS
else: else:

View File

@ -2,28 +2,26 @@
import logging import logging
from unittest.mock import patch 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.config_entries import SOURCE_IMPORT, SOURCE_USER
from homeassistant.const import CONF_API_KEY from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from . import ( from . import (
CONF_CONFIG_ENTRY, CONFIG_DATA,
CONF_CONFIG_FLOW_API_KEY, CONFIG_DATA_DEFAULTS,
CONF_CONFIG_FLOW_USER, CONFIG_ENTRY,
CONF_DATA, CONFIG_ENTRY_IMPORTED,
CONFIG_FLOW_USER,
NAME, NAME,
ZERO_DATA,
_create_mocked_hole, _create_mocked_hole,
_patch_config_flow_hole, _patch_config_flow_hole,
_patch_init_hole,
) )
from tests.common import MockConfigEntry
def _flow_next(hass, flow_id):
return next(
flow
for flow in hass.config_entries.flow.async_progress()
if flow["flow_id"] == flow_id
)
def _patch_setup(): 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.""" """Test import flow."""
mocked_hole = _create_mocked_hole() mocked_hole = _create_mocked_hole()
with _patch_config_flow_hole(mocked_hole), _patch_setup(): with _patch_config_flow_hole(mocked_hole), _patch_setup():
result = await hass.config_entries.flow.async_init( 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["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == NAME assert result["title"] == NAME
assert result["data"] == CONF_CONFIG_ENTRY assert result["data"] == CONFIG_ENTRY_IMPORTED
# duplicated server # duplicated server
result = await hass.config_entries.flow.async_init( 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["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured" 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.""" """Test import flow with invalid server."""
mocked_hole = _create_mocked_hole(True) mocked_hole = _create_mocked_hole(True)
with _patch_config_flow_hole(mocked_hole), _patch_setup(): with _patch_config_flow_hole(mocked_hole), _patch_setup():
result = await hass.config_entries.flow.async_init( 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["type"] == FlowResultType.ABORT
assert result["reason"] == "cannot_connect" assert result["reason"] == "cannot_connect"
assert len([x for x in caplog.records if x.levelno == logging.ERROR]) == 1 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.""" """Test user initialized flow."""
mocked_hole = _create_mocked_hole() mocked_hole = _create_mocked_hole(has_data=False)
with _patch_config_flow_hole(mocked_hole), _patch_setup(): with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole):
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, DOMAIN,
context={"source": SOURCE_USER}, context={"source": SOURCE_USER},
@ -75,69 +73,68 @@ async def test_flow_user(hass):
assert result["type"] == FlowResultType.FORM assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user" assert result["step_id"] == "user"
assert result["errors"] == {} assert result["errors"] == {}
_flow_next(hass, result["flow_id"])
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
user_input=CONF_CONFIG_FLOW_USER, user_input=CONFIG_FLOW_USER,
) )
assert result["type"] == FlowResultType.FORM assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "api_key" assert result["step_id"] == "user"
assert result["errors"] is None assert result["errors"] == {CONF_API_KEY: "invalid_auth"}
_flow_next(hass, result["flow_id"])
mocked_hole.data = ZERO_DATA
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
user_input=CONF_CONFIG_FLOW_API_KEY, user_input=CONFIG_FLOW_USER,
) )
assert result["type"] == FlowResultType.CREATE_ENTRY assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == NAME assert result["title"] == NAME
assert result["data"] == CONF_CONFIG_ENTRY assert result["data"] == CONFIG_ENTRY
# duplicated server # duplicated server
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, DOMAIN,
context={"source": SOURCE_USER}, context={"source": SOURCE_USER},
data=CONF_CONFIG_FLOW_USER, data=CONFIG_FLOW_USER,
) )
assert result["type"] == FlowResultType.ABORT assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured" 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): async def test_flow_user_invalid(hass):
"""Test user initialized flow with invalid server.""" """Test user initialized flow with invalid server."""
mocked_hole = _create_mocked_hole(True) mocked_hole = _create_mocked_hole(True)
with _patch_config_flow_hole(mocked_hole), _patch_setup(): with _patch_config_flow_hole(mocked_hole), _patch_setup():
result = await hass.config_entries.flow.async_init( 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["type"] == FlowResultType.FORM
assert result["step_id"] == "user" assert result["step_id"] == "user"
assert result["errors"] == {"base": "cannot_connect"} 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"

View File

@ -7,27 +7,16 @@ from hole.exceptions import HoleError
from homeassistant.components import pi_hole, switch from homeassistant.components import pi_hole, switch
from homeassistant.components.pi_hole.const import ( from homeassistant.components.pi_hole.const import (
CONF_STATISTICS_ONLY, CONF_STATISTICS_ONLY,
DEFAULT_LOCATION,
DEFAULT_NAME,
DEFAULT_SSL,
DEFAULT_VERIFY_SSL,
SERVICE_DISABLE, SERVICE_DISABLE,
SERVICE_DISABLE_ATTR_DURATION, SERVICE_DISABLE_ATTR_DURATION,
) )
from homeassistant.const import ( from homeassistant.config_entries import ConfigEntryState
ATTR_ENTITY_ID, from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY, CONF_HOST
CONF_API_KEY, from homeassistant.core import HomeAssistant
CONF_HOST,
CONF_LOCATION,
CONF_NAME,
CONF_SSL,
CONF_VERIFY_SSL,
)
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from . import ( from . import (
CONF_CONFIG_ENTRY, CONFIG_DATA_DEFAULTS,
CONF_DATA,
SWITCH_ENTITY_ID, SWITCH_ENTITY_ID,
_create_mocked_hole, _create_mocked_hole,
_patch_config_flow_hole, _patch_config_flow_hole,
@ -37,7 +26,7 @@ from . import (
from tests.common import MockConfigEntry 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.""" """Tests component setup with minimal config."""
mocked_hole = _create_mocked_hole() mocked_hole = _create_mocked_hole()
with _patch_config_flow_hole(mocked_hole), _patch_init_hole(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" 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.""" """Tests component setup with a custom name."""
mocked_hole = _create_mocked_hole() mocked_hole = _create_mocked_hole()
with _patch_config_flow_hole(mocked_hole), _patch_init_hole(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.""" """Test Pi-hole switch."""
mocked_hole = _create_mocked_hole() mocked_hole = _create_mocked_hole()
with _patch_config_flow_hole(mocked_hole), _patch_init_hole(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" 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.""" """Test disable service call with no Pi-hole named."""
mocked_hole = _create_mocked_hole() mocked_hole = _create_mocked_hole()
with _patch_config_flow_hole(mocked_hole), _patch_init_hole(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() 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.""" """Test unload entities."""
entry = MockConfigEntry( entry = MockConfigEntry(
domain=pi_hole.DOMAIN, domain=pi_hole.DOMAIN,
data={ data={**CONFIG_DATA_DEFAULTS, CONF_HOST: "pi.hole"},
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,
},
) )
entry.add_to_hass(hass) entry.add_to_hass(hass)
mocked_hole = _create_mocked_hole() 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] assert entry.entry_id not in hass.data[pi_hole.DOMAIN]
async def test_migrate(hass): async def test_remove_obsolete(hass: HomeAssistant):
"""Test migrate from old config entry.""" """Test removing obsolete config entry parameters."""
entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=CONF_DATA)
entry.add_to_hass(hass)
mocked_hole = _create_mocked_hole() mocked_hole = _create_mocked_hole()
with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole): entry = MockConfigEntry(
await hass.config_entries.async_setup(entry.entry_id) domain=pi_hole.DOMAIN, data={**CONFIG_DATA_DEFAULTS, CONF_STATISTICS_ONLY: True}
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.add_to_hass(hass) 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() mocked_hole = _create_mocked_hole()
with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole): data = CONFIG_DATA_DEFAULTS.copy()
await hass.config_entries.async_setup(entry.entry_id) data.pop(CONF_API_KEY)
await hass.async_block_till_done() entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=data)
entry.add_to_hass(hass)
config_entry_data = {**CONF_CONFIG_ENTRY} with _patch_init_hole(mocked_hole):
config_entry_data[CONF_STATISTICS_ONLY] = True assert not await hass.config_entries.async_setup(entry.entry_id)
config_entry_data[CONF_API_KEY] = "" assert entry.state == ConfigEntryState.SETUP_ERROR
assert entry.data == config_entry_data

View File

@ -24,7 +24,7 @@ TEST_NVR_NAME = "test_reolink_name"
TEST_USE_HTTPS = True 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.""" """Return a mock gateway info instance."""
host_mock = Mock() host_mock = Mock()
if error is None: 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.nvr_name = TEST_NVR_NAME
host_mock.port = TEST_PORT host_mock.port = TEST_PORT
host_mock.use_https = TEST_USE_HTTPS host_mock.use_https = TEST_USE_HTTPS
host_mock.is_admin = user_level == "admin"
host_mock.user_level = user_level
return host_mock 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["type"] is data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "user" 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)) host_mock = get_mock_info(error=json.JSONDecodeError("test_error", "test", 1))
with patch("homeassistant.components.reolink.host.Host", return_value=host_mock): 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["type"] is data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "user" 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")) host_mock = get_mock_info(error=CredentialsInvalidError("Test error"))
with patch("homeassistant.components.reolink.host.Host", return_value=host_mock): 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["type"] is data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "user" 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")) host_mock = get_mock_info(error=ApiError("Test error"))
with patch("homeassistant.components.reolink.host.Host", return_value=host_mock): 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["type"] is data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "user" 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 = await hass.config_entries.flow.async_configure(
result["flow_id"], 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_HOST] == TEST_HOST2
assert config_entry.data[CONF_USERNAME] == TEST_USERNAME2 assert config_entry.data[CONF_USERNAME] == TEST_USERNAME2
assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD2 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

View File

@ -706,6 +706,30 @@ async def test_zeroconf_already_configured(hass):
assert entry.data["host"] == "1.1.1.1" 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): async def test_zeroconf_with_wifi_ap_ip(hass):
"""Test we ignore the Wi-FI AP IP.""" """Test we ignore the Wi-FI AP IP."""

View File

@ -936,3 +936,59 @@ async def test_web_socket_triggers_discovery_request_callbacks(hass, hass_ws_cli
assert response["success"] assert response["success"]
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_callback.mock_calls) == 1 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

View File

@ -1,6 +1,7 @@
"""The tests for the LG webOS media player platform.""" """The tests for the LG webOS media player platform."""
import asyncio import asyncio
from datetime import timedelta from datetime import timedelta
from http import HTTPStatus
from unittest.mock import Mock from unittest.mock import Mock
import pytest import pytest
@ -697,3 +698,68 @@ async def test_supported_features_ignore_cache(hass, client):
attrs = hass.states.get(ENTITY_ID).attributes attrs = hass.states.get(ENTITY_ID).attributes
assert attrs[ATTR_SUPPORTED_FEATURES] == supported 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"

View File

@ -134,8 +134,9 @@ async def test_abort_if_authorization_timeout(
flow = flow_handler() flow = flow_handler()
flow.hass = hass flow.hass = hass
with patch.object( with patch(
local_impl, "async_generate_authorize_url", side_effect=asyncio.TimeoutError "homeassistant.helpers.config_entry_oauth2_flow.async_timeout.timeout",
side_effect=asyncio.TimeoutError,
): ):
result = await flow.async_step_user() result = await flow.async_step_user()
@ -278,6 +279,62 @@ async def test_abort_if_oauth_rejected(
assert result["description_placeholders"] == {"error": "access_denied"} 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): async def test_step_discovery(hass, flow_handler, local_impl):
"""Check flow triggers from discovery.""" """Check flow triggers from discovery."""
flow_handler.async_register_implementation(hass, local_impl) flow_handler.async_register_implementation(hass, local_impl)