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
/homeassistant/components/webhook/ @home-assistant/core
/tests/components/webhook/ @home-assistant/core
/homeassistant/components/webostv/ @bendavid @thecode
/tests/components/webostv/ @bendavid @thecode
/homeassistant/components/webostv/ @thecode
/tests/components/webostv/ @thecode
/homeassistant/components/websocket_api/ @home-assistant/core
/tests/components/websocket_api/ @home-assistant/core
/homeassistant/components/wemo/ @esev

View File

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

View File

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

View File

@ -3,7 +3,7 @@
"name": "HomeKit Controller",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
"requirements": ["aiohomekit==2.4.3"],
"requirements": ["aiohomekit==2.4.4"],
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."],
"bluetooth": [{ "manufacturer_id": 76, "manufacturer_data_start": [6] }],
"dependencies": ["bluetooth", "zeroconf"],

View File

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

View File

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

View File

@ -5,7 +5,7 @@
"dependencies": ["ffmpeg", "http", "application_credentials"],
"after_dependencies": ["media_source"],
"documentation": "https://www.home-assistant.io/integrations/nest",
"requirements": ["python-nest==4.2.0", "google-nest-sdm==2.1.0"],
"requirements": ["python-nest==4.2.0", "google-nest-sdm==2.2.2"],
"codeowners": ["@allenporter"],
"quality_scale": "platinum",
"dhcp": [

View File

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

View File

@ -15,8 +15,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from . import PiHoleEntity
from .const import (
BINARY_SENSOR_TYPES,
BINARY_SENSOR_TYPES_STATISTICS_ONLY,
CONF_STATISTICS_ONLY,
DATA_KEY_API,
DATA_KEY_COORDINATOR,
DOMAIN as PIHOLE_DOMAIN,
@ -42,18 +40,6 @@ async def async_setup_entry(
for description in BINARY_SENSOR_TYPES
]
if entry.data[CONF_STATISTICS_ONLY]:
binary_sensors += [
PiHoleBinarySensor(
hole_data[DATA_KEY_API],
hole_data[DATA_KEY_COORDINATOR],
name,
entry.entry_id,
description,
)
for description in BINARY_SENSOR_TYPES_STATISTICS_ONLY
]
async_add_entities(binary_sensors, True)

View File

@ -1,6 +1,7 @@
"""Config flow to configure the Pi-hole integration."""
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
@ -26,7 +27,6 @@ from .const import (
DEFAULT_LOCATION,
DEFAULT_NAME,
DEFAULT_SSL,
DEFAULT_STATISTICS_ONLY,
DEFAULT_VERIFY_SSL,
DOMAIN,
)
@ -47,65 +47,29 @@ class PiHoleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle a flow initiated by the user."""
return await self.async_step_init(user_input)
async def async_step_import(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle a flow initiated by import."""
return await self.async_step_init(user_input, is_import=True)
async def async_step_init(
self, user_input: dict[str, Any] | None, is_import: bool = False
) -> FlowResult:
"""Handle init step of a flow."""
errors = {}
if user_input is not None:
host = (
user_input[CONF_HOST]
if is_import
else f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}"
)
name = user_input[CONF_NAME]
location = user_input[CONF_LOCATION]
tls = user_input[CONF_SSL]
verify_tls = user_input[CONF_VERIFY_SSL]
endpoint = f"{host}/{location}"
self._config = {
CONF_HOST: f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}",
CONF_NAME: user_input[CONF_NAME],
CONF_LOCATION: user_input[CONF_LOCATION],
CONF_SSL: user_input[CONF_SSL],
CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL],
CONF_API_KEY: user_input[CONF_API_KEY],
}
if await self._async_endpoint_existed(endpoint):
return self.async_abort(reason="already_configured")
try:
await self._async_try_connect(host, location, tls, verify_tls)
except HoleError as ex:
_LOGGER.debug("Connection failed: %s", ex)
if is_import:
_LOGGER.error("Failed to import: %s", ex)
return self.async_abort(reason="cannot_connect")
errors["base"] = "cannot_connect"
else:
self._config = {
CONF_HOST: host,
CONF_NAME: name,
CONF_LOCATION: location,
CONF_SSL: tls,
CONF_VERIFY_SSL: verify_tls,
self._async_abort_entries_match(
{
CONF_HOST: f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}",
CONF_LOCATION: user_input[CONF_LOCATION],
}
if is_import:
api_key = user_input.get(CONF_API_KEY)
return self.async_create_entry(
title=name,
data={
**self._config,
CONF_STATISTICS_ONLY: api_key is None,
CONF_API_KEY: api_key,
},
)
self._config[CONF_STATISTICS_ONLY] = user_input[CONF_STATISTICS_ONLY]
if self._config[CONF_STATISTICS_ONLY]:
return self.async_create_entry(title=name, data=self._config)
return await self.async_step_api_key()
)
if not (errors := await self._async_try_connect()):
return self.async_create_entry(
title=user_input[CONF_NAME], data=self._config
)
user_input = user_input or {}
return self.async_show_form(
@ -116,6 +80,7 @@ class PiHoleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
vol.Required(
CONF_PORT, default=user_input.get(CONF_PORT, 80)
): vol.Coerce(int),
vol.Required(CONF_API_KEY): str,
vol.Required(
CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME)
): str,
@ -123,12 +88,6 @@ class PiHoleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
CONF_LOCATION,
default=user_input.get(CONF_LOCATION, DEFAULT_LOCATION),
): str,
vol.Required(
CONF_STATISTICS_ONLY,
default=user_input.get(
CONF_STATISTICS_ONLY, DEFAULT_STATISTICS_ONLY
),
): bool,
vol.Required(
CONF_SSL,
default=user_input.get(CONF_SSL, DEFAULT_SSL),
@ -142,24 +101,94 @@ class PiHoleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_api_key(
self, user_input: dict[str, Any] | None = None
async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult:
"""Handle a flow initiated by import."""
host = user_input[CONF_HOST]
name = user_input[CONF_NAME]
location = user_input[CONF_LOCATION]
tls = user_input[CONF_SSL]
verify_tls = user_input[CONF_VERIFY_SSL]
endpoint = f"{host}/{location}"
if await self._async_endpoint_existed(endpoint):
return self.async_abort(reason="already_configured")
try:
await self._async_try_connect_legacy(host, location, tls, verify_tls)
except HoleError as ex:
_LOGGER.debug("Connection failed: %s", ex)
_LOGGER.error("Failed to import: %s", ex)
return self.async_abort(reason="cannot_connect")
self._config = {
CONF_HOST: host,
CONF_NAME: name,
CONF_LOCATION: location,
CONF_SSL: tls,
CONF_VERIFY_SSL: verify_tls,
}
api_key = user_input.get(CONF_API_KEY)
return self.async_create_entry(
title=name,
data={
**self._config,
CONF_STATISTICS_ONLY: api_key is None,
CONF_API_KEY: api_key,
},
)
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
"""Perform reauth upon an API authentication error."""
self._config = dict(entry_data)
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
"""Handle step to setup API key."""
"""Perform reauth confirm upon an API authentication error."""
errors = {}
if user_input is not None:
return self.async_create_entry(
title=self._config[CONF_NAME],
data={
**self._config,
CONF_API_KEY: user_input.get(CONF_API_KEY, ""),
},
)
self._config = {**self._config, CONF_API_KEY: user_input[CONF_API_KEY]}
if not (errors := await self._async_try_connect()):
entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
assert entry
self.hass.config_entries.async_update_entry(entry, data=self._config)
self.hass.async_create_task(
self.hass.config_entries.async_reload(self.context["entry_id"])
)
return self.async_abort(reason="reauth_successful")
return self.async_show_form(
step_id="api_key",
data_schema=vol.Schema({vol.Optional(CONF_API_KEY): str}),
step_id="reauth_confirm",
description_placeholders={
CONF_HOST: self._config[CONF_HOST],
CONF_LOCATION: self._config[CONF_LOCATION],
},
data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
errors=errors,
)
async def _async_try_connect(self) -> dict[str, str]:
session = async_get_clientsession(self.hass, self._config[CONF_VERIFY_SSL])
pi_hole = Hole(
self._config[CONF_HOST],
session,
location=self._config[CONF_LOCATION],
tls=self._config[CONF_SSL],
api_token=self._config[CONF_API_KEY],
)
try:
await pi_hole.get_data()
except HoleError as ex:
_LOGGER.debug("Connection failed: %s", ex)
return {"base": "cannot_connect"}
if not isinstance(pi_hole.data, dict):
return {CONF_API_KEY: "invalid_auth"}
return {}
async def _async_endpoint_existed(self, endpoint: str) -> bool:
existing_endpoints = [
f"{entry.data.get(CONF_HOST)}/{entry.data.get(CONF_LOCATION)}"
@ -167,7 +196,7 @@ class PiHoleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
]
return endpoint in existing_endpoints
async def _async_try_connect(
async def _async_try_connect_legacy(
self, host: str, location: str, tls: bool, verify_tls: bool
) -> None:
session = async_get_clientsession(self.hass, verify_tls)

View File

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

View File

@ -8,28 +8,25 @@
"name": "[%key:common::config_flow::data::name%]",
"location": "[%key:common::config_flow::data::location%]",
"api_key": "[%key:common::config_flow::data::api_key%]",
"statistics_only": "Statistics Only",
"ssl": "[%key:common::config_flow::data::ssl%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
}
},
"api_key": {
"reauth_confirm": {
"title": "PI-Hole [%key:common::config_flow::title::reauth%]",
"description": "Please enter a new api key for PI-Hole at {host}/{location}",
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
}
},
"issues": {
"deprecated_yaml": {
"title": "The PI-Hole YAML configuration is being removed",
"description": "Configuring PI-Hole using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the PI-Hole YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
}
}

View File

@ -1,16 +1,20 @@
{
"config": {
"abort": {
"already_configured": "Service is already configured"
"already_configured": "Service is already configured",
"reauth_successful": "Re-authentication was successful"
},
"error": {
"cannot_connect": "Failed to connect"
"cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication"
},
"step": {
"api_key": {
"reauth_confirm": {
"data": {
"api_key": "API Key"
}
},
"description": "Please enter a new api key for PI-Hole at {host}/{location}",
"title": "PI-Hole Reauthenticate Integration"
},
"user": {
"data": {
@ -20,16 +24,9 @@
"name": "Name",
"port": "Port",
"ssl": "Uses an SSL certificate",
"statistics_only": "Statistics Only",
"verify_ssl": "Verify SSL certificate"
}
}
}
},
"issues": {
"deprecated_yaml": {
"description": "Configuring PI-Hole using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the PI-Hole YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue.",
"title": "The PI-Hole YAML configuration is being removed"
}
}
}

View File

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

View File

@ -1,19 +1,21 @@
"""Config flow for the Reolink camera component."""
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
from reolink_aio.exceptions import ApiError, CredentialsInvalidError
import voluptuous as vol
from homeassistant import config_entries, core, exceptions
from homeassistant import config_entries, exceptions
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import config_validation as cv
from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DEFAULT_PROTOCOL, DOMAIN
from .exceptions import UserNotAdmin
from .host import ReolinkHost
_LOGGER = logging.getLogger(__name__)
@ -53,6 +55,13 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1
def __init__(self) -> None:
"""Initialize."""
self._host: str | None = None
self._username: str = "admin"
self._password: str | None = None
self._reauth: bool = False
@staticmethod
@callback
def async_get_options_flow(
@ -61,16 +70,37 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Options callback for Reolink."""
return ReolinkOptionsFlowHandler(config_entry)
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
"""Perform reauth upon an authentication error or no admin privileges."""
self._host = entry_data[CONF_HOST]
self._username = entry_data[CONF_USERNAME]
self._password = entry_data[CONF_PASSWORD]
self._reauth = True
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Dialog that informs the user that reauth is required."""
if user_input is not None:
return await self.async_step_user()
return self.async_show_form(step_id="reauth_confirm")
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
errors = {}
placeholders = {}
placeholders = {"error": ""}
if user_input is not None:
host = ReolinkHost(self.hass, user_input, DEFAULT_OPTIONS)
try:
host = await async_obtain_host_settings(self.hass, user_input)
await async_obtain_host_settings(host)
except UserNotAdmin:
errors[CONF_USERNAME] = "not_admin"
placeholders["username"] = host.api.username
placeholders["userlevel"] = host.api.user_level
except CannotConnect:
errors[CONF_HOST] = "cannot_connect"
except CredentialsInvalidError:
@ -87,7 +117,17 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
user_input[CONF_PORT] = host.api.port
user_input[CONF_USE_HTTPS] = host.api.use_https
await self.async_set_unique_id(host.unique_id, raise_on_progress=False)
existing_entry = await self.async_set_unique_id(
host.unique_id, raise_on_progress=False
)
if existing_entry and self._reauth:
if self.hass.config_entries.async_update_entry(
existing_entry, data=user_input
):
await self.hass.config_entries.async_reload(
existing_entry.entry_id
)
return self.async_abort(reason="reauth_successful")
self._abort_if_unique_id_configured(updates=user_input)
return self.async_create_entry(
@ -98,9 +138,9 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
data_schema = vol.Schema(
{
vol.Required(CONF_USERNAME, default="admin"): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(CONF_HOST): str,
vol.Required(CONF_USERNAME, default=self._username): str,
vol.Required(CONF_PASSWORD, default=self._password): str,
vol.Required(CONF_HOST, default=self._host): str,
}
)
if errors:
@ -119,20 +159,14 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
)
async def async_obtain_host_settings(
hass: core.HomeAssistant, user_input: dict
) -> ReolinkHost:
async def async_obtain_host_settings(host: ReolinkHost) -> None:
"""Initialize the Reolink host and get the host information."""
host = ReolinkHost(hass, user_input, DEFAULT_OPTIONS)
try:
if not await host.async_init():
raise CannotConnect
finally:
await host.stop()
return host
class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""

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 .const import CONF_PROTOCOL, CONF_USE_HTTPS, DEFAULT_TIMEOUT
from .exceptions import UserNotAdmin
_LOGGER = logging.getLogger(__name__)
@ -68,6 +69,12 @@ class ReolinkHost:
if self._api.mac_address is None:
return False
if not self._api.is_admin:
await self.stop()
raise UserNotAdmin(
f"User '{self._api.username}' has authorization level '{self._api.user_level}', only admin users can change camera settings"
)
enable_onvif = None
enable_rtmp = None
enable_rtsp = None

View File

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

View File

@ -2,6 +2,7 @@
"config": {
"step": {
"user": {
"description": "{error}",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]",
@ -9,16 +10,22 @@
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
}
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The Reolink integration needs to re-authenticate your connection details"
}
},
"error": {
"api_error": "API error occurred: {error}",
"api_error": "API error occurred",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]: {error}"
"not_admin": "User needs to be admin, user ''{username}'' has authorisation level ''{userlevel}''",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"options": {

View File

@ -1,15 +1,21 @@
{
"config": {
"abort": {
"already_configured": "Device is already configured"
"already_configured": "Device is already configured",
"reauth_successful": "Re-authentication was successful"
},
"error": {
"api_error": "API error occurred: {error}",
"api_error": "API error occurred",
"cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication",
"unknown": "Unexpected error: {error}"
"not_admin": "User needs to be admin, user ''{username}'' has authorisation level ''{userlevel}''",
"unknown": "Unexpected error"
},
"step": {
"reauth_confirm": {
"description": "The Reolink integration needs to re-authenticate your connection details",
"title": "Reauthenticate Integration"
},
"user": {
"data": {
"host": "Host",
@ -17,7 +23,8 @@
"port": "Port",
"use_https": "Enable HTTPS",
"username": "Username"
}
},
"description": "{error}"
}
}
},

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."""
if (
current_entry := await self.async_set_unique_id(mac)
) and current_entry.data[CONF_HOST] == host:
) and current_entry.data.get(CONF_HOST) == host:
await async_reconnect_soon(self.hass, current_entry)
if host == INTERNAL_WIFI_AP_IP:
# If the device is broadcasting the internal wifi ap ip

View File

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

View File

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

View File

@ -61,6 +61,18 @@ def async_register_scan_request_callback(
return discovery.async_register_scan_request_callback(callback)
@hass_callback
def async_register_initial_scan_callback(
hass: HomeAssistant, callback: CALLBACK_TYPE
) -> CALLBACK_TYPE:
"""Register to receive a callback when the initial USB scan is done.
If the initial scan is already done, the callback is called immediately.
"""
discovery: USBDiscovery = hass.data[DOMAIN]
return discovery.async_register_initial_scan_callback(callback)
@hass_callback
def async_is_plugged_in(hass: HomeAssistant, matcher: USBCallbackMatcher) -> bool:
"""Return True is a USB device is present."""
@ -186,6 +198,8 @@ class USBDiscovery:
self.observer_active = False
self._request_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None
self._request_callbacks: list[CALLBACK_TYPE] = []
self.initial_scan_done = False
self._initial_scan_callbacks: list[CALLBACK_TYPE] = []
async def async_setup(self) -> None:
"""Set up USB Discovery."""
@ -249,7 +263,7 @@ class USBDiscovery:
self,
_callback: CALLBACK_TYPE,
) -> CALLBACK_TYPE:
"""Register a callback."""
"""Register a scan request callback."""
self._request_callbacks.append(_callback)
@hass_callback
@ -258,6 +272,26 @@ class USBDiscovery:
return _async_remove_callback
@hass_callback
def async_register_initial_scan_callback(
self,
callback: CALLBACK_TYPE,
) -> CALLBACK_TYPE:
"""Register an initial scan callback."""
if self.initial_scan_done:
callback()
return lambda: None
self._initial_scan_callbacks.append(callback)
@hass_callback
def _async_remove_callback() -> None:
if callback not in self._initial_scan_callbacks:
return
self._initial_scan_callbacks.remove(callback)
return _async_remove_callback
@hass_callback
def _async_process_discovered_usb_device(self, device: USBDevice) -> None:
"""Process a USB discovery."""
@ -307,6 +341,12 @@ class USBDiscovery:
async def _async_scan_serial(self) -> None:
"""Scan serial ports."""
self._async_process_ports(await self.hass.async_add_executor_job(comports))
if self.initial_scan_done:
return
self.initial_scan_done = True
while self._initial_scan_callbacks:
self._initial_scan_callbacks.pop()()
async def _async_scan(self) -> None:
"""Scan for USB devices and notify callbacks to scan as well."""

View File

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

View File

@ -116,7 +116,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
host = urlparse(discovery_info.ssdp_location).hostname
assert host
self._host = host
self._name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME]
self._name = discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, DEFAULT_NAME)
uuid = discovery_info.upnp[ssdp.ATTR_UPNP_UDN]
assert uuid

View File

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

View File

@ -1,14 +1,18 @@
"""Support for interface with an LG webOS Smart TV."""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable, Coroutine
from contextlib import suppress
from datetime import timedelta
from functools import wraps
from http import HTTPStatus
import logging
from ssl import SSLContext
from typing import Any, TypeVar, cast
from aiowebostv import WebOsClient, WebOsTvPairError
import async_timeout
from typing_extensions import Concatenate, ParamSpec
from homeassistant import util
@ -28,6 +32,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -466,3 +471,25 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
async def async_command(self, command: str, **kwargs: Any) -> None:
"""Send a command."""
await self._client.request(command, payload=kwargs.get(ATTR_PAYLOAD))
async def _async_fetch_image(self, url: str) -> tuple[bytes | None, str | None]:
"""Retrieve an image.
webOS uses self-signed certificates, thus we need to use an empty
SSLContext to bypass validation errors if url starts with https.
"""
content = None
ssl_context = None
if url.startswith("https"):
ssl_context = SSLContext()
websession = async_get_clientsession(self.hass)
with suppress(asyncio.TimeoutError), async_timeout.timeout(10):
response = await websession.get(url, ssl=ssl_context)
if response.status == HTTPStatus.OK:
content = await response.read()
if content is None:
_LOGGER.warning("Error retrieving proxied image from %s", url)
return content, None

View File

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

View File

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

View File

@ -71,6 +71,7 @@
"no_devices_found": "No devices found on the network",
"webhook_not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive webhook messages.",
"oauth2_error": "Received invalid token data.",
"oauth2_timeout": "Timeout resolving OAuth token.",
"oauth2_missing_configuration": "The component is not configured. Please follow the documentation.",
"oauth2_missing_credentials": "The integration requires application credentials.",
"oauth2_authorize_url_timeout": "Timeout generating authorize URL.",

View File

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

View File

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

View File

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

View File

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

View File

@ -2,9 +2,10 @@
from unittest.mock import patch
from homeassistant.components.homeassistant_sky_connect.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.core import EVENT_HOMEASSISTANT_STARTED, HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, MockModule, mock_integration
from tests.common import MockConfigEntry
CONFIG_ENTRY_DATA = {
"device": "bla_device",
@ -29,7 +30,8 @@ async def test_hardware_info(
hass: HomeAssistant, hass_ws_client, addon_store_info
) -> None:
"""Test we can get the board info."""
mock_integration(hass, MockModule("usb"))
assert await async_setup_component(hass, "usb", {})
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
# Setup the config entry
config_entry = MockConfigEntry(

View File

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

View File

@ -3,7 +3,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
from hole.exceptions import HoleError
from homeassistant.components.pi_hole.const import CONF_STATISTICS_ONLY
from homeassistant.components.pi_hole.const import (
CONF_STATISTICS_ONLY,
DEFAULT_LOCATION,
DEFAULT_NAME,
DEFAULT_SSL,
DEFAULT_VERIFY_SSL,
)
from homeassistant.const import (
CONF_API_KEY,
CONF_HOST,
@ -47,7 +53,16 @@ API_KEY = "apikey"
SSL = False
VERIFY_SSL = True
CONF_DATA = {
CONFIG_DATA_DEFAULTS = {
CONF_HOST: f"{HOST}:{PORT}",
CONF_LOCATION: DEFAULT_LOCATION,
CONF_NAME: DEFAULT_NAME,
CONF_SSL: DEFAULT_SSL,
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
CONF_API_KEY: API_KEY,
}
CONFIG_DATA = {
CONF_HOST: f"{HOST}:{PORT}",
CONF_LOCATION: LOCATION,
CONF_NAME: NAME,
@ -56,34 +71,35 @@ CONF_DATA = {
CONF_VERIFY_SSL: VERIFY_SSL,
}
CONF_CONFIG_FLOW_USER = {
CONFIG_FLOW_USER = {
CONF_HOST: HOST,
CONF_PORT: PORT,
CONF_API_KEY: API_KEY,
CONF_LOCATION: LOCATION,
CONF_NAME: NAME,
CONF_STATISTICS_ONLY: False,
CONF_SSL: SSL,
CONF_VERIFY_SSL: VERIFY_SSL,
}
CONF_CONFIG_FLOW_API_KEY = {
CONFIG_FLOW_API_KEY = {
CONF_API_KEY: API_KEY,
}
CONF_CONFIG_ENTRY = {
CONFIG_ENTRY = {
CONF_HOST: f"{HOST}:{PORT}",
CONF_LOCATION: LOCATION,
CONF_NAME: NAME,
CONF_STATISTICS_ONLY: False,
CONF_API_KEY: API_KEY,
CONF_SSL: SSL,
CONF_VERIFY_SSL: VERIFY_SSL,
}
CONFIG_ENTRY_IMPORTED = {**CONFIG_ENTRY, CONF_STATISTICS_ONLY: False}
SWITCH_ENTITY_ID = "switch.pi_hole"
def _create_mocked_hole(raise_exception=False, has_versions=True):
def _create_mocked_hole(raise_exception=False, has_versions=True, has_data=True):
mocked_hole = MagicMock()
type(mocked_hole).get_data = AsyncMock(
side_effect=HoleError("") if raise_exception else None
@ -93,7 +109,10 @@ def _create_mocked_hole(raise_exception=False, has_versions=True):
)
type(mocked_hole).enable = AsyncMock()
type(mocked_hole).disable = AsyncMock()
mocked_hole.data = ZERO_DATA
if has_data:
mocked_hole.data = ZERO_DATA
else:
mocked_hole.data = []
if has_versions:
mocked_hole.versions = SAMPLE_VERSIONS
else:

View File

@ -2,28 +2,26 @@
import logging
from unittest.mock import patch
from homeassistant.components.pi_hole.const import CONF_STATISTICS_ONLY, DOMAIN
from homeassistant.components.pi_hole.const import DOMAIN
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import (
CONF_CONFIG_ENTRY,
CONF_CONFIG_FLOW_API_KEY,
CONF_CONFIG_FLOW_USER,
CONF_DATA,
CONFIG_DATA,
CONFIG_DATA_DEFAULTS,
CONFIG_ENTRY,
CONFIG_ENTRY_IMPORTED,
CONFIG_FLOW_USER,
NAME,
ZERO_DATA,
_create_mocked_hole,
_patch_config_flow_hole,
_patch_init_hole,
)
def _flow_next(hass, flow_id):
return next(
flow
for flow in hass.config_entries.flow.async_progress()
if flow["flow_id"] == flow_id
)
from tests.common import MockConfigEntry
def _patch_setup():
@ -33,41 +31,41 @@ def _patch_setup():
)
async def test_flow_import(hass, caplog):
async def test_flow_import(hass: HomeAssistant, caplog):
"""Test import flow."""
mocked_hole = _create_mocked_hole()
with _patch_config_flow_hole(mocked_hole), _patch_setup():
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=CONF_DATA
DOMAIN, context={"source": SOURCE_IMPORT}, data=CONFIG_DATA
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == NAME
assert result["data"] == CONF_CONFIG_ENTRY
assert result["data"] == CONFIG_ENTRY_IMPORTED
# duplicated server
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=CONF_DATA
DOMAIN, context={"source": SOURCE_IMPORT}, data=CONFIG_DATA
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_flow_import_invalid(hass, caplog):
async def test_flow_import_invalid(hass: HomeAssistant, caplog):
"""Test import flow with invalid server."""
mocked_hole = _create_mocked_hole(True)
with _patch_config_flow_hole(mocked_hole), _patch_setup():
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=CONF_DATA
DOMAIN, context={"source": SOURCE_IMPORT}, data=CONFIG_DATA
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
assert len([x for x in caplog.records if x.levelno == logging.ERROR]) == 1
async def test_flow_user(hass):
async def test_flow_user(hass: HomeAssistant):
"""Test user initialized flow."""
mocked_hole = _create_mocked_hole()
with _patch_config_flow_hole(mocked_hole), _patch_setup():
mocked_hole = _create_mocked_hole(has_data=False)
with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
@ -75,69 +73,68 @@ async def test_flow_user(hass):
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
_flow_next(hass, result["flow_id"])
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=CONF_CONFIG_FLOW_USER,
user_input=CONFIG_FLOW_USER,
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "api_key"
assert result["errors"] is None
_flow_next(hass, result["flow_id"])
assert result["step_id"] == "user"
assert result["errors"] == {CONF_API_KEY: "invalid_auth"}
mocked_hole.data = ZERO_DATA
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=CONF_CONFIG_FLOW_API_KEY,
user_input=CONFIG_FLOW_USER,
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == NAME
assert result["data"] == CONF_CONFIG_ENTRY
assert result["data"] == CONFIG_ENTRY
# duplicated server
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data=CONF_CONFIG_FLOW_USER,
data=CONFIG_FLOW_USER,
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_flow_statistics_only(hass):
"""Test user initialized flow with statistics only."""
mocked_hole = _create_mocked_hole()
with _patch_config_flow_hole(mocked_hole), _patch_setup():
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
_flow_next(hass, result["flow_id"])
user_input = {**CONF_CONFIG_FLOW_USER}
user_input[CONF_STATISTICS_ONLY] = True
config_entry_data = {**CONF_CONFIG_ENTRY}
config_entry_data[CONF_STATISTICS_ONLY] = True
config_entry_data.pop(CONF_API_KEY)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=user_input,
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == NAME
assert result["data"] == config_entry_data
async def test_flow_user_invalid(hass):
"""Test user initialized flow with invalid server."""
mocked_hole = _create_mocked_hole(True)
with _patch_config_flow_hole(mocked_hole), _patch_setup():
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW_USER
DOMAIN, context={"source": SOURCE_USER}, data=CONFIG_FLOW_USER
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "cannot_connect"}
async def test_flow_reauth(hass: HomeAssistant):
"""Test reauth flow."""
mocked_hole = _create_mocked_hole(has_data=False)
entry = MockConfigEntry(domain=DOMAIN, data=CONFIG_DATA_DEFAULTS)
entry.add_to_hass(hass)
with _patch_init_hole(mocked_hole), _patch_config_flow_hole(mocked_hole):
assert not await hass.config_entries.async_setup(entry.entry_id)
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
assert flows[0]["step_id"] == "reauth_confirm"
assert flows[0]["context"]["entry_id"] == entry.entry_id
mocked_hole.data = ZERO_DATA
result = await hass.config_entries.flow.async_configure(
flows[0]["flow_id"],
user_input={CONF_API_KEY: "newkey"},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert entry.data[CONF_API_KEY] == "newkey"

View File

@ -7,27 +7,16 @@ from hole.exceptions import HoleError
from homeassistant.components import pi_hole, switch
from homeassistant.components.pi_hole.const import (
CONF_STATISTICS_ONLY,
DEFAULT_LOCATION,
DEFAULT_NAME,
DEFAULT_SSL,
DEFAULT_VERIFY_SSL,
SERVICE_DISABLE,
SERVICE_DISABLE_ATTR_DURATION,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_API_KEY,
CONF_HOST,
CONF_LOCATION,
CONF_NAME,
CONF_SSL,
CONF_VERIFY_SSL,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY, CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from . import (
CONF_CONFIG_ENTRY,
CONF_DATA,
CONFIG_DATA_DEFAULTS,
SWITCH_ENTITY_ID,
_create_mocked_hole,
_patch_config_flow_hole,
@ -37,7 +26,7 @@ from . import (
from tests.common import MockConfigEntry
async def test_setup_minimal_config(hass):
async def test_setup_minimal_config(hass: HomeAssistant):
"""Tests component setup with minimal config."""
mocked_hole = _create_mocked_hole()
with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole):
@ -88,7 +77,7 @@ async def test_setup_minimal_config(hass):
assert state.state == "off"
async def test_setup_name_config(hass):
async def test_setup_name_config(hass: HomeAssistant):
"""Tests component setup with a custom name."""
mocked_hole = _create_mocked_hole()
with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole):
@ -106,7 +95,7 @@ async def test_setup_name_config(hass):
)
async def test_switch(hass, caplog):
async def test_switch(hass: HomeAssistant, caplog):
"""Test Pi-hole switch."""
mocked_hole = _create_mocked_hole()
with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole):
@ -154,7 +143,7 @@ async def test_switch(hass, caplog):
assert errors[-1].message == "Unable to disable Pi-hole: Error2"
async def test_disable_service_call(hass):
async def test_disable_service_call(hass: HomeAssistant):
"""Test disable service call with no Pi-hole named."""
mocked_hole = _create_mocked_hole()
with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole):
@ -180,21 +169,14 @@ async def test_disable_service_call(hass):
await hass.async_block_till_done()
mocked_hole.disable.assert_called_once_with(1)
mocked_hole.disable.assert_called_with(1)
async def test_unload(hass):
async def test_unload(hass: HomeAssistant):
"""Test unload entities."""
entry = MockConfigEntry(
domain=pi_hole.DOMAIN,
data={
CONF_NAME: DEFAULT_NAME,
CONF_HOST: "pi.hole",
CONF_LOCATION: DEFAULT_LOCATION,
CONF_SSL: DEFAULT_SSL,
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
CONF_STATISTICS_ONLY: True,
},
data={**CONFIG_DATA_DEFAULTS, CONF_HOST: "pi.hole"},
)
entry.add_to_hass(hass)
mocked_hole = _create_mocked_hole()
@ -208,32 +190,25 @@ async def test_unload(hass):
assert entry.entry_id not in hass.data[pi_hole.DOMAIN]
async def test_migrate(hass):
"""Test migrate from old config entry."""
entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=CONF_DATA)
entry.add_to_hass(hass)
async def test_remove_obsolete(hass: HomeAssistant):
"""Test removing obsolete config entry parameters."""
mocked_hole = _create_mocked_hole()
with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.data == CONF_CONFIG_ENTRY
async def test_migrate_statistics_only(hass):
"""Test migrate from old config entry with statistics only."""
conf_data = {**CONF_DATA}
conf_data[CONF_API_KEY] = ""
entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=conf_data)
entry = MockConfigEntry(
domain=pi_hole.DOMAIN, data={**CONFIG_DATA_DEFAULTS, CONF_STATISTICS_ONLY: True}
)
entry.add_to_hass(hass)
with _patch_init_hole(mocked_hole):
assert await hass.config_entries.async_setup(entry.entry_id)
assert CONF_STATISTICS_ONLY not in entry.data
async def test_missing_api_key(hass: HomeAssistant):
"""Tests start reauth flow if api key is missing."""
mocked_hole = _create_mocked_hole()
with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
config_entry_data = {**CONF_CONFIG_ENTRY}
config_entry_data[CONF_STATISTICS_ONLY] = True
config_entry_data[CONF_API_KEY] = ""
assert entry.data == config_entry_data
data = CONFIG_DATA_DEFAULTS.copy()
data.pop(CONF_API_KEY)
entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=data)
entry.add_to_hass(hass)
with _patch_init_hole(mocked_hole):
assert not await hass.config_entries.async_setup(entry.entry_id)
assert entry.state == ConfigEntryState.SETUP_ERROR

View File

@ -24,7 +24,7 @@ TEST_NVR_NAME = "test_reolink_name"
TEST_USE_HTTPS = True
def get_mock_info(error=None, host_data_return=True):
def get_mock_info(error=None, host_data_return=True, user_level="admin"):
"""Return a mock gateway info instance."""
host_mock = Mock()
if error is None:
@ -40,6 +40,8 @@ def get_mock_info(error=None, host_data_return=True):
host_mock.nvr_name = TEST_NVR_NAME
host_mock.port = TEST_PORT
host_mock.use_https = TEST_USE_HTTPS
host_mock.is_admin = user_level == "admin"
host_mock.user_level = user_level
return host_mock
@ -110,7 +112,22 @@ async def test_config_flow_errors(hass):
assert result["type"] is data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"host": "cannot_connect"}
assert result["errors"] == {CONF_HOST: "cannot_connect"}
host_mock = get_mock_info(user_level="guest")
with patch("homeassistant.components.reolink.host.Host", return_value=host_mock):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
CONF_HOST: TEST_HOST,
},
)
assert result["type"] is data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {CONF_USERNAME: "not_admin"}
host_mock = get_mock_info(error=json.JSONDecodeError("test_error", "test", 1))
with patch("homeassistant.components.reolink.host.Host", return_value=host_mock):
@ -125,7 +142,7 @@ async def test_config_flow_errors(hass):
assert result["type"] is data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"host": "unknown"}
assert result["errors"] == {CONF_HOST: "unknown"}
host_mock = get_mock_info(error=CredentialsInvalidError("Test error"))
with patch("homeassistant.components.reolink.host.Host", return_value=host_mock):
@ -140,7 +157,7 @@ async def test_config_flow_errors(hass):
assert result["type"] is data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"host": "invalid_auth"}
assert result["errors"] == {CONF_HOST: "invalid_auth"}
host_mock = get_mock_info(error=ApiError("Test error"))
with patch("homeassistant.components.reolink.host.Host", return_value=host_mock):
@ -155,7 +172,7 @@ async def test_config_flow_errors(hass):
assert result["type"] is data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"host": "api_error"}
assert result["errors"] == {CONF_HOST: "api_error"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@ -261,3 +278,64 @@ async def test_change_connection_settings(hass):
assert config_entry.data[CONF_HOST] == TEST_HOST2
assert config_entry.data[CONF_USERNAME] == TEST_USERNAME2
assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD2
async def test_reauth(hass):
"""Test a reauth flow."""
config_entry = MockConfigEntry(
domain=const.DOMAIN,
unique_id=format_mac(TEST_MAC),
data={
CONF_HOST: TEST_HOST,
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
CONF_PORT: TEST_PORT,
const.CONF_USE_HTTPS: TEST_USE_HTTPS,
},
options={
const.CONF_PROTOCOL: const.DEFAULT_PROTOCOL,
},
title=TEST_NVR_NAME,
)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_init(
const.DOMAIN,
context={
"source": config_entries.SOURCE_REAUTH,
"entry_id": config_entry.entry_id,
"title_placeholders": {"name": TEST_NVR_NAME},
"unique_id": format_mac(TEST_MAC),
},
data=config_entry.data,
)
assert result["type"] == "form"
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
assert result["type"] == "form"
assert result["step_id"] == "user"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: TEST_HOST2,
CONF_USERNAME: TEST_USERNAME2,
CONF_PASSWORD: TEST_PASSWORD2,
},
)
assert result["type"] == "abort"
assert result["reason"] == "reauth_successful"
assert config_entry.data[CONF_HOST] == TEST_HOST2
assert config_entry.data[CONF_USERNAME] == TEST_USERNAME2
assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD2

View File

@ -706,6 +706,30 @@ async def test_zeroconf_already_configured(hass):
assert entry.data["host"] == "1.1.1.1"
async def test_zeroconf_ignored(hass):
"""Test zeroconf when the device was previously ignored."""
entry = MockConfigEntry(
domain="shelly",
unique_id="test-mac",
data={},
source=config_entries.SOURCE_IGNORE,
)
entry.add_to_hass(hass)
with patch(
"aioshelly.common.get_info",
return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False},
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
data=DISCOVERY_INFO,
context={"source": config_entries.SOURCE_ZEROCONF},
)
assert result["type"] == data_entry_flow.FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_zeroconf_with_wifi_ap_ip(hass):
"""Test we ignore the Wi-FI AP IP."""

View File

@ -936,3 +936,59 @@ async def test_web_socket_triggers_discovery_request_callbacks(hass, hass_ws_cli
assert response["success"]
await hass.async_block_till_done()
assert len(mock_callback.mock_calls) == 1
async def test_initial_scan_callback(hass, hass_ws_client):
"""Test it's possible to register a callback when the initial scan is done."""
mock_callback_1 = Mock()
mock_callback_2 = Mock()
with patch("pyudev.Context", side_effect=ImportError), patch(
"homeassistant.components.usb.async_get_usb", return_value=[]
), patch("homeassistant.components.usb.comports", return_value=[]), patch.object(
hass.config_entries.flow, "async_init"
):
assert await async_setup_component(hass, "usb", {"usb": {}})
cancel_1 = usb.async_register_initial_scan_callback(hass, mock_callback_1)
assert len(mock_callback_1.mock_calls) == 0
await hass.async_block_till_done()
assert len(mock_callback_1.mock_calls) == 0
# This triggers the initial scan
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
assert len(mock_callback_1.mock_calls) == 1
# A callback registered now should be called immediately. The old callback
# should not be called again
cancel_2 = usb.async_register_initial_scan_callback(hass, mock_callback_2)
assert len(mock_callback_1.mock_calls) == 1
assert len(mock_callback_2.mock_calls) == 1
# Calling the cancels should be allowed even if the callback has been called
cancel_1()
cancel_2()
async def test_cancel_initial_scan_callback(hass, hass_ws_client):
"""Test it's possible to cancel an initial scan callback."""
mock_callback = Mock()
with patch("pyudev.Context", side_effect=ImportError), patch(
"homeassistant.components.usb.async_get_usb", return_value=[]
), patch("homeassistant.components.usb.comports", return_value=[]), patch.object(
hass.config_entries.flow, "async_init"
):
assert await async_setup_component(hass, "usb", {"usb": {}})
cancel = usb.async_register_initial_scan_callback(hass, mock_callback)
assert len(mock_callback.mock_calls) == 0
await hass.async_block_till_done()
assert len(mock_callback.mock_calls) == 0
cancel()
# This triggers the initial scan
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
assert len(mock_callback.mock_calls) == 0

View File

@ -1,6 +1,7 @@
"""The tests for the LG webOS media player platform."""
import asyncio
from datetime import timedelta
from http import HTTPStatus
from unittest.mock import Mock
import pytest
@ -697,3 +698,68 @@ async def test_supported_features_ignore_cache(hass, client):
attrs = hass.states.get(ENTITY_ID).attributes
assert attrs[ATTR_SUPPORTED_FEATURES] == supported
async def test_get_image_http(
hass, client, hass_client_no_auth, aioclient_mock, monkeypatch
):
"""Test get image via http."""
url = "http://something/valid_icon"
monkeypatch.setitem(client.apps[LIVE_TV_APP_ID], "icon", url)
await setup_webostv(hass)
await client.mock_state_update()
attrs = hass.states.get(ENTITY_ID).attributes
assert "entity_picture_local" not in attrs
aioclient_mock.get(url, text="image")
client = await hass_client_no_auth()
resp = await client.get(attrs["entity_picture"])
content = await resp.read()
assert content == b"image"
async def test_get_image_http_error(
hass, client, hass_client_no_auth, aioclient_mock, caplog, monkeypatch
):
"""Test get image via http error."""
url = "http://something/icon_error"
monkeypatch.setitem(client.apps[LIVE_TV_APP_ID], "icon", url)
await setup_webostv(hass)
await client.mock_state_update()
attrs = hass.states.get(ENTITY_ID).attributes
assert "entity_picture_local" not in attrs
aioclient_mock.get(url, exc=asyncio.TimeoutError())
client = await hass_client_no_auth()
resp = await client.get(attrs["entity_picture"])
content = await resp.read()
assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR
assert f"Error retrieving proxied image from {url}" in caplog.text
assert content == b""
async def test_get_image_https(
hass, client, hass_client_no_auth, aioclient_mock, monkeypatch
):
"""Test get image via http."""
url = "https://something/valid_icon_https"
monkeypatch.setitem(client.apps[LIVE_TV_APP_ID], "icon", url)
await setup_webostv(hass)
await client.mock_state_update()
attrs = hass.states.get(ENTITY_ID).attributes
assert "entity_picture_local" not in attrs
aioclient_mock.get(url, text="https_image")
client = await hass_client_no_auth()
resp = await client.get(attrs["entity_picture"])
content = await resp.read()
assert content == b"https_image"

View File

@ -134,8 +134,9 @@ async def test_abort_if_authorization_timeout(
flow = flow_handler()
flow.hass = hass
with patch.object(
local_impl, "async_generate_authorize_url", side_effect=asyncio.TimeoutError
with patch(
"homeassistant.helpers.config_entry_oauth2_flow.async_timeout.timeout",
side_effect=asyncio.TimeoutError,
):
result = await flow.async_step_user()
@ -278,6 +279,62 @@ async def test_abort_if_oauth_rejected(
assert result["description_placeholders"] == {"error": "access_denied"}
async def test_abort_on_oauth_timeout_error(
hass,
flow_handler,
local_impl,
hass_client_no_auth,
aioclient_mock,
current_request_with_host,
):
"""Check timeout during oauth token exchange."""
flow_handler.async_register_implementation(hass, local_impl)
config_entry_oauth2_flow.async_register_implementation(
hass, TEST_DOMAIN, MockOAuth2Implementation()
)
result = await hass.config_entries.flow.async_init(
TEST_DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "pick_implementation"
# Pick implementation
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"implementation": TEST_DOMAIN}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP
assert result["url"] == (
f"{AUTHORIZE_URL}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}&scope=read+write"
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
with patch(
"homeassistant.helpers.config_entry_oauth2_flow.async_timeout.timeout",
side_effect=asyncio.TimeoutError,
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == data_entry_flow.FlowResultType.ABORT
assert result["reason"] == "oauth2_timeout"
async def test_step_discovery(hass, flow_handler, local_impl):
"""Check flow triggers from discovery."""
flow_handler.async_register_implementation(hass, local_impl)