diff --git a/README.md b/README.md index 96f5d46..a826df1 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,47 @@ Go to WeBack app : * Create a new account * Add your robot to your new account +## Maps and Rooms + +Maps are supported for `yw_ls` (LiDAR) vacuums. Others may work. Tested on: + + - Electriq "Helga" iQlean-LR01 + +Integration with [PiotrMachowski/lovelace-xiaomi-vacuum-map-card](https://github.com/PiotrMachowski/lovelace-xiaomi-vacuum-map-card) supports automatic map calibration and room boundaries. + +The vacuum entity has been modified to accept `send_command`s for room / segment cleaning. + +### Example `lovelace-xiaomi-vacuum-map-card` card setup + +To support automatic room boundaries, the Lovelace card needs to be templated. An example of this using [iantrich/config-template-card](https://github.com/iantrich/config-template-card) + +*Please set both vacuum and camera entities appropriately. `camera.robot_map` and `vacuum.robot` in this example* + + +``` YAML +type: custom:config-template-card +variables: + ROOMS: states['camera.robot_map'].attributes.rooms +entities: + - camera.robot_map +card: + type: custom:xiaomi-vacuum-map-card + map_source: + camera: camera.robot_map + calibration_source: + camera: true + entity: vacuum.robot + vacuum_platform: send_command + title: Vacuum + preset_name: Live map + map_modes: + - template: vacuum_clean_zone + - template: vacuum_clean_segment + name: Rooms + icon: mdi:floor-plan + predefined_selections: ${ROOMS} + +``` ## Issues diff --git a/custom_components/weback_vacuum/VacDevice.py b/custom_components/weback_vacuum/VacDevice.py index 5b2775b..6031c0e 100644 --- a/custom_components/weback_vacuum/VacDevice.py +++ b/custom_components/weback_vacuum/VacDevice.py @@ -1,6 +1,8 @@ import logging +import io from .WebackApi import WebackWssCtrl +from .VacMap import VacMap, VacMapDraw, VacMapRoom _LOGGER = logging.getLogger(__name__) @@ -13,6 +15,8 @@ def __init__(self, thing_name, thing_nickname, sub_type, thing_status, self.name = thing_name self.nickname = thing_nickname self.sub_type = sub_type + self.map = None + self.map_image_buffer = None # First init status from HTTP API if self.robot_status is None: @@ -29,6 +33,31 @@ async def watch_state(self): except: _LOGGER.exception('Error on watch_state starting refresh_handler') + async def load_maps(self): + """Load the current reuse map""" + map_data = await self.get_reuse_map_by_id(self.robot_status["hismap_id"], self.sub_type, self.name) + if map_data is not []: + self.map = VacMap(map_data) + self.render_map() + + def render_map(self): + if not self.map: + return False + + vac_map_draw = VacMapDraw(self.map) + vac_map_draw.draw_charger_point() + vac_map_draw.draw_path() + vac_map_draw.draw_robot_position() + + img = vac_map_draw.get_image() + + img_byte_arr = io.BytesIO() + img.save(img_byte_arr, format='PNG') + img.close() + self.map_image_buffer = img_byte_arr.getvalue() + + return True + # ========================================================== # Vacuum Entity # -> Properties @@ -201,3 +230,10 @@ async def undisturb_mode(self, state: str): else: _LOGGER.error(f"Undisturb mode can't be set with value : {state}") return + + async def clean_room(self, room_ids: list): + room_data = list() + for id in room_ids: + room_data.append(dict(room_id = id)) + working_payload = {self.ASK_STATUS: self.CLEAN_MODE_ROOMS, self.SELECTED_ZONE: room_data} + await self.send_command(self.name, self.sub_type, working_payload) diff --git a/custom_components/weback_vacuum/VacMap.py b/custom_components/weback_vacuum/VacMap.py new file mode 100644 index 0000000..4fe88dc --- /dev/null +++ b/custom_components/weback_vacuum/VacMap.py @@ -0,0 +1,307 @@ +import base64 +import zlib +import json +import random +import io +import struct + +from PIL import Image, ImageDraw, ImageOps + +class VacMapDraw: + + def __init__(self, vac_map): + self.vac_map = vac_map + self.img = self.vac_map.get_map_image() + self.draw = ImageDraw.Draw(self.img, 'RGBA') + + def draw_charger_point(self, col = (0x1C, 0xE3, 0x78, 0xFF), radius = 10): + point = self.vac_map.get_charger_point_pixel() + coords = (point[0] - (radius / 2), point[1] - (radius / 2), point[0] + (radius / 2), point[1] + (radius / 2)) + self.draw.ellipse(coords, col, col) + + def draw_robot_position(self, col = (0xDA, 0x36, 0x25, 0xFF), radius = 10): + point = self.vac_map.get_robot_position_pixel() + if(point == False): + return + coords = (point[0] - (radius / 2), point[1] - (radius / 2), point[0] + (radius / 2), point[1] + (radius / 2)) + self.draw.ellipse(coords, col, col) + + def draw_room(self, room): + self.draw.polygon(self.vac_map._virtual_to_pixel_list(room.get_room_bounds()), tuple(random.choices(range(256), k=4)), (255,255,0,128)) + + def draw_rooms(self): + rooms = self.vac_map.get_rooms() + for room in rooms: + self.draw_room(room) + + def draw_path(self, col = (0x1C, 0xE3, 0xDA, 0xFF)): + path, point_types = self.vac_map.get_path() + + last_coord = None + + for i, coord in enumerate(path): + if not last_coord: + last_coord = coord + continue + + point_type = point_types[i] + + self.draw.line((last_coord, coord), col if point_type == VacMap.PATH_VACUUMING else (255,255,255,0), width=3) + + last_coord = coord + + def get_image(self): + return self.img + +class VacMapRoom: + def __init__(self, data): + self.data = data + def get_clean_times(self): + return self.data["clean_times"] + def get_clean_order(self): + return self.data["clean_order"] + def get_room_id(self): + return self.data["room_id"] + def get_room_name(self): + if("room_name" in self.data): + return self.data["room_name"] + return None + def get_room_bounds(self, tuple = True): + r = list() + for i in range(0, len(self.data["room_point_x"])): + if(tuple): + r.append((self.data["room_point_x"][i], self.data["room_point_y"][i])) + else: + r.append(list([self.data["room_point_x"][i], self.data["room_point_y"][i]])) + return r + def get_room_label_offset(self): + bounds = self.get_room_bounds() + + min_coord = min(bounds)[0], min(bounds)[1] + max_coord = max(bounds)[0], max(bounds)[1] + + return min_coord[0] + ((max_coord[0] - min_coord[0]) / 2), min_coord[1] + ((max_coord[1] - min_coord[1]) / 2) + + def get_xaiomi_vacuum_map_card_rooms(self): + label_offset = self.get_room_label_offset() + ret = { + "id": self.get_room_id(), + "outline": self.get_room_bounds(False), + "label": { "text": self.get_room_name(), "x": label_offset[0], "y": label_offset[1] } + } + + return ret + +class VacMap: + + MAP_FORMAT_YW_LASER = "yw_ls" + MAP_FORMAT_YW_VISUAL = "yw_vs" + MAP_FORMAT_GYRO = "gyro" + MAP_FORMAT_YW_ES = "yw_es" + MAP_FORMAT_YW_ES_OLD = "yw_es_old" + MAP_FORMAT_BV_LASER = "bv_ls" + MAP_FORMAT_BV_VISUAL = "bv_vs" + + PATH_RELOCATING = 0x40 + PATH_VACUUMING = 0x0 + + ALLOW_MAP_FORMATS = { MAP_FORMAT_YW_LASER, MAP_FORMAT_BV_LASER } + + def __init__(self, input): + self.load_data(input) + + def load_data(self, input): + self.data = json.loads(zlib.decompress(base64.b64decode(input))) + self.map_data = bytearray(base64.b64decode(self.data['MapData'])) + self.map_bitmap = False + self.map_scale = 4 + if("PointData" in self.data): + self.data["PointData"] = base64.b64decode(self.data['PointData']) + self.data["PointType"] = base64.b64decode(self.data['PointType']) + + def wss_update(self, input): + existing_room_data = self.data["room_zone_info"] + + self.load_data(input) + + for i, room in enumerate(self.data["room_zone_info"]): + existing_room = next(room for room in existing_room_data if room["room_id"] == self.data["room_zone_info"][i]["room_id"]) + self.data["room_zone_info"][i]["room_name"] = existing_room["room_name"] + + + def get_map_bitmap(self): + """Parse MapData into 8-Bit lightness (grayscale) bitmap, return it as bytes""" + self.map_bitmap = bytearray(b"") + for i in range(0, len(self.map_data)): + byte = self.map_data[i] + + self.map_bitmap.append(((byte & 192) >> 6) * 85) + self.map_bitmap.append(((byte & 48) >> 4) * 85) + self.map_bitmap.append(((byte & 12) >> 2) * 85) + self.map_bitmap.append((byte & 3) * 85) + + return self.map_bitmap + + def get_map_image(self, black = (0x1c, 0x89, 0xE3), white = (0xFF, 0xFF, 0xFF)): + """Get a PIL image of the current map""" + + if(not self.map_bitmap): + self.get_map_bitmap() + + img = Image.frombytes("L", (int(self.data['MapWidth']), int(self.data['MapHigh'])), bytes(self.map_bitmap)) + img = ImageOps.colorize(img, black, white) + img = img.convert('RGBA') + + img_data = img.getdata() + + new_img_data = [] + + for pixel in img_data: + if pixel[0] == 255 and pixel[1] == 255 and pixel[2] == 255: + new_img_data.append((255, 255, 255, 0)) + else: + new_img_data.append(pixel) + + img.putdata(new_img_data) + del new_img_data + + + + img = img.resize((int((self.get_map_width()) * self.map_scale), int((self.get_map_height()) * self.map_scale)), Image.NEAREST) + return img + + def get_map_width(self): + return self.data["MapWidth"] + + def get_map_height(self): + return self.data["MapHigh"] + + def get_map_resolution(self): + return self.data["MapResolution"] + + def get_room_id_by_name(self, name): + return next(room for room in self.data["room_zone_info"] if room["room_name"] == name)["room_id"] + + def get_room_by_id(self, id): + return VacMapRoom(next(room for room in self.data["room_zone_info"] if room["room_id"] == id)) + + def get_rooms(self): + rooms = list() + + for room in self.data["room_zone_info"]: + rooms.append(VacMapRoom(room)) + + return rooms + + def get_room_by_name(self, name): + if(id := self.get_room_id_by_name(name)): + return self.get_room_by_id(id) + return None + + def get_charger_point_pixel(self): + return self._scale_up_pixel_coords(self._pixel_apply_offset((self.data["ChargerPoint"][0], self.data["ChargerPoint"][1]))) + + def get_charger_point_virtual(self): + return self._pixel_to_virtual(self.get_charger_point_pixel()) + + def get_robot_position_pixel(self): + path, point_types = self.get_path() + if(len(path) > 0): + return path[len(path) - 1] + return False + + def get_robot_position_virtual(self): + return self._pixel_to_virtual(self.get_robot_position_pixel()) + + + def get_path(self): + if "PointData" not in self.data: + return list(), list() + + point_data = io.BytesIO(self.data["PointData"]) + coords = list() + point_types = list() + + coords.append(self.get_charger_point_pixel()) + + while (x := point_data.read(2)): + coords.append(self._virtual_to_pixel((struct.unpack('h', x)[0], struct.unpack('h', point_data.read(2))[0]))) + + for i, coord in enumerate(coords): + byte = int(i * 2 / 8) + bit = int((i * 2 / 8 % 1) * 8) + + if(len(self.data["PointType"]) > byte): + value = (self.data["PointType"][byte] << bit) & 192 + else: + value = self.PATH_RELOCATING + + point_types.append(value) + + return coords, point_types + + def _pixel_apply_offset(self, coords): + """Apply origin offset to (x,y) pixel coordinates""" + x,y = coords + return x + self.data["MapOrigin"][0], y + self.data["MapOrigin"][1] + + def _scale_up_pixel_coords(self, coords): + """Scale coords up by MapResolution""" + x,y = coords + return x * self.map_scale, y * self.map_scale + + def _virtual_to_pixel_list(self, coords): + ret = list() + for coord in coords: + ret.append(self._virtual_to_pixel(coord)) + return ret + + def _virtual_to_pixel(self, coords): + """Convert virtual (laser map coordinates) to pixel coords, taking origin into account""" + x, y = coords + x, y = round((self.data['MapOrigin'][0] + (x * 2 * self.get_map_resolution())) * self.map_scale), round((self.data['MapOrigin'][1] + (y * 2 * self.get_map_resolution())) * self.map_scale) + return x , y + + def _pixel_to_virtual(self, coords): + x, y = coords + x, y = x / self.map_scale, y / self.map_scale + x, y = (x - self.data["MapOrigin"][0]) / self.get_map_resolution() / 2, (y - self.data["MapOrigin"][1]) / self.get_map_resolution() / 2 + return x, y + + def calibration_points(self): + cal = list() + + map_point = self._virtual_to_pixel((0,0)) + cal.append({ + "vacuum": {"x": 0, "y": 0}, + "map": {"x": int(map_point[0]), "y": int(map_point[1])} + }) + + map_point = self._virtual_to_pixel((self.get_map_width(),self.get_map_height())) + cal.append({ + "vacuum": {"x": self.get_map_width(), "y": self.get_map_height()}, + "map": {"x": int(map_point[0]), "y": int(map_point[1])} + }) + + map_point = self._virtual_to_pixel((0,self.get_map_height())) + cal.append({ + "vacuum": {"x": 0, "y": self.get_map_height()}, + "map": {"x": int(map_point[0]), "y": int(map_point[1])} + }) + + map_point = self._virtual_to_pixel((self.get_map_width(), 0)) + cal.append({ + "vacuum": {"x": self.get_map_width(), "y": 0}, + "map": {"x": int(map_point[0]), "y": int(map_point[1])} + }) + + return cal + + def get_predefined_selections(self): + all_rooms = self.get_rooms() + predefined_selections = list() + + for room in all_rooms: + predefined_selections.append(room.get_xaiomi_vacuum_map_card_rooms()) + + return predefined_selections \ No newline at end of file diff --git a/custom_components/weback_vacuum/WebackApi.py b/custom_components/weback_vacuum/WebackApi.py index 729b125..2cfc3c5 100644 --- a/custom_components/weback_vacuum/WebackApi.py +++ b/custom_components/weback_vacuum/WebackApi.py @@ -10,6 +10,8 @@ import httpx import websocket +from .VacMap import VacMap + _LOGGER = logging.getLogger(__name__) # Socket @@ -208,6 +210,34 @@ async def get_robot_list(self): else: _LOGGER.error(f"WebackApi failed to get robot list (details : {resp})") return [] + + async def get_reuse_map_by_id(self, id, sub_type, thing_name): + """ + Get reuse map object by id + """ + _LOGGER.debug(f"WebackApi ask : get reuse map {id}") + + params = { + "json": { + "opt": "reuse_map_get", + "map_id": str(id), + "sub_type": sub_type, + "thing_name": thing_name + }, + "headers": { + 'Token': self.jwt_token, + 'Region': self.region_name + } + } + + resp = await self.send_http(self.api_url, **params) + + if resp['msg'] == SUCCESS_OK: + _LOGGER.debug(f"WebackApi get reuse map OK") + return resp['data']['map_data'] + else: + _LOGGER.error(f"WebackApi failed to get reuse map (details : {resp})") + return [] @staticmethod async def send_http(url, **params): @@ -247,6 +277,7 @@ class WebackWssCtrl(WebackApi): CLEAN_MODE_EDGE_DETECT = 'EdgeDetect' CLEAN_MODE_SPOT = 'SpotClean' CLEAN_MODE_SINGLE_ROOM = 'RoomClean' + CLEAN_MODE_ROOMS = 'SelectClean' CLEAN_MODE_MOP = 'MopClean' CLEAN_MODE_SMART = 'SmartClean' CLEAN_MODE_Z = 'ZmodeClean' @@ -356,7 +387,7 @@ class WebackWssCtrl(WebackApi): CLEANING_STATES = { DIRECTION_CONTROL, ROBOT_PLANNING_RECT, RELOCATION, CLEAN_MODE_Z, CLEAN_MODE_AUTO, CLEAN_MODE_EDGE, CLEAN_MODE_EDGE_DETECT, CLEAN_MODE_SPOT, CLEAN_MODE_SINGLE_ROOM, - CLEAN_MODE_MOP, CLEAN_MODE_SMART + CLEAN_MODE_ROOMS, CLEAN_MODE_MOP, CLEAN_MODE_SMART } CHARGING_STATES = { @@ -373,6 +404,7 @@ class WebackWssCtrl(WebackApi): GOTO_POINT = "goto_point" RECTANGLE_INFO = "virtual_rect_info" SPEAKER_VOLUME = "volume" + SELECTED_ZONE = "selected_zone" # Payload switches VOICE_SWITCH = "voice_switch" UNDISTURB_MODE = "undisturb_mode" @@ -508,8 +540,10 @@ def on_message(self, ws, message): else: _LOGGER.debug('No update from cloud') elif wss_data["notify_info"] == MAP_DATA: - # TODO : MAP support - _LOGGER.debug(f"WebackApi (WSS) MAP data received") + _LOGGER.debug(f"WebackApi (WSS) Map data received") + self.map.wss_update(wss_data['map_data']) + self.render_map() + self._call_subscriber() else: _LOGGER.error(f"WebackApi (WSS) Received an unknown message from server : {wss_data}") @@ -583,8 +617,13 @@ async def update_status(self, thing_name, sub_type): """ _LOGGER.debug(f"WebackApi (WSS) update_status {thing_name}") payload = { - "opt": "thing_status_get", + "topic_name": "grit_tech/notify/server_2_device/" + thing_name, + "opt": "sync_thing", "sub_type": sub_type, + "topic_payload": { + "notify_info": "sync_thing", + "cmd_timestamp_s": int(time.time()) + }, "thing_name": thing_name, } await self.publish_wss(payload) diff --git a/custom_components/weback_vacuum/__init__.py b/custom_components/weback_vacuum/__init__.py index e138157..943f9e7 100644 --- a/custom_components/weback_vacuum/__init__.py +++ b/custom_components/weback_vacuum/__init__.py @@ -87,9 +87,11 @@ async def async_setup(hass, config): config[DOMAIN].get(CONF_CLIENT_ID), config[DOMAIN].get(CONF_API_VERSION), ) + await vacuum_device.load_maps() hass.data[DOMAIN].append(vacuum_device) if hass.data[DOMAIN]: _LOGGER.debug("Starting vacuum robot components") hass.helpers.discovery.load_platform("vacuum", DOMAIN, {}, config) + hass.helpers.discovery.load_platform("camera", DOMAIN, {}, config) return True diff --git a/custom_components/weback_vacuum/camera.py b/custom_components/weback_vacuum/camera.py new file mode 100644 index 0000000..bd2cc43 --- /dev/null +++ b/custom_components/weback_vacuum/camera.py @@ -0,0 +1,86 @@ +"""Support for Weback Vacuum Robot map camera.""" +import logging +import resource +import time + +import homeassistant.helpers.config_validation as cv +import voluptuous as vol +from homeassistant.components.vacuum import (STATE_CLEANING, STATE_DOCKED, + STATE_ERROR, STATE_IDLE, + STATE_PAUSED, STATE_RETURNING, + StateVacuumEntity, + VacuumEntityFeature) +from homeassistant.helpers import entity_platform +from homeassistant.helpers.icon import icon_for_battery_level +from homeassistant.helpers.entity import generate_entity_id +from homeassistant.components.camera import Camera, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, SUPPORT_ON_OFF, CameraEntityFeature + +from . import DOMAIN, VacDevice +from .VacMap import VacMap, VacMapDraw + +import io + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the camera entities for each robot""" + vacuums = [] + + for device in hass.data[DOMAIN]: + entity_id = generate_entity_id(ENTITY_ID_FORMAT, device.name, hass=hass) + vacuums.append(WebackVacuumCamera(device, entity_id)) + hass.loop.create_task(device.watch_state()) + + _LOGGER.debug("Adding Weback Vacuums Maps to Home Assistant: %s", vacuums) + + async_add_entities(vacuums) + +class WebackVacuumCamera(Camera): + """ + Weback Camera + """ + def __init__(self, device: VacDevice, entity_id): + """Initialize the Weback Vacuum Map""" + super().__init__() + self._vacdevice = device + # self.entity_id = entity_id + self.content_type = "image/png" + + # self._vacdevice.subscribe(lambda vacdevice: self.schedule_update_ha_state(False)) + + self._error = None + + # self._attr_supported_features = () + _LOGGER.info(f"Vacuum Camera initialized: {self.name}") + + @property + def name(self): + """Return the name of the device.""" + return self._vacdevice.nickname + " Map" + + @property + def unique_id(self) -> str: + """Return an unique ID.""" + return self._vacdevice.name + "_map" + + @property + def extra_state_attributes(self): + attributes = {} + if(self._vacdevice.map is not None): + attributes["calibration_points"] = self._vacdevice.map.calibration_points() + attributes["rooms"] = self._vacdevice.map.get_predefined_selections() + + + return attributes + + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: + """Return bytes of camera image.""" + if(self._vacdevice.map): + return self.generate_image() + else: + return None + + def generate_image(self): + return self._vacdevice.map_image_buffer diff --git a/custom_components/weback_vacuum/vacuum.py b/custom_components/weback_vacuum/vacuum.py index cb6dae8..8c53d4b 100644 --- a/custom_components/weback_vacuum/vacuum.py +++ b/custom_components/weback_vacuum/vacuum.py @@ -22,6 +22,7 @@ VacDevice.CLEAN_MODE_EDGE_DETECT: STATE_CLEANING, VacDevice.CLEAN_MODE_SPOT: STATE_CLEANING, VacDevice.CLEAN_MODE_SINGLE_ROOM: STATE_CLEANING, + VacDevice.CLEAN_MODE_ROOMS: STATE_CLEANING, VacDevice.CLEAN_MODE_MOP: STATE_CLEANING, VacDevice.CLEAN_MODE_SMART: STATE_CLEANING, VacDevice.ROBOT_PLANNING_LOCATION: STATE_CLEANING, @@ -297,4 +298,7 @@ async def async_clean_rectangle(self, rectangle: str): async def async_send_command(self, command, params=None, **kwargs): """Send a command to a vacuum cleaner.""" _LOGGER.debug(f"Vacuum: send_command (command={command} / params={params} / kwargs={kwargs})") - await self.device.send_command(self.name, self.sub, params) + if(command == 'app_segment_clean'): + await self.device.clean_room(params) + else: + await self.device.send_command(self.name, self.sub, params)