Bump pydroid-ipcam to 2.0.0 (#76906)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Rami Mosleh 2022-08-19 12:57:30 +03:00 committed by GitHub
parent 324f5555ed
commit 63dcd8ec08
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 91 additions and 37 deletions

View file

@ -57,4 +57,4 @@ class IPWebcamBinarySensor(AndroidIPCamBaseEntity, BinarySensorEntity):
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return if motion is detected.""" """Return if motion is detected."""
return self.cam.export_sensor(MOTION_ACTIVE)[0] == 1.0 return self.cam.get_sensor_value(MOTION_ACTIVE) == 1.0

View file

@ -4,6 +4,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from pydroid_ipcam import PyDroidIPCam from pydroid_ipcam import PyDroidIPCam
from pydroid_ipcam.exceptions import PyDroidIPCamException, Unauthorized
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
@ -33,7 +34,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
) )
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool: async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]:
"""Validate the user input allows us to connect.""" """Validate the user input allows us to connect."""
websession = async_get_clientsession(hass) websession = async_get_clientsession(hass)
@ -45,8 +46,16 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool:
password=data.get(CONF_PASSWORD), password=data.get(CONF_PASSWORD),
ssl=False, ssl=False,
) )
await cam.update() errors = {}
return cam.available try:
await cam.update()
except Unauthorized:
errors[CONF_USERNAME] = "invalid_auth"
errors[CONF_PASSWORD] = "invalid_auth"
except PyDroidIPCamException:
errors["base"] = "cannot_connect"
return errors
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
@ -68,13 +77,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
) )
# to be removed when YAML import is removed # to be removed when YAML import is removed
title = user_input.get(CONF_NAME) or user_input[CONF_HOST] title = user_input.get(CONF_NAME) or user_input[CONF_HOST]
if await validate_input(self.hass, user_input): if not (errors := await validate_input(self.hass, user_input)):
return self.async_create_entry(title=title, data=user_input) return self.async_create_entry(title=title, data=user_input)
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user",
data_schema=STEP_USER_DATA_SCHEMA, data_schema=STEP_USER_DATA_SCHEMA,
errors={"base": "cannot_connect"}, errors=errors,
) )
async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult:

View file

@ -4,6 +4,7 @@ from datetime import timedelta
import logging import logging
from pydroid_ipcam import PyDroidIPCam from pydroid_ipcam import PyDroidIPCam
from pydroid_ipcam.exceptions import PyDroidIPCamException
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST from homeassistant.const import CONF_HOST
@ -37,6 +38,7 @@ class AndroidIPCamDataUpdateCoordinator(DataUpdateCoordinator[None]):
async def _async_update_data(self) -> None: async def _async_update_data(self) -> None:
"""Update Android IP Webcam entities.""" """Update Android IP Webcam entities."""
await self.cam.update() try:
if not self.cam.available: await self.cam.update()
raise UpdateFailed except PyDroidIPCamException as err:
raise UpdateFailed(err) from err

View file

@ -4,7 +4,7 @@
"config_flow": true, "config_flow": true,
"dependencies": ["repairs"], "dependencies": ["repairs"],
"documentation": "https://www.home-assistant.io/integrations/android_ip_webcam", "documentation": "https://www.home-assistant.io/integrations/android_ip_webcam",
"requirements": ["pydroid-ipcam==1.3.1"], "requirements": ["pydroid-ipcam==2.0.0"],
"codeowners": ["@engrbm87"], "codeowners": ["@engrbm87"],
"iot_class": "local_polling" "iot_class": "local_polling"
} }

View file

@ -54,8 +54,8 @@ SENSOR_TYPES: tuple[AndroidIPWebcamSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.BATTERY, device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda ipcam: ipcam.export_sensor("battery_level")[0], value_fn=lambda ipcam: ipcam.get_sensor_value("battery_level"),
unit_fn=lambda ipcam: ipcam.export_sensor("battery_level")[1], unit_fn=lambda ipcam: ipcam.get_sensor_unit("battery_level"),
), ),
AndroidIPWebcamSensorEntityDescription( AndroidIPWebcamSensorEntityDescription(
key="battery_temp", key="battery_temp",
@ -63,56 +63,56 @@ SENSOR_TYPES: tuple[AndroidIPWebcamSensorEntityDescription, ...] = (
icon="mdi:thermometer", icon="mdi:thermometer",
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda ipcam: ipcam.export_sensor("battery_temp")[0], value_fn=lambda ipcam: ipcam.get_sensor_value("battery_temp"),
unit_fn=lambda ipcam: ipcam.export_sensor("battery_temp")[1], unit_fn=lambda ipcam: ipcam.get_sensor_unit("battery_temp"),
), ),
AndroidIPWebcamSensorEntityDescription( AndroidIPWebcamSensorEntityDescription(
key="battery_voltage", key="battery_voltage",
name="Battery voltage", name="Battery voltage",
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda ipcam: ipcam.export_sensor("battery_voltage")[0], value_fn=lambda ipcam: ipcam.get_sensor_value("battery_voltage"),
unit_fn=lambda ipcam: ipcam.export_sensor("battery_voltage")[1], unit_fn=lambda ipcam: ipcam.get_sensor_unit("battery_voltage"),
), ),
AndroidIPWebcamSensorEntityDescription( AndroidIPWebcamSensorEntityDescription(
key="light", key="light",
name="Light level", name="Light level",
icon="mdi:flashlight", icon="mdi:flashlight",
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda ipcam: ipcam.export_sensor("light")[0], value_fn=lambda ipcam: ipcam.get_sensor_value("light"),
unit_fn=lambda ipcam: ipcam.export_sensor("light")[1], unit_fn=lambda ipcam: ipcam.get_sensor_unit("light"),
), ),
AndroidIPWebcamSensorEntityDescription( AndroidIPWebcamSensorEntityDescription(
key="motion", key="motion",
name="Motion", name="Motion",
icon="mdi:run", icon="mdi:run",
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda ipcam: ipcam.export_sensor("motion")[0], value_fn=lambda ipcam: ipcam.get_sensor_value("motion"),
unit_fn=lambda ipcam: ipcam.export_sensor("motion")[1], unit_fn=lambda ipcam: ipcam.get_sensor_unit("motion"),
), ),
AndroidIPWebcamSensorEntityDescription( AndroidIPWebcamSensorEntityDescription(
key="pressure", key="pressure",
name="Pressure", name="Pressure",
icon="mdi:gauge", icon="mdi:gauge",
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda ipcam: ipcam.export_sensor("pressure")[0], value_fn=lambda ipcam: ipcam.get_sensor_value("pressure"),
unit_fn=lambda ipcam: ipcam.export_sensor("pressure")[1], unit_fn=lambda ipcam: ipcam.get_sensor_unit("pressure"),
), ),
AndroidIPWebcamSensorEntityDescription( AndroidIPWebcamSensorEntityDescription(
key="proximity", key="proximity",
name="Proximity", name="Proximity",
icon="mdi:map-marker-radius", icon="mdi:map-marker-radius",
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda ipcam: ipcam.export_sensor("proximity")[0], value_fn=lambda ipcam: ipcam.get_sensor_value("proximity"),
unit_fn=lambda ipcam: ipcam.export_sensor("proximity")[1], unit_fn=lambda ipcam: ipcam.get_sensor_unit("proximity"),
), ),
AndroidIPWebcamSensorEntityDescription( AndroidIPWebcamSensorEntityDescription(
key="sound", key="sound",
name="Sound", name="Sound",
icon="mdi:speaker", icon="mdi:speaker",
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda ipcam: ipcam.export_sensor("sound")[0], value_fn=lambda ipcam: ipcam.get_sensor_value("sound"),
unit_fn=lambda ipcam: ipcam.export_sensor("sound")[1], unit_fn=lambda ipcam: ipcam.get_sensor_unit("sound"),
), ),
AndroidIPWebcamSensorEntityDescription( AndroidIPWebcamSensorEntityDescription(
key="video_connections", key="video_connections",

View file

@ -11,7 +11,8 @@
} }
}, },
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]" "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"

View file

@ -55,8 +55,8 @@ SWITCH_TYPES: tuple[AndroidIPWebcamSwitchEntityDescription, ...] = (
name="Focus", name="Focus",
icon="mdi:image-filter-center-focus", icon="mdi:image-filter-center-focus",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
on_func=lambda ipcam: ipcam.torch(activate=True), on_func=lambda ipcam: ipcam.focus(activate=True),
off_func=lambda ipcam: ipcam.torch(activate=False), off_func=lambda ipcam: ipcam.focus(activate=False),
), ),
AndroidIPWebcamSwitchEntityDescription( AndroidIPWebcamSwitchEntityDescription(
key="gps_active", key="gps_active",
@ -111,8 +111,8 @@ SWITCH_TYPES: tuple[AndroidIPWebcamSwitchEntityDescription, ...] = (
name="Video recording", name="Video recording",
icon="mdi:record-rec", icon="mdi:record-rec",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
on_func=lambda ipcam: ipcam.record(activate=True), on_func=lambda ipcam: ipcam.record(record=True),
off_func=lambda ipcam: ipcam.record(activate=False), off_func=lambda ipcam: ipcam.record(record=False),
), ),
) )

View file

@ -4,7 +4,8 @@
"already_configured": "Device is already configured" "already_configured": "Device is already configured"
}, },
"error": { "error": {
"cannot_connect": "Failed to connect" "cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication"
}, },
"step": { "step": {
"user": { "user": {

View file

@ -1476,7 +1476,7 @@ pydexcom==0.2.3
pydoods==1.0.2 pydoods==1.0.2
# homeassistant.components.android_ip_webcam # homeassistant.components.android_ip_webcam
pydroid-ipcam==1.3.1 pydroid-ipcam==2.0.0
# homeassistant.components.ebox # homeassistant.components.ebox
pyebox==1.1.4 pyebox==1.1.4

View file

@ -1025,7 +1025,7 @@ pydeconz==103
pydexcom==0.2.3 pydexcom==0.2.3
# homeassistant.components.android_ip_webcam # homeassistant.components.android_ip_webcam
pydroid-ipcam==1.3.1 pydroid-ipcam==2.0.0
# homeassistant.components.econet # homeassistant.components.econet
pyeconet==0.1.15 pyeconet==0.1.15

View file

@ -1,6 +1,6 @@
"""Test the Android IP Webcam config flow.""" """Test the Android IP Webcam config flow."""
from datetime import timedelta from datetime import timedelta
from unittest.mock import patch from unittest.mock import Mock, patch
import aiohttp import aiohttp
@ -99,6 +99,27 @@ async def test_device_already_configured(
assert result2["reason"] == "already_configured" assert result2["reason"] == "already_configured"
async def test_form_invalid_auth(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test we handle invalid auth error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
aioclient_mock.get(
"http://1.1.1.1:8080/status.json?show_avail=1",
exc=aiohttp.ClientResponseError(Mock(), (), status=401),
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"host": "1.1.1.1", "port": 8080, "username": "user", "password": "wrong-pass"},
)
assert result2["type"] == FlowResultType.FORM
assert result2["errors"] == {"username": "invalid_auth", "password": "invalid_auth"}
async def test_form_cannot_connect( async def test_form_cannot_connect(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None:

View file

@ -3,6 +3,7 @@
from collections.abc import Awaitable from collections.abc import Awaitable
from typing import Callable from typing import Callable
from unittest.mock import Mock
import aiohttp import aiohttp
@ -19,6 +20,8 @@ MOCK_CONFIG_DATA = {
"name": "IP Webcam", "name": "IP Webcam",
"host": "1.1.1.1", "host": "1.1.1.1",
"port": 8080, "port": 8080,
"username": "user",
"password": "pass",
} }
@ -50,10 +53,10 @@ async def test_successful_config_entry(
assert entry.state == ConfigEntryState.LOADED assert entry.state == ConfigEntryState.LOADED
async def test_setup_failed( async def test_setup_failed_connection_error(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None:
"""Test integration failed due to an error.""" """Test integration failed due to connection error."""
entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA) entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA)
entry.add_to_hass(hass) entry.add_to_hass(hass)
@ -67,6 +70,23 @@ async def test_setup_failed(
assert entry.state == ConfigEntryState.SETUP_RETRY assert entry.state == ConfigEntryState.SETUP_RETRY
async def test_setup_failed_invalid_auth(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test integration failed due to invalid auth."""
entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA)
entry.add_to_hass(hass)
aioclient_mock.get(
"http://1.1.1.1:8080/status.json?show_avail=1",
exc=aiohttp.ClientResponseError(Mock(), (), status=401),
)
await hass.config_entries.async_setup(entry.entry_id)
assert entry.state == ConfigEntryState.SETUP_RETRY
async def test_unload_entry(hass: HomeAssistant, aioclient_mock_fixture) -> None: async def test_unload_entry(hass: HomeAssistant, aioclient_mock_fixture) -> None:
"""Test removing integration.""" """Test removing integration."""
entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA) entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA)