diff --git a/README.md b/README.md index 2b35847..996a52f 100644 --- a/README.md +++ b/README.md @@ -297,14 +297,13 @@ attributes: ## To Do -- 2FA Notifications -- Support for non lidar Dreame vacuums +- Support for VSLAM Dreame vacuums - Cleaning history map support - Map recovery support - Schedule editing - Map rendering and streaming optimizations - AI Obstacle image support -- Live camera feed support +- Live camera stream support - Custom lovelace card for map editing ## Known Issues diff --git a/custom_components/dreame_vacuum/config_flow.py b/custom_components/dreame_vacuum/config_flow.py index 7b6217a..1492ce1 100644 --- a/custom_components/dreame_vacuum/config_flow.py +++ b/custom_components/dreame_vacuum/config_flow.py @@ -16,6 +16,7 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.device_registry import format_mac +from homeassistant.components import persistent_notification from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -34,7 +35,9 @@ CONF_MAP_OBJECTS, CONF_PREFER_CLOUD, NOTIFICATION, - MAP_OBJECTS + MAP_OBJECTS, + NOTIFICATION_ID_2FA_LOGIN, + NOTIFICATION_2FA_LOGIN, ) SUPPORTED_MODELS = [ @@ -282,6 +285,7 @@ async def async_step_with_map( self, user_input: dict[str, Any] | None = None, errors: dict[str, Any] | None = {} ) -> FlowResult: """Configure a dreame vacuum device through the Miio Cloud.""" + placeholders = {} if user_input is not None: username = user_input.get(CONF_USERNAME) password = user_input.get(CONF_PASSWORD) @@ -298,9 +302,18 @@ async def async_step_with_map( if self.protocol.cloud.two_factor_url is not None: errors["base"] = "2fa_required" + persistent_notification.create( + self.hass, + f"{NOTIFICATION_2FA_LOGIN}[{self.protocol.cloud.two_factor_url}]({self.protocol.cloud.two_factor_url})", + f'Login to Dreame Vacuum: {self.username}', + f'{DOMAIN}_{NOTIFICATION_ID_2FA_LOGIN}', + ) + placeholders = {'url': self.protocol.cloud.two_factor_url } elif self.protocol.cloud.logged_in is False: errors["base"] = "login_error" elif self.protocol.cloud.logged_in: + persistent_notification.dismiss(self.hass, f'{DOMAIN}_{NOTIFICATION_ID_2FA_LOGIN}') + devices = await self.hass.async_add_executor_job( self.protocol.cloud.get_devices ) @@ -350,6 +363,7 @@ async def async_step_with_map( vol.Required(CONF_PREFER_CLOUD, default=self.prefer_cloud): bool, } ), + description_placeholders=placeholders, errors=errors, ) diff --git a/custom_components/dreame_vacuum/const.py b/custom_components/dreame_vacuum/const.py index de6bfb4..cf0b770 100644 --- a/custom_components/dreame_vacuum/const.py +++ b/custom_components/dreame_vacuum/const.py @@ -149,7 +149,7 @@ NOTIFICATION_REPLACE_MULTI_MAP: Final = ( "### A new map has been generated\nMulti-floor maps that can be saved have reached the upper limit. You need to replace or discard map before using it." ) -NOTIFICATION_2FA_LOGIN: Final = "### 2FA Login required\n" +NOTIFICATION_2FA_LOGIN: Final = "### Additional authentication required.\nOpen following URL using device that has the same public IP, as your Home Assistant instance:\n" EVENT_TASK_STATUS: Final = "task_status" EVENT_CONSUMABLE: Final = "consumable" diff --git a/custom_components/dreame_vacuum/coordinator.py b/custom_components/dreame_vacuum/coordinator.py index 7c15538..8cc7cb7 100644 --- a/custom_components/dreame_vacuum/coordinator.py +++ b/custom_components/dreame_vacuum/coordinator.py @@ -89,6 +89,7 @@ def __init__( self._available = False self._has_warning = False self._has_temporary_map = None + self._two_factor_url = None self.device = DreameVacuumDevice( entry.data[CONF_NAME], @@ -119,12 +120,11 @@ def __init__( LOGGER, name=DOMAIN, ) - - if self._notify: - hass.bus.async_listen( - persistent_notification.EVENT_PERSISTENT_NOTIFICATIONS_UPDATED, - self._notification_dismiss_listener, - ) + + hass.bus.async_listen( + persistent_notification.EVENT_PERSISTENT_NOTIFICATIONS_UPDATED, + self._notification_dismiss_listener, + ) def _dust_collection_changed(self, previous_value=None) -> None: if previous_value is not None: @@ -285,8 +285,9 @@ def _remove_persistent_notification(self, notification_id) -> None: self.hass, f"{DOMAIN}_{notification_id}") def _notification_dismiss_listener(self, event) -> None: + notifications = self.hass.data.get(persistent_notification.DOMAIN) + if self._has_warning: - notifications = self.hass.data.get(persistent_notification.DOMAIN) if ( f"{persistent_notification.DOMAIN}.{DOMAIN}_{NOTIFICATION_ID_WARNING}" not in notifications @@ -294,6 +295,13 @@ def _notification_dismiss_listener(self, event) -> None: self._has_warning = False self.device.clear_warning() + if self._two_factor_url: + if ( + f"{persistent_notification.DOMAIN}.{DOMAIN}_{NOTIFICATION_ID_2FA_LOGIN}" + not in notifications + ): + self._two_factor_url = None + def _fire_event(self, event_id, data) -> None: event_data = {ATTR_ENTITY_ID: generate_entity_id("vacuum.{}", self.device.name, hass=self.hass)} if data: @@ -324,12 +332,17 @@ def async_set_updated_data(self, device=None) -> None: data[CONF_HOST] = self._host data[CONF_TOKEN] = self._token self.hass.config_entries.async_update_entry(self._entry, data=data) + + if self._two_factor_url != self.device.two_factor_url: + if self.device.two_factor_url: + self._create_persistent_notification( + f"{NOTIFICATION_2FA_LOGIN}[{self.device.two_factor_url}]({self.device.two_factor_url})", NOTIFICATION_ID_2FA_LOGIN + ) + self._fire_event(EVENT_2FA_LOGIN, {"url": self.device.two_factor_url}) + else: + self._remove_persistent_notification(NOTIFICATION_ID_2FA_LOGIN) - #if not self.device.cloud_connected and self.device.status.map_available and self.device._protocol.cloud.two_factor_url: - # self._create_persistent_notification( - # f"{NOTIFICATION_2FA_LOGIN} [Click here]({self.device._protocol.cloud.two_factor_url}) to continue!", NOTIFICATION_ID_2FA_LOGIN - # ) - # self._fire_event(EVENT_2FA_LOGIN, {"url": self.device._protocol.cloud.two_factor_url}) + self._two_factor_url = self.device.two_factor_url self._available = self.device.available diff --git a/custom_components/dreame_vacuum/dreame/device.py b/custom_components/dreame_vacuum/dreame/device.py index 2d6099b..157bde6 100644 --- a/custom_components/dreame_vacuum/dreame/device.py +++ b/custom_components/dreame_vacuum/dreame/device.py @@ -164,6 +164,7 @@ def __init__( self.mac = mac self.token = token self.host = host + self.two_factor_url = None self.status = DreameVacuumDeviceStatus(self) self.listen(self._task_status_changed, @@ -696,15 +697,18 @@ def connect_device(self) -> None: def connect_cloud(self) -> None: """Connect to the cloud api.""" if self._protocol.cloud and not self._protocol.cloud.logged_in: - self._protocol.cloud.login() - if self._protocol.cloud.two_factor_url is not None: - _LOGGER.warning("2FA required") - return - elif self._protocol.cloud.logged_in is False: - _LOGGER.error("Unable to log in, check credentials") + self._protocol.cloud.login() + if self._protocol.cloud.logged_in is False: + if self._protocol.cloud.two_factor_url: + self.two_factor_url = self._protocol.cloud.two_factor_url self._map_manager.schedule_update(-1) return elif self._protocol.cloud.logged_in: + if self.two_factor_url: + self.two_factor_url = None + self._property_changed() + + self._map_manager.schedule_update(5) self.token, self.host = self._protocol.cloud.get_info( self.mac) self._protocol.set_credentials( @@ -825,7 +829,6 @@ def get_map_for_render(self, map_index: int) -> MapData | None: if (self.status.zone_cleaning and map_data.active_areas) or (self.status.spot_cleaning and map_data.active_points): # App does not render segments when zone or spot cleaning map_data.segments = None - else: map_data.path = None @@ -836,6 +839,10 @@ def get_map_for_render(self, map_index: int) -> MapData | None: # Device currently may not be docked but map data can be old and still showing when robot is docked map_data.docked = bool(map_data.docked or self.status.docked) + if not map_data.saved_map and not self.status.lidar_navigation and map_data.saved_map_status == 1 and map_data.docked: + # For correct scaling of vslam saved map + map_data.saved_map_status = 2 + if map_data.charger_position == None and map_data.docked and map_data.robot_position: map_data.charger_position = copy.deepcopy(map_data.robot_position) if not self.status.self_wash_base_available: @@ -914,18 +921,13 @@ def get_map_for_render(self, map_index: int) -> MapData | None: def get_map(self, map_index: int) -> MapData | None: """Get stored map data by index from map manager.""" if self._map_manager: - if self.status.multi_map: + if self.status.multi_map and self.status.lidar_navigation: return self._map_manager.get_map(map_index) if map_index == 1: return self._map_manager.selected_map if map_index == 0: return self.status.current_map - - def get_map_by_id(self, map_id: int) -> MapData | None: - """Get stored map data by id from map manager.""" - if self._map_manager: - return self._map_manager.get_map_by_id(map_id) - + def update_map(self) -> None: """Trigger a map update. This function is used for requesting map data when a image request has been made to renderer""" @@ -3095,7 +3097,23 @@ def selected_map(self) -> MapData | None: def current_map(self) -> MapData | None: """Return the current map data""" if self.map_available: - return self._map_manager.get_map() + map_data = self._map_manager.get_map() + if map_data: + if not self.lidar_navigation and map_data.saved_map_status == 1 and 0 in self._map_manager.map_data_list and self.docked and not self.started: + saved_map_data = self._map_manager.map_data_list[0] + if saved_map_data: + vslam_map_data = copy.deepcopy(map_data) + vslam_map_data.segments = copy.deepcopy(saved_map_data.segments) + vslam_map_data.data = saved_map_data.data + vslam_map_data.pixel_type = saved_map_data.pixel_type + vslam_map_data.dimensions = saved_map_data.dimensions + vslam_map_data.charger_position = saved_map_data.charger_position + vslam_map_data.robot_position = None + vslam_map_data.docked = True + vslam_map_data.restored_map = True + vslam_map_data.path = None + return vslam_map_data + return map_data @property def map_list(self) -> list[int] | None: diff --git a/custom_components/dreame_vacuum/dreame/map.py b/custom_components/dreame_vacuum/dreame/map.py index 234885f..b8723b4 100644 --- a/custom_components/dreame_vacuum/dreame/map.py +++ b/custom_components/dreame_vacuum/dreame/map.py @@ -894,13 +894,6 @@ def get_map(self, map_index: int = 0) -> MapData | None: return None return self._map_data - def get_map_by_id(self, map_id: int = 0) -> MapData | None: - if map_id: - if map_id in self._saved_map_data: - return self._saved_map_data[map_id] - return None - return self._map_data - def listen(self, callback) -> None: self._update_callback = callback @@ -957,7 +950,8 @@ def update(self) -> None: #if self._map_data and not self._map_data.empty_map and time.time() - (self._current_timestamp_ms / 1000.0) > 30: # self.request_new_map() #else: - self._request_current_map() + if self._protocol.cloud.logged_in: + self._request_current_map() elif not self._request_map_from_cloud() and self._device_running: _LOGGER.info("No new map data received, retrying") sleep(0.5) @@ -1040,7 +1034,7 @@ def set_recovery_map_list_object_name(self, map_list: dict[int, str]) -> bool: return False def request_map_list(self) -> None: - if self._map_list_object_name: + if self._map_list_object_name and self._protocol.cloud.logged_in: _LOGGER.info("Get Map List: %s", self._map_list_object_name) try: response = self._get_interim_file_data(self._map_list_object_name) @@ -2175,6 +2169,22 @@ def decode_map_data_from_partial( elif segment_id == 2: map_data.pixel_type[x, y] = MapPixelType.WALL.value + elif ( + map_data.saved_map_status == 1 + or map_data.saved_map_status == 0 + ): + for y in range(height): + for x in range(width): + segment_id = map_data.data[(width * y) + x] & 0b01111111 + # as implemented on the app + if segment_id == 1 or segment_id == 3: + map_data.empty_map = False + map_data.pixel_type[x, + y] = MapPixelType.NEW_SEGMENT.value + elif segment_id == 2: + map_data.empty_map = False + map_data.pixel_type[x, + y] = MapPixelType.WALL.value else: for y in range(height): for x in range(width): @@ -2187,20 +2197,7 @@ def decode_map_data_from_partial( else: segment_id = pixel & 0b01111111 if segment_id > 0: - if ( - map_data.saved_map_status == 1 - or map_data.saved_map_status == 0 - ): - # as implemented on the app - if segment_id == 1 or segment_id == 3: - map_data.pixel_type[x, - y] = MapPixelType.NEW_SEGMENT.value - elif segment_id == 2: - map_data.pixel_type[x, - y] = MapPixelType.WALL.value - elif segment_id < 64: - map_data.pixel_type[x, - y] = segment_id + map_data.pixel_type[x, y] = segment_id segments = DreameVacuumMapDecoder.get_segments(map_data) if segments and data_json.get("seg_inf"): @@ -2225,7 +2222,7 @@ def decode_map_data_from_partial( map_data.segments = segments saved_map_data = None - #_LOGGER.warn("TEST: %s", data_json) + if data_json.get("rism"): saved_map_data = DreameVacuumMapDecoder.decode_saved_map( data_json["rism"], map_data.rotation @@ -3346,20 +3343,20 @@ def _calculate_padding(dimensions, no_mopping_areas, no_go_areas, walls, padding if min_x < 0: padding[0] = padding[0] + int(-min_x) if max_x > dimensions.width: - padding[1] = padding[1] + int(max_x - dimensions.width) + padding[2] = padding[2] + int(max_x - dimensions.width) if min_y < 0: - padding[2] = padding[2] + int(-min_y) + padding[1] = padding[1] + int(-min_y) if max_y > dimensions.height: padding[3] = padding[3] + int(max_y - dimensions.height) if dimensions.width < min_width: size = int((min_width - dimensions.width) / 2) padding[0] = padding[0] + size - padding[1] = padding[1] + size + padding[2] = padding[2] + size if dimensions.height < min_height: size = int((min_height - dimensions.height) / 2) - padding[2] = padding[2] + size + padding[1] = padding[1] + size padding[3] = padding[3] + size for k in range(4): @@ -3527,7 +3524,7 @@ def render_map(self, map_data: MapData, robot_status: int = 0) -> bytes: max_y != map_data.dimensions.height - 1 ) ): - map_data.dimensions.crop = [min_x * scale, (map_data.dimensions.width - (max_x + 1)) * scale, min_y * scale, (map_data.dimensions.height - (max_y + 1)) * scale] + map_data.dimensions.crop = [min_x * scale, min_y * scale, (map_data.dimensions.width - (max_x + 1)) * scale, (map_data.dimensions.height - (max_y + 1)) * scale] pixels = pixels[min_y:(max_y + 1), min_x:(max_x + 1)] self._calibration_points = self._calculate_calibration_points(map_data) diff --git a/custom_components/dreame_vacuum/dreame/protocol.py b/custom_components/dreame_vacuum/dreame/protocol.py index a4c8f6e..7cc84e1 100644 --- a/custom_components/dreame_vacuum/dreame/protocol.py +++ b/custom_components/dreame_vacuum/dreame/protocol.py @@ -3,6 +3,7 @@ import hashlib import requests import time +import traceback from .exceptions import DeviceException from typing import Any, Optional, Tuple from micloud import miutils @@ -135,19 +136,46 @@ def _login_step2(self, sign): self.pass_token = response_json['passToken'] return location - + def login(self): - try: + if not self._check_credentials(): + return False + + if self.user_id and self.service_token: + return True + + if self.failed_logins <= 2: _LOGGER.info("Logging in...") - self.two_factor_url = None - self.captcha_url = None - response = super().login() - self._fail_count = 0 - self._connected = True - return response + + self.two_factor_url = None + self.captcha_url = None + try: + if self._login_request(): + self.failed_logins = 0 + else: + self.failed_logins += 1 + except MiCloudException as ex: + self.failed_logins += 1 + self.service_token = None + if self.failed_logins > 10: + self._init_session(reset=True) + _LOGGER.warning("Error logging on to Xiaomi cloud (%s): %s", self.failed_logins, str(ex)) + return False + except MiCloudAccessDenied as ex: + self.failed_logins += 1 + self.service_token = None + if self.failed_logins > 10: + self._init_session(reset=True) + if self.failed_logins <= 3: + _LOGGER.warning(str(ex)) + return False except Exception as ex: - _LOGGER.error("Login failed: %s", ex) - return None + _LOGGER.error("Login failed: %s", traceback.format_exc()) + return False + + self._fail_count = 0 + self._connected = True + return True def get_file(self, url: str = "") -> Any: try: diff --git a/custom_components/dreame_vacuum/dreame/types.py b/custom_components/dreame_vacuum/dreame/types.py index cf5014f..11bb755 100644 --- a/custom_components/dreame_vacuum/dreame/types.py +++ b/custom_components/dreame_vacuum/dreame/types.py @@ -724,15 +724,15 @@ def rotated(self, image_dimensions, degree) -> Point: w = int( (image_dimensions.width * image_dimensions.scale) + image_dimensions.padding[0] - + image_dimensions.padding[1] + + image_dimensions.padding[2] - image_dimensions.crop[0] - - image_dimensions.crop[1] + - image_dimensions.crop[2] ) h = int( (image_dimensions.height * image_dimensions.scale) - + image_dimensions.padding[2] + + image_dimensions.padding[1] + image_dimensions.padding[3] - - image_dimensions.crop[2] + - image_dimensions.crop[1] - image_dimensions.crop[3] ) x = self.x @@ -1080,7 +1080,7 @@ def to_img(self, point: Point) -> Point: / self.grid_size ) * self.scale - + self.padding[2] - self.crop[2], + + self.padding[1] - self.crop[1], ) def __eq__(self: MapImageDimensions, other: MapImageDimensions) -> bool: @@ -1247,7 +1247,7 @@ def as_dict(self) -> Dict[str, Any]: attributes_list = {} if self.charger_position is not None: attributes_list[ATTR_CHARGER] = self.charger_position - if self.segments is not None and (self.saved_map or self.saved_map_status == 2): + if self.segments is not None and (self.saved_map or self.saved_map_status == 2 or self.restored_map): attributes_list[ATTR_ROOMS] = {k: v.as_dict() for k, v in sorted(self.segments.items())} if not self.saved_map and self.robot_position is not None: attributes_list[ATTR_ROBOT_POSITION] = self.robot_position diff --git a/custom_components/dreame_vacuum/manifest.json b/custom_components/dreame_vacuum/manifest.json index c5dad85..ed4c501 100644 --- a/custom_components/dreame_vacuum/manifest.json +++ b/custom_components/dreame_vacuum/manifest.json @@ -14,6 +14,6 @@ "python-miio>=0.5.6", "micloud>=0.5" ], - "version": "v0.14.3", + "version": "v0.14.4", "iot_class": "local_polling" } diff --git a/custom_components/dreame_vacuum/strings.json b/custom_components/dreame_vacuum/strings.json index fbd3623..9846e99 100644 --- a/custom_components/dreame_vacuum/strings.json +++ b/custom_components/dreame_vacuum/strings.json @@ -8,7 +8,7 @@ "cannot_connect": "Failed to connect.", "unsupported": "Device is not supported", "wrong_token": "Checksum error, wrong token", - "2fa_required": "2FA Login required", + "2fa_required": "2FA Login required\n{url}", "credentials_incomplete": "Credentials incomplete, please fill in username, password and country", "login_error": "Could not login to Xiaomi Miio Cloud, check the credentials.", "no_devices": "No supported devices found in this Xiaomi Miio cloud account on selected country." diff --git a/custom_components/dreame_vacuum/translations/de.json b/custom_components/dreame_vacuum/translations/de.json index 6dc1696..39056da 100644 --- a/custom_components/dreame_vacuum/translations/de.json +++ b/custom_components/dreame_vacuum/translations/de.json @@ -8,7 +8,7 @@ "cannot_connect": "Verbindung fehlgeschlagen.", "unsupported": "Gerät wird nicht unterstützt", "wrong_token": "Prüfsummenfehler, falscher Token", - "2fa_required": "Zweifaktoren Anmeldung erforderlich", + "2fa_required": "Zweifaktoren Anmeldung erforderlich\n{url}", "credentials_incomplete": "Anmeldedaten unvollständig, bitte geben Sie Benutzername, Passwort und das Land ein", "login_error": "Anmeldung bei Xiaomi Miio Cloud nicht möglich, bitte überprüfen Sie die Anmeldedaten.", "no_devices": "Keine unterstützten Geräte in diesem Xiaomi Miio Cloud-Konto für das ausgewählte Land gefunden." diff --git a/custom_components/dreame_vacuum/translations/en.json b/custom_components/dreame_vacuum/translations/en.json index fbd3623..9846e99 100644 --- a/custom_components/dreame_vacuum/translations/en.json +++ b/custom_components/dreame_vacuum/translations/en.json @@ -8,7 +8,7 @@ "cannot_connect": "Failed to connect.", "unsupported": "Device is not supported", "wrong_token": "Checksum error, wrong token", - "2fa_required": "2FA Login required", + "2fa_required": "2FA Login required\n{url}", "credentials_incomplete": "Credentials incomplete, please fill in username, password and country", "login_error": "Could not login to Xiaomi Miio Cloud, check the credentials.", "no_devices": "No supported devices found in this Xiaomi Miio cloud account on selected country." diff --git a/custom_components/dreame_vacuum/translations/pl.json b/custom_components/dreame_vacuum/translations/pl.json index 5ebdfa5..2ea3ee6 100644 --- a/custom_components/dreame_vacuum/translations/pl.json +++ b/custom_components/dreame_vacuum/translations/pl.json @@ -8,7 +8,7 @@ "cannot_connect": "Nie udało się połączyć.", "unsupported": "Urządzenie nie jest obsługiwane", "wrong_token": "Błąd sumy kontrolnej, zły token", - "2fa_required": "Wymagane logowanie 2FA", + "2fa_required": "Wymagane logowanie 2FA\n{url}", "credentials_incomplete": "Niepoprawne dane logowania, proszę podać nazwę użytkownika, hasło i kraj", "login_error": "Nie można zalogować się do chmury Xiaomi Miio, sprawdź dane logowania.", "no_devices": "Nie znaleziono obsługiwanych urządzeń na tym koncie w chmurze Xiaomi Miio w wybranym kraju." diff --git a/custom_components/dreame_vacuum/translations/ru.json b/custom_components/dreame_vacuum/translations/ru.json index a8caef3..7ab2566 100644 --- a/custom_components/dreame_vacuum/translations/ru.json +++ b/custom_components/dreame_vacuum/translations/ru.json @@ -8,7 +8,7 @@ "cannot_connect": "Ошибка подключения.", "unsupported": "Устройство не поддерживается", "wrong_token": "Ошибка проверки токена, неверный токен", - "2fa_required": "Требуется двухфакторная аутентификация", + "2fa_required": "Требуется двухфакторная аутентификация\n{url}", "credentials_incomplete": "Данные учётной записи не введены, пожадуйста заполните имя пользователя, пароль и регион", "login_error": "Не удалось подключиться к облаку Xiaomi, проверьте учётные данные", "no_devices": "В учётной записи Xiaomi для данного региона, не обнаружено поддерживаемых устройств" diff --git a/docs/events.md b/docs/events.md index 6fe6125..44c1b64 100644 --- a/docs/events.md +++ b/docs/events.md @@ -1,4 +1,4 @@ - Events +# Events Integration tracks certain device properties and notifies HA on specific events similar to the notification feature but events always will be fired even notifications are disabled from integration options. ### Cleanup started or finished @@ -54,4 +54,11 @@ Fires when there is a fault code on the device. #### `dreame_vacuum_error` - `entity_id`: Vacuum entity - `error`: Error description -- `code`: Fault code of the error \ No newline at end of file +- `code`: Fault code of the error + +### 2FA Login Required +Fires when there two factor authentication is required to login. + +#### `dreame_vacuum_2fa_login_` +- `entity_id`: Vacuum entity +- `url`: 2FA login URL \ No newline at end of file