From 5f67c015b820c0ef982c31b450b4a34957c1b9fb Mon Sep 17 00:00:00 2001 From: Deblock Thomas Date: Thu, 11 Jun 2020 09:14:33 +0200 Subject: [PATCH] feat: add vacuum map generation --- README.md | 29 ++++++ custom_components/proscenic/manifest.json | 4 +- custom_components/proscenic/vacuum.py | 9 +- .../proscenic/vacuum_map_generator.py | 84 ++++++++++++++++++ .../proscenic/vacuum_proscenic.py | 73 ++++++++++++--- doc/map.png | Bin 0 -> 11839 bytes 6 files changed, 185 insertions(+), 14 deletions(-) create mode 100644 custom_components/proscenic/vacuum_map_generator.py create mode 100644 doc/map.png diff --git a/README.md b/README.md index 7b0b48e..d75c8ae 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ It allow home assistant to: - pause cleaning - go to dock - retrieve vacuum informations (battery, state) +- show the cleaning map ![screenshot](./doc/screen.png) @@ -47,6 +48,34 @@ deviceId, token and userId can be retrieved using the Proscenic robotic applicat 2. click on the line `:8888` 3. get you informations ![screenshot](./doc/packet_with_info.jpg) 5. you can add your vacuum on lovelace ui entities + 1. You can simply add it as an entity + 2. You can use the [vacuum-card](https://github.com/denysdovhan/vacuum-card) + +## Cleaning map management + +The vacuum cleaning map can be displayed on lovelace-ui. + +![map](./doc/map.png) + +to work you should add a camera entity. + +``` yaml +camera: + - platform: local_file + name: vacuum_map + file_path: "/tmp/proscenic_vacuum_map.svg" +``` + +You can use this camera on lovelace to show the map. + +The default path to generate the map is `/tmp/proscenic_vacuum_map.svg`. You can define another using this configuration : + +``` yaml +vacuum: + - platform: proscenic + map_path: "your_custome_map_path" +``` + ## Know issue diff --git a/custom_components/proscenic/manifest.json b/custom_components/proscenic/manifest.json index 1d55243..d603bda 100644 --- a/custom_components/proscenic/manifest.json +++ b/custom_components/proscenic/manifest.json @@ -7,5 +7,7 @@ "codeowners": [ "deblockt" ], - "requirements": [] + "requirements": [ + "drawSvg==1.6.0" + ] } diff --git a/custom_components/proscenic/vacuum.py b/custom_components/proscenic/vacuum.py index 74c9710..dc9819c 100644 --- a/custom_components/proscenic/vacuum.py +++ b/custom_components/proscenic/vacuum.py @@ -50,6 +50,7 @@ CONF_USER_ID = 'userId' CONF_SLEEP = 'sleep_duration_on_exit' CONF_AUTH_CODE = 'authCode' +CONF_MAP_PATH='map_path' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -58,6 +59,7 @@ vol.Required(CONF_USER_ID): cv.string, vol.Required(CONF_AUTH_CODE): cv.string, vol.Optional(CONF_SLEEP, default = 60): int, + vol.Optional(CONF_MAP_PATH, default = '/tmp/proscenic_vacuum_map.svg'): cv.string, vol.Optional(CONF_NAME): cv.string }) @@ -67,8 +69,8 @@ WorkState.PENDING: STATE_IDLE, WorkState.UNKNONW3: STATE_ERROR, WorkState.NEAR_BASE: STATE_DOCKED, - WorkState.POWER_OFF: STATE_PAUSED, - WorkState.OTHER_POWER_OFF: STATE_PAUSED, + WorkState.POWER_OFF: 'off', + WorkState.OTHER_POWER_OFF: 'off', WorkState.CHARGING: STATE_DOCKED, None: STATE_ERROR } @@ -86,9 +88,10 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= CONF_AUTH_CODE: config[CONF_AUTH_CODE] } name = config[CONF_NAME] if CONF_NAME in config else '790T vacuum' - device = Vacuum(config[CONF_HOST], auth, loop = hass.loop, config = {CONF_SLEEP: config[CONF_SLEEP]}) + device = Vacuum(config[CONF_HOST], auth, loop = hass.loop, config = {CONF_SLEEP: config[CONF_SLEEP], CONF_MAP_PATH: config[CONF_MAP_PATH]}) vacuums = [ProscenicVacuum(device, name)] hass.loop.create_task(device.listen_state_change()) + hass.loop.create_task(device.start_map_generation()) _LOGGER.debug("Adding 790T Vacuums to Home Assistant: %s", vacuums) async_add_entities(vacuums, update_before_add = False) diff --git a/custom_components/proscenic/vacuum_map_generator.py b/custom_components/proscenic/vacuum_map_generator.py new file mode 100644 index 0000000..3a844d5 --- /dev/null +++ b/custom_components/proscenic/vacuum_map_generator.py @@ -0,0 +1,84 @@ +import base64 +import struct +import drawSvg as draw + +def build_map(m, track, file_path): + inp = base64.b64decode(m) + d = struct.unpack('<' + 'B' * (len(inp)), inp) + full = [['.' for i in range(100)] for j in range(110)] + akt = 0 + i = 0 + + def placebyte(by): + pair = by + if pair & 0b10 == 0b10: + full[((akt + 3) // 100)][((akt + 3) % 100)] = '_' + elif pair & 0b01 == 0b01: + full[((akt + 3) // 100)][((akt + 3) % 100)] = '0' + pair = by >> 2 + if pair & 0b10 == 0b10: + full[((akt + 2) // 100)][((akt + 2) % 100)] = '_' + elif pair & 0b01 == 0b01: + full[((akt + 2) // 100)][((akt + 2) % 100)] = '0' + pair = by >> 4 + if pair & 0b10 == 0b10: + full[((akt + 1) // 100)][((akt + 1) % 100)] = '_' + elif pair & 0b01 == 0b01: + full[((akt + 1) // 100)][((akt + 1) % 100)] = '0' + pair = by >> 6 + if pair & 0b10 == 0b10: + full[(akt // 100)][(akt % 100)] = '_' + elif pair & 0b01 == 0b01: + full[(akt // 100)][(akt % 100)] = '0' + + while i < len(d): + if i >= 9: + if d[i] & 0b11000000 == 0b11000000: + mul = d[i] & 0b00111111 + if d[i + 1] & 0b11000000 == 0b11000000: + i += 1 + mul <<= 6 + mul |= (d[i] & 0b00111111) + for rep in range(mul): + placebyte(d[i + 1]) + akt += 4 + i += 1 + else: + placebyte(d[i]) + akt = akt + 4 + i += 1 + + # print("\n".join(["".join(map(str,fline)) for fline in full])) + + wallx = [] + wally = [] + floorx = [] + floory = [] + for idy,l in enumerate(full): + for idx,r in enumerate(l): + if r == '0': + wallx.append(idx) + wally.append(idy) + if r == '_': + floorx.append(idx) + floory.append(idy) + + inp = base64.b64decode(track) + path = struct.unpack('<' + 'b'*(len(inp)-4), inp[4:]) + + d = draw.Drawing(500, 550) + for i in range(len(wallx)): + d.append(draw.Rectangle(wallx[i] * 5, 500 - (wally[i] * 5), 5, 5, fill='#000000', fill_opacity=0.7)) + + for i in range(len(floorx)): + d.append(draw.Rectangle(floorx[i] * 5, 500 - (floory[i] * 5), 5, 5, fill='#000000', fill_opacity=0.3)) + + draw_path = [((coord * 5) + 2.5 if i % 2 == 0 else (500 - (coord * 5)) + 2.5) for i, coord in enumerate(path)] + d.append(draw.Lines(*draw_path, fill="white", fill_opacity=0, stroke = 'black', stroke_opacity = 0.8)) + + d.saveSvg(file_path) + +#m = "AAAAAAAAZABkwvIAFUDXABXCVUDVABaqwlXVAFbCqqlA1ABmw6pUVUDSAGbDqqTCVVDRAFbDqqVqqpDRAFbDqqVVqJDRAFqqqaqlVqSQ0QBaqpZqwqqkkNEAWqqZmsKqpJDRAFqqmWrCqqSQ0QAVVapawqqkkNIABVVawqqkkNQAFsKqpJDUABbCqqWQ1AAWwqqplNQAFsOqpdQAGsOqqUDTABrDqppQ0wAaw6qmkNMAFWrCqplQ0wAFw6qZQNMABcOqmkDTAAXDqplA0wAFw6pJQNMABcOqSdQABalqqkpA0wBWqVqqolDTAGqkFqqmkNIAAWqkBqqklNIAAaqQBqqkkNIAAaqQBqqkkNIAAZqQGqqklVTRAAGqkCqqpaqk0QABqpAqw6qoFNAAAaqQFqqlqpqk0AAGqpAqqqWqkKTQAAaqkCrDqlWU0AAGqpQaqsKWqZDQAAaqpGqqlqqpoNAABaqpasKqpalo0AABWqqawqpWqmqA0AAaxKpVwqrRABVVaqpaw6rSAAFVmcSq0wABVVbCVVbVAAVAAALYAALYAAVA0P0A" +#track = "ASg+ATI0NDQrNCs1KzM2MzYyNzIgMiEyHDIcMRoxIDEfMR4wGTAZLxgvHS8cLhcuFy0cLRwsFywYKxwrHCoYKhgpHCkcKBgoGCccJxwmGSYZJR0lHSQZJBojHiMdIhsiHiIeIRshHyEfICAgGyAcHxsfKB8oHhseHB4bHhsdKB0oHBwcHBspGygaGhoaGSgZJxgaGBoXJhclFhoWGhUlFSUUGhQaEyUTJRIZEhkRJRElEBgQGA8XDyUPJQ4mDh4OHw0fDh8NIA4hDiINJg0lDCIMIQ0hDCENHA4NDg0NHA0cDA0MDQsaCxkKDgoOCRcJEQkPCA4IDg8NDxYPFBANEA0RFBEUEhESDxEhICcgJyEgISEiJyInIygjIiMjJCkkKSUjJSMmKSYpJyMnIygpKCkpIikiKiEqLiouKy8rLyowKjArKyssKywsMCwvLC8tIS0iLSEtIi4pLiguKC8hLyEwLTAtLysvMi8yMDAwMDEzMTAxMzE1MjUxMS4yLiwuKSwpKysqLSooLCsuKzEiMSIzLjMqNCk0NjQkNCQzIDMgMhoyGjEZMRkwGDAXLxctFy4XLBgqGCYZJhgmGCUZJRkkGiQcIhwbGhsaGhsYGxUaFBoSGRIYEBgRFxAWERcRFhIWExcTExMTEg4SDg4PDg8JFAkUChoKHAwdDB0OHg4eDx8PHxAfDx8QIBAhDiENIw0hDSEOIg0oDScNKA0oGCkYKRkqGSoaKxorGywbKxwqHCodKx0qHSoeKx4qHiohKiAqISshKyIqIisjKyQtJCwlLCgtKCwoLCkxKTEqMioyKzQrNCw1LDQsNSw1KzUsNCw0LzUvNTA2MDYyNzM3Nw==" + +#build_map(m, track, 'map.svg') \ No newline at end of file diff --git a/custom_components/proscenic/vacuum_proscenic.py b/custom_components/proscenic/vacuum_proscenic.py index 01c483b..eef4896 100644 --- a/custom_components/proscenic/vacuum_proscenic.py +++ b/custom_components/proscenic/vacuum_proscenic.py @@ -2,6 +2,7 @@ import json from enum import Enum import logging +from .vacuum_map_generator import build_map _LOGGER = logging.getLogger(__name__) @@ -27,19 +28,29 @@ def __init__(self, ip, auth, loop = None, config = {}): self.auth = auth self.device_id = auth['deviceId'] self.sleep_duration_on_exit = config['sleep_duration_on_exit'] if 'sleep_duration_on_exit' in config else 60 - + self.map_path = config['map_path'] if 'map_path' in config else '/tmp/map.svg' + + async def start_map_generation(self): + while True: + try: + await self._wait_for_map_input() + except: + _LOGGER.debug('can not contact the vacuum. Wait 60 second before retry. (maybe that the vacuum switch is off)') + await asyncio.sleep(60) + pass + async def listen_state_change(self): try: await self._refresh_loop() except: _LOGGER.exception('error while listening proscenic vacuum state change') - + def subcribe(self, subscriber): self.listner.append(subscriber) - + async def clean(self): await self._send_command(b'{"transitCmd":"100"}') - + async def stop(self): await self._send_command(b'{"transitCmd":"102"}') @@ -66,18 +77,15 @@ async def _send_command(self, command: bytes, input_writer = None): _LOGGER.debug('send command {}'.format(str(body))) writer.write(header + body) await writer.drain() - - if not input_writer: - writer.close(); - await writer.wait_closed() except OSError: raise VacuumUnavailable('can not connect to the vacuum. Turn on the physical switch button.') async def _ping(self, writer): + _LOGGER.debug('send ping request') body = b'\x14\x00\x00\x00\x00\x01\xc8\x00\x00\x00\x01\x00\x22\x27\x00\x00\x00\x00\x00\x00' writer.write(body) await writer.drain() - + async def _login(self, writer): header = b'\xfb\x00\x00\x00\x10\x00\xc8\x00\x00\x00\x29\x27\x2a\x27\x00\x00\x00\x00\x00\x00' body = b'{"cmd":0,"control":{"targetId":""},"seq":0,"value":{"appKey":"67ce4fabe562405d9492cad9097e09bf","deviceId":"' \ @@ -95,7 +103,7 @@ async def _wait_for_state_refresh(self, reader): while not disconnected: data = await reader.read(1000) if data != b'': - _LOGGER.debug(str(data)) + _LOGGER.debug('receive from state refresh: {}'.format(str(data))) data = self._extract_json(data) if data and'msg' in data and data['msg'] == 'exit succeed': _LOGGER.warn('receive exit succeed - I have been disconnected') @@ -120,12 +128,57 @@ async def _wait_for_state_refresh(self, reader): return disconnected + async def _get_map(self): + (reader, writer) = await asyncio.open_connection(self.ip, 8888, loop = self.loop) + await self._send_command(b'{"transitCmd":"131"}', writer) + read_data = '' + while True: + data = await reader.read(1000) + if data == b'': + break + try: + read_data = read_data + data.decode() + #_LOGGER.info('read data {}'.format(read_data)) + nb_openning = read_data.count('{') + nb_close = read_data.count('}') + if nb_openning > 0 and nb_openning == nb_close: + #_LOGGER.info('return valid json {}'.format(read_data)) + return read_data + elif nb_close > nb_openning: + #_LOGGER.info('malformed json json {}'.format(read_data)) + read_data = '' + except: + _LOGGER.error('unreadable data {}'.format(data)) + read_data = '' + + async def _wait_for_map_input(self): + while True: + try: + if self.work_state == WorkState.CLEANING: + _LOGGER.debug('try to get the map') + data = await asyncio.wait_for(self._get_map(), timeout=60.0) + if data: + _LOGGER.info('receive map {}'.format(data)) + json = self._extract_json(str.encode(data)) + if 'value' in json and 'map' in json['value']: + build_map(json['value']['map'], json['value']['track'], self.map_path) + await asyncio.sleep(5) + else: + _LOGGER.debug('do not get the map. The vacuum is not cleaning. Waiting 30 seconds') + await asyncio.sleep(30) + except ConnectionResetError: + await asyncio.sleep(60) + except asyncio.TimeoutError: + _LOGGER.error('unable to get map on time') + async def verify_vacuum_online(self): try: + _LOGGER.debug('verify vacuum online') await self._send_command(b'{"transitCmd":"131"}') if self.work_state == WorkState.POWER_OFF or self.work_state == WorkState.OTHER_POWER_OFF: self.work_state = WorkState.PENDING except VacuumUnavailable: + _LOGGER.debug('the vacuum is unavailable') self.work_state = WorkState.POWER_OFF self._call_listners() diff --git a/doc/map.png b/doc/map.png new file mode 100644 index 0000000000000000000000000000000000000000..86d730a4cf722481be072fdcc63092d54a8d0d7c GIT binary patch literal 11839 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