From 0848f06160ba8c00edc618e062cca6134a827f4f Mon Sep 17 00:00:00 2001 From: Thomas Deblock Date: Fri, 12 Jun 2020 15:17:43 +0200 Subject: [PATCH 01/30] Add by me a coffe badge --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3ced3b6..07e230c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Home assistant proscenic 790T vacuum integration -the purpose of this integration is to provide an integration of proscenic 790T vacuum. +[![buymeacoffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/deblockt) + +The purpose of this integration is to provide an integration of proscenic 790T vacuum. It allow home assistant to: - start cleaning - pause cleaning From 27fa353cd587c1131a217b1e2563711caaba577a Mon Sep 17 00:00:00 2001 From: Deblock Thomas Date: Fri, 12 Jun 2020 18:49:46 +0200 Subject: [PATCH 02/30] feat: add vacuum error management close #9 --- README.md | 10 ++++++++ custom_components/proscenic/vacuum.py | 5 +++- .../proscenic/vacuum_proscenic.py | 24 +++++++++++++++---- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 07e230c..0c8c415 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,16 @@ tap_action: action: more-info ``` + + +## Available attributes + +Theses attributes are available to be displayed on lovelace-ui: +- `clear_area`: number of m2 cleaned +- `clear_duration`: last clean duration in second +- `error_code`: the current error code, if vacuum is on error status +- `error_detail`: the current error message (in english), if vacuum is on error status + ## Know issue - At home assistant startup the vacuum cleaner status is not retrieved. You should perform an action on home assistant to get the vacuum cleaner status. diff --git a/custom_components/proscenic/vacuum.py b/custom_components/proscenic/vacuum.py index 1945aaf..ba1c050 100644 --- a/custom_components/proscenic/vacuum.py +++ b/custom_components/proscenic/vacuum.py @@ -68,6 +68,7 @@ WorkState.CLEANING: STATE_CLEANING, WorkState.PENDING: STATE_IDLE, WorkState.UNKNONW3: STATE_ERROR, + WorkState.ERROR: STATE_ERROR, WorkState.NEAR_BASE: STATE_DOCKED, WorkState.POWER_OFF: 'off', WorkState.OTHER_POWER_OFF: 'off', @@ -201,5 +202,7 @@ def device_state_attributes(self): """Return the device-specific state attributes of this vacuum.""" return { 'clear_area': self.device.last_clear_area, - 'clear_duration': None if not self.device.last_clear_duration else (self.device.last_clear_duration // 60) + 'clear_duration': None if not self.device.last_clear_duration else (self.device.last_clear_duration // 60), + 'error_code': self.device.error_code, + 'error_detail': self.device.error_detail } \ No newline at end of file diff --git a/custom_components/proscenic/vacuum_proscenic.py b/custom_components/proscenic/vacuum_proscenic.py index ad3a0bd..0176ad3 100644 --- a/custom_components/proscenic/vacuum_proscenic.py +++ b/custom_components/proscenic/vacuum_proscenic.py @@ -15,6 +15,12 @@ class WorkState(Enum): CHARGING = 6 POWER_OFF = 7 OTHER_POWER_OFF = 0 + ERROR = 1111111 + +ERROR_CODES = { + '14': 'the left wheel is suspended', + '13': 'the right wheel is suspended' +} class Vacuum(): @@ -22,6 +28,8 @@ def __init__(self, ip, auth, loop = None, config = {}): self.ip = ip self.battery = None self.fan_speed = 2 + self.error_code = None + self.error_detail = None self.work_state = WorkState.CHARGING self.last_clear_area = None self.last_clear_duration = None @@ -113,10 +121,18 @@ async def _wait_for_state_refresh(self, reader): elif data and 'value' in data: values = data['value'] if 'workState' in values and values['workState'] != '': - try: - self.work_state = WorkState(int(values['workState'])) - except: - logging.exception('error setting work state {}'.format(str(values['workState']))) + if 'error' in values and values['error'] != '' and values['error'] != '0': + self.error_code = values['error'] + self.error_detail = ERROR_CODES[self.error_code] if self.error_code in ERROR_CODES else None + self.work_state = WorkState.ERROR + else: + try: + self.work_state = WorkState(int(values['workState'])) + except: + logging.exception('error setting work state {}'.format(str(values['workState']))) + if self.work_state != WorkState.ERROR: + self.error_code = None + self.error_detail = None if self.work_state != WorkState.POWER_OFF: if 'battery' in values and values['battery'] != '': self.battery = int(values['battery']) From b43404568f0bac9ebe602e95778045d82832deda Mon Sep 17 00:00:00 2001 From: Deblock Thomas Date: Fri, 12 Jun 2020 20:06:32 +0200 Subject: [PATCH 03/30] feat: Add error code detail for code 3 --- custom_components/proscenic/vacuum_proscenic.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/custom_components/proscenic/vacuum_proscenic.py b/custom_components/proscenic/vacuum_proscenic.py index 0176ad3..5e94165 100644 --- a/custom_components/proscenic/vacuum_proscenic.py +++ b/custom_components/proscenic/vacuum_proscenic.py @@ -19,7 +19,8 @@ class WorkState(Enum): ERROR_CODES = { '14': 'the left wheel is suspended', - '13': 'the right wheel is suspended' + '13': 'the right wheel is suspended', + '3': 'Power switch is not switched on during charging' } class Vacuum(): From e5a63d862d38615f8839665a2caa5cd07d7e2d12 Mon Sep 17 00:00:00 2001 From: Deblock Thomas Date: Wed, 17 Jun 2020 20:55:05 +0200 Subject: [PATCH 04/30] refactor: use svgwrite insteadof drawSvg #4 --- custom_components/proscenic/manifest.json | 2 +- .../proscenic/vacuum_map_generator.py | 19 +++++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/custom_components/proscenic/manifest.json b/custom_components/proscenic/manifest.json index d603bda..6b4ebd6 100644 --- a/custom_components/proscenic/manifest.json +++ b/custom_components/proscenic/manifest.json @@ -8,6 +8,6 @@ "deblockt" ], "requirements": [ - "drawSvg==1.6.0" + "svgwrite==1.4" ] } diff --git a/custom_components/proscenic/vacuum_map_generator.py b/custom_components/proscenic/vacuum_map_generator.py index 3a844d5..eecbc6c 100644 --- a/custom_components/proscenic/vacuum_map_generator.py +++ b/custom_components/proscenic/vacuum_map_generator.py @@ -1,6 +1,6 @@ import base64 import struct -import drawSvg as draw +import svgwrite def build_map(m, track, file_path): inp = base64.b64decode(m) @@ -66,17 +66,24 @@ def placebyte(by): inp = base64.b64decode(track) path = struct.unpack('<' + 'b'*(len(inp)-4), inp[4:]) - d = draw.Drawing(500, 550) + dwg = svgwrite.Drawing(file_path, size=(500,500)) + 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)) + dwg.add(dwg.rect(insert=(wallx[i] * 5, 500 - (wally[i] * 5)), size=(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)) + dwg.add(dwg.rect(insert=(floorx[i] * 5, 500 - (floory[i] * 5)), size=(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)) + dwg.add(dwg.circle(center = (draw_path[-2], draw_path[-1]), r = 2.5, fill='#000000', fill_opacity=0.65)) - d.saveSvg(file_path) + dwg_path = dwg.path(['M{},{}'.format(draw_path[0], draw_path[1])], fill="white", fill_opacity=0, stroke = 'black', stroke_opacity = 0.6) + + for i in range(len(draw_path) // 2): + dwg_path.push('L{},{}'.format(draw_path[2*i], draw_path[2*i+1])) + + dwg.add(dwg_path) + dwg.save() #m = "AAAAAAAAZABkwvIAFUDXABXCVUDVABaqwlXVAFbCqqlA1ABmw6pUVUDSAGbDqqTCVVDRAFbDqqVqqpDRAFbDqqVVqJDRAFqqqaqlVqSQ0QBaqpZqwqqkkNEAWqqZmsKqpJDRAFqqmWrCqqSQ0QAVVapawqqkkNIABVVawqqkkNQAFsKqpJDUABbCqqWQ1AAWwqqplNQAFsOqpdQAGsOqqUDTABrDqppQ0wAaw6qmkNMAFWrCqplQ0wAFw6qZQNMABcOqmkDTAAXDqplA0wAFw6pJQNMABcOqSdQABalqqkpA0wBWqVqqolDTAGqkFqqmkNIAAWqkBqqklNIAAaqQBqqkkNIAAaqQBqqkkNIAAZqQGqqklVTRAAGqkCqqpaqk0QABqpAqw6qoFNAAAaqQFqqlqpqk0AAGqpAqqqWqkKTQAAaqkCrDqlWU0AAGqpQaqsKWqZDQAAaqpGqqlqqpoNAABaqpasKqpalo0AABWqqawqpWqmqA0AAaxKpVwqrRABVVaqpaw6rSAAFVmcSq0wABVVbCVVbVAAVAAALYAALYAAVA0P0A" #track = "ASg+ATI0NDQrNCs1KzM2MzYyNzIgMiEyHDIcMRoxIDEfMR4wGTAZLxgvHS8cLhcuFy0cLRwsFywYKxwrHCoYKhgpHCkcKBgoGCccJxwmGSYZJR0lHSQZJBojHiMdIhsiHiIeIRshHyEfICAgGyAcHxsfKB8oHhseHB4bHhsdKB0oHBwcHBspGygaGhoaGSgZJxgaGBoXJhclFhoWGhUlFSUUGhQaEyUTJRIZEhkRJRElEBgQGA8XDyUPJQ4mDh4OHw0fDh8NIA4hDiINJg0lDCIMIQ0hDCENHA4NDg0NHA0cDA0MDQsaCxkKDgoOCRcJEQkPCA4IDg8NDxYPFBANEA0RFBEUEhESDxEhICcgJyEgISEiJyInIygjIiMjJCkkKSUjJSMmKSYpJyMnIygpKCkpIikiKiEqLiouKy8rLyowKjArKyssKywsMCwvLC8tIS0iLSEtIi4pLiguKC8hLyEwLTAtLysvMi8yMDAwMDEzMTAxMzE1MjUxMS4yLiwuKSwpKysqLSooLCsuKzEiMSIzLjMqNCk0NjQkNCQzIDMgMhoyGjEZMRkwGDAXLxctFy4XLBgqGCYZJhgmGCUZJRkkGiQcIhwbGhsaGhsYGxUaFBoSGRIYEBgRFxAWERcRFhIWExcTExMTEg4SDg4PDg8JFAkUChoKHAwdDB0OHg4eDx8PHxAfDx8QIBAhDiENIw0hDSEOIg0oDScNKA0oGCkYKRkqGSoaKxorGywbKxwqHCodKx0qHSoeKx4qHiohKiAqISshKyIqIisjKyQtJCwlLCgtKCwoLCkxKTEqMioyKzQrNCw1LDQsNSw1KzUsNCw0LzUvNTA2MDYyNzM3Nw==" From cf03e3c6a16125d328739717b13d6dadd167bc63 Mon Sep 17 00:00:00 2001 From: Deblock Thomas Date: Thu, 18 Jun 2020 09:14:18 +0200 Subject: [PATCH 05/30] feat: update map size in function of clear area --- .../proscenic/vacuum_map_generator.py | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/custom_components/proscenic/vacuum_map_generator.py b/custom_components/proscenic/vacuum_map_generator.py index eecbc6c..20ef315 100644 --- a/custom_components/proscenic/vacuum_map_generator.py +++ b/custom_components/proscenic/vacuum_map_generator.py @@ -2,6 +2,11 @@ import struct import svgwrite + +cell_size=5 +min_image_height=250 +min_image_width=450 + def build_map(m, track, file_path): inp = base64.b64decode(m) d = struct.unpack('<' + 'B' * (len(inp)), inp) @@ -66,16 +71,28 @@ def placebyte(by): inp = base64.b64decode(track) path = struct.unpack('<' + 'b'*(len(inp)-4), inp[4:]) - dwg = svgwrite.Drawing(file_path, size=(500,500)) + min_x=min(wallx) + min_y=min(wally) + map_width=(max(wallx) - min_x) * cell_size + map_height=(max(wally) - min_y) * cell_size + + if map_width < min_image_width: + min_x = min_x - ((min_image_width - map_width) / 2) // cell_size + map_width = min_image_width + if map_height < min_image_height: + min_y = min_y - ((min_image_height - map_height) / 2) // cell_size + map_height = min_image_height + + dwg = svgwrite.Drawing(file_path, size=(map_width, map_height)) for i in range(len(wallx)): - dwg.add(dwg.rect(insert=(wallx[i] * 5, 500 - (wally[i] * 5)), size=(5, 5), fill='#000000', fill_opacity=0.7)) + dwg.add(dwg.rect(insert=((wallx[i] - min_x) * cell_size, map_height - ((wally[i] - min_y) * cell_size)), size=(cell_size, cell_size), fill='#000000', fill_opacity=0.7)) for i in range(len(floorx)): - dwg.add(dwg.rect(insert=(floorx[i] * 5, 500 - (floory[i] * 5)), size=(5, 5), fill='#000000', fill_opacity=0.3)) + dwg.add(dwg.rect(insert=((floorx[i] - min_x) * cell_size, map_height - ((floory[i] - min_y) * cell_size)), size=(cell_size, cell_size), 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)] - dwg.add(dwg.circle(center = (draw_path[-2], draw_path[-1]), r = 2.5, fill='#000000', fill_opacity=0.65)) + draw_path = [(((coord - min_x) * cell_size) + (cell_size / 2) if i % 2 == 0 else (map_height - ((coord - min_y) * cell_size)) + (cell_size / 2)) for i, coord in enumerate(path)] + dwg.add(dwg.circle(center = (draw_path[-2], draw_path[-1]), r = (cell_size / 2), fill='#000000', fill_opacity=0.65)) dwg_path = dwg.path(['M{},{}'.format(draw_path[0], draw_path[1])], fill="white", fill_opacity=0, stroke = 'black', stroke_opacity = 0.6) @@ -88,4 +105,8 @@ def placebyte(by): #m = "AAAAAAAAZABkwvIAFUDXABXCVUDVABaqwlXVAFbCqqlA1ABmw6pUVUDSAGbDqqTCVVDRAFbDqqVqqpDRAFbDqqVVqJDRAFqqqaqlVqSQ0QBaqpZqwqqkkNEAWqqZmsKqpJDRAFqqmWrCqqSQ0QAVVapawqqkkNIABVVawqqkkNQAFsKqpJDUABbCqqWQ1AAWwqqplNQAFsOqpdQAGsOqqUDTABrDqppQ0wAaw6qmkNMAFWrCqplQ0wAFw6qZQNMABcOqmkDTAAXDqplA0wAFw6pJQNMABcOqSdQABalqqkpA0wBWqVqqolDTAGqkFqqmkNIAAWqkBqqklNIAAaqQBqqkkNIAAaqQBqqkkNIAAZqQGqqklVTRAAGqkCqqpaqk0QABqpAqw6qoFNAAAaqQFqqlqpqk0AAGqpAqqqWqkKTQAAaqkCrDqlWU0AAGqpQaqsKWqZDQAAaqpGqqlqqpoNAABaqpasKqpalo0AABWqqawqpWqmqA0AAaxKpVwqrRABVVaqpaw6rSAAFVmcSq0wABVVbCVVbVAAVAAALYAALYAAVA0P0A" #track = "ASg+ATI0NDQrNCs1KzM2MzYyNzIgMiEyHDIcMRoxIDEfMR4wGTAZLxgvHS8cLhcuFy0cLRwsFywYKxwrHCoYKhgpHCkcKBgoGCccJxwmGSYZJR0lHSQZJBojHiMdIhsiHiIeIRshHyEfICAgGyAcHxsfKB8oHhseHB4bHhsdKB0oHBwcHBspGygaGhoaGSgZJxgaGBoXJhclFhoWGhUlFSUUGhQaEyUTJRIZEhkRJRElEBgQGA8XDyUPJQ4mDh4OHw0fDh8NIA4hDiINJg0lDCIMIQ0hDCENHA4NDg0NHA0cDA0MDQsaCxkKDgoOCRcJEQkPCA4IDg8NDxYPFBANEA0RFBEUEhESDxEhICcgJyEgISEiJyInIygjIiMjJCkkKSUjJSMmKSYpJyMnIygpKCkpIikiKiEqLiouKy8rLyowKjArKyssKywsMCwvLC8tIS0iLSEtIi4pLiguKC8hLyEwLTAtLysvMi8yMDAwMDEzMTAxMzE1MjUxMS4yLiwuKSwpKysqLSooLCsuKzEiMSIzLjMqNCk0NjQkNCQzIDMgMhoyGjEZMRkwGDAXLxctFy4XLBgqGCYZJhgmGCUZJRkkGiQcIhwbGhsaGhsYGxUaFBoSGRIYEBgRFxAWERcRFhIWExcTExMTEg4SDg4PDg8JFAkUChoKHAwdDB0OHg4eDx8PHxAfDx8QIBAhDiENIw0hDSEOIg0oDScNKA0oGCkYKRkqGSoaKxorGywbKxwqHCodKx0qHSoeKx4qHiohKiAqISshKyIqIisjKyQtJCwlLCgtKCwoLCkxKTEqMioyKzQrNCw1LDQsNSw1KzUsNCw0LzUvNTA2MDYyNzM3Nw==" -#build_map(m, track, 'map.svg') \ No newline at end of file +#build_map(m, track, 'map.svg') + +#m = "AAAAAAAAZABk0vYAxqqVQNAAAceqQNAAAcKqmsOqqUDQAAHCqpDXAAKg0ucA" +#track = "AQQHADIxMzEYMRgyIDIgMx8z" +#build_map(m, track, 'map.svg') From 1c9c098976ec19a69cb5e6e2c315c90fb76275ad Mon Sep 17 00:00:00 2001 From: Deblock Thomas Date: Mon, 6 Jul 2020 09:09:11 +0200 Subject: [PATCH 06/30] ci(hass): update codeowner --- custom_components/proscenic/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/proscenic/manifest.json b/custom_components/proscenic/manifest.json index 6b4ebd6..7bbc0c6 100644 --- a/custom_components/proscenic/manifest.json +++ b/custom_components/proscenic/manifest.json @@ -5,7 +5,7 @@ "issue_tracker": "https://github.com/deblockt/hass-proscenic-790T-vacuum/issues", "dependencies": [], "codeowners": [ - "deblockt" + "@deblockt" ], "requirements": [ "svgwrite==1.4" From 293ea96bc31fbf33e02331c8eaeafc98165bfec5 Mon Sep 17 00:00:00 2001 From: Deblock Thomas Date: Mon, 6 Jul 2020 09:12:57 +0200 Subject: [PATCH 07/30] ci(hass): add info.md --- info.md | 107 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 info.md diff --git a/info.md b/info.md new file mode 100644 index 0000000..f1b48bb --- /dev/null +++ b/info.md @@ -0,0 +1,107 @@ +# Home assistant proscenic 790T vacuum integration + +[![buymeacoffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/deblockt) + +The purpose of this integration is to provide an integration of proscenic 790T vacuum. +It allow home assistant to: +- start cleaning +- pause cleaning +- go to dock +- retrieve vacuum informations (battery, state) +- show the cleaning map + +![screenshot](./doc/screen.png) + +## Configuration + +To add your vacuum on home assistant, you should add this: + +``` yaml +vacuum: + - platform: proscenic + host: "" + deviceId: "" + token: "" + authCode: "" + userId: "" + name: "" + sleep_duration_on_exit: # default 60. number of second waiting before reconnection (if you use proscenic app) +``` + +deviceId, token and userId can be retrieved using the Proscenic robotic application : +1. On your smartphone, install [Packet capture](https://play.google.com/store/apps/details?id=app.greyshirts.sslcapture&hl=fr) +2. Open Packet capture and start a capture ![screenshot](./doc/packet_capture_button.png) select Proscenic Robotic app +3. Open the proscenic application, and open the vacuum view +4. Reopen Packet capture + 1. click on the first line + 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 + +![map](./doc/map.png) + +### Configuration + +The vacuum cleaning map can be displayed on lovelace-ui (it will be displayed only after the first vacuum clean process). + +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" +``` + +### Add to lovelace + + To display the camera on lovelace, you can : +- use the [vacuum-card](https://github.com/denysdovhan/vacuum-card) configure the card using code editor and add map property `map: camera.vacuum_map`. +``` yaml +entity: vacuum.my_vacuum +image: default +map: camera.vacuum_map +type: 'custom:vacuum-card' +``` + +- or use a card of type `picture-entity` +``` yaml +type: picture-entity +entity: vacuum.my_vacuum +camera_image: camera.vacuum_map +aspect_ratio: 100% +show_state: true +show_name: true +tap_action: + action: more-info +``` + + + +## Available attributes + +Theses attributes are available to be displayed on lovelace-ui: +- `clear_area`: number of m2 cleaned +- `clear_duration`: last clean duration in second +- `error_code`: the current error code, if vacuum is on error status +- `error_detail`: the current error message (in english), if vacuum is on error status + +## Know issue + +- At home assistant startup the vacuum cleaner status is not retrieved. You should perform an action on home assistant to get the vacuum cleaner status. +- If you start the proscenic application, the status of the vacuum cleaner will not be refreshed on home assistant for 60 seconds. +- If you start the proscenic application, you will be disconnected 60 seconds later. You can configure this time using `sleep_duration_on_exit` configuration. From a1f749c0b2c77c37300adec8dfcec6c6077e67b9 Mon Sep 17 00:00:00 2001 From: Thomas Deblock Date: Wed, 28 Jul 2021 14:03:07 +0200 Subject: [PATCH 08/30] ci: update the hacs action --- .github/workflows/validate.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index caf6643..494e475 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -12,7 +12,7 @@ jobs: steps: - uses: "actions/checkout@v2" - name: HACS validation - uses: "hacs/integration/action@master" + uses: "hacs/integration/action@main" with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CATEGORY: "integration" From ddfa488914e3499d2587be3d9fe8dea5208974f8 Mon Sep 17 00:00:00 2001 From: Thomas Deblock Date: Wed, 28 Jul 2021 14:03:41 +0200 Subject: [PATCH 09/30] feat: use config_flow to add create entity --- custom_components/proscenic/__init__.py | 13 ++++ custom_components/proscenic/config_flow.py | 72 +++++++++++++++++++ custom_components/proscenic/const.py | 11 +++ custom_components/proscenic/manifest.json | 5 +- custom_components/proscenic/strings.json | 29 ++++++++ .../proscenic/translations/en.json | 30 ++++++++ .../proscenic/translations/fr.json | 30 ++++++++ custom_components/proscenic/vacuum.py | 44 ++++++++---- .../proscenic/vacuum_map_generator.py | 0 .../proscenic/vacuum_proscenic.py | 28 ++++---- 10 files changed, 236 insertions(+), 26 deletions(-) mode change 100644 => 100755 custom_components/proscenic/__init__.py create mode 100755 custom_components/proscenic/config_flow.py create mode 100755 custom_components/proscenic/const.py mode change 100644 => 100755 custom_components/proscenic/manifest.json create mode 100755 custom_components/proscenic/strings.json create mode 100755 custom_components/proscenic/translations/en.json create mode 100755 custom_components/proscenic/translations/fr.json mode change 100644 => 100755 custom_components/proscenic/vacuum.py mode change 100644 => 100755 custom_components/proscenic/vacuum_map_generator.py mode change 100644 => 100755 custom_components/proscenic/vacuum_proscenic.py diff --git a/custom_components/proscenic/__init__.py b/custom_components/proscenic/__init__.py old mode 100644 new mode 100755 index e69de29..f3cf303 --- a/custom_components/proscenic/__init__.py +++ b/custom_components/proscenic/__init__.py @@ -0,0 +1,13 @@ +from .const import DOMAIN, CONF_SLEEP, CONF_MAP_PATH, DEFAULT_CONF_MAP_PATH, DEFAULT_CONF_SLEEP + +async def async_setup_entry(hass, entry): + """Test""" + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = dict(entry.data) + hass.data[DOMAIN][entry.entry_id][CONF_SLEEP] = entry.options.get(CONF_SLEEP, DEFAULT_CONF_SLEEP) + hass.data[DOMAIN][entry.entry_id][CONF_MAP_PATH] = entry.options.get(CONF_MAP_PATH, DEFAULT_CONF_MAP_PATH) + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, 'vacuum') + ) + return True diff --git a/custom_components/proscenic/config_flow.py b/custom_components/proscenic/config_flow.py new file mode 100755 index 0000000..4f1bb6e --- /dev/null +++ b/custom_components/proscenic/config_flow.py @@ -0,0 +1,72 @@ +from homeassistant import config_entries +from .const import DOMAIN + +import voluptuous as vol + +from homeassistant.core import callback + +from homeassistant.const import CONF_HOST +from .const import CONF_DEVICE_ID, CONF_TOKEN, CONF_USER_ID, CONF_AUTH_CODE, CONF_SLEEP, CONF_MAP_PATH, DEFAULT_CONF_MAP_PATH, DEFAULT_CONF_SLEEP + +class ProscenicConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + VERSION = 1 + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get option flow.""" + return ProscenicOptionsFlowHandler(config_entry) + + async def async_step_user(self, user_input=None): + if user_input is not None: + # See next section on create entry usage + return self.async_create_entry( + title="proscenic vacuum configuration", + data= { + CONF_HOST: user_input[CONF_HOST], + CONF_DEVICE_ID: user_input[CONF_DEVICE_ID], + CONF_TOKEN: user_input[CONF_TOKEN], + CONF_USER_ID: user_input[CONF_USER_ID], + CONF_AUTH_CODE: user_input[CONF_AUTH_CODE] + } + ) + + + schema = { + vol.Required(CONF_HOST): str, + vol.Required(CONF_DEVICE_ID): str, + vol.Required(CONF_TOKEN): str, + vol.Required(CONF_USER_ID): str, + vol.Required(CONF_AUTH_CODE): str + } + + return self.async_show_form( + step_id='user', data_schema=vol.Schema(schema) + ) + +class ProscenicOptionsFlowHandler(config_entries.OptionsFlow): + """Handle option.""" + + def __init__(self, config_entry): + """Initialize the options flow.""" + self.config_entry = config_entry + self._sleep_duration_on_exit = self.config_entry.options.get( + CONF_SLEEP, DEFAULT_CONF_SLEEP + ) + self._map_path = self.config_entry.options.get( + CONF_MAP_PATH, DEFAULT_CONF_MAP_PATH + ) + + async def async_step_init(self, user_input=None): + """Handle a flow initialized by the user.""" + options_schema = vol.Schema( + { + vol.Required(CONF_SLEEP, default = self._sleep_duration_on_exit): int, + vol.Required(CONF_MAP_PATH, default = self._map_path): str, + }, + ) + + if user_input is not None: + return self.async_create_entry(title='proscenic vacuum configuration', data=user_input) + + return self.async_show_form(step_id='init', data_schema=options_schema) diff --git a/custom_components/proscenic/const.py b/custom_components/proscenic/const.py new file mode 100755 index 0000000..a30a011 --- /dev/null +++ b/custom_components/proscenic/const.py @@ -0,0 +1,11 @@ +DOMAIN="proscenic" + +CONF_DEVICE_ID = 'deviceId' +CONF_TOKEN = 'token' +CONF_USER_ID = 'userId' +CONF_SLEEP = 'sleep_duration_on_exit' +CONF_AUTH_CODE = 'authCode' +CONF_MAP_PATH = 'map_path' + +DEFAULT_CONF_SLEEP = 60 +DEFAULT_CONF_MAP_PATH = '/tmp/proscenic_vacuum_map.svg' \ No newline at end of file diff --git a/custom_components/proscenic/manifest.json b/custom_components/proscenic/manifest.json old mode 100644 new mode 100755 index 7bbc0c6..e9de152 --- a/custom_components/proscenic/manifest.json +++ b/custom_components/proscenic/manifest.json @@ -1,6 +1,7 @@ { "domain": "proscenic", "name": "proscenic-vacuum", + "version": "0.0.5", "documentation": "https://github.com/deblockt/hass-proscenic-790T-vacuum", "issue_tracker": "https://github.com/deblockt/hass-proscenic-790T-vacuum/issues", "dependencies": [], @@ -9,5 +10,7 @@ ], "requirements": [ "svgwrite==1.4" - ] + ], + "iot_class": "local_polling", + "config_flow": true } diff --git a/custom_components/proscenic/strings.json b/custom_components/proscenic/strings.json new file mode 100755 index 0000000..95a562c --- /dev/null +++ b/custom_components/proscenic/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "title": "[%key:components::proscenic::config::step::user::title%]", + "description": "[%key:components::proscenic::config::step::user::description%]", + "data": { + "host": "[%key:components::proscenic::config::step::user::data::host%]", + "deviceId": "[%key:components::proscenic::config::step::user::data::deviceId%]", + "token": "[%key:components::proscenic::config::step::user::data::token%]", + "userId": "[%key:components::proscenic::config::step::user::data::userId%]", + "authCode": "[%key:components::proscenic::config::step::user::data::authCode%]" + } + } + } + }, + "options": { + "step": { + "init": { + "title": "[%key:components::proscenic::options::step::init::title%]", + "description": "[%key:components::proscenic::options::step::init::description%]", + "data": { + "sleep_duration_on_exit": "[%key:components::proscenic::config::step::user::data::sleep_duration_on_exit%]", + "map_path": "[%key:components::proscenic::config::step::user::data::map_path%]" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/proscenic/translations/en.json b/custom_components/proscenic/translations/en.json new file mode 100755 index 0000000..c47cde9 --- /dev/null +++ b/custom_components/proscenic/translations/en.json @@ -0,0 +1,30 @@ +{ + "config": { + "title": "Proscenic vacuum", + "step": { + "user": { + "title": "Vacuum proscenic configuration", + "description": "To get connection informations read the [user guide](https://github.com/deblockt/hass-proscenic-790T-vacuum#configuration)", + "data": { + "host": "host of the vacuum", + "deviceId": "device id", + "token": "authentication token", + "userId": "user id", + "authCode": "authentication code" + } + } + } + }, + "options": { + "step": { + "init": { + "title": "Vacuum proscenic options", + "description": "You should restart home assistant to take this options on account.", + "data": { + "sleep_duration_on_exit": "duration to reconnect to vacuum when offline", + "map_path": "path the save vacuum map" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/proscenic/translations/fr.json b/custom_components/proscenic/translations/fr.json new file mode 100755 index 0000000..544bd15 --- /dev/null +++ b/custom_components/proscenic/translations/fr.json @@ -0,0 +1,30 @@ +{ + "config": { + "title": "Aspirateur proscenic", + "step": { + "user": { + "title": "Configuration de l'aspirateur proscenic", + "description": "Pour récupérer les information de connexion merci de lire la [notice d'installation](https://github.com/deblockt/hass-proscenic-790T-vacuum#configuration).", + "data": { + "host": "Address Ip de l'aspirateur", + "deviceId": "id de l'aspirateur", + "token": "token d'authentification", + "userId": "id d'utilisateur", + "authCode": "code d'authentification" + } + } + } + }, + "options": { + "step": { + "init": { + "title": "Options de l'aspirateur proscenic", + "description": "Vous devez redémarrer home assistant pour prendre ces options en compte.", + "data": { + "sleep_duration_on_exit": "durée avant reconnexion à l'aspiration quand il est eteind", + "map_path": "chemin du fichier pour sauvegarder la carte" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/proscenic/vacuum.py b/custom_components/proscenic/vacuum.py old mode 100644 new mode 100755 index ba1c050..f41ad20 --- a/custom_components/proscenic/vacuum.py +++ b/custom_components/proscenic/vacuum.py @@ -33,6 +33,8 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv +from .const import DOMAIN, CONF_DEVICE_ID, CONF_TOKEN, CONF_USER_ID, CONF_SLEEP, CONF_AUTH_CODE, CONF_MAP_PATH, DEFAULT_CONF_SLEEP, DEFAULT_CONF_MAP_PATH + _LOGGER = logging.getLogger(__name__) SUPPORT_PROSCENIC = ( @@ -45,21 +47,14 @@ # | SUPPORT_LOCATE ) -CONF_DEVICE_ID = 'deviceId' -CONF_TOKEN = 'token' -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, vol.Required(CONF_DEVICE_ID): cv.string, vol.Required(CONF_TOKEN): cv.string, 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_SLEEP, default = DEFAULT_CONF_SLEEP): int, + vol.Optional(CONF_MAP_PATH, default = DEFAULT_CONF_MAP_PATH): cv.string, vol.Optional(CONF_NAME): cv.string }) @@ -76,12 +71,33 @@ None: STATE_ERROR } -ATTR_ERROR = "error" -ATTR_COMPONENT_PREFIX = "component_" +async def async_setup_entry(hass, config_entry, async_add_entities): + """Test""" + _LOGGER.info("setup entry " + str(hass.data[DOMAIN][config_entry.entry_id])) + config = hass.data[DOMAIN][config_entry.entry_id] + conf_sleep = config[CONF_SLEEP] if CONF_SLEEP in config else DEFAULT_CONF_SLEEP + conf_map_path = config[CONF_MAP_PATH] if CONF_MAP_PATH in config else DEFAULT_CONF_MAP_PATH + + auth = { + CONF_DEVICE_ID: config[CONF_DEVICE_ID], + CONF_TOKEN: config[CONF_TOKEN], + CONF_USER_ID: config[CONF_USER_ID], + CONF_AUTH_CODE: config[CONF_AUTH_CODE] + } + + device = Vacuum(config[CONF_HOST], auth, loop = hass.loop, config = {CONF_SLEEP: conf_sleep, CONF_MAP_PATH: conf_map_path}) + vacuums = [ProscenicVacuum(device, config[CONF_DEVICE_ID])] + 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) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the 790T vacuums.""" + _LOGGER.info("setup platform ") + _LOGGER.warn("Proscenic vacuum integration yaml configuration is now deprecated. You should configure the integration using the UI.") auth = { CONF_DEVICE_ID: config[CONF_DEVICE_ID], CONF_TOKEN: config[CONF_TOKEN], @@ -93,7 +109,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= 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) @@ -122,6 +137,11 @@ def unique_id(self) -> str: """Return an unique ID.""" return self.device.device_id + @property + def device_info(self): + """Return the device info.""" + return {"identifiers": {(DOMAIN, self.device.device_id)}} + @property def is_on(self): """Return true if vacuum is currently cleaning.""" diff --git a/custom_components/proscenic/vacuum_map_generator.py b/custom_components/proscenic/vacuum_map_generator.py old mode 100644 new mode 100755 diff --git a/custom_components/proscenic/vacuum_proscenic.py b/custom_components/proscenic/vacuum_proscenic.py old mode 100644 new mode 100755 index 5e94165..028aa9f --- a/custom_components/proscenic/vacuum_proscenic.py +++ b/custom_components/proscenic/vacuum_proscenic.py @@ -4,6 +4,8 @@ import logging from .vacuum_map_generator import build_map +from .const import CONF_DEVICE_ID, CONF_AUTH_CODE, CONF_TOKEN, CONF_USER_ID, CONF_SLEEP, CONF_MAP_PATH, DEFAULT_CONF_MAP_PATH, DEFAULT_CONF_SLEEP + _LOGGER = logging.getLogger(__name__) class WorkState(Enum): @@ -37,9 +39,9 @@ def __init__(self, ip, auth, loop = None, config = {}): self.listner = [] self.loop = loop 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' + self.device_id = auth[CONF_DEVICE_ID] + self.sleep_duration_on_exit = config[CONF_SLEEP] if CONF_SLEEP in config else DEFAULT_CONF_SLEEP + self.map_path = config[CONF_MAP_PATH] if CONF_MAP_PATH in config else DEFAULT_CONF_MAP_PATH async def start_map_generation(self): while True: @@ -47,7 +49,7 @@ async def start_map_generation(self): 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) + await asyncio.sleep(self.sleep_duration_on_exit) pass async def listen_state_change(self): @@ -74,14 +76,14 @@ async def _send_command(self, command: bytes, input_writer = None): (_, writer) = await asyncio.open_connection(self.ip, 8888, loop = self.loop) else: writer = input_writer - + header = b'\xd2\x00\x00\x00\xfa\x00\xc8\x00\x00\x00\xeb\x27\xea\x27\x00\x00\x00\x00\x00\x00' body = b'{"cmd":0,"control":{"authCode":"' \ - + str.encode(self.auth['authCode']) \ + + str.encode(self.auth[CONF_AUTH_CODE]) \ + b'","deviceIp":"' \ + str.encode(self.ip) \ + b'","devicePort":"8888","targetId":"' \ - + str.encode(self.auth['deviceId']) \ + + str.encode(self.auth[CONF_DEVICE_ID]) \ + b'","targetType":"3"},"seq":0,"value":' \ + command \ + b',"version":"1.5.11"}' @@ -100,11 +102,11 @@ async def _ping(self, writer): 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":"' \ - + str.encode(self.auth['deviceId']) \ + + str.encode(self.auth[CONF_DEVICE_ID]) \ + b'","deviceType":"3","token":"' \ - + str.encode(self.auth['token']) \ + + str.encode(self.auth[CONF_TOKEN]) \ + b'","userId":"' \ - + str.encode(self.auth['userId']) \ + + str.encode(self.auth[CONF_USER_ID]) \ + b'"}}' writer.write(header + body) await writer.drain() @@ -139,7 +141,7 @@ async def _wait_for_state_refresh(self, reader): self.battery = int(values['battery']) if 'fan' in values and values['fan'] != '': self.fan_speed = int(values['fan']) - + self._call_listners() else: _LOGGER.warn('receive empty message - I have been disconnected') @@ -193,7 +195,7 @@ async def _wait_for_map_input(self): _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) + await asyncio.sleep(60) except asyncio.TimeoutError: _LOGGER.error('unable to get map on time') @@ -205,7 +207,7 @@ async def verify_vacuum_online(self): self.work_state = WorkState.PENDING except VacuumUnavailable: _LOGGER.debug('the vacuum is unavailable') - self.work_state = WorkState.POWER_OFF + self.work_state = WorkState.POWER_OFF self._call_listners() async def _refresh_loop(self): From ad59032e5fab401af4c8ee281ef91df7dc5d3da6 Mon Sep 17 00:00:00 2001 From: Thomas Deblock Date: Wed, 28 Jul 2021 16:31:01 +0200 Subject: [PATCH 10/30] doc: update the readme for new configuration flow --- README.md | 79 ++++--------- .../proscenic/translations/en.json | 2 +- .../proscenic/translations/fr.json | 2 +- hacs.json | 5 +- info.md | 107 ------------------ 5 files changed, 28 insertions(+), 167 deletions(-) delete mode 100644 info.md diff --git a/README.md b/README.md index 0c8c415..8850b0c 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # Home assistant proscenic 790T vacuum integration -[![buymeacoffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/deblockt) +[![GitHub release](https://img.shields.io/github/release/deblockt/hass-proscenic-790T-vacuum)](https://github.com/deblockt/hass-proscenic-790T-vacuum/releases/latest) +[![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/custom-components/hacs) -The purpose of this integration is to provide an integration of proscenic 790T vacuum. +The purpose of this integration is to provide an integration of proscenic 790T vacuum. It allow home assistant to: - start cleaning - pause cleaning @@ -16,7 +17,7 @@ It allow home assistant to: ### HACS installation -TODO +You can use [HACS](https://hacs.xyz/) to install this component. Search for the Integration "proscenic 790T vacuum" ### Manual installation @@ -27,32 +28,27 @@ TODO ## Configuration -To add your vacuum on home assistant, you should add this: +Add your device via the Integration menu. -``` yaml -vacuum: - - platform: proscenic - host: "" - deviceId: "" - token: "" - authCode: "" - userId: "" - name: "" - sleep_duration_on_exit: # default 60. number of second waiting before reconnection (if you use proscenic app) -``` +[![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=proscenic) -deviceId, token and userId can be retrieved using the Proscenic robotic application : -1. On your smartphone, install [Packet capture](https://play.google.com/store/apps/details?id=app.greyshirts.sslcapture&hl=fr) +### Get authentifications data + +device id, token, user id and authentication code can be retrieved using the Proscenic robotic application : +1. On your android smartphone (no solution for iphone), install [Packet capture](https://play.google.com/store/apps/details?id=app.greyshirts.sslcapture&hl=fr) 2. Open Packet capture and start a capture ![screenshot](./doc/packet_capture_button.png) select Proscenic Robotic app 3. Open the proscenic application, and open the vacuum view -4. Reopen Packet capture - 1. click on the first line - 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 +4. Reopen Packet capture + 1. Click on the first line + 2. Click on the line `:8888` + 3. Get you informations ![screenshot](./doc/packet_with_info.jpg) +5. You can now enter your informations on home assistant +6. 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) +> **Note**: YAML configuration is deprecated. This will be removed soon. + ## Cleaning map management ![map](./doc/map.png) @@ -65,45 +61,14 @@ to work you should add a camera entity. ``` yaml camera: - - platform: local_file + - 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" -``` - -### Add to lovelace - - To display the camera on lovelace, you can : -- use the [vacuum-card](https://github.com/denysdovhan/vacuum-card) configure the card using code editor and add map property `map: camera.vacuum_map`. -``` yaml -entity: vacuum.my_vacuum -image: default -map: camera.vacuum_map -type: 'custom:vacuum-card' -``` - -- or use a card of type `picture-entity` -``` yaml -type: picture-entity -entity: vacuum.my_vacuum -camera_image: camera.vacuum_map -aspect_ratio: 100% -show_state: true -show_name: true -tap_action: - action: more-info -``` - - +The default path to generate the map is `/tmp/proscenic_vacuum_map.svg`. You can define another using the option on Integration menu. ## Available attributes @@ -115,6 +80,8 @@ Theses attributes are available to be displayed on lovelace-ui: ## Know issue -- At home assistant startup the vacuum cleaner status is not retrieved. You should perform an action on home assistant to get the vacuum cleaner status. +- At home assistant startup the vacuum cleaner status is not retrieved. You should perform an action on home assistant to get the vacuum cleaner status. - If you start the proscenic application, the status of the vacuum cleaner will not be refreshed on home assistant for 60 seconds. - If you start the proscenic application, you will be disconnected 60 seconds later. You can configure this time using `sleep_duration_on_exit` configuration. + +[![buymeacoffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/deblockt) diff --git a/custom_components/proscenic/translations/en.json b/custom_components/proscenic/translations/en.json index c47cde9..62db4f5 100755 --- a/custom_components/proscenic/translations/en.json +++ b/custom_components/proscenic/translations/en.json @@ -4,7 +4,7 @@ "step": { "user": { "title": "Vacuum proscenic configuration", - "description": "To get connection informations read the [user guide](https://github.com/deblockt/hass-proscenic-790T-vacuum#configuration)", + "description": "To get connection informations read the [user guide](https://github.com/deblockt/hass-proscenic-790T-vacuum#get-authentifications-data)", "data": { "host": "host of the vacuum", "deviceId": "device id", diff --git a/custom_components/proscenic/translations/fr.json b/custom_components/proscenic/translations/fr.json index 544bd15..4b27ca0 100755 --- a/custom_components/proscenic/translations/fr.json +++ b/custom_components/proscenic/translations/fr.json @@ -4,7 +4,7 @@ "step": { "user": { "title": "Configuration de l'aspirateur proscenic", - "description": "Pour récupérer les information de connexion merci de lire la [notice d'installation](https://github.com/deblockt/hass-proscenic-790T-vacuum#configuration).", + "description": "Pour récupérer les information de connexion merci de lire la [notice d'installation](https://github.com/deblockt/hass-proscenic-790T-vacuum#get-authentifications-data).", "data": { "host": "Address Ip de l'aspirateur", "deviceId": "id de l'aspirateur", diff --git a/hacs.json b/hacs.json index 9cbfbc9..2b5c126 100644 --- a/hacs.json +++ b/hacs.json @@ -1,6 +1,7 @@ { "name": "proscenic 790T vacuum", "domains": ["vacuum"], - "homeassistant": "0.109.6", - "iot_class": "" + "homeassistant": "2021.7.4", + "iot_class": "local_polling", + "render_readme": true } \ No newline at end of file diff --git a/info.md b/info.md deleted file mode 100644 index f1b48bb..0000000 --- a/info.md +++ /dev/null @@ -1,107 +0,0 @@ -# Home assistant proscenic 790T vacuum integration - -[![buymeacoffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/deblockt) - -The purpose of this integration is to provide an integration of proscenic 790T vacuum. -It allow home assistant to: -- start cleaning -- pause cleaning -- go to dock -- retrieve vacuum informations (battery, state) -- show the cleaning map - -![screenshot](./doc/screen.png) - -## Configuration - -To add your vacuum on home assistant, you should add this: - -``` yaml -vacuum: - - platform: proscenic - host: "" - deviceId: "" - token: "" - authCode: "" - userId: "" - name: "" - sleep_duration_on_exit: # default 60. number of second waiting before reconnection (if you use proscenic app) -``` - -deviceId, token and userId can be retrieved using the Proscenic robotic application : -1. On your smartphone, install [Packet capture](https://play.google.com/store/apps/details?id=app.greyshirts.sslcapture&hl=fr) -2. Open Packet capture and start a capture ![screenshot](./doc/packet_capture_button.png) select Proscenic Robotic app -3. Open the proscenic application, and open the vacuum view -4. Reopen Packet capture - 1. click on the first line - 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 - -![map](./doc/map.png) - -### Configuration - -The vacuum cleaning map can be displayed on lovelace-ui (it will be displayed only after the first vacuum clean process). - -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" -``` - -### Add to lovelace - - To display the camera on lovelace, you can : -- use the [vacuum-card](https://github.com/denysdovhan/vacuum-card) configure the card using code editor and add map property `map: camera.vacuum_map`. -``` yaml -entity: vacuum.my_vacuum -image: default -map: camera.vacuum_map -type: 'custom:vacuum-card' -``` - -- or use a card of type `picture-entity` -``` yaml -type: picture-entity -entity: vacuum.my_vacuum -camera_image: camera.vacuum_map -aspect_ratio: 100% -show_state: true -show_name: true -tap_action: - action: more-info -``` - - - -## Available attributes - -Theses attributes are available to be displayed on lovelace-ui: -- `clear_area`: number of m2 cleaned -- `clear_duration`: last clean duration in second -- `error_code`: the current error code, if vacuum is on error status -- `error_detail`: the current error message (in english), if vacuum is on error status - -## Know issue - -- At home assistant startup the vacuum cleaner status is not retrieved. You should perform an action on home assistant to get the vacuum cleaner status. -- If you start the proscenic application, the status of the vacuum cleaner will not be refreshed on home assistant for 60 seconds. -- If you start the proscenic application, you will be disconnected 60 seconds later. You can configure this time using `sleep_duration_on_exit` configuration. From a2b05d0d261af899d10cf8fd22553f5297cfac1c Mon Sep 17 00:00:00 2001 From: Thomas Deblock Date: Wed, 28 Jul 2021 16:58:40 +0200 Subject: [PATCH 11/30] ci: upgrade version to 0.0.6 --- custom_components/proscenic/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/proscenic/manifest.json b/custom_components/proscenic/manifest.json index e9de152..36455b9 100755 --- a/custom_components/proscenic/manifest.json +++ b/custom_components/proscenic/manifest.json @@ -1,7 +1,7 @@ { "domain": "proscenic", "name": "proscenic-vacuum", - "version": "0.0.5", + "version": "0.0.6", "documentation": "https://github.com/deblockt/hass-proscenic-790T-vacuum", "issue_tracker": "https://github.com/deblockt/hass-proscenic-790T-vacuum/issues", "dependencies": [], From 64357f9ff0b68f6acc4d1c1e33071b028411738c Mon Sep 17 00:00:00 2001 From: Thomas Deblock Date: Wed, 28 Jul 2021 17:21:54 +0200 Subject: [PATCH 12/30] ci: add hassfest validation --- .github/workflows/hassfest.yaml | 14 ++++++++++++++ .github/workflows/validate.yaml | 5 ++--- 2 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/hassfest.yaml diff --git a/.github/workflows/hassfest.yaml b/.github/workflows/hassfest.yaml new file mode 100644 index 0000000..109eae9 --- /dev/null +++ b/.github/workflows/hassfest.yaml @@ -0,0 +1,14 @@ +name: Validate with hassfest + +on: + push: + pull_request: + schedule: + - cron: "0 0 * * *" + +jobs: + validate: + runs-on: "ubuntu-latest" + steps: + - uses: "actions/checkout@v2" + - uses: home-assistant/actions/hassfest@master \ No newline at end of file diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index 494e475..fc1b5f9 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -12,7 +12,6 @@ jobs: steps: - uses: "actions/checkout@v2" - name: HACS validation - uses: "hacs/integration/action@main" + uses: "hacs/action@main" with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CATEGORY: "integration" + category: "integration" From b4ba642d1ad86a8acdda9bb6cbbecd120a0d49e8 Mon Sep 17 00:00:00 2001 From: Thomas Deblock Date: Thu, 29 Jul 2021 14:18:48 +0200 Subject: [PATCH 13/30] feat: add camera entity --- README.md | 19 ++----------- custom_components/proscenic/__init__.py | 3 ++ custom_components/proscenic/camera.py | 34 +++++++++++++++++++++++ custom_components/proscenic/manifest.json | 4 ++- custom_components/proscenic/vacuum.py | 12 ++++---- 5 files changed, 50 insertions(+), 22 deletions(-) create mode 100755 custom_components/proscenic/camera.py diff --git a/README.md b/README.md index 8850b0c..d8d439b 100644 --- a/README.md +++ b/README.md @@ -53,22 +53,9 @@ device id, token, user id and authentication code can be retrieved using the Pro ![map](./doc/map.png) -### Configuration - -The vacuum cleaning map can be displayed on lovelace-ui (it will be displayed only after the first vacuum clean process). - -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 the option on Integration menu. +The camera entity will be automaticaly added. +The map is stored on your file system, the path is `/tmp/proscenic_vacuum_map.svg`. +The path can be updated on the integration options. ## Available attributes diff --git a/custom_components/proscenic/__init__.py b/custom_components/proscenic/__init__.py index f3cf303..6789075 100755 --- a/custom_components/proscenic/__init__.py +++ b/custom_components/proscenic/__init__.py @@ -10,4 +10,7 @@ async def async_setup_entry(hass, entry): hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, 'vacuum') ) + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, 'camera') + ) return True diff --git a/custom_components/proscenic/camera.py b/custom_components/proscenic/camera.py new file mode 100755 index 0000000..4859744 --- /dev/null +++ b/custom_components/proscenic/camera.py @@ -0,0 +1,34 @@ +"""Support for proscenic 790T Vaccum map.""" +import logging + +from .const import DOMAIN, CONF_MAP_PATH, DEFAULT_CONF_MAP_PATH, CONF_DEVICE_ID +from homeassistant.components.local_file.camera import LocalFile + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the 790T vacuums map camera.""" + config = hass.data[DOMAIN][config_entry.entry_id] + conf_map_path = config[CONF_MAP_PATH] if CONF_MAP_PATH in config else DEFAULT_CONF_MAP_PATH + device_id = config[CONF_DEVICE_ID] + + _LOGGER.debug("Adding 790T Vacuums camera to Home Assistant") + async_add_entities([ProscenicMapCamera(device_id, conf_map_path)], update_before_add = False) + + +class ProscenicMapCamera(LocalFile): + """Representation of a proscenic vacuum map camera.""" + + def __init__(self, device_id, file_path): + super().__init__(device_id + '_map', file_path) + self.device_id = device_id + + @property + def unique_id(self) -> str: + """Return an unique ID.""" + return "camera" + self.device_id + + @property + def device_info(self): + """Return the device info.""" + return {"identifiers": {(DOMAIN, self.device_id)}} \ No newline at end of file diff --git a/custom_components/proscenic/manifest.json b/custom_components/proscenic/manifest.json index 36455b9..7260573 100755 --- a/custom_components/proscenic/manifest.json +++ b/custom_components/proscenic/manifest.json @@ -4,7 +4,9 @@ "version": "0.0.6", "documentation": "https://github.com/deblockt/hass-proscenic-790T-vacuum", "issue_tracker": "https://github.com/deblockt/hass-proscenic-790T-vacuum/issues", - "dependencies": [], + "dependencies": [ + "local_file" + ], "codeowners": [ "@deblockt" ], diff --git a/custom_components/proscenic/vacuum.py b/custom_components/proscenic/vacuum.py index f41ad20..1de43fe 100755 --- a/custom_components/proscenic/vacuum.py +++ b/custom_components/proscenic/vacuum.py @@ -72,8 +72,7 @@ } async def async_setup_entry(hass, config_entry, async_add_entities): - """Test""" - _LOGGER.info("setup entry " + str(hass.data[DOMAIN][config_entry.entry_id])) + """Set up the 790T vacuums.""" config = hass.data[DOMAIN][config_entry.entry_id] conf_sleep = config[CONF_SLEEP] if CONF_SLEEP in config else DEFAULT_CONF_SLEEP conf_map_path = config[CONF_MAP_PATH] if CONF_MAP_PATH in config else DEFAULT_CONF_MAP_PATH @@ -96,7 +95,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the 790T vacuums.""" - _LOGGER.info("setup platform ") _LOGGER.warn("Proscenic vacuum integration yaml configuration is now deprecated. You should configure the integration using the UI.") auth = { CONF_DEVICE_ID: config[CONF_DEVICE_ID], @@ -135,12 +133,16 @@ def should_poll(self) -> bool: @property def unique_id(self) -> str: """Return an unique ID.""" - return self.device.device_id + return "vacuum" + self.device.device_id @property def device_info(self): """Return the device info.""" - return {"identifiers": {(DOMAIN, self.device.device_id)}} + return { + "identifiers": {(DOMAIN, self.device.device_id)}, + "name": "Proscenic vacuum", + "manufacturer": "Proscenic" + } @property def is_on(self): From ea8ca618039a50f23f3f3c73c2325cb5bb2f1a6e Mon Sep 17 00:00:00 2001 From: Thomas Deblock Date: Thu, 29 Jul 2021 15:14:27 +0200 Subject: [PATCH 14/30] ci: upgrade version to 0.0.7 --- custom_components/proscenic/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/proscenic/manifest.json b/custom_components/proscenic/manifest.json index 7260573..94737a3 100755 --- a/custom_components/proscenic/manifest.json +++ b/custom_components/proscenic/manifest.json @@ -1,7 +1,7 @@ { "domain": "proscenic", "name": "proscenic-vacuum", - "version": "0.0.6", + "version": "0.0.7", "documentation": "https://github.com/deblockt/hass-proscenic-790T-vacuum", "issue_tracker": "https://github.com/deblockt/hass-proscenic-790T-vacuum/issues", "dependencies": [ From 06e770fde9738a43b690e56fec37a426a4d7b5f1 Mon Sep 17 00:00:00 2001 From: Thomas Deblock Date: Sun, 31 Oct 2021 10:55:43 +0100 Subject: [PATCH 15/30] fix: startup issue with local_file dependencies --- custom_components/proscenic/camera.py | 50 ++++++++++++++++++----- custom_components/proscenic/manifest.json | 4 +- hacs.json | 12 +++--- 3 files changed, 47 insertions(+), 19 deletions(-) diff --git a/custom_components/proscenic/camera.py b/custom_components/proscenic/camera.py index 4859744..775c4a4 100755 --- a/custom_components/proscenic/camera.py +++ b/custom_components/proscenic/camera.py @@ -1,8 +1,10 @@ """Support for proscenic 790T Vaccum map.""" import logging +import os from .const import DOMAIN, CONF_MAP_PATH, DEFAULT_CONF_MAP_PATH, CONF_DEVICE_ID -from homeassistant.components.local_file.camera import LocalFile +from homeassistant.components.camera import Camera + _LOGGER = logging.getLogger(__name__) @@ -15,20 +17,48 @@ async def async_setup_entry(hass, config_entry, async_add_entities): _LOGGER.debug("Adding 790T Vacuums camera to Home Assistant") async_add_entities([ProscenicMapCamera(device_id, conf_map_path)], update_before_add = False) - -class ProscenicMapCamera(LocalFile): - """Representation of a proscenic vacuum map camera.""" +class ProscenicMapCamera(Camera): + """Representation of a local file camera.""" def __init__(self, device_id, file_path): - super().__init__(device_id + '_map', file_path) - self.device_id = device_id + """Initialize Local File Camera component.""" + super().__init__() + + self._device_id = device_id + self._file_path = file_path + self.content_type = 'image/svg+xml' + + def camera_image(self, width = None, height = None): + """Return image response.""" + try: + with open(self._file_path, "rb") as file: + return file.read() + except FileNotFoundError: + _LOGGER.info("Not map has been generated for the vacuum device %s", self._device_id) + + return b'' + + def update_file_path(self, file_path): + """Update the file_path.""" + self._file_path = file_path + self.schedule_update_ha_state() @property - def unique_id(self) -> str: - """Return an unique ID.""" - return "camera" + self.device_id + def name(self): + """Return the name of this camera.""" + return self._device_id + '_map' + + @property + def extra_state_attributes(self): + """Return the camera state attributes.""" + return {"file_path": self._file_path} @property def device_info(self): """Return the device info.""" - return {"identifiers": {(DOMAIN, self.device_id)}} \ No newline at end of file + return {"identifiers": {(DOMAIN, self._device_id)}} + + @property + def unique_id(self) -> str: + """Return an unique ID.""" + return "camera" + self._device_id diff --git a/custom_components/proscenic/manifest.json b/custom_components/proscenic/manifest.json index 94737a3..9727e68 100755 --- a/custom_components/proscenic/manifest.json +++ b/custom_components/proscenic/manifest.json @@ -4,9 +4,7 @@ "version": "0.0.7", "documentation": "https://github.com/deblockt/hass-proscenic-790T-vacuum", "issue_tracker": "https://github.com/deblockt/hass-proscenic-790T-vacuum/issues", - "dependencies": [ - "local_file" - ], + "dependencies": [], "codeowners": [ "@deblockt" ], diff --git a/hacs.json b/hacs.json index 2b5c126..265b452 100644 --- a/hacs.json +++ b/hacs.json @@ -1,7 +1,7 @@ { - "name": "proscenic 790T vacuum", - "domains": ["vacuum"], - "homeassistant": "2021.7.4", - "iot_class": "local_polling", - "render_readme": true - } \ No newline at end of file + "name": "proscenic 790T vacuum", + "domains": ["vacuum"], + "homeassistant": "2021.7.4", + "iot_class": "local_polling", + "render_readme": true +} \ No newline at end of file From dd7d8b7737a4a9bde558437b12f26cd6e4863abb Mon Sep 17 00:00:00 2001 From: Thomas Deblock Date: Sun, 31 Oct 2021 12:05:24 +0100 Subject: [PATCH 16/30] ci: add release process --- .github/workflows/release.yaml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/release.yaml diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..aece9c2 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,32 @@ +name: Release + +on: + workflow_dispatch: + branches: + - master + inputs: + version: + description: 'the new version number' + required: true + +jobs: + release: + runs-on: "ubuntu-latest" + steps: + - uses: "actions/checkout@v2" + - name: "update version number" + run: cat <<< $(jq '.version = "${{ github.event.inputs.version }}"' custom_components/proscenic/manifest.json) > custom_components/proscenic/manifest.json + - name: "commit version update" + uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: "ci: upgrade version to ${{ github.event.inputs.version }}" + - name: "zip custom_component" + run: cd custom_components/proscenic; zip -r ../../proscenic.zip * + - name: release + uses: ncipollo/release-action@v1 + id: create_release + with: + token: ${{ secrets.GITHUB_TOKEN }} + tag: ${{ github.event.inputs.version }} + artifacts: proscenic.zip + name: version ${{ github.event.inputs.version }} \ No newline at end of file From ba3cebcc0734ccaabed28ae758571b2a1a5a193b Mon Sep 17 00:00:00 2001 From: deblockt Date: Sun, 31 Oct 2021 11:09:23 +0000 Subject: [PATCH 17/30] ci: upgrade version to 0.0.8 --- custom_components/proscenic/manifest.json | 28 +++++++++++------------ 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/custom_components/proscenic/manifest.json b/custom_components/proscenic/manifest.json index 9727e68..b0d8362 100755 --- a/custom_components/proscenic/manifest.json +++ b/custom_components/proscenic/manifest.json @@ -1,16 +1,16 @@ { - "domain": "proscenic", - "name": "proscenic-vacuum", - "version": "0.0.7", - "documentation": "https://github.com/deblockt/hass-proscenic-790T-vacuum", - "issue_tracker": "https://github.com/deblockt/hass-proscenic-790T-vacuum/issues", - "dependencies": [], - "codeowners": [ - "@deblockt" - ], - "requirements": [ - "svgwrite==1.4" - ], - "iot_class": "local_polling", - "config_flow": true + "domain": "proscenic", + "name": "proscenic-vacuum", + "version": "0.0.8", + "documentation": "https://github.com/deblockt/hass-proscenic-790T-vacuum", + "issue_tracker": "https://github.com/deblockt/hass-proscenic-790T-vacuum/issues", + "dependencies": [], + "codeowners": [ + "@deblockt" + ], + "requirements": [ + "svgwrite==1.4" + ], + "iot_class": "local_polling", + "config_flow": true } From 9b5c6569ac29d55a77ce0080e7b1365b5f25fcf6 Mon Sep 17 00:00:00 2001 From: Thomas Deblock Date: Fri, 1 Apr 2022 14:50:26 +0200 Subject: [PATCH 18/30] feat: first try to add cloud support --- custom_components/proscenic/camera.py | 2 +- custom_components/proscenic/config_flow.py | 50 ++- custom_components/proscenic/const.py | 15 +- custom_components/proscenic/strings.json | 28 +- .../proscenic/translations/en.json | 10 +- .../proscenic/translations/fr.json | 10 +- custom_components/proscenic/vacuum.py | 19 +- .../proscenic/vacuum_map_generator.py | 7 +- .../proscenic/vacuum_proscenic.py | 363 +++++++++++------- 9 files changed, 321 insertions(+), 183 deletions(-) diff --git a/custom_components/proscenic/camera.py b/custom_components/proscenic/camera.py index 775c4a4..b0fbacc 100755 --- a/custom_components/proscenic/camera.py +++ b/custom_components/proscenic/camera.py @@ -34,7 +34,7 @@ def camera_image(self, width = None, height = None): with open(self._file_path, "rb") as file: return file.read() except FileNotFoundError: - _LOGGER.info("Not map has been generated for the vacuum device %s", self._device_id) + _LOGGER.info("No map has been generated for the vacuum device %s", self._device_id) return b'' diff --git a/custom_components/proscenic/config_flow.py b/custom_components/proscenic/config_flow.py index 4f1bb6e..c24a8e7 100755 --- a/custom_components/proscenic/config_flow.py +++ b/custom_components/proscenic/config_flow.py @@ -4,9 +4,10 @@ import voluptuous as vol from homeassistant.core import callback - from homeassistant.const import CONF_HOST -from .const import CONF_DEVICE_ID, CONF_TOKEN, CONF_USER_ID, CONF_AUTH_CODE, CONF_SLEEP, CONF_MAP_PATH, DEFAULT_CONF_MAP_PATH, DEFAULT_CONF_SLEEP +from homeassistant.helpers.selector import SelectSelector + +from .const import LOCAL_MODE, CLOUD_MODE, CONF_CONNECTION_MODE, CONF_DEVICE_ID, CONF_TARGET_ID, CONF_TOKEN, CONF_USER_ID, CONF_AUTH_CODE, CONF_SLEEP, CONF_MAP_PATH, DEFAULT_CONF_MAP_PATH, DEFAULT_CONF_SLEEP class ProscenicConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 @@ -18,12 +19,42 @@ def async_get_options_flow(config_entry): return ProscenicOptionsFlowHandler(config_entry) async def async_step_user(self, user_input=None): + schema = { + vol.Required(CONF_CONNECTION_MODE): vol.In(['cloud', 'local']) + } + + return self.async_show_form( + step_id='config_mode_selection', data_schema=vol.Schema(schema) + ) + + async def async_step_config_mode_selection(self, user_input=None): if user_input is not None: - # See next section on create entry usage + mode = user_input[CONF_CONNECTION_MODE] + schema = { + vol.Required(CONF_HOST): str, + vol.Required(CONF_DEVICE_ID): str, + vol.Required(CONF_TOKEN): str, + vol.Required(CONF_USER_ID): str, + vol.Required(CONF_AUTH_CODE): str + } + + if mode == 'cloud': + schema[vol.Required(CONF_TARGET_ID)] = str + + return self.async_show_form( + step_id='config_connection_info', data_schema=vol.Schema(schema) + ) + + return self.async_step_user(user_input) + + async def async_step_config_connection_info(self, user_input=None): + if user_input is not None and CONF_DEVICE_ID in user_input: return self.async_create_entry( title="proscenic vacuum configuration", data= { + CONF_CONNECTION_MODE: CLOUD_MODE if CONF_TARGET_ID in user_input else LOCAL_MODE, CONF_HOST: user_input[CONF_HOST], + CONF_TARGET_ID: user_input[CONF_TARGET_ID] if CONF_TARGET_ID in user_input else None, CONF_DEVICE_ID: user_input[CONF_DEVICE_ID], CONF_TOKEN: user_input[CONF_TOKEN], CONF_USER_ID: user_input[CONF_USER_ID], @@ -31,18 +62,7 @@ async def async_step_user(self, user_input=None): } ) - - schema = { - vol.Required(CONF_HOST): str, - vol.Required(CONF_DEVICE_ID): str, - vol.Required(CONF_TOKEN): str, - vol.Required(CONF_USER_ID): str, - vol.Required(CONF_AUTH_CODE): str - } - - return self.async_show_form( - step_id='user', data_schema=vol.Schema(schema) - ) + return self.async_step_user(user_input) class ProscenicOptionsFlowHandler(config_entries.OptionsFlow): """Handle option.""" diff --git a/custom_components/proscenic/const.py b/custom_components/proscenic/const.py index a30a011..9472c73 100755 --- a/custom_components/proscenic/const.py +++ b/custom_components/proscenic/const.py @@ -1,11 +1,24 @@ DOMAIN="proscenic" CONF_DEVICE_ID = 'deviceId' +CONF_TARGET_ID = 'targetId' CONF_TOKEN = 'token' CONF_USER_ID = 'userId' CONF_SLEEP = 'sleep_duration_on_exit' CONF_AUTH_CODE = 'authCode' CONF_MAP_PATH = 'map_path' +CONF_CONNECTION_MODE = 'connection_mode' DEFAULT_CONF_SLEEP = 60 -DEFAULT_CONF_MAP_PATH = '/tmp/proscenic_vacuum_map.svg' \ No newline at end of file +DEFAULT_CONF_MAP_PATH = '/tmp/proscenic_vacuum_map.svg' + +CLOUD_PROSCENIC_IP = '47.91.67.181' +CLOUD_PROSCENIC_PORT = 20008 + +LOCAL_MODE = 'local' +CLOUD_MODE = 'cloud' + +def get_or_default(dict, key, default): + if dict is None or not key in dict or dict[key] is None: + return default + return dict[key] \ No newline at end of file diff --git a/custom_components/proscenic/strings.json b/custom_components/proscenic/strings.json index 95a562c..16141c1 100755 --- a/custom_components/proscenic/strings.json +++ b/custom_components/proscenic/strings.json @@ -1,15 +1,23 @@ { "config": { "step": { - "user": { - "title": "[%key:components::proscenic::config::step::user::title%]", - "description": "[%key:components::proscenic::config::step::user::description%]", + "config_connection_info": { + "title": "[%key:components::proscenic::config::step::config_connection_info::title%]", + "description": "[%key:components::proscenic::config::step::config_connection_info::description%]", "data": { - "host": "[%key:components::proscenic::config::step::user::data::host%]", - "deviceId": "[%key:components::proscenic::config::step::user::data::deviceId%]", - "token": "[%key:components::proscenic::config::step::user::data::token%]", - "userId": "[%key:components::proscenic::config::step::user::data::userId%]", - "authCode": "[%key:components::proscenic::config::step::user::data::authCode%]" + "host": "[%key:components::proscenic::config::step::config_connection_info::data::host%]", + "deviceId": "[%key:components::proscenic::config::step::config_connection_info::data::deviceId%]", + "targetId": "[%key:components::proscenic::config::step::config_connection_info::data::targetId%]", + "token": "[%key:components::proscenic::config::step::config_connection_info::data::token%]", + "userId": "[%key:components::proscenic::config::step::config_connection_info::data::userId%]", + "authCode": "[%key:components::proscenic::config::step::config_connection_info::data::authCode%]" + } + }, + "config_mode_selection": { + "title": "[%key:components::proscenic::config::step::config_mode_selection::title%]", + "description": "[%key:components::proscenic::config::step::config_mode_selection::description%]", + "data": { + "connection_mode": "[%key:components::proscenic::config::step::config_mode_selection::data::connection_mode%]" } } } @@ -20,8 +28,8 @@ "title": "[%key:components::proscenic::options::step::init::title%]", "description": "[%key:components::proscenic::options::step::init::description%]", "data": { - "sleep_duration_on_exit": "[%key:components::proscenic::config::step::user::data::sleep_duration_on_exit%]", - "map_path": "[%key:components::proscenic::config::step::user::data::map_path%]" + "sleep_duration_on_exit": "[%key:components::proscenic::config::step::init::data::sleep_duration_on_exit%]", + "map_path": "[%key:components::proscenic::config::step::init::data::map_path%]" } } } diff --git a/custom_components/proscenic/translations/en.json b/custom_components/proscenic/translations/en.json index 62db4f5..a89eebe 100755 --- a/custom_components/proscenic/translations/en.json +++ b/custom_components/proscenic/translations/en.json @@ -2,16 +2,24 @@ "config": { "title": "Proscenic vacuum", "step": { - "user": { + "config_connection_info": { "title": "Vacuum proscenic configuration", "description": "To get connection informations read the [user guide](https://github.com/deblockt/hass-proscenic-790T-vacuum#get-authentifications-data)", "data": { "host": "host of the vacuum", "deviceId": "device id", + "targetId": "target id", "token": "authentication token", "userId": "user id", "authCode": "authentication code" } + }, + "config_mode_selection": { + "title": "Vacuum proscenic configuration - connection mode", + "description": "The vacuum connection can be \"local\" or use de proscenic \"cloud\". Some vacuum can work only with the cloud mode.", + "data": { + "connection_mode": "connection mode" + } } } }, diff --git a/custom_components/proscenic/translations/fr.json b/custom_components/proscenic/translations/fr.json index 4b27ca0..dd48de6 100755 --- a/custom_components/proscenic/translations/fr.json +++ b/custom_components/proscenic/translations/fr.json @@ -2,16 +2,24 @@ "config": { "title": "Aspirateur proscenic", "step": { - "user": { + "config_connection_info": { "title": "Configuration de l'aspirateur proscenic", "description": "Pour récupérer les information de connexion merci de lire la [notice d'installation](https://github.com/deblockt/hass-proscenic-790T-vacuum#get-authentifications-data).", "data": { "host": "Address Ip de l'aspirateur", "deviceId": "id de l'aspirateur", + "targetId": "id de cible de l'aspirateur", "token": "token d'authentification", "userId": "id d'utilisateur", "authCode": "code d'authentification" } + }, + "config_mode_selection": { + "title": "Configuration de l'aspirateur proscenic - mode de connexion", + "description": "La connexion avec l'aspirateur peut être \"local\" ou via le \"cloud\" proscenic. Certains aspirateurs ne peuvent communiquer que via le cloud.", + "data": { + "connection_mode": "mode de connexion" + } } } }, diff --git a/custom_components/proscenic/vacuum.py b/custom_components/proscenic/vacuum.py index 1de43fe..1789c2d 100755 --- a/custom_components/proscenic/vacuum.py +++ b/custom_components/proscenic/vacuum.py @@ -33,7 +33,7 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv -from .const import DOMAIN, CONF_DEVICE_ID, CONF_TOKEN, CONF_USER_ID, CONF_SLEEP, CONF_AUTH_CODE, CONF_MAP_PATH, DEFAULT_CONF_SLEEP, DEFAULT_CONF_MAP_PATH +from .const import get_or_default, LOCAL_MODE, DOMAIN, CONF_CONNECTION_MODE, CONF_DEVICE_ID, CONF_TOKEN, CONF_USER_ID, CONF_SLEEP, CONF_AUTH_CODE, CONF_MAP_PATH, DEFAULT_CONF_SLEEP, DEFAULT_CONF_MAP_PATH, CONF_TARGET_ID _LOGGER = logging.getLogger(__name__) @@ -81,13 +81,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): CONF_DEVICE_ID: config[CONF_DEVICE_ID], CONF_TOKEN: config[CONF_TOKEN], CONF_USER_ID: config[CONF_USER_ID], - CONF_AUTH_CODE: config[CONF_AUTH_CODE] + CONF_AUTH_CODE: config[CONF_AUTH_CODE], + CONF_TARGET_ID: get_or_default(config, CONF_TARGET_ID, config[CONF_DEVICE_ID]) } - device = Vacuum(config[CONF_HOST], auth, loop = hass.loop, config = {CONF_SLEEP: conf_sleep, CONF_MAP_PATH: conf_map_path}) + ip = get_or_default(config, CONF_HOST, None) + mode = get_or_default(config, CONF_CONNECTION_MODE, LOCAL_MODE) + device = Vacuum(auth, ip, mode, loop = hass.loop, config = {CONF_SLEEP: conf_sleep, CONF_MAP_PATH: conf_map_path}) vacuums = [ProscenicVacuum(device, config[CONF_DEVICE_ID])] 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) @@ -100,10 +102,12 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= CONF_DEVICE_ID: config[CONF_DEVICE_ID], CONF_TOKEN: config[CONF_TOKEN], CONF_USER_ID: config[CONF_USER_ID], - CONF_AUTH_CODE: config[CONF_AUTH_CODE] + CONF_AUTH_CODE: config[CONF_AUTH_CODE], + CONF_TARGET_ID: config[CONF_TARGET_ID] if CONF_TARGET_ID in config else config[CONF_DEVICE_ID] } 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], CONF_MAP_PATH: config[CONF_MAP_PATH]}) + ip = config[CONF_HOST] if CONF_HOST in config else None + device = Vacuum(auth, ip, 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()) @@ -115,10 +119,9 @@ class ProscenicVacuum(VacuumEntity): """790T Vacuums such as Deebot.""" def __init__(self, device, name): - """Initialize the Ecovacs Vacuum.""" + """Initialize the Proscenic Vacuum.""" self.device = device self.device.subcribe(lambda vacuum: self.schedule_update_ha_state(force_refresh = False)) - self.device.subcribe(lambda vacuum: self.schedule_update_ha_state(force_refresh = False)) self._name = name self._fan_speed = None self._error = None diff --git a/custom_components/proscenic/vacuum_map_generator.py b/custom_components/proscenic/vacuum_map_generator.py index 20ef315..e5ff727 100755 --- a/custom_components/proscenic/vacuum_map_generator.py +++ b/custom_components/proscenic/vacuum_map_generator.py @@ -71,6 +71,9 @@ def placebyte(by): inp = base64.b64decode(track) path = struct.unpack('<' + 'b'*(len(inp)-4), inp[4:]) + if len(wallx) == 0 or len(wally) == 0: + return + min_x=min(wallx) min_y=min(wally) map_width=(max(wallx) - min_x) * cell_size @@ -84,7 +87,7 @@ def placebyte(by): map_height = min_image_height dwg = svgwrite.Drawing(file_path, size=(map_width, map_height)) - + for i in range(len(wallx)): dwg.add(dwg.rect(insert=((wallx[i] - min_x) * cell_size, map_height - ((wally[i] - min_y) * cell_size)), size=(cell_size, cell_size), fill='#000000', fill_opacity=0.7)) @@ -98,7 +101,7 @@ def placebyte(by): for i in range(len(draw_path) // 2): dwg_path.push('L{},{}'.format(draw_path[2*i], draw_path[2*i+1])) - + dwg.add(dwg_path) dwg.save() diff --git a/custom_components/proscenic/vacuum_proscenic.py b/custom_components/proscenic/vacuum_proscenic.py index 028aa9f..93433b2 100755 --- a/custom_components/proscenic/vacuum_proscenic.py +++ b/custom_components/proscenic/vacuum_proscenic.py @@ -1,10 +1,12 @@ +from dataclasses import dataclass + import asyncio import json from enum import Enum import logging from .vacuum_map_generator import build_map -from .const import CONF_DEVICE_ID, CONF_AUTH_CODE, CONF_TOKEN, CONF_USER_ID, CONF_SLEEP, CONF_MAP_PATH, DEFAULT_CONF_MAP_PATH, DEFAULT_CONF_SLEEP +from .const import get_or_default, LOCAL_MODE, CLOUD_MODE, CONF_TARGET_ID, CONF_DEVICE_ID, CONF_AUTH_CODE, CONF_TOKEN, CONF_USER_ID, CONF_SLEEP, CONF_MAP_PATH, DEFAULT_CONF_MAP_PATH, DEFAULT_CONF_SLEEP, CLOUD_PROSCENIC_IP, CLOUD_PROSCENIC_PORT _LOGGER = logging.getLogger(__name__) @@ -25,9 +27,31 @@ class WorkState(Enum): '3': 'Power switch is not switched on during charging' } +def _extract_json(response): + first_index = response.find(b'{') + last_index = response.rfind(b'}') + if first_index >= 0 and last_index >= 0: + try: + return json.loads(response[first_index:(last_index + 1)]) + except: + _LOGGER.exception('error decoding json {}'.format(response[first_index:(last_index + 1)])) + return None + + return None + +@dataclass +class VacuumState: + """Class for keeping track of an item in inventory.""" + work_state: WorkState = None + battery_level: int = None + fan_speed: int = None + error_code: str = None + error_detail: str = None + class Vacuum(): - def __init__(self, ip, auth, loop = None, config = {}): + def __init__(self, auth, ip = None, mode = LOCAL_MODE, loop = None, config = {}): + self.mode = mode self.ip = ip self.battery = None self.fan_speed = 2 @@ -40,21 +64,16 @@ def __init__(self, ip, auth, loop = None, config = {}): self.loop = loop self.auth = auth self.device_id = auth[CONF_DEVICE_ID] - self.sleep_duration_on_exit = config[CONF_SLEEP] if CONF_SLEEP in config else DEFAULT_CONF_SLEEP - self.map_path = config[CONF_MAP_PATH] if CONF_MAP_PATH in config else DEFAULT_CONF_MAP_PATH - - 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(self.sleep_duration_on_exit) - pass + self.target_id = get_or_default(auth, CONF_TARGET_ID, auth[CONF_DEVICE_ID]) + self.sleep_duration_on_exit = get_or_default(config, CONF_SLEEP, DEFAULT_CONF_SLEEP) + self.map_path = get_or_default(config, CONF_MAP_PATH, DEFAULT_CONF_MAP_PATH) + self.cloud = ProscenicCloud(auth, loop, self.sleep_duration_on_exit) + self.cloud.add_device_state_updated_handler(lambda state: self._update_device_state(state)) + self.map_generator_task = None async def listen_state_change(self): try: - await self._refresh_loop() + await self.cloud.start_state_refresh_loop() except: _LOGGER.exception('error while listening proscenic vacuum state change') @@ -72,105 +91,28 @@ async def return_to_base(self): async def _send_command(self, command: bytes, input_writer = None): try: - if not input_writer: - (_, writer) = await asyncio.open_connection(self.ip, 8888, loop = self.loop) - else: - writer = input_writer - header = b'\xd2\x00\x00\x00\xfa\x00\xc8\x00\x00\x00\xeb\x27\xea\x27\x00\x00\x00\x00\x00\x00' body = b'{"cmd":0,"control":{"authCode":"' \ + str.encode(self.auth[CONF_AUTH_CODE]) \ - + b'","deviceIp":"' \ - + str.encode(self.ip) \ - + b'","devicePort":"8888","targetId":"' \ - + str.encode(self.auth[CONF_DEVICE_ID]) \ + + b'","deviceIp":"' + str.encode(self.ip) + b'","devicePort":"8888","targetId":"' \ + + str.encode(self.target_id if self.mode == CLOUD_MODE and not input_writer else self.device_id) \ + b'","targetType":"3"},"seq":0,"value":' \ + command \ + b',"version":"1.5.11"}' _LOGGER.debug('send command {}'.format(str(body))) - writer.write(header + body) - await writer.drain() - 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":"' \ - + str.encode(self.auth[CONF_DEVICE_ID]) \ - + b'","deviceType":"3","token":"' \ - + str.encode(self.auth[CONF_TOKEN]) \ - + b'","userId":"' \ - + str.encode(self.auth[CONF_USER_ID]) \ - + b'"}}' - writer.write(header + body) - await writer.drain() + if self.mode == LOCAL_MODE or input_writer: + if not input_writer: + (_, writer) = await asyncio.open_connection(self.ip, 8888, loop = self.loop) + else: + writer = input_writer - async def _wait_for_state_refresh(self, reader): - disconnected = False - while not disconnected: - data = await reader.read(1000) - if data != b'': - _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') - disconnected = True - elif data and 'value' in data: - values = data['value'] - if 'workState' in values and values['workState'] != '': - if 'error' in values and values['error'] != '' and values['error'] != '0': - self.error_code = values['error'] - self.error_detail = ERROR_CODES[self.error_code] if self.error_code in ERROR_CODES else None - self.work_state = WorkState.ERROR - else: - try: - self.work_state = WorkState(int(values['workState'])) - except: - logging.exception('error setting work state {}'.format(str(values['workState']))) - if self.work_state != WorkState.ERROR: - self.error_code = None - self.error_detail = None - if self.work_state != WorkState.POWER_OFF: - if 'battery' in values and values['battery'] != '': - self.battery = int(values['battery']) - if 'fan' in values and values['fan'] != '': - self.fan_speed = int(values['fan']) - - self._call_listners() + writer.write(header + body) + await writer.drain() else: - _LOGGER.warn('receive empty message - I have been disconnected') - disconnected = True - - 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 = '' + await self.cloud.send_command(body) + except OSError: + raise VacuumUnavailable('can not connect to the vacuum. Turn on the physical switch button.') async def _wait_for_map_input(self): while True: @@ -180,7 +122,7 @@ async def _wait_for_map_input(self): 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)) + json = _extract_json(str.encode(data)) if 'value' in json: value = json['value'] if 'map' in value: @@ -192,60 +134,193 @@ async def _wait_for_map_input(self): self._call_listners() await asyncio.sleep(5) else: - _LOGGER.debug('do not get the map. The vacuum is not cleaning. Waiting 30 seconds') - await asyncio.sleep(30) + _LOGGER.debug('The cleaning session is ended. End of map generation process.') + return 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() - - async def _refresh_loop(self): - await self.verify_vacuum_online() + async def _get_map(self): + _LOGGER.debug('opening the socket to get the map') + (reader, writer) = await asyncio.open_connection(self.ip, 8888, loop = self.loop) + _LOGGER.debug('send the command to get the map') + await self._send_command(b'{"transitCmd":"131"}', writer) + read_data = '' while True: + data = await reader.read(1000) + if data == b'': + _LOGGER.debug('No data read during map generation.') + break try: - _LOGGER.info('sign in to proscenic server') - (reader, writer) = await asyncio.open_connection('47.91.67.181', 20008, loop = self.loop) - await self._login(writer) - disconnected = False - while not disconnected: - try: - disconnected = await asyncio.wait_for(self._wait_for_state_refresh(reader), timeout=60.0) - except asyncio.TimeoutError: - await self._ping(writer) - await self.verify_vacuum_online() - - _LOGGER.debug('sleep {} second before reconnecting'.format(self.sleep_duration_on_exit)) - await asyncio.sleep(self.sleep_duration_on_exit) - except OSError: - _LOGGER.exception('error on refresh loop') + read_data = read_data + data.decode() + _LOGGER.debug('map generation. 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('map generation. return valid json {}'.format(read_data)) + return read_data + elif nb_close > nb_openning: + _LOGGER.info('map generation. malformed json json {}'.format(read_data)) + read_data = '' + except: + _LOGGER.debug('unreadable data {}'.format(data)) + read_data = '' def _call_listners(self): for listner in self.listner: listner(self) - def _extract_json(self, response): - first_index = response.find(b'{') - last_index = response.rfind(b'}') - if first_index >= 0 and last_index >= 0: - try: - return json.loads(response[first_index:(last_index + 1)]) - except: - _LOGGER.exception('error decoding json {}'.format(response[first_index:(last_index + 1)])) - return None + def _update_device_state(self, state: VacuumState): + self.error_code = state.error_code + self.error_detail = state.error_detail + if state.battery_level: + self.battery = state.battery_level + if state.fan_speed: + self.fan_speed = state.fan_speed + if state.work_state: + self.work_state = state.work_state + + if self.work_state == WorkState.CLEANING: + if not self.map_generator_task or self.map_generator_task.done(): + _LOGGER.debug('Vacuum is cleaning. Start the map generation.') + self.map_generator_task = self.loop.create_task(self._wait_for_map_input()) + else: + _LOGGER.debug('Vacuum is cleaning. Do not restart map generation process, because it is already running.') + + self._call_listners() + + +class ProscenicCloud: + def __init__(self, auth, loop, sleep_duration_on_exit): + self._reader = None + self._writer = None + self._state = 'disconnected' + self._device_state_updated_handlers = [] + self._loop = loop + self._auth = auth + self._sleep_duration_on_exit = sleep_duration_on_exit + self._connectedFuture = None + self._is_refresh_loop_runing = False + + def add_device_state_updated_handler(self, handler): + self._device_state_updated_handlers.append(handler) + + async def send_command(self, command): + await self._connect() + + header = b'\xd0\x00\x00\x00\xfa\x00\xc8\x00\x00\x00\x24\x27\x25\x27\x00\x00\x00\x00\x00\x00' + self.writer.write(header + command) + await self.writer.drain() + + # refresh loop is used to read vacuum update (status, battery, etc...) + async def start_state_refresh_loop(self): + if self._is_refresh_loop_runing: + _LOGGER.debug('The refresh loop is already running, don\'t start a new refresh loop') + return + + _LOGGER.debug('Start the refresh loop function') + self._is_refresh_loop_runing = True + try: + await self._connect(wait_for_login_response = False) # we don't wait for login response, because we need the refresh loop started to know if loggin is OK + + while self._state != 'disconnected': + try: + await asyncio.wait_for(self._wait_for_state_refresh(), timeout=60.0) + except asyncio.TimeoutError: + await self._ping() + + self._is_refresh_loop_runing = False + self._loop.create_task(self._wait_and_rererun_refresh_loop()) + except OSError: + _LOGGER.exception('error on refresh loop') + self._is_refresh_loop_runing = False + + async def _connect(self, wait_for_login_response = True): + if self._state == 'disconnected': + _LOGGER.info('opening socket with proscenic cloud.') + self._state = 'connecting' + (self.reader, self.writer) = await asyncio.open_connection(CLOUD_PROSCENIC_IP, CLOUD_PROSCENIC_PORT, loop = self._loop) + await self._login(wait_for_login_response) + + async def _login(self, wait_for_login_response = True): + if wait_for_login_response: + self._connectedFuture = self._loop.create_future() + + _LOGGER.info('loging to proscenic cloud.') + 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":"' \ + + str.encode(self._auth[CONF_DEVICE_ID]) \ + + b'","deviceType":"3","token":"' \ + + str.encode(self._auth[CONF_TOKEN]) \ + + b'","userId":"' \ + + str.encode(self._auth[CONF_USER_ID]) \ + + b'"}}' + self.writer.write(header + body) + await self.writer.drain() + + if wait_for_login_response: + _LOGGER.debug('waiting for proscenic login success response.') + if not self._is_refresh_loop_runing: + self._loop.create_task(self.start_state_refresh_loop()) + await self._connectedFuture + self._connectedFuture = None + + async def _ping(self): + _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' + self.writer.write(body) + await self.writer.drain() # manage error (socket closed) + + async def _wait_for_state_refresh(self): + while self._state != 'disconnected': + data = await self.reader.read(1000) + if data != b'': + _LOGGER.debug('receive from state refresh: {}'.format(str(data))) + data = _extract_json(data) + if data and 'msg' in data and data['msg'] == 'exit succeed': + _LOGGER.warn('receive exit succeed - I have been disconnected') + self._state = 'disconnected' + if data and 'msg' in data and data['msg'] == 'login succeed': + _LOGGER.info('connected to proscenic cloud.') + self._state = 'connected' + if self._connectedFuture: + self._connectedFuture.set_result(True) + elif data and 'value' in data: + values = data['value'] + if not 'errRecordId' in values: + state = VacuumState() + + if 'workState' in values and values['workState'] != '': + if 'error' in values and values['error'] != '' and values['error'] != '0': + state.error_code = values['error'] + state.error_detail = ERROR_CODES[state.error_code] if state.error_code in ERROR_CODES else None + + try: + state.work_state = WorkState(int(values['workState'])) + except: + logging.exception('error setting work state {}'.format(str(values['workState']))) + + if state.work_state != WorkState.POWER_OFF: + if 'battery' in values and values['battery'] != '': + state.battery_level = int(values['battery']) + if 'fan' in values and values['fan'] != '': + state.fan_speed = int(values['fan']) + + self._call_state_updated_listners(state) + else: + _LOGGER.warn('receive empty message - I have been disconnected') + self._state = 'disconnected' - return None + async def _wait_and_rererun_refresh_loop(self): + _LOGGER.debug('sleep {} second before reconnecting'.format(self._sleep_duration_on_exit)) + await asyncio.sleep(self._sleep_duration_on_exit) + await self.start_state_refresh_loop() + def _call_state_updated_listners(self, device_state: VacuumState): + _LOGGER.debug('update the vacuum state: {}'.format(str(device_state))) + for listner in self._device_state_updated_handlers: + listner(device_state) class VacuumUnavailable(Exception): pass \ No newline at end of file From e3edc3f75f8b8c7e9229e446d82a6d600e6caf36 Mon Sep 17 00:00:00 2001 From: Thomas Deblock Date: Sat, 2 Apr 2022 14:34:05 +0200 Subject: [PATCH 19/30] doc: add cloud support information --- README.md | 11 +++++-- custom_components/proscenic/strings.json | 37 ------------------------ 2 files changed, 9 insertions(+), 39 deletions(-) delete mode 100755 custom_components/proscenic/strings.json diff --git a/README.md b/README.md index d8d439b..d2c79c3 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,13 @@ Add your device via the Integration menu. [![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=proscenic) +You can choose between two connection mode: +- *local*: the integration will use your local network to contact your vacuum (send command to start/stop). But the cloud will be used to get vacuum status. +- *cloud*: All interactions are done using the cloud. Only the map generation will use the local network. + +> Note: Some vacuum don't support the local mode. + + ### Get authentifications data device id, token, user id and authentication code can be retrieved using the Proscenic robotic application : @@ -40,10 +47,10 @@ device id, token, user id and authentication code can be retrieved using the Pro 3. Open the proscenic application, and open the vacuum view 4. Reopen Packet capture 1. Click on the first line - 2. Click on the line `:8888` + 2. Click on the line `47.91.67.181:20008` 3. Get you informations ![screenshot](./doc/packet_with_info.jpg) 5. You can now enter your informations on home assistant -6. you can add your vacuum on lovelace ui entities +6. 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) diff --git a/custom_components/proscenic/strings.json b/custom_components/proscenic/strings.json deleted file mode 100755 index 16141c1..0000000 --- a/custom_components/proscenic/strings.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "config": { - "step": { - "config_connection_info": { - "title": "[%key:components::proscenic::config::step::config_connection_info::title%]", - "description": "[%key:components::proscenic::config::step::config_connection_info::description%]", - "data": { - "host": "[%key:components::proscenic::config::step::config_connection_info::data::host%]", - "deviceId": "[%key:components::proscenic::config::step::config_connection_info::data::deviceId%]", - "targetId": "[%key:components::proscenic::config::step::config_connection_info::data::targetId%]", - "token": "[%key:components::proscenic::config::step::config_connection_info::data::token%]", - "userId": "[%key:components::proscenic::config::step::config_connection_info::data::userId%]", - "authCode": "[%key:components::proscenic::config::step::config_connection_info::data::authCode%]" - } - }, - "config_mode_selection": { - "title": "[%key:components::proscenic::config::step::config_mode_selection::title%]", - "description": "[%key:components::proscenic::config::step::config_mode_selection::description%]", - "data": { - "connection_mode": "[%key:components::proscenic::config::step::config_mode_selection::data::connection_mode%]" - } - } - } - }, - "options": { - "step": { - "init": { - "title": "[%key:components::proscenic::options::step::init::title%]", - "description": "[%key:components::proscenic::options::step::init::description%]", - "data": { - "sleep_duration_on_exit": "[%key:components::proscenic::config::step::init::data::sleep_duration_on_exit%]", - "map_path": "[%key:components::proscenic::config::step::init::data::map_path%]" - } - } - } - } -} \ No newline at end of file From e311d50f500fdd329eaf20d1b8665b90c51bdebb Mon Sep 17 00:00:00 2001 From: deblockt Date: Sat, 2 Apr 2022 14:15:26 +0000 Subject: [PATCH 20/30] ci: upgrade version to 0.0.9 --- custom_components/proscenic/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/proscenic/manifest.json b/custom_components/proscenic/manifest.json index b0d8362..ce347b5 100755 --- a/custom_components/proscenic/manifest.json +++ b/custom_components/proscenic/manifest.json @@ -1,7 +1,7 @@ { "domain": "proscenic", "name": "proscenic-vacuum", - "version": "0.0.8", + "version": "0.0.9", "documentation": "https://github.com/deblockt/hass-proscenic-790T-vacuum", "issue_tracker": "https://github.com/deblockt/hass-proscenic-790T-vacuum/issues", "dependencies": [], From 452d9284f4f5a8e1b79759409c13cbe8541a1c8d Mon Sep 17 00:00:00 2001 From: Thomas Deblock Date: Tue, 12 Apr 2022 08:47:01 +0200 Subject: [PATCH 21/30] feat: get the map from the proscenic cloud --- README.md | 7 +- custom_components/proscenic/__init__.py | 23 ++- custom_components/proscenic/camera.py | 34 ++-- custom_components/proscenic/config_flow.py | 8 +- custom_components/proscenic/const.py | 2 - custom_components/proscenic/vacuum.py | 19 +- .../proscenic/vacuum_map_generator.py | 7 +- .../proscenic/vacuum_proscenic.py | 170 ++++++++++-------- 8 files changed, 138 insertions(+), 132 deletions(-) diff --git a/README.md b/README.md index d2c79c3..400a20c 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,8 @@ Add your device via the Integration menu. [![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=proscenic) You can choose between two connection mode: -- *local*: the integration will use your local network to contact your vacuum (send command to start/stop). But the cloud will be used to get vacuum status. -- *cloud*: All interactions are done using the cloud. Only the map generation will use the local network. +- *local*: the integration will use your local network to contact your vacuum (send command to start/stop). But the cloud will be used to get vacuum status, and the cleaning map. +- *cloud*: All interactions are done using the cloud. > Note: Some vacuum don't support the local mode. @@ -61,8 +61,7 @@ device id, token, user id and authentication code can be retrieved using the Pro ![map](./doc/map.png) The camera entity will be automaticaly added. -The map is stored on your file system, the path is `/tmp/proscenic_vacuum_map.svg`. -The path can be updated on the integration options. +The proscenic cloud is used to generate the map. ## Available attributes diff --git a/custom_components/proscenic/__init__.py b/custom_components/proscenic/__init__.py index 6789075..38e5761 100755 --- a/custom_components/proscenic/__init__.py +++ b/custom_components/proscenic/__init__.py @@ -1,11 +1,28 @@ -from .const import DOMAIN, CONF_SLEEP, CONF_MAP_PATH, DEFAULT_CONF_MAP_PATH, DEFAULT_CONF_SLEEP +from .const import get_or_default, DOMAIN, CONF_SLEEP, DEFAULT_CONF_SLEEP, CONF_DEVICE_ID, CONF_TOKEN, CONF_USER_ID, CONF_AUTH_CODE, CONF_TARGET_ID, CONF_CONNECTION_MODE, LOCAL_MODE +from homeassistant.const import CONF_HOST +from .vacuum_proscenic import Vacuum async def async_setup_entry(hass, entry): """Test""" hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = dict(entry.data) - hass.data[DOMAIN][entry.entry_id][CONF_SLEEP] = entry.options.get(CONF_SLEEP, DEFAULT_CONF_SLEEP) - hass.data[DOMAIN][entry.entry_id][CONF_MAP_PATH] = entry.options.get(CONF_MAP_PATH, DEFAULT_CONF_MAP_PATH) + + config = dict(entry.data) + conf_sleep = entry.options.get(CONF_SLEEP, DEFAULT_CONF_SLEEP) + + auth = { + CONF_DEVICE_ID: config[CONF_DEVICE_ID], + CONF_TOKEN: config[CONF_TOKEN], + CONF_USER_ID: config[CONF_USER_ID], + CONF_AUTH_CODE: config[CONF_AUTH_CODE], + CONF_TARGET_ID: get_or_default(config, CONF_TARGET_ID, config[CONF_DEVICE_ID]) + } + + ip = get_or_default(config, CONF_HOST, None) + mode = get_or_default(config, CONF_CONNECTION_MODE, LOCAL_MODE) + device = Vacuum(auth, ip, mode, loop = hass.loop, config = {CONF_SLEEP: conf_sleep}) + + hass.data[DOMAIN][entry.entry_id]['device'] = device hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, 'vacuum') diff --git a/custom_components/proscenic/camera.py b/custom_components/proscenic/camera.py index b0fbacc..976a6b0 100755 --- a/custom_components/proscenic/camera.py +++ b/custom_components/proscenic/camera.py @@ -2,7 +2,9 @@ import logging import os -from .const import DOMAIN, CONF_MAP_PATH, DEFAULT_CONF_MAP_PATH, CONF_DEVICE_ID +from .const import DOMAIN, CONF_DEVICE_ID +from .vacuum_proscenic import WorkState + from homeassistant.components.camera import Camera @@ -11,54 +13,44 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the 790T vacuums map camera.""" config = hass.data[DOMAIN][config_entry.entry_id] - conf_map_path = config[CONF_MAP_PATH] if CONF_MAP_PATH in config else DEFAULT_CONF_MAP_PATH - device_id = config[CONF_DEVICE_ID] + device = config['device'] _LOGGER.debug("Adding 790T Vacuums camera to Home Assistant") - async_add_entities([ProscenicMapCamera(device_id, conf_map_path)], update_before_add = False) + async_add_entities([ProscenicMapCamera(device)], update_before_add = False) class ProscenicMapCamera(Camera): """Representation of a local file camera.""" - def __init__(self, device_id, file_path): + def __init__(self, device): """Initialize Local File Camera component.""" super().__init__() - self._device_id = device_id - self._file_path = file_path + self._device = device self.content_type = 'image/svg+xml' def camera_image(self, width = None, height = None): """Return image response.""" - try: - with open(self._file_path, "rb") as file: - return file.read() - except FileNotFoundError: - _LOGGER.info("No map has been generated for the vacuum device %s", self._device_id) + if self._device.map_svg: + return self._device.map_svg return b'' - def update_file_path(self, file_path): - """Update the file_path.""" - self._file_path = file_path - self.schedule_update_ha_state() - @property def name(self): """Return the name of this camera.""" - return self._device_id + '_map' + return self._device.device_id + '_map' @property def extra_state_attributes(self): """Return the camera state attributes.""" - return {"file_path": self._file_path} + return {} @property def device_info(self): """Return the device info.""" - return {"identifiers": {(DOMAIN, self._device_id)}} + return {"identifiers": {(DOMAIN, self._device.device_id)}} @property def unique_id(self) -> str: """Return an unique ID.""" - return "camera" + self._device_id + return "camera" + self._device.device_id diff --git a/custom_components/proscenic/config_flow.py b/custom_components/proscenic/config_flow.py index c24a8e7..ca93d00 100755 --- a/custom_components/proscenic/config_flow.py +++ b/custom_components/proscenic/config_flow.py @@ -7,7 +7,7 @@ from homeassistant.const import CONF_HOST from homeassistant.helpers.selector import SelectSelector -from .const import LOCAL_MODE, CLOUD_MODE, CONF_CONNECTION_MODE, CONF_DEVICE_ID, CONF_TARGET_ID, CONF_TOKEN, CONF_USER_ID, CONF_AUTH_CODE, CONF_SLEEP, CONF_MAP_PATH, DEFAULT_CONF_MAP_PATH, DEFAULT_CONF_SLEEP +from .const import LOCAL_MODE, CLOUD_MODE, CONF_CONNECTION_MODE, CONF_DEVICE_ID, CONF_TARGET_ID, CONF_TOKEN, CONF_USER_ID, CONF_AUTH_CODE, CONF_SLEEP, DEFAULT_CONF_SLEEP class ProscenicConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 @@ -73,16 +73,12 @@ def __init__(self, config_entry): self._sleep_duration_on_exit = self.config_entry.options.get( CONF_SLEEP, DEFAULT_CONF_SLEEP ) - self._map_path = self.config_entry.options.get( - CONF_MAP_PATH, DEFAULT_CONF_MAP_PATH - ) async def async_step_init(self, user_input=None): """Handle a flow initialized by the user.""" options_schema = vol.Schema( { - vol.Required(CONF_SLEEP, default = self._sleep_duration_on_exit): int, - vol.Required(CONF_MAP_PATH, default = self._map_path): str, + vol.Required(CONF_SLEEP, default = self._sleep_duration_on_exit): int }, ) diff --git a/custom_components/proscenic/const.py b/custom_components/proscenic/const.py index 9472c73..b152311 100755 --- a/custom_components/proscenic/const.py +++ b/custom_components/proscenic/const.py @@ -6,11 +6,9 @@ CONF_USER_ID = 'userId' CONF_SLEEP = 'sleep_duration_on_exit' CONF_AUTH_CODE = 'authCode' -CONF_MAP_PATH = 'map_path' CONF_CONNECTION_MODE = 'connection_mode' DEFAULT_CONF_SLEEP = 60 -DEFAULT_CONF_MAP_PATH = '/tmp/proscenic_vacuum_map.svg' CLOUD_PROSCENIC_IP = '47.91.67.181' CLOUD_PROSCENIC_PORT = 20008 diff --git a/custom_components/proscenic/vacuum.py b/custom_components/proscenic/vacuum.py index 1789c2d..fcf1783 100755 --- a/custom_components/proscenic/vacuum.py +++ b/custom_components/proscenic/vacuum.py @@ -33,7 +33,7 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv -from .const import get_or_default, LOCAL_MODE, DOMAIN, CONF_CONNECTION_MODE, CONF_DEVICE_ID, CONF_TOKEN, CONF_USER_ID, CONF_SLEEP, CONF_AUTH_CODE, CONF_MAP_PATH, DEFAULT_CONF_SLEEP, DEFAULT_CONF_MAP_PATH, CONF_TARGET_ID +from .const import get_or_default, LOCAL_MODE, DOMAIN, CONF_CONNECTION_MODE, CONF_DEVICE_ID, CONF_TOKEN, CONF_USER_ID, CONF_SLEEP, CONF_AUTH_CODE, DEFAULT_CONF_SLEEP, CONF_TARGET_ID _LOGGER = logging.getLogger(__name__) @@ -54,7 +54,6 @@ vol.Required(CONF_USER_ID): cv.string, vol.Required(CONF_AUTH_CODE): cv.string, vol.Optional(CONF_SLEEP, default = DEFAULT_CONF_SLEEP): int, - vol.Optional(CONF_MAP_PATH, default = DEFAULT_CONF_MAP_PATH): cv.string, vol.Optional(CONF_NAME): cv.string }) @@ -74,20 +73,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the 790T vacuums.""" config = hass.data[DOMAIN][config_entry.entry_id] - conf_sleep = config[CONF_SLEEP] if CONF_SLEEP in config else DEFAULT_CONF_SLEEP - conf_map_path = config[CONF_MAP_PATH] if CONF_MAP_PATH in config else DEFAULT_CONF_MAP_PATH + device = config['device'] - auth = { - CONF_DEVICE_ID: config[CONF_DEVICE_ID], - CONF_TOKEN: config[CONF_TOKEN], - CONF_USER_ID: config[CONF_USER_ID], - CONF_AUTH_CODE: config[CONF_AUTH_CODE], - CONF_TARGET_ID: get_or_default(config, CONF_TARGET_ID, config[CONF_DEVICE_ID]) - } - - ip = get_or_default(config, CONF_HOST, None) - mode = get_or_default(config, CONF_CONNECTION_MODE, LOCAL_MODE) - device = Vacuum(auth, ip, mode, loop = hass.loop, config = {CONF_SLEEP: conf_sleep, CONF_MAP_PATH: conf_map_path}) vacuums = [ProscenicVacuum(device, config[CONF_DEVICE_ID])] hass.loop.create_task(device.listen_state_change()) @@ -107,7 +94,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= } name = config[CONF_NAME] if CONF_NAME in config else '790T vacuum' ip = config[CONF_HOST] if CONF_HOST in config else None - device = Vacuum(auth, ip, loop = hass.loop, config = {CONF_SLEEP: config[CONF_SLEEP], CONF_MAP_PATH: config[CONF_MAP_PATH]}) + device = Vacuum(auth, ip, loop = hass.loop, config = {CONF_SLEEP: config[CONF_SLEEP]}) vacuums = [ProscenicVacuum(device, name)] hass.loop.create_task(device.listen_state_change()) hass.loop.create_task(device.start_map_generation()) diff --git a/custom_components/proscenic/vacuum_map_generator.py b/custom_components/proscenic/vacuum_map_generator.py index e5ff727..50da262 100755 --- a/custom_components/proscenic/vacuum_map_generator.py +++ b/custom_components/proscenic/vacuum_map_generator.py @@ -7,7 +7,7 @@ min_image_height=250 min_image_width=450 -def build_map(m, track, file_path): +def build_map(m, track): inp = base64.b64decode(m) d = struct.unpack('<' + 'B' * (len(inp)), inp) full = [['.' for i in range(100)] for j in range(110)] @@ -86,7 +86,7 @@ def placebyte(by): min_y = min_y - ((min_image_height - map_height) / 2) // cell_size map_height = min_image_height - dwg = svgwrite.Drawing(file_path, size=(map_width, map_height)) + dwg = svgwrite.Drawing(size=(map_width, map_height)) for i in range(len(wallx)): dwg.add(dwg.rect(insert=((wallx[i] - min_x) * cell_size, map_height - ((wally[i] - min_y) * cell_size)), size=(cell_size, cell_size), fill='#000000', fill_opacity=0.7)) @@ -103,7 +103,8 @@ def placebyte(by): dwg_path.push('L{},{}'.format(draw_path[2*i], draw_path[2*i+1])) dwg.add(dwg_path) - dwg.save() + + return dwg.tostring() #m = "AAAAAAAAZABkwvIAFUDXABXCVUDVABaqwlXVAFbCqqlA1ABmw6pUVUDSAGbDqqTCVVDRAFbDqqVqqpDRAFbDqqVVqJDRAFqqqaqlVqSQ0QBaqpZqwqqkkNEAWqqZmsKqpJDRAFqqmWrCqqSQ0QAVVapawqqkkNIABVVawqqkkNQAFsKqpJDUABbCqqWQ1AAWwqqplNQAFsOqpdQAGsOqqUDTABrDqppQ0wAaw6qmkNMAFWrCqplQ0wAFw6qZQNMABcOqmkDTAAXDqplA0wAFw6pJQNMABcOqSdQABalqqkpA0wBWqVqqolDTAGqkFqqmkNIAAWqkBqqklNIAAaqQBqqkkNIAAaqQBqqkkNIAAZqQGqqklVTRAAGqkCqqpaqk0QABqpAqw6qoFNAAAaqQFqqlqpqk0AAGqpAqqqWqkKTQAAaqkCrDqlWU0AAGqpQaqsKWqZDQAAaqpGqqlqqpoNAABaqpasKqpalo0AABWqqawqpWqmqA0AAaxKpVwqrRABVVaqpaw6rSAAFVmcSq0wABVVbCVVbVAAVAAALYAALYAAVA0P0A" #track = "ASg+ATI0NDQrNCs1KzM2MzYyNzIgMiEyHDIcMRoxIDEfMR4wGTAZLxgvHS8cLhcuFy0cLRwsFywYKxwrHCoYKhgpHCkcKBgoGCccJxwmGSYZJR0lHSQZJBojHiMdIhsiHiIeIRshHyEfICAgGyAcHxsfKB8oHhseHB4bHhsdKB0oHBwcHBspGygaGhoaGSgZJxgaGBoXJhclFhoWGhUlFSUUGhQaEyUTJRIZEhkRJRElEBgQGA8XDyUPJQ4mDh4OHw0fDh8NIA4hDiINJg0lDCIMIQ0hDCENHA4NDg0NHA0cDA0MDQsaCxkKDgoOCRcJEQkPCA4IDg8NDxYPFBANEA0RFBEUEhESDxEhICcgJyEgISEiJyInIygjIiMjJCkkKSUjJSMmKSYpJyMnIygpKCkpIikiKiEqLiouKy8rLyowKjArKyssKywsMCwvLC8tIS0iLSEtIi4pLiguKC8hLyEwLTAtLysvMi8yMDAwMDEzMTAxMzE1MjUxMS4yLiwuKSwpKysqLSooLCsuKzEiMSIzLjMqNCk0NjQkNCQzIDMgMhoyGjEZMRkwGDAXLxctFy4XLBgqGCYZJhgmGCUZJRkkGiQcIhwbGhsaGhsYGxUaFBoSGRIYEBgRFxAWERcRFhIWExcTExMTEg4SDg4PDg8JFAkUChoKHAwdDB0OHg4eDx8PHxAfDx8QIBAhDiENIw0hDSEOIg0oDScNKA0oGCkYKRkqGSoaKxorGywbKxwqHCodKx0qHSoeKx4qHiohKiAqISshKyIqIisjKyQtJCwlLCgtKCwoLCkxKTEqMioyKzQrNCw1LDQsNSw1KzUsNCw0LzUvNTA2MDYyNzM3Nw==" diff --git a/custom_components/proscenic/vacuum_proscenic.py b/custom_components/proscenic/vacuum_proscenic.py index 93433b2..0487e9b 100755 --- a/custom_components/proscenic/vacuum_proscenic.py +++ b/custom_components/proscenic/vacuum_proscenic.py @@ -6,7 +6,7 @@ import logging from .vacuum_map_generator import build_map -from .const import get_or_default, LOCAL_MODE, CLOUD_MODE, CONF_TARGET_ID, CONF_DEVICE_ID, CONF_AUTH_CODE, CONF_TOKEN, CONF_USER_ID, CONF_SLEEP, CONF_MAP_PATH, DEFAULT_CONF_MAP_PATH, DEFAULT_CONF_SLEEP, CLOUD_PROSCENIC_IP, CLOUD_PROSCENIC_PORT +from .const import get_or_default, LOCAL_MODE, CLOUD_MODE, CONF_TARGET_ID, CONF_DEVICE_ID, CONF_AUTH_CODE, CONF_TOKEN, CONF_USER_ID, CONF_SLEEP, DEFAULT_CONF_SLEEP, CLOUD_PROSCENIC_IP, CLOUD_PROSCENIC_PORT _LOGGER = logging.getLogger(__name__) @@ -27,18 +27,6 @@ class WorkState(Enum): '3': 'Power switch is not switched on during charging' } -def _extract_json(response): - first_index = response.find(b'{') - last_index = response.rfind(b'}') - if first_index >= 0 and last_index >= 0: - try: - return json.loads(response[first_index:(last_index + 1)]) - except: - _LOGGER.exception('error decoding json {}'.format(response[first_index:(last_index + 1)])) - return None - - return None - @dataclass class VacuumState: """Class for keeping track of an item in inventory.""" @@ -48,6 +36,13 @@ class VacuumState: error_code: str = None error_detail: str = None +@dataclass +class VacuumMap: + """Class for keeping track of an item in inventory.""" + map_svg: str = None + last_clear_area: int = None + last_clear_duration: int = None + class Vacuum(): def __init__(self, auth, ip = None, mode = LOCAL_MODE, loop = None, config = {}): @@ -60,20 +55,23 @@ def __init__(self, auth, ip = None, mode = LOCAL_MODE, loop = None, config = {}) self.work_state = WorkState.CHARGING self.last_clear_area = None self.last_clear_duration = None + self.map_svg = None self.listner = [] self.loop = loop self.auth = auth self.device_id = auth[CONF_DEVICE_ID] self.target_id = get_or_default(auth, CONF_TARGET_ID, auth[CONF_DEVICE_ID]) self.sleep_duration_on_exit = get_or_default(config, CONF_SLEEP, DEFAULT_CONF_SLEEP) - self.map_path = get_or_default(config, CONF_MAP_PATH, DEFAULT_CONF_MAP_PATH) self.cloud = ProscenicCloud(auth, loop, self.sleep_duration_on_exit) self.cloud.add_device_state_updated_handler(lambda state: self._update_device_state(state)) + self.cloud.add_map_updated_handler(lambda map_data: self._update_map_data(map_data)) self.map_generator_task = None async def listen_state_change(self): try: await self.cloud.start_state_refresh_loop() + except asyncio.exceptions.CancelledError: + _LOGGER.info('the asyncio is cancelled. Stop to listen proscenic vacuum state update.') except: _LOGGER.exception('error while listening proscenic vacuum state change') @@ -89,24 +87,20 @@ async def stop(self): async def return_to_base(self): await self._send_command(b'{"transitCmd":"104"}') - async def _send_command(self, command: bytes, input_writer = None): + async def _send_command(self, command: bytes): try: header = b'\xd2\x00\x00\x00\xfa\x00\xc8\x00\x00\x00\xeb\x27\xea\x27\x00\x00\x00\x00\x00\x00' body = b'{"cmd":0,"control":{"authCode":"' \ + str.encode(self.auth[CONF_AUTH_CODE]) \ + b'","deviceIp":"' + str.encode(self.ip) + b'","devicePort":"8888","targetId":"' \ - + str.encode(self.target_id if self.mode == CLOUD_MODE and not input_writer else self.device_id) \ + + str.encode(self.target_id if self.mode == CLOUD_MODE else self.device_id) \ + b'","targetType":"3"},"seq":0,"value":' \ + command \ + b',"version":"1.5.11"}' _LOGGER.debug('send command {}'.format(str(body))) - if self.mode == LOCAL_MODE or input_writer: - if not input_writer: - (_, writer) = await asyncio.open_connection(self.ip, 8888, loop = self.loop) - else: - writer = input_writer - + if self.mode == LOCAL_MODE: + (_, writer) = await asyncio.open_connection(self.ip, 8888, loop = self.loop) writer.write(header + body) await writer.drain() else: @@ -114,58 +108,19 @@ async def _send_command(self, command: bytes, input_writer = None): except OSError: raise VacuumUnavailable('can not connect to the vacuum. Turn on the physical switch button.') - async def _wait_for_map_input(self): - while True: + async def _map_generation_loop(self): + while self.work_state == WorkState.CLEANING: 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 = _extract_json(str.encode(data)) - if 'value' in json: - value = json['value'] - if 'map' in value: - build_map(value['map'], value['track'], self.map_path) - if 'clearArea' in value: - self.last_clear_area = int(value['clearArea']) - if 'clearTime' in value: - self.last_clear_duration = int(value['clearTime']) - self._call_listners() - await asyncio.sleep(5) - else: - _LOGGER.debug('The cleaning session is ended. End of map generation process.') - return - except ConnectionResetError: - await asyncio.sleep(60) - except asyncio.TimeoutError: - _LOGGER.error('unable to get map on time') - - async def _get_map(self): - _LOGGER.debug('opening the socket to get the map') - (reader, writer) = await asyncio.open_connection(self.ip, 8888, loop = self.loop) - _LOGGER.debug('send the command to get the map') - await self._send_command(b'{"transitCmd":"131"}', writer) - read_data = '' - while True: - data = await reader.read(1000) - if data == b'': - _LOGGER.debug('No data read during map generation.') - break - try: - read_data = read_data + data.decode() - _LOGGER.debug('map generation. 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('map generation. return valid json {}'.format(read_data)) - return read_data - elif nb_close > nb_openning: - _LOGGER.info('map generation. malformed json json {}'.format(read_data)) - read_data = '' + await self._send_command(b'{"transitCmd":"131"}') + await asyncio.sleep(5) + except asyncio.exceptions.CancelledError: + _LOGGER.info('the asyncio is cancelled. Stop the map generation loop.') + return except: - _LOGGER.debug('unreadable data {}'.format(data)) - read_data = '' + _LOGGER.exception('unknown error during map generation.') + return + + _LOGGER.debug('The cleaning session is ended. End of map generation process.') def _call_listners(self): for listner in self.listner: @@ -184,12 +139,19 @@ def _update_device_state(self, state: VacuumState): if self.work_state == WorkState.CLEANING: if not self.map_generator_task or self.map_generator_task.done(): _LOGGER.debug('Vacuum is cleaning. Start the map generation.') - self.map_generator_task = self.loop.create_task(self._wait_for_map_input()) + self.map_generator_task = self.loop.create_task(self._map_generation_loop()) else: _LOGGER.debug('Vacuum is cleaning. Do not restart map generation process, because it is already running.') self._call_listners() + def _update_map_data(self, map: VacuumMap): + if map.last_clear_area: + self.last_clear_area = map.last_clear_area + if map.last_clear_duration: + self.last_clear_duration = map.last_clear_duration + if map.map_svg: + self.map_svg = map.map_svg class ProscenicCloud: def __init__(self, auth, loop, sleep_duration_on_exit): @@ -197,6 +159,7 @@ def __init__(self, auth, loop, sleep_duration_on_exit): self._writer = None self._state = 'disconnected' self._device_state_updated_handlers = [] + self._map_updated_handlers = [] self._loop = loop self._auth = auth self._sleep_duration_on_exit = sleep_duration_on_exit @@ -206,6 +169,9 @@ def __init__(self, auth, loop, sleep_duration_on_exit): def add_device_state_updated_handler(self, handler): self._device_state_updated_handlers.append(handler) + def add_map_updated_handler(self, handler): + self._map_updated_handlers.append(handler) + async def send_command(self, command): await self._connect() @@ -272,12 +238,46 @@ async def _ping(self): self.writer.write(body) await self.writer.drain() # manage error (socket closed) + async def _wait_data_from_cloud(self): + read_data = b'' + + while True: + data = await self.reader.read(1000) + if data == b'': + return read_data + + _LOGGER.debug('receive "{}"'.format(data)) + read_data = read_data + data + + last_opening_brace_index = read_data.find(b'{') + + if last_opening_brace_index >= 0: + try: + decoded_data = read_data[last_opening_brace_index:].decode() + + first_carriage_return_index = decoded_data.find('\n') + if first_carriage_return_index > 0: + # if the message contains a \n, we read only the first part of the message. The second part is ignored + decoded_data = decoded_data[:(first_carriage_return_index + 1)] + + nb_openning = decoded_data.count('{') + nb_close = decoded_data.count('}') + if nb_openning > 0 and nb_openning == nb_close: + _LOGGER.debug('receive json from proscenic cloud {}'.format(decoded_data)) + last_closing_brace_index = decoded_data.rfind('}') + json_string = decoded_data[0:(last_closing_brace_index + 1)] + return json.loads(json_string) + elif nb_close > nb_openning: + _LOGGER.warn('malformed json received from cloud: {}'.format(read_data)) + read_data = b'' + except UnicodeDecodeError: + _LOGGER.exception('decoding issue for message {}'.format(read_data)) + read_data = b'' + async def _wait_for_state_refresh(self): while self._state != 'disconnected': - data = await self.reader.read(1000) + data = await self._wait_data_from_cloud() if data != b'': - _LOGGER.debug('receive from state refresh: {}'.format(str(data))) - data = _extract_json(data) if data and 'msg' in data and data['msg'] == 'exit succeed': _LOGGER.warn('receive exit succeed - I have been disconnected') self._state = 'disconnected' @@ -288,7 +288,9 @@ async def _wait_for_state_refresh(self): self._connectedFuture.set_result(True) elif data and 'value' in data: values = data['value'] - if not 'errRecordId' in values: + if 'map' in values: + self._map_received(values) + elif not 'errRecordId' in values: state = VacuumState() if 'workState' in values and values['workState'] != '': @@ -312,6 +314,19 @@ async def _wait_for_state_refresh(self): _LOGGER.warn('receive empty message - I have been disconnected') self._state = 'disconnected' + def _map_received(self, value): + map = VacuumMap() + + if 'map' in value: + map.map_svg = build_map(value['map'], value['track']) + if 'clearArea' in value: + map.last_clear_area = int(value['clearArea']) + if 'clearTime' in value: + map.last_clear_duration = int(value['clearTime']) + + for listner in self._map_updated_handlers: + listner(map) + async def _wait_and_rererun_refresh_loop(self): _LOGGER.debug('sleep {} second before reconnecting'.format(self._sleep_duration_on_exit)) await asyncio.sleep(self._sleep_duration_on_exit) @@ -322,5 +337,6 @@ def _call_state_updated_listners(self, device_state: VacuumState): for listner in self._device_state_updated_handlers: listner(device_state) + class VacuumUnavailable(Exception): pass \ No newline at end of file From bcf3d3b36b8f2764f2b889cd745ce05efb00b6d2 Mon Sep 17 00:00:00 2001 From: deblockt Date: Tue, 12 Apr 2022 10:01:03 +0000 Subject: [PATCH 22/30] ci: upgrade version to 0.0.10 --- custom_components/proscenic/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/proscenic/manifest.json b/custom_components/proscenic/manifest.json index ce347b5..ffa52dc 100755 --- a/custom_components/proscenic/manifest.json +++ b/custom_components/proscenic/manifest.json @@ -1,7 +1,7 @@ { "domain": "proscenic", "name": "proscenic-vacuum", - "version": "0.0.9", + "version": "0.0.10", "documentation": "https://github.com/deblockt/hass-proscenic-790T-vacuum", "issue_tracker": "https://github.com/deblockt/hass-proscenic-790T-vacuum/issues", "dependencies": [], From 6045c3720de61ab38d27aacacc0095fdc2ac514f Mon Sep 17 00:00:00 2001 From: Thomas Deblock Date: Tue, 12 Apr 2022 18:34:12 +0200 Subject: [PATCH 23/30] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 400a20c..f4d419b 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ device id, token, user id and authentication code can be retrieved using the Pro The camera entity will be automaticaly added. The proscenic cloud is used to generate the map. +> ⚠️ 🚨: updating to 0.0.10 you should remove the integration and re-add it using cloud configuration to keep the map generation working + ## Available attributes Theses attributes are available to be displayed on lovelace-ui: From ddfed408a0a244f1a3e6564d3c7af52d05479a0c Mon Sep 17 00:00:00 2001 From: Thomas Deblock Date: Sat, 16 Apr 2022 08:56:14 +0200 Subject: [PATCH 24/30] fix: timeout issue on proscenic cloud --- .../proscenic/vacuum_proscenic.py | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/custom_components/proscenic/vacuum_proscenic.py b/custom_components/proscenic/vacuum_proscenic.py index 0487e9b..75eebcd 100755 --- a/custom_components/proscenic/vacuum_proscenic.py +++ b/custom_components/proscenic/vacuum_proscenic.py @@ -153,11 +153,13 @@ def _update_map_data(self, map: VacuumMap): if map.map_svg: self.map_svg = map.map_svg +PROSCENIC_CLOUD_DISONNECTED = 'disconnected' + class ProscenicCloud: def __init__(self, auth, loop, sleep_duration_on_exit): self._reader = None self._writer = None - self._state = 'disconnected' + self._state = PROSCENIC_CLOUD_DISONNECTED self._device_state_updated_handlers = [] self._map_updated_handlers = [] self._loop = loop @@ -176,8 +178,13 @@ async def send_command(self, command): await self._connect() header = b'\xd0\x00\x00\x00\xfa\x00\xc8\x00\x00\x00\x24\x27\x25\x27\x00\x00\x00\x00\x00\x00' - self.writer.write(header + command) - await self.writer.drain() + try: + self.writer.write(header + command) + await self.writer.drain() + except TimeoutError: + _LOGGER.debug('Timeout sending command to proscenic cloud. Reconnect and retry.') + self._state = PROSCENIC_CLOUD_DISONNECTED + self.send_command(command) # refresh loop is used to read vacuum update (status, battery, etc...) async def start_state_refresh_loop(self): @@ -190,11 +197,21 @@ async def start_state_refresh_loop(self): try: await self._connect(wait_for_login_response = False) # we don't wait for login response, because we need the refresh loop started to know if loggin is OK - while self._state != 'disconnected': + while self._state != PROSCENIC_CLOUD_DISONNECTED: try: await asyncio.wait_for(self._wait_for_state_refresh(), timeout=60.0) except asyncio.TimeoutError: await self._ping() + except TimeoutError: + self._state = PROSCENIC_CLOUD_DISONNECTED + _LOGGER.debug('Timeout occurs on proscenic cloud socket. Restart refresh loop.') + except asyncio.exceptions.CancelledError: + _LOGGER.debug('Refresh loop has been cancel by system.') + self._is_refresh_loop_runing = False + return + except: + self._state = PROSCENIC_CLOUD_DISONNECTED + _LOGGER.exception('Unknon error on refresh loop. Restart refresh loop.') self._is_refresh_loop_runing = False self._loop.create_task(self._wait_and_rererun_refresh_loop()) @@ -203,7 +220,7 @@ async def start_state_refresh_loop(self): self._is_refresh_loop_runing = False async def _connect(self, wait_for_login_response = True): - if self._state == 'disconnected': + if self._state == PROSCENIC_CLOUD_DISONNECTED: _LOGGER.info('opening socket with proscenic cloud.') self._state = 'connecting' (self.reader, self.writer) = await asyncio.open_connection(CLOUD_PROSCENIC_IP, CLOUD_PROSCENIC_PORT, loop = self._loop) @@ -275,12 +292,12 @@ async def _wait_data_from_cloud(self): read_data = b'' async def _wait_for_state_refresh(self): - while self._state != 'disconnected': + while self._state != PROSCENIC_CLOUD_DISONNECTED: data = await self._wait_data_from_cloud() if data != b'': if data and 'msg' in data and data['msg'] == 'exit succeed': _LOGGER.warn('receive exit succeed - I have been disconnected') - self._state = 'disconnected' + self._state = PROSCENIC_CLOUD_DISONNECTED if data and 'msg' in data and data['msg'] == 'login succeed': _LOGGER.info('connected to proscenic cloud.') self._state = 'connected' @@ -311,8 +328,8 @@ async def _wait_for_state_refresh(self): self._call_state_updated_listners(state) else: - _LOGGER.warn('receive empty message - I have been disconnected') - self._state = 'disconnected' + _LOGGER.warn('receive empty message - disconnected from proscenic cloud') + self._state = PROSCENIC_CLOUD_DISONNECTED def _map_received(self, value): map = VacuumMap() From 5913040bb6764cc6f524e361b700c191490cdc2e Mon Sep 17 00:00:00 2001 From: deblockt Date: Tue, 19 Apr 2022 06:00:20 +0000 Subject: [PATCH 25/30] ci: upgrade version to 0.0.11 --- custom_components/proscenic/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/proscenic/manifest.json b/custom_components/proscenic/manifest.json index ffa52dc..bb99cbb 100755 --- a/custom_components/proscenic/manifest.json +++ b/custom_components/proscenic/manifest.json @@ -1,7 +1,7 @@ { "domain": "proscenic", "name": "proscenic-vacuum", - "version": "0.0.10", + "version": "0.0.11", "documentation": "https://github.com/deblockt/hass-proscenic-790T-vacuum", "issue_tracker": "https://github.com/deblockt/hass-proscenic-790T-vacuum/issues", "dependencies": [], From 9c14bd52aadabcd78a4ae4845d9baec7da68efc0 Mon Sep 17 00:00:00 2001 From: Thomas Deblock Date: Tue, 31 May 2022 10:10:40 +0200 Subject: [PATCH 26/30] docs: update hacs.json --- hacs.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/hacs.json b/hacs.json index 265b452..90d00e5 100644 --- a/hacs.json +++ b/hacs.json @@ -1,7 +1,5 @@ { "name": "proscenic 790T vacuum", - "domains": ["vacuum"], "homeassistant": "2021.7.4", - "iot_class": "local_polling", "render_readme": true -} \ No newline at end of file +} From b49263d44fbcbd8c8206df4c3f8726c86a677744 Mon Sep 17 00:00:00 2001 From: Sven Serlier <85389871+wrt54g@users.noreply.github.com> Date: Fri, 3 Jun 2022 16:42:37 +0200 Subject: [PATCH 27/30] docs: update hacs url --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f4d419b..12819a2 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Home assistant proscenic 790T vacuum integration [![GitHub release](https://img.shields.io/github/release/deblockt/hass-proscenic-790T-vacuum)](https://github.com/deblockt/hass-proscenic-790T-vacuum/releases/latest) -[![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/custom-components/hacs) +[![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/hacs/integration) The purpose of this integration is to provide an integration of proscenic 790T vacuum. It allow home assistant to: From e2a8441d410ace650691516dc1479aec22818804 Mon Sep 17 00:00:00 2001 From: Thomas Deblock Date: Sat, 9 Jul 2022 16:17:47 +0200 Subject: [PATCH 28/30] fix: python 3.10 compatibility issue --- custom_components/proscenic/vacuum_proscenic.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/proscenic/vacuum_proscenic.py b/custom_components/proscenic/vacuum_proscenic.py index 75eebcd..57897a1 100755 --- a/custom_components/proscenic/vacuum_proscenic.py +++ b/custom_components/proscenic/vacuum_proscenic.py @@ -100,7 +100,7 @@ async def _send_command(self, command: bytes): _LOGGER.debug('send command {}'.format(str(body))) if self.mode == LOCAL_MODE: - (_, writer) = await asyncio.open_connection(self.ip, 8888, loop = self.loop) + (_, writer) = await asyncio.open_connection(self.ip, 8888) writer.write(header + body) await writer.drain() else: @@ -223,7 +223,7 @@ async def _connect(self, wait_for_login_response = True): if self._state == PROSCENIC_CLOUD_DISONNECTED: _LOGGER.info('opening socket with proscenic cloud.') self._state = 'connecting' - (self.reader, self.writer) = await asyncio.open_connection(CLOUD_PROSCENIC_IP, CLOUD_PROSCENIC_PORT, loop = self._loop) + (self.reader, self.writer) = await asyncio.open_connection(CLOUD_PROSCENIC_IP, CLOUD_PROSCENIC_PORT) await self._login(wait_for_login_response) async def _login(self, wait_for_login_response = True): From af9997d6d607a74ca67a03eaa6d9b2ff4cdedb27 Mon Sep 17 00:00:00 2001 From: deblockt Date: Sat, 9 Jul 2022 14:18:51 +0000 Subject: [PATCH 29/30] ci: upgrade version to 0.0.12 --- custom_components/proscenic/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/proscenic/manifest.json b/custom_components/proscenic/manifest.json index bb99cbb..1591f00 100755 --- a/custom_components/proscenic/manifest.json +++ b/custom_components/proscenic/manifest.json @@ -1,7 +1,7 @@ { "domain": "proscenic", "name": "proscenic-vacuum", - "version": "0.0.11", + "version": "0.0.12", "documentation": "https://github.com/deblockt/hass-proscenic-790T-vacuum", "issue_tracker": "https://github.com/deblockt/hass-proscenic-790T-vacuum/issues", "dependencies": [], From 42a2e704c1f4b3a67993009129271c9e243277ce Mon Sep 17 00:00:00 2001 From: Thomas Deblock Date: Sat, 23 Sep 2023 16:32:52 +0200 Subject: [PATCH 30/30] fix: use StateVacuumEntity instead of VacuumEntity --- custom_components/proscenic/vacuum.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/proscenic/vacuum.py b/custom_components/proscenic/vacuum.py index fcf1783..52a50cc 100755 --- a/custom_components/proscenic/vacuum.py +++ b/custom_components/proscenic/vacuum.py @@ -18,7 +18,7 @@ STATE_IDLE, STATE_PAUSED, STATE_RETURNING, - VacuumEntity, + StateVacuumEntity, PLATFORM_SCHEMA ) from homeassistant.const import ( @@ -102,7 +102,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async_add_entities(vacuums, update_before_add = False) -class ProscenicVacuum(VacuumEntity): +class ProscenicVacuum(StateVacuumEntity): """790T Vacuums such as Deebot.""" def __init__(self, device, name):