Skip to content

Commit

Permalink
feat: add vacuum map generation
Browse files Browse the repository at this point in the history
  • Loading branch information
deblockt committed Jun 11, 2020
1 parent 6f96b5a commit 5f67c01
Show file tree
Hide file tree
Showing 6 changed files with 185 additions and 14 deletions.
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -47,6 +48,34 @@ deviceId, token and userId can be retrieved using the Proscenic robotic applicat
2. click on the line `<your_vacuum_ip>: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

Expand Down
4 changes: 3 additions & 1 deletion custom_components/proscenic/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,7 @@
"codeowners": [
"deblockt"
],
"requirements": []
"requirements": [
"drawSvg==1.6.0"
]
}
9 changes: 6 additions & 3 deletions custom_components/proscenic/vacuum.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
})

Expand All @@ -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
}
Expand All @@ -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)
Expand Down
84 changes: 84 additions & 0 deletions custom_components/proscenic/vacuum_map_generator.py
Original file line number Diff line number Diff line change
@@ -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')
73 changes: 63 additions & 10 deletions custom_components/proscenic/vacuum_proscenic.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import json
from enum import Enum
import logging
from .vacuum_map_generator import build_map

_LOGGER = logging.getLogger(__name__)

Expand All @@ -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"}')

Expand All @@ -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":"' \
Expand All @@ -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')
Expand All @@ -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()

Expand Down
Binary file added doc/map.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 5f67c01

Please sign in to comment.