Skip to content

Commit

Permalink
Map rendering fixes
Browse files Browse the repository at this point in the history
  • Loading branch information
Yiğit Topcu committed Nov 8, 2022
1 parent ee71c8c commit 1bbbdd9
Show file tree
Hide file tree
Showing 15 changed files with 161 additions and 85 deletions.
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion custom_components/dreame_vacuum/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -34,7 +35,9 @@
CONF_MAP_OBJECTS,
CONF_PREFER_CLOUD,
NOTIFICATION,
MAP_OBJECTS
MAP_OBJECTS,
NOTIFICATION_ID_2FA_LOGIN,
NOTIFICATION_2FA_LOGIN,
)

SUPPORTED_MODELS = [
Expand Down Expand Up @@ -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)
Expand All @@ -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
)
Expand Down Expand Up @@ -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,
)

Expand Down
2 changes: 1 addition & 1 deletion custom_components/dreame_vacuum/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
37 changes: 25 additions & 12 deletions custom_components/dreame_vacuum/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -285,15 +285,23 @@ 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
):
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:
Expand Down Expand Up @@ -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

Expand Down
48 changes: 33 additions & 15 deletions custom_components/dreame_vacuum/dreame/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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"""
Expand Down Expand Up @@ -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:
Expand Down
55 changes: 26 additions & 29 deletions custom_components/dreame_vacuum/dreame/map.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand All @@ -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"):
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
Loading

0 comments on commit 1bbbdd9

Please sign in to comment.