From 74f44d57198f2a1ba5d224ad43802ebe90ae7973 Mon Sep 17 00:00:00 2001 From: Renier Moorcroft <66512715+RenierM26@users.noreply.github.com> Date: Sun, 5 Jan 2025 08:39:40 +0200 Subject: [PATCH] Improve encrypted image support. (#153) * Improve encrypted image support * Config flow logic for separate encryption key * Migrate config version. * Make test RTSP creds optional (Allows for encryption to be used for cameras without RTSP) --- custom_components/ezviz_cloud/__init__.py | 44 +- custom_components/ezviz_cloud/config_flow.py | 22 +- custom_components/ezviz_cloud/const.py | 2 + custom_components/ezviz_cloud/image.py | 9 +- custom_components/ezviz_cloud/manifest.json | 2 +- custom_components/ezviz_cloud/strings.json | 3 +- .../ezviz_cloud/translations/en.json | 387 +++++++++--------- 7 files changed, 258 insertions(+), 211 deletions(-) diff --git a/custom_components/ezviz_cloud/__init__.py b/custom_components/ezviz_cloud/__init__.py index adf9adc..dad0e32 100644 --- a/custom_components/ezviz_cloud/__init__.py +++ b/custom_components/ezviz_cloud/__init__.py @@ -12,13 +12,21 @@ ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_TIMEOUT, CONF_TYPE, CONF_URL, Platform +from homeassistant.const import ( + CONF_PASSWORD, + CONF_TIMEOUT, + CONF_TYPE, + CONF_URL, + CONF_USERNAME, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import ( ATTR_TYPE_CAMERA, ATTR_TYPE_CLOUD, + CONF_ENC_KEY, CONF_FFMPEG_ARGUMENTS, CONF_RF_SESSION_ID, CONF_SESSION_ID, @@ -67,10 +75,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Initialize EZVIZ cloud entities if PLATFORMS_BY_TYPE[sensor_type]: - # Initiate reauth config flow if account token if not present. - if not entry.data.get(CONF_SESSION_ID): - raise ConfigEntryAuthFailed - ezviz_client = EzvizClient( token={ CONF_SESSION_ID: entry.data[CONF_SESSION_ID], @@ -134,3 +138,33 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old camera entry.""" + _LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version) + + if entry.version == 1: + if entry.data[CONF_TYPE] == ATTR_TYPE_CAMERA: + data = { + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + CONF_ENC_KEY: entry.data[CONF_PASSWORD], + CONF_TYPE: ATTR_TYPE_CAMERA, + } + + hass.config_entries.async_update_entry(entry, data=data, version=2) + + if entry.data[CONF_TYPE] == ATTR_TYPE_CLOUD: + if not entry.data.get(CONF_SESSION_ID): + raise ConfigEntryAuthFailed + hass.config_entries.async_update_entry(entry, data=entry.data, version=2) + + _LOGGER.info( + "Migration to version %s.%s successful for %s account", + entry.version, + entry.minor_version, + entry.data[CONF_TYPE], + ) + + return True diff --git a/custom_components/ezviz_cloud/config_flow.py b/custom_components/ezviz_cloud/config_flow.py index d8fbea7..6b2539e 100644 --- a/custom_components/ezviz_cloud/config_flow.py +++ b/custom_components/ezviz_cloud/config_flow.py @@ -38,9 +38,11 @@ ATTR_SERIAL, ATTR_TYPE_CAMERA, ATTR_TYPE_CLOUD, + CONF_ENC_KEY, CONF_FFMPEG_ARGUMENTS, CONF_RF_SESSION_ID, CONF_SESSION_ID, + CONF_TEST_RTSP_CREDENTIALS, DEFAULT_CAMERA_USERNAME, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, @@ -84,7 +86,7 @@ def _get_cam_enc_key(data: dict, ezviz_client: EzvizClient) -> Any: class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for EZVIZ.""" - VERSION = 1 + VERSION = 2 ip_address: str username: str | None @@ -145,20 +147,25 @@ async def _validate_and_create_camera_rtsp(self, data: dict) -> ConfigFlowResult # Create Ezviz API Client. await self.hass.async_add_executor_job(ezviz_client.login) - # If no encryption key is provided, get it. Sometimes a old key is provided and doesn't work. + # Fetch encryption key from ezviz api. + data[CONF_ENC_KEY] = await self.hass.async_add_executor_job( + _get_cam_enc_key, data, ezviz_client + ) + + # If newer camera, the encryption key is the password. if data[CONF_PASSWORD] == "fetch_my_key": - data[CONF_PASSWORD] = await self.hass.async_add_executor_job( - _get_cam_enc_key, data, ezviz_client - ) + data[CONF_PASSWORD] = data[CONF_ENC_KEY] - # Test camera RTSP credentials. - await self.hass.async_add_executor_job(_wake_camera, data, ezviz_client) + # Test camera RTSP credentials. Older cameras still use the verification code on the camera and not the encryption key. + if data[CONF_TEST_RTSP_CREDENTIALS]: + await self.hass.async_add_executor_job(_wake_camera, data, ezviz_client) return self.async_create_entry( title=data[ATTR_SERIAL], data={ CONF_USERNAME: data[CONF_USERNAME], CONF_PASSWORD: data[CONF_PASSWORD], + CONF_ENC_KEY: data[CONF_ENC_KEY], CONF_TYPE: ATTR_TYPE_CAMERA, }, options=DEFAULT_OPTIONS, @@ -337,6 +344,7 @@ async def async_step_confirm( { vol.Required(CONF_USERNAME, default=DEFAULT_CAMERA_USERNAME): str, vol.Required(CONF_PASSWORD, default="fetch_my_key"): str, + vol.Optional(CONF_TEST_RTSP_CREDENTIALS, default=True): bool, } ) diff --git a/custom_components/ezviz_cloud/const.py b/custom_components/ezviz_cloud/const.py index 54a54d6..226f774 100644 --- a/custom_components/ezviz_cloud/const.py +++ b/custom_components/ezviz_cloud/const.py @@ -11,6 +11,8 @@ CONF_SESSION_ID = "session_id" CONF_RF_SESSION_ID = "rf_session_id" CONF_EZVIZ_ACCOUNT = "ezviz_account" +CONF_ENC_KEY = "enc_key" +CONF_TEST_RTSP_CREDENTIALS = "test_rtsp_credentials" # Service names SERVICE_WAKE_DEVICE = "wake_device" diff --git a/custom_components/ezviz_cloud/image.py b/custom_components/ezviz_cloud/image.py index 87f4d27..ab6afd7 100644 --- a/custom_components/ezviz_cloud/image.py +++ b/custom_components/ezviz_cloud/image.py @@ -8,13 +8,12 @@ from pyezvizapi.utils import decrypt_image from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD +from homeassistant.config_entries import SOURCE_IGNORE, ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from .const import DATA_COORDINATOR, DOMAIN +from .const import CONF_ENC_KEY, DATA_COORDINATOR, DOMAIN from .coordinator import EzvizDataUpdateCoordinator from .entity import EzvizEntity @@ -57,7 +56,9 @@ def __init__( ) camera = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, serial) self.alarm_image_password = ( - camera.data.get(CONF_PASSWORD) if camera is not None else None + camera.data[CONF_ENC_KEY] + if camera and camera.source != SOURCE_IGNORE + else None ) async def _async_load_image_from_url(self, url: str) -> Image | None: diff --git a/custom_components/ezviz_cloud/manifest.json b/custom_components/ezviz_cloud/manifest.json index 5fcd530..1558b2a 100644 --- a/custom_components/ezviz_cloud/manifest.json +++ b/custom_components/ezviz_cloud/manifest.json @@ -9,5 +9,5 @@ "issue_tracker": "https://github.com/RenierM26/ha-ezviz/issues", "loggers": ["paho_mqtt", "pyezvizapi"], "requirements": ["pyezvizapi==1.0.0.0"], - "version": "0.1.0.45" + "version": "0.1.0.46" } diff --git a/custom_components/ezviz_cloud/strings.json b/custom_components/ezviz_cloud/strings.json index 9898f80..6d39fa9 100644 --- a/custom_components/ezviz_cloud/strings.json +++ b/custom_components/ezviz_cloud/strings.json @@ -24,7 +24,8 @@ "description": "Enter RTSP credentials for EZVIZ camera {serial} with IP {ip_address}", "data": { "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "test_rtsp_credentials": "Test RTSP credentials" } }, "reauth_confirm": { diff --git a/custom_components/ezviz_cloud/translations/en.json b/custom_components/ezviz_cloud/translations/en.json index 5e00574..ffb9962 100644 --- a/custom_components/ezviz_cloud/translations/en.json +++ b/custom_components/ezviz_cloud/translations/en.json @@ -1,206 +1,207 @@ { - "config": { - "abort": { - "already_configured_account": "Account is already configured", - "ezviz_cloud_account_missing": "Ezviz cloud account missing. Please reconfigure Ezviz cloud account", - "reauth_successful": "Re-authentication was successful", - "unknown": "Unexpected error" - }, - "error": { - "cannot_connect": "Failed to connect", - "invalid_auth": "Invalid authentication", - "invalid_host": "Invalid hostname or IP address", - "mfa_required": "2FA enabled on account, please disable and retry" - }, - "flow_title": "{serial}", - "step": { - "confirm": { - "data": { - "password": "Password", - "username": "Username" - }, - "description": "Enter RTSP credentials for EZVIZ camera {serial} with IP {ip_address}", - "title": "Discovered EZVIZ Camera" - }, - "reauth_confirm": { - "data": { - "password": "Password", - "username": "Username" - }, - "description": "Enter credentials to reauthenticate to ezviz cloud account", - "title": "Authentication expired for {name}" - }, - "reauth_mfa": { - "data": { - "sms_code": "MFA Code" - }, - "description": "Enter MFA code to authenticate ezviz cloud account", - "title": "EZVIZ Cloud MFA Authentication" - }, - "user": { - "data": { - "password": "Password", - "url": "URL", - "username": "Username" - }, - "title": "Connect to EZVIZ Cloud" - }, - "user_custom_url": { - "data": { - "password": "Password", - "url": "URL", - "username": "Username" - }, - "description": "Manually specify your region URL", - "title": "Connect to custom EZVIZ URL" - }, - "user_mfa_confirm": { - "data": { - "sms_code": "MFA Code" - }, - "description": "Enter MFA code to authenticate ezviz cloud account", - "title": "EZVIZ Cloud MFA Authentication" - } - } + "config": { + "abort": { + "already_configured_account": "Account is already configured", + "ezviz_cloud_account_missing": "Ezviz cloud account missing. Please reconfigure Ezviz cloud account", + "reauth_successful": "Re-authentication was successful", + "unknown": "Unexpected error" }, - "entity": { - "binary_sensor": { - "alarm_schedules_enabled": { - "name": "Alarm schedules enabled" - }, - "encrypted": { - "name": "Encryption" - } - }, - "button": { - "ptz_down": { - "name": "PTZ down" - }, - "ptz_left": { - "name": "PTZ left" - }, - "ptz_right": { - "name": "PTZ right" - }, - "ptz_up": { - "name": "PTZ up" - } - }, - "image": { - "last_motion_image": { - "name": "Last motion image" - } + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "invalid_host": "Invalid hostname or IP address", + "mfa_required": "2FA enabled on account, please disable and retry" + }, + "flow_title": "{serial}", + "step": { + "confirm": { + "data": { + "password": "Password", + "username": "Username", + "test_rtsp_credentials": "Test RTSP credentials" }, - "light": { - "light": { - "name": "Light" - } + "description": "Enter RTSP credentials for EZVIZ camera {serial} with IP {ip_address}", + "title": "Discovered EZVIZ Camera" + }, + "reauth_confirm": { + "data": { + "password": "Password", + "username": "Username" }, - "number": { - "detection_sensibility": { - "name": "Detection sensitivity" - } + "description": "Enter credentials to reauthenticate to ezviz cloud account", + "title": "Authentication expired for {name}" + }, + "reauth_mfa": { + "data": { + "sms_code": "MFA Code" }, - "select": { - "alarm_sound_mode": { - "name": "Warning sound", - "state": { - "intensive": "Intensive", - "silent": "Silent", - "soft": "Soft" - } - } + "description": "Enter MFA code to authenticate ezviz cloud account", + "title": "EZVIZ Cloud MFA Authentication" + }, + "user": { + "data": { + "password": "Password", + "url": "URL", + "username": "Username" }, - "sensor": { - "alarm_sound_mod": { - "name": "Alarm sound level" - }, - "last_alarm_pic": { - "name": "Last alarm picture URL" - }, - "last_alarm_time": { - "name": "Last alarm time" - }, - "last_alarm_type_code": { - "name": "Last alarm type code" - }, - "last_alarm_type_name": { - "name": "Last alarm type name" - }, - "local_ip": { - "name": "Local IP" - }, - "pir_status": { - "name": "PIR status" - }, - "seconds_last_trigger": { - "name": "Seconds since last trigger" - }, - "supported_channels": { - "name": "Supported channels" - }, - "wan_ip": { - "name": "WAN IP" - } + "title": "Connect to EZVIZ Cloud" + }, + "user_custom_url": { + "data": { + "password": "Password", + "url": "URL", + "username": "Username" }, - "siren": { - "siren": { - "name": "Siren" - } + "description": "Manually specify your region URL", + "title": "Connect to custom EZVIZ URL" + }, + "user_mfa_confirm": { + "data": { + "sms_code": "MFA Code" }, - "switch": { - "all_day_video_recording": { - "name": "All day video recording" - }, - "audio": { - "name": "Audio" - }, - "auto_sleep": { - "name": "Auto sleep" - }, - "flicker_light_on_movement": { - "name": "Flicker light on movement" - }, - "follow_movement": { - "name": "Follow movement" - }, - "infrared_light": { - "name": "Infrared light" - }, - "motion_tracking": { - "name": "Motion tracking" - }, - "pir_motion_activated_light": { - "name": "PIR motion activated light" - }, - "privacy": { - "name": "Privacy" - }, - "sleep": { - "name": "Sleep" - }, - "status_light": { - "name": "Status light" - }, - "tamper_alarm": { - "name": "Tamper alarm" - } - } + "description": "Enter MFA code to authenticate ezviz cloud account", + "title": "EZVIZ Cloud MFA Authentication" + } + } + }, + "entity": { + "binary_sensor": { + "alarm_schedules_enabled": { + "name": "Alarm schedules enabled" + }, + "encrypted": { + "name": "Encryption" + } }, - "options": { - "step": { - "init": { - "data": { - "ffmpeg_arguments": "Arguments passed to ffmpeg for cameras", - "timeout": "Request Timeout (seconds)" - } - } + "button": { + "ptz_down": { + "name": "PTZ down" + }, + "ptz_left": { + "name": "PTZ left" + }, + "ptz_right": { + "name": "PTZ right" + }, + "ptz_up": { + "name": "PTZ up" + } + }, + "image": { + "last_motion_image": { + "name": "Last motion image" + } + }, + "light": { + "light": { + "name": "Light" + } + }, + "number": { + "detection_sensibility": { + "name": "Detection sensitivity" + } + }, + "select": { + "alarm_sound_mode": { + "name": "Warning sound", + "state": { + "intensive": "Intensive", + "silent": "Silent", + "soft": "Soft" } + } }, - "services": { - "wake_device": { - "description": "This can be used to wake the camera/device from hibernation.", - "name": "Wake camera" + "sensor": { + "alarm_sound_mod": { + "name": "Alarm sound level" + }, + "last_alarm_pic": { + "name": "Last alarm picture URL" + }, + "last_alarm_time": { + "name": "Last alarm time" + }, + "last_alarm_type_code": { + "name": "Last alarm type code" + }, + "last_alarm_type_name": { + "name": "Last alarm type name" + }, + "local_ip": { + "name": "Local IP" + }, + "pir_status": { + "name": "PIR status" + }, + "seconds_last_trigger": { + "name": "Seconds since last trigger" + }, + "supported_channels": { + "name": "Supported channels" + }, + "wan_ip": { + "name": "WAN IP" + } + }, + "siren": { + "siren": { + "name": "Siren" + } + }, + "switch": { + "all_day_video_recording": { + "name": "All day video recording" + }, + "audio": { + "name": "Audio" + }, + "auto_sleep": { + "name": "Auto sleep" + }, + "flicker_light_on_movement": { + "name": "Flicker light on movement" + }, + "follow_movement": { + "name": "Follow movement" + }, + "infrared_light": { + "name": "Infrared light" + }, + "motion_tracking": { + "name": "Motion tracking" + }, + "pir_motion_activated_light": { + "name": "PIR motion activated light" + }, + "privacy": { + "name": "Privacy" + }, + "sleep": { + "name": "Sleep" + }, + "status_light": { + "name": "Status light" + }, + "tamper_alarm": { + "name": "Tamper alarm" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "ffmpeg_arguments": "Arguments passed to ffmpeg for cameras", + "timeout": "Request Timeout (seconds)" } + } + } + }, + "services": { + "wake_device": { + "description": "This can be used to wake the camera/device from hibernation.", + "name": "Wake camera" } -} \ No newline at end of file + } +}