Supplementary fixes to new motionEye integration (#49626)

This commit is contained in:
Dermot Duffy 2021-04-25 06:27:46 -07:00 committed by GitHub
parent 914451d99c
commit 3077363f44
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 162 additions and 112 deletions

View File

@ -13,10 +13,10 @@ from motioneye_client.client import (
from motioneye_client.const import KEY_CAMERAS, KEY_ID, KEY_NAME from motioneye_client.const import KEY_CAMERAS, KEY_ID, KEY_NAME
from homeassistant.components.camera.const import DOMAIN as CAMERA_DOMAIN from homeassistant.components.camera.const import DOMAIN as CAMERA_DOMAIN
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_SOURCE, CONF_URL from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.dispatcher import (
async_dispatcher_connect, async_dispatcher_connect,
@ -28,7 +28,6 @@ from .const import (
CONF_ADMIN_PASSWORD, CONF_ADMIN_PASSWORD,
CONF_ADMIN_USERNAME, CONF_ADMIN_USERNAME,
CONF_CLIENT, CONF_CLIENT,
CONF_CONFIG_ENTRY,
CONF_COORDINATOR, CONF_COORDINATOR,
CONF_SURVEILLANCE_PASSWORD, CONF_SURVEILLANCE_PASSWORD,
CONF_SURVEILLANCE_USERNAME, CONF_SURVEILLANCE_USERNAME,
@ -98,22 +97,6 @@ def listen_for_new_cameras(
) )
async def _create_reauth_flow(
hass: HomeAssistant,
config_entry: ConfigEntry,
) -> None:
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={
CONF_SOURCE: SOURCE_REAUTH,
CONF_CONFIG_ENTRY: config_entry,
},
data=config_entry.data,
)
)
@callback @callback
def _add_camera( def _add_camera(
hass: HomeAssistant, hass: HomeAssistant,
@ -155,10 +138,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
try: try:
await client.async_client_login() await client.async_client_login()
except MotionEyeClientInvalidAuthError: except MotionEyeClientInvalidAuthError as exc:
await client.async_client_close() await client.async_client_close()
await _create_reauth_flow(hass, entry) raise ConfigEntryAuthFailed from exc
return False
except MotionEyeClientError as exc: except MotionEyeClientError as exc:
await client.async_client_close() await client.async_client_close()
raise ConfigEntryNotReady from exc raise ConfigEntryNotReady from exc

View File

@ -59,7 +59,7 @@ PLATFORMS = ["camera"]
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable
) -> bool: ) -> None:
"""Set up motionEye from a config entry.""" """Set up motionEye from a config entry."""
entry_data = hass.data[DOMAIN][entry.entry_id] entry_data = hass.data[DOMAIN][entry.entry_id]
@ -82,7 +82,6 @@ async def async_setup_entry(
) )
listen_for_new_cameras(hass, entry, camera_add) listen_for_new_cameras(hass, entry, camera_add)
return True
class MotionEyeMjpegCamera(MjpegCamera, CoordinatorEntity): class MotionEyeMjpegCamera(MjpegCamera, CoordinatorEntity):
@ -96,7 +95,7 @@ class MotionEyeMjpegCamera(MjpegCamera, CoordinatorEntity):
camera: dict[str, Any], camera: dict[str, Any],
client: MotionEyeClient, client: MotionEyeClient,
coordinator: DataUpdateCoordinator, coordinator: DataUpdateCoordinator,
): ) -> None:
"""Initialize a MJPEG camera.""" """Initialize a MJPEG camera."""
self._surveillance_username = username self._surveillance_username = username
self._surveillance_password = password self._surveillance_password = password
@ -109,7 +108,7 @@ class MotionEyeMjpegCamera(MjpegCamera, CoordinatorEntity):
config_entry_id, self._camera_id, TYPE_MOTIONEYE_MJPEG_CAMERA config_entry_id, self._camera_id, TYPE_MOTIONEYE_MJPEG_CAMERA
) )
self._motion_detection_enabled: bool = camera.get(KEY_MOTION_DETECTION, False) self._motion_detection_enabled: bool = camera.get(KEY_MOTION_DETECTION, False)
self._available = MotionEyeMjpegCamera._is_acceptable_streaming_camera(camera) self._available = self._is_acceptable_streaming_camera(camera)
# motionEye cameras are always streaming or unavailable. # motionEye cameras are always streaming or unavailable.
self.is_streaming = True self.is_streaming = True
@ -184,7 +183,7 @@ class MotionEyeMjpegCamera(MjpegCamera, CoordinatorEntity):
available = False available = False
if self.coordinator.last_update_success: if self.coordinator.last_update_success:
camera = get_camera_from_cameras(self._camera_id, self.coordinator.data) camera = get_camera_from_cameras(self._camera_id, self.coordinator.data)
if MotionEyeMjpegCamera._is_acceptable_streaming_camera(camera): if self._is_acceptable_streaming_camera(camera):
assert camera assert camera
self._set_mjpeg_camera_state_for_camera(camera) self._set_mjpeg_camera_state_for_camera(camera)
self._motion_detection_enabled = camera.get(KEY_MOTION_DETECTION, False) self._motion_detection_enabled = camera.get(KEY_MOTION_DETECTION, False)

View File

@ -24,7 +24,6 @@ from . import create_motioneye_client
from .const import ( from .const import (
CONF_ADMIN_PASSWORD, CONF_ADMIN_PASSWORD,
CONF_ADMIN_USERNAME, CONF_ADMIN_USERNAME,
CONF_CONFIG_ENTRY,
CONF_SURVEILLANCE_PASSWORD, CONF_SURVEILLANCE_PASSWORD,
CONF_SURVEILLANCE_USERNAME, CONF_SURVEILLANCE_USERNAME,
DOMAIN, DOMAIN,
@ -43,81 +42,96 @@ class MotionEyeConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: ConfigType | None = None self, user_input: ConfigType | None = None
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Handle the initial step.""" """Handle the initial step."""
out: dict[str, Any] = {}
errors = {} def _get_form(
user_input: ConfigType, errors: dict[str, str] | None = None
) -> dict[str, Any]:
"""Show the form to the user."""
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(
CONF_URL, default=user_input.get(CONF_URL, "")
): str,
vol.Optional(
CONF_ADMIN_USERNAME,
default=user_input.get(CONF_ADMIN_USERNAME),
): str,
vol.Optional(
CONF_ADMIN_PASSWORD,
default=user_input.get(CONF_ADMIN_PASSWORD),
): str,
vol.Optional(
CONF_SURVEILLANCE_USERNAME,
default=user_input.get(CONF_SURVEILLANCE_USERNAME),
): str,
vol.Optional(
CONF_SURVEILLANCE_PASSWORD,
default=user_input.get(CONF_SURVEILLANCE_PASSWORD),
): str,
}
),
errors=errors,
)
reauth_entry = None
if self.context.get("entry_id"):
reauth_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
if user_input is None: if user_input is None:
entry = self.context.get(CONF_CONFIG_ENTRY) return _get_form(reauth_entry.data if reauth_entry else {})
user_input = entry.data if entry else {}
else:
try:
# Cannot use cv.url validation in the schema itself, so
# apply extra validation here.
cv.url(user_input[CONF_URL])
except vol.Invalid:
errors["base"] = "invalid_url"
else:
client = create_motioneye_client(
user_input[CONF_URL],
admin_username=user_input.get(CONF_ADMIN_USERNAME),
admin_password=user_input.get(CONF_ADMIN_PASSWORD),
surveillance_username=user_input.get(CONF_SURVEILLANCE_USERNAME),
surveillance_password=user_input.get(CONF_SURVEILLANCE_PASSWORD),
)
try: try:
await client.async_client_login() # Cannot use cv.url validation in the schema itself, so
except MotionEyeClientConnectionError: # apply extra validation here.
errors["base"] = "cannot_connect" cv.url(user_input[CONF_URL])
except MotionEyeClientInvalidAuthError: except vol.Invalid:
errors["base"] = "invalid_auth" return _get_form(user_input, {"base": "invalid_url"})
except MotionEyeClientRequestError:
errors["base"] = "unknown"
else:
entry = self.context.get(CONF_CONFIG_ENTRY)
if (
self.context.get(CONF_SOURCE) == SOURCE_REAUTH
and entry is not None
):
self.hass.config_entries.async_update_entry(
entry, data=user_input
)
# Need to manually reload, as the listener won't have been
# installed because the initial load did not succeed (the reauth
# flow will not be initiated if the load succeeds).
await self.hass.config_entries.async_reload(entry.entry_id)
out = self.async_abort(reason="reauth_successful")
return out
out = self.async_create_entry( client = create_motioneye_client(
title=f"{user_input[CONF_URL]}", user_input[CONF_URL],
data=user_input, admin_username=user_input.get(CONF_ADMIN_USERNAME),
) admin_password=user_input.get(CONF_ADMIN_PASSWORD),
return out surveillance_username=user_input.get(CONF_SURVEILLANCE_USERNAME),
surveillance_password=user_input.get(CONF_SURVEILLANCE_PASSWORD),
out = self.async_show_form( )
step_id="user",
data_schema=vol.Schema( errors = {}
{ try:
vol.Required(CONF_URL, default=user_input.get(CONF_URL, "")): str, await client.async_client_login()
vol.Optional( except MotionEyeClientConnectionError:
CONF_ADMIN_USERNAME, default=user_input.get(CONF_ADMIN_USERNAME) errors["base"] = "cannot_connect"
): str, except MotionEyeClientInvalidAuthError:
vol.Optional( errors["base"] = "invalid_auth"
CONF_ADMIN_PASSWORD, default=user_input.get(CONF_ADMIN_PASSWORD) except MotionEyeClientRequestError:
): str, errors["base"] = "unknown"
vol.Optional( finally:
CONF_SURVEILLANCE_USERNAME, await client.async_client_close()
default=user_input.get(CONF_SURVEILLANCE_USERNAME),
): str, if errors:
vol.Optional( return _get_form(user_input, errors)
CONF_SURVEILLANCE_PASSWORD,
default=user_input.get(CONF_SURVEILLANCE_PASSWORD), if self.context.get(CONF_SOURCE) == SOURCE_REAUTH and reauth_entry is not None:
): str, self.hass.config_entries.async_update_entry(reauth_entry, data=user_input)
} # Need to manually reload, as the listener won't have been
), # installed because the initial load did not succeed (the reauth
errors=errors, # flow will not be initiated if the load succeeds).
await self.hass.config_entries.async_reload(reauth_entry.entry_id)
return self.async_abort(reason="reauth_successful")
# Search for duplicates: there isn't a useful unique_id, but
# at least prevent entries with the same motionEye URL.
for existing_entry in self._async_current_entries(include_ignore=False):
if existing_entry.data.get(CONF_URL) == user_input[CONF_URL]:
return self.async_abort(reason="already_configured")
return self.async_create_entry(
title=f"{user_input[CONF_URL]}",
data=user_input,
) )
return out
async def async_step_reauth( async def async_step_reauth(
self, self,

View File

@ -3,7 +3,6 @@ from datetime import timedelta
DOMAIN = "motioneye" DOMAIN = "motioneye"
CONF_CONFIG_ENTRY = "config_entry"
CONF_CLIENT = "client" CONF_CLIENT = "client"
CONF_COORDINATOR = "coordinator" CONF_COORDINATOR = "coordinator"
CONF_ADMIN_PASSWORD = "admin_password" CONF_ADMIN_PASSWORD = "admin_password"

View File

@ -12,7 +12,6 @@ from homeassistant import config_entries, data_entry_flow, setup
from homeassistant.components.motioneye.const import ( from homeassistant.components.motioneye.const import (
CONF_ADMIN_PASSWORD, CONF_ADMIN_PASSWORD,
CONF_ADMIN_USERNAME, CONF_ADMIN_USERNAME,
CONF_CONFIG_ENTRY,
CONF_SURVEILLANCE_PASSWORD, CONF_SURVEILLANCE_PASSWORD,
CONF_SURVEILLANCE_USERNAME, CONF_SURVEILLANCE_USERNAME,
DOMAIN, DOMAIN,
@ -22,6 +21,8 @@ from homeassistant.core import HomeAssistant
from . import TEST_URL, create_mock_motioneye_client, create_mock_motioneye_config_entry from . import TEST_URL, create_mock_motioneye_client, create_mock_motioneye_config_entry
from tests.common import MockConfigEntry
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -32,7 +33,7 @@ async def test_user_success(hass: HomeAssistant) -> None:
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
) )
assert result["type"] == "form" assert result["type"] == "form"
assert result["errors"] == {} assert not result["errors"]
mock_client = create_mock_motioneye_client() mock_client = create_mock_motioneye_client()
@ -65,6 +66,7 @@ async def test_user_success(hass: HomeAssistant) -> None:
CONF_SURVEILLANCE_PASSWORD: "surveillance-password", CONF_SURVEILLANCE_PASSWORD: "surveillance-password",
} }
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
assert mock_client.async_client_close.called
async def test_user_invalid_auth(hass: HomeAssistant) -> None: async def test_user_invalid_auth(hass: HomeAssistant) -> None:
@ -92,10 +94,11 @@ async def test_user_invalid_auth(hass: HomeAssistant) -> None:
CONF_SURVEILLANCE_PASSWORD: "surveillance-password", CONF_SURVEILLANCE_PASSWORD: "surveillance-password",
}, },
) )
await mock_client.async_client_close() await hass.async_block_till_done()
assert result["type"] == "form" assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_auth"} assert result["errors"] == {"base": "invalid_auth"}
assert mock_client.async_client_close.called
async def test_user_invalid_url(hass: HomeAssistant) -> None: async def test_user_invalid_url(hass: HomeAssistant) -> None:
@ -105,9 +108,10 @@ async def test_user_invalid_url(hass: HomeAssistant) -> None:
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
) )
mock_client = create_mock_motioneye_client()
with patch( with patch(
"homeassistant.components.motioneye.MotionEyeClient", "homeassistant.components.motioneye.MotionEyeClient",
return_value=create_mock_motioneye_client(), return_value=mock_client,
): ):
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
@ -119,6 +123,7 @@ async def test_user_invalid_url(hass: HomeAssistant) -> None:
CONF_SURVEILLANCE_PASSWORD: "surveillance-password", CONF_SURVEILLANCE_PASSWORD: "surveillance-password",
}, },
) )
await hass.async_block_till_done()
assert result["type"] == "form" assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_url"} assert result["errors"] == {"base": "invalid_url"}
@ -149,10 +154,11 @@ async def test_user_cannot_connect(hass: HomeAssistant) -> None:
CONF_SURVEILLANCE_PASSWORD: "surveillance-password", CONF_SURVEILLANCE_PASSWORD: "surveillance-password",
}, },
) )
await mock_client.async_client_close() await hass.async_block_till_done()
assert result["type"] == "form" assert result["type"] == "form"
assert result["errors"] == {"base": "cannot_connect"} assert result["errors"] == {"base": "cannot_connect"}
assert mock_client.async_client_close.called
async def test_user_request_error(hass: HomeAssistant) -> None: async def test_user_request_error(hass: HomeAssistant) -> None:
@ -178,10 +184,11 @@ async def test_user_request_error(hass: HomeAssistant) -> None:
CONF_SURVEILLANCE_PASSWORD: "surveillance-password", CONF_SURVEILLANCE_PASSWORD: "surveillance-password",
}, },
) )
await mock_client.async_client_close() await hass.async_block_till_done()
assert result["type"] == "form" assert result["type"] == "form"
assert result["errors"] == {"base": "unknown"} assert result["errors"] == {"base": "unknown"}
assert mock_client.async_client_close.called
async def test_reauth(hass: HomeAssistant) -> None: async def test_reauth(hass: HomeAssistant) -> None:
@ -197,11 +204,11 @@ async def test_reauth(hass: HomeAssistant) -> None:
DOMAIN, DOMAIN,
context={ context={
"source": config_entries.SOURCE_REAUTH, "source": config_entries.SOURCE_REAUTH,
CONF_CONFIG_ENTRY: config_entry, "entry_id": config_entry.entry_id,
}, },
) )
assert result["type"] == "form" assert result["type"] == "form"
assert result["errors"] == {} assert not result["errors"]
mock_client = create_mock_motioneye_client() mock_client = create_mock_motioneye_client()
@ -226,8 +233,57 @@ async def test_reauth(hass: HomeAssistant) -> None:
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "reauth_successful" assert result["reason"] == "reauth_successful"
assert config_entry.data == new_data assert config_entry.data == new_data
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
assert mock_client.async_client_close.called
async def test_duplicate(hass: HomeAssistant) -> None:
"""Test that a duplicate entry (same URL) is rejected."""
config_data = {
CONF_URL: TEST_URL,
}
# Add an existing entry with the same URL.
existing_entry: MockConfigEntry = MockConfigEntry( # type: ignore[no-untyped-call]
domain=DOMAIN,
data=config_data,
)
existing_entry.add_to_hass(hass) # type: ignore[no-untyped-call]
# Now do the usual config entry process, and verify it is rejected.
create_mock_motioneye_config_entry(hass, data=config_data)
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert not result["errors"]
mock_client = create_mock_motioneye_client()
new_data = {
CONF_URL: TEST_URL,
CONF_ADMIN_USERNAME: "admin-username",
CONF_ADMIN_PASSWORD: "admin-password",
CONF_SURVEILLANCE_USERNAME: "surveillance-username",
CONF_SURVEILLANCE_PASSWORD: "surveillance-password",
}
with patch(
"homeassistant.components.motioneye.MotionEyeClient",
return_value=mock_client,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
new_data,
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
assert mock_client.async_client_close.called