├── .automated.eslintrc.json ├── .eslintrc.json ├── .github └── ISSUE_TEMPLATE │ └── config.yml ├── LICENSE ├── README.md ├── dist └── valetudo-map-card.js ├── hacs.json ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── lib │ ├── RawMapData.ts │ ├── colors │ │ ├── ColorUtils.ts │ │ ├── FourColorTheoremSolver.ts │ │ ├── MapAreaGraph.ts │ │ └── MapAreaVertex.ts │ ├── mapUtils.ts │ ├── pngUtils.ts │ └── types.ts ├── res │ └── consts.ts └── valetudo-map-card.ts └── tsconfig.json /.automated.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "brace-style": [ 4 | "error", 5 | "1tbs" 6 | ], 7 | "no-trailing-spaces": [ 8 | "error", 9 | { 10 | "ignoreComments": true 11 | } 12 | ], 13 | "keyword-spacing": "error", 14 | "eol-last": [ 15 | "error", 16 | "always" 17 | ], 18 | "no-multi-spaces": [ 19 | "error", 20 | { 21 | "ignoreEOLComments": true 22 | } 23 | ], 24 | "semi": [ 25 | "error", 26 | "always" 27 | ], 28 | "quotes": [ 29 | "error", 30 | "double" 31 | ], 32 | "indent": [ 33 | "error", 34 | 4, 35 | { 36 | "SwitchCase": 1 37 | } 38 | ], 39 | "no-empty": "error" 40 | } 41 | } -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es6": true, 6 | "node": true, 7 | "mocha": true 8 | }, 9 | "plugins": ["jsdoc", "sort-keys-fix", "sort-requires", "regexp", "@typescript-eslint"], 10 | "parser": "@typescript-eslint/parser", 11 | "extends": ["eslint:recommended", "plugin:regexp/recommended"], 12 | "ignorePatterns": ["dist/*.js"], 13 | "globals": { 14 | "Atomics": "readonly", 15 | "SharedArrayBuffer": "readonly" 16 | }, 17 | "parserOptions": { 18 | "ecmaVersion": 2020, 19 | "sourceType": "module" 20 | }, 21 | "settings": { 22 | "jsdoc": { 23 | "mode": "closure", 24 | "tagNamePreference": { 25 | "returns": "returns", 26 | "augments": "extends" 27 | } 28 | } 29 | }, 30 | "rules": { 31 | "no-labels": "error", 32 | "max-classes-per-file": "error", 33 | "eqeqeq": "error", 34 | "curly": "error", 35 | "default-case-last": "error", 36 | "block-scoped-var": "error", 37 | "no-new": "error", 38 | "no-multi-str": "error", 39 | "no-new-wrappers": "error", 40 | "no-sequences": "error", 41 | "no-self-compare": "error", 42 | "no-multi-assign": "error", 43 | "no-whitespace-before-property": "error", 44 | "no-magic-numbers": ["off", { "ignoreArrayIndexes": true }], 45 | "no-unused-vars": ["warn", { "args": "none" }], 46 | "jsdoc/check-alignment": "error", 47 | "jsdoc/check-param-names": "error", 48 | "jsdoc/check-tag-names": "error", 49 | "jsdoc/check-types": "error", 50 | "jsdoc/implements-on-classes": "error", 51 | "jsdoc/newline-after-description": "error", 52 | "jsdoc/no-undefined-types": "error", 53 | "jsdoc/require-param": "error", 54 | "jsdoc/require-param-name": "error", 55 | "jsdoc/require-param-type": "error", 56 | "jsdoc/require-returns-check": "error", 57 | "jsdoc/require-returns-type": "error", 58 | "sort-requires/sort-requires": "warn", 59 | "operator-linebreak": ["error", "after"], 60 | "no-unneeded-ternary": ["error", { "defaultAssignment": false }], 61 | "arrow-body-style": ["error", "always"], 62 | "regexp/no-unused-capturing-group": "off", 63 | 64 | 65 | 66 | "no-empty": "off", 67 | "brace-style": "off", 68 | "no-trailing-spaces": "off", 69 | "keyword-spacing": "off", 70 | "eol-last": "off", 71 | "no-multi-spaces": "off", 72 | "semi": "off", 73 | "quotes": "off", 74 | "indent": "off" 75 | 76 | 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Read the README 4 | url: https://github.com/Hypfer/lovelace-valetudo-map-card#readme 5 | about: Did you read the README? 6 | - name: How to clear cache 7 | url: https://github.com/Hypfer/lovelace-valetudo-map-card/issues/85#issuecomment-790248620 8 | about: "Have you restarted Home Assistant? If that didn't work, have you cleared your cache? Several times? Like, 20+ times? I'm serious. Do that first." 9 | - name: Map card release notes 10 | url: https://github.com/Hypfer/lovelace-valetudo-map-card/releases 11 | about: Did something break after updating? Did you read the release notes? 12 | - name: Valetudo release notes 13 | url: https://github.com/Hypfer/Valetudo/releases 14 | about: "Did you also read Valetudo's release notes?" 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Sylvia van Os 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Valetudo Map Card 2 | 3 | Display the map from a valetudo-enabled robot in a home assistant dashboard card. 4 | 5 | ## Installation 6 | 7 | It is highly recommended to use [HACS](https://hacs.xyz/) for managing custom extensions of Home Assistant. 8 | 9 | Follow the HACS [installation instructions](https://hacs.xyz/docs/use/download/prerequisites/). 10 | 11 | It is necessary to "take control" over the dashboards before downloading the "Valetudo Map Card". 12 | 1. Go to the Overview dashboard 13 | 2. Click on the pencil-icon on the top right 14 | 3. In the dialog click on the three dots on the top right 15 | 4. In the context menu click on "take control" 16 | 5. In the next dialog click on "take control" again 17 | 18 | Then, open HACS, go to Frontend and click "Explore & Download Repositories" and search for "Valetudo Map Card". Select it and choose "Download". 19 | 20 | ## Configuration 21 | 22 | ### MQTT 23 | 24 | This card makes use of [Valetudo's MQTT support](https://valetudo.cloud/pages/integrations/mqtt.html). 25 | MQTT has to be configured in [Home Assistant](https://www.home-assistant.io/docs/mqtt/broker) and [Valetudo](https://valetudo.cloud/pages/integrations/home-assistant-integration.html). 26 | 27 | ### Custom card 28 | 29 | To get the card up and running, head over to [https://hass.valetudo.cloud](https://hass.valetudo.cloud) for a short walkthrough. 30 | 31 | ## Usage examples 32 | 33 | ### Displaying with the vacuum entity 34 | 35 | ![image](https://user-images.githubusercontent.com/974410/198376172-db7a5441-0f5f-429c-8022-fc43d28446b9.png) 36 | 37 | For easy control of the vacuum, consider using a vertical-stack with an entities card like so: 38 | 39 | ``` 40 | type: vertical-stack 41 | cards: 42 | - vacuum: valetudo_thirstyserpentinestingray 43 | type: custom:valetudo-map-card 44 | - entities: 45 | - vacuum.valetudo_thirstyserpentinestingray 46 | type: entities 47 | ``` 48 | 49 | ### Displaying as overlay 50 | 51 | When combining this card with Home Assistant's `picture-elements`, you could use this to show your vacuum's position on top of your house. Make sure to set both `show_floor: false` and `background_color: transparent` in this card: 52 | 53 | ``` 54 | type: picture-elements 55 | image: https://online.visual-paradigm.com/repository/images/e5728e49-09ce-4c95-b83c-482deee24386.png 56 | elements: 57 | - type: 'custom:valetudo-map-card' 58 | vacuum: valetudo_thirstyserpentinestingray 59 | show_floor: false 60 | background_color: transparent 61 | ``` 62 | 63 | Then use map_scale and crop to make it fit. 64 | 65 | ## Options 66 | 67 | | Name | Type | Default | Description 68 | |-------------------------------------|---------|---------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- 69 | | type | string | **Required** | `custom:valetudo-map-card` 70 | | vacuum | string | **Required** | Name of the vacuum in MQTT (without vacuum. prefix) 71 | | title | string | Vacuum | Title to show in the card header 72 | | show_map | boolean | true | Show the map 73 | | background_color | string | | Background color of the card 74 | | floor_color | string | '--valetudo-map-floor-color', '--secondary-background-color' | Floor color 75 | | floor_opacity | number | 1 | Floor opacity 76 | | wall_color | string | '--valetudo-map-wall-color', '--accent-color' | Wall 77 | | wall_opacity | number | 1 | Wall opacity 78 | | currently_cleaned_zone_color | string | '--valetudo-currently_cleaned_zone_color', '--secondary-text-color' | Color of zones selected for cleanup 79 | | currently_cleaned_zone_opacity | number | 0.5 | Opacity of the currently cleaned zones 80 | | no_go_area_color | string | '--valetudo-no-go-area-color', '--accent-color' | No go area color 81 | | no_go_area_opacity | number | 0.5 | Opacity of the no go areas 82 | | no_mop_area_color | string | '--valetudo-no-mop-area-color', '--secondary-text-color' | No mop area color 83 | | no_mop_area_opacity | number | 0.5 | Opacity of the no mop areas 84 | | virtual_wall_color | string | '--valetudo-virtual-wall-color', '--accent-color' | Virtual wall color 85 | | virtual_wall_opacity | number | 1 | Virtual wall opacity 86 | | virtual_wall_width | number | 1 | Virtual wall line width 87 | | path_color | string | '--valetudo-map-path-color', '--primary-text-color' | Path color 88 | | path_opacity | number | 1 | Path opacity 89 | | path_width | number | 1 | Path line width 90 | | segment_colors | array | '#19A1A1', '#7AC037', '#DF5618', '#F7C841' | Segment colors 91 | | segment_opacity | number | 0.75 | Segment opacity 92 | | show_floor | boolean | true | Draw the floor on the map 93 | | show_dock | boolean | true | Draw the charging dock on the map 94 | | show_vacuum | boolean | true | Draw the vacuum on the map 95 | | show_walls | boolean | true | Draw walls on the map 96 | | show_currently_cleaned_zones | boolean | true | Show zones selected for zoned cleanup on the map 97 | | show_no_go_areas | boolean | true | Draw no go areas on the map 98 | | show_no_mop_areas | boolean | true | Draw no mop areas on the map 99 | | show_virtual_walls | boolean | true | Draw virtual walls on the map 100 | | show_path | boolean | true | Draw the path the vacuum took 101 | | show_currently_cleaned_zones_border | boolean | true | Draw a border around the currently cleaned zones 102 | | show_no_go_border | boolean | true | Draw a border around no go areas 103 | | show_no_mop_border | boolean | true | Draw a border around no mop areas 104 | | show_predicted_path | boolean | true | Draw the predicted path for the vacuum 105 | | show_goto_target | boolean | true | Draw the go to target 106 | | show_segments | boolean | true | Draw the floor segments on the map 107 | | show_status | boolean | true | Show the status of vacuum_entity 108 | | show_battery_level | boolean | true | Show the battery level of vacuum_entity 109 | | show_start_button | boolean | true | Show the start button for vacuum_entity 110 | | show_pause_button | boolean | true | Show the pause button for vacuum_entity 111 | | show_stop_button | boolean | true | Show the stop button for vacuum_entity 112 | | show_home_button | boolean | true | Show the home button for vacuum_entity 113 | | show_locate_button | boolean | true | Show the locate button for vacuum_entity 114 | | goto_target_icon | string | mdi:pin | The icon to use for the go to target 115 | | goto_target_color | string | 'blue' | The color to use for the go to target icon 116 | | dock_icon | string | mdi:flash | The icon to use for the charging dock 117 | | dock_color | string | 'green' | The color to use for the charging dock icon 118 | | vacuum_icon | string | mdi:robot-vacuum | The icon to use for the vacuum 119 | | vacuum_color | string | '--primary-text-color' | The color to use for the vacuum icon 120 | | map_scale | number | 1 | Scale the map by this value 121 | | icon_scale | number | 1 | Scale the icons (vacuum & dock) by this value 122 | | rotate | number | 0 | Value to rotate the map by (default is in deg, but a value like `2rad` is valid too) 123 | | left_padding | number | 0 | Value that moves the map `number` pixels from left to right 124 | | crop | Object | {top: 0, bottom: 0, left: 0, right: 0} | Crop the map 125 | | min_height | string | 0 | The minimum height of the card the map is displayed in, regardless of the map's size itself. Suffix with 'w' if you want it to be times the width (ex: 0.5625w is equivalent to a picture card's 16x9 aspect_ratio) 126 | | custom_buttons | array | [] | An array of custom buttons. Options detailed below. 127 | 128 | Colors can be any valid CSS value in the card config, like name (red), hex code (#FF0000), rgb(255,255,255), rgba(255,255,255,0.8)... 129 | 130 | ## Custom Buttons 131 | 132 | Custom buttons can be added to this card when vacuum_entity is set. Each custom button supports the following options: 133 | 134 | | Name | Type | Default | Description 135 | |--------------|--------|--------------------|---------------------------------------------------------- 136 | | service | string | **Required** | The service to call when this button is pressed 137 | | service_data | Object | {} | Optional service data that will be passed to the service 138 | | icon | string | mdi:radiobox-blank | The icon that will represent the custom button 139 | | text | string | "" | Optional text to display next to the icon 140 | 141 | ## License 142 | 143 | Lovelace Valetudo Map Card is licensed under the MIT license. 144 | -------------------------------------------------------------------------------- /dist/valetudo-map-card.js: -------------------------------------------------------------------------------- 1 | var t=function(){function t(t){this.id=t,this.adjacentVertexIds=new Set,this.color=void 0}return t.prototype.appendVertex=function(t){void 0!==t&&this.adjacentVertexIds.add(t)},t}(),e=function(){function t(t){var e=this;this.vertices=t,this.vertexLookup=new Map,this.vertices.forEach((function(t){e.vertexLookup.set(t.id,t)}))}return t.prototype.connectVertices=function(t,e){void 0!==t&&void 0!==e&&t!==e&&(this.vertexLookup.has(t)&&this.vertexLookup.get(t).appendVertex(e),this.vertexLookup.has(e)&&this.vertexLookup.get(e).appendVertex(t))},t.prototype.colorAllVertices=function(){var t=this;this.vertices.sort((function(t,e){return e.adjacentVertexIds.size-t.adjacentVertexIds.size})),this.vertices.forEach((function(e){if(e.adjacentVertexIds.size<=0)e.color=0;else{var i=t.getAdjacentVertices(e).filter((function(t){return void 0!==t.color})).map((function(t){return t.color}));e.color=t.lowestColor(i)}}))},t.prototype.getAdjacentVertices=function(t){var e=this;return Array.from(t.adjacentVertexIds).map((function(t){return e.getById(t)})).filter((function(t){return void 0!==t}))},t.prototype.getById=function(t){return this.vertices.find((function(e){return e.id===t}))},t.prototype.lowestColor=function(t){if(t.length<=0)return 0;for(var e=0;et.maxX&&(t.maxX=e.x),e.y>t.maxY&&(t.maxY=e.y)},a.prototype.createPixelToSegmentMapping=function(t){var e,a,n=(e=t.boundaries.maxX+1,a=t.boundaries.maxY+1,i([],new Array(e),!0).map((function(t){return i([],new Array(a),!0)}))),o=[];return t.segments.forEach((function(t){o.push(t.segmentId),t.pixels.forEach((function(e){n[e.x][e.y]=t.segmentId}))})),{map:n,segmentIds:o,boundaries:t.boundaries}},a.prototype.buildGraph=function(i){var a=i.segmentIds.map((function(e){return new t(e)})),n=new e(a);return this.traverseMap(i.boundaries,i.map,(function(t,e,i,a){var o=a[t][e];return n.connectVertices(i,o),void 0!==o?o:i})),n},a.prototype.traverseMap=function(t,e,i){for(var a=t.minY;a<=t.maxY;a=this.stepFunction(a))for(var n=void 0,o=t.minX;o<=t.maxX;o=this.stepFunction(o))n=i(o,a,n,e);for(o=t.minX;o<=t.maxX;o=this.stepFunction(o)){var s=void 0;for(a=t.minY;a<=t.maxY;a=this.stepFunction(a))s=i(o,a,s,e)}},a}(); 2 | /*! pako 2.0.4 https://github.com/nodeca/pako @license (MIT AND Zlib) */function n(t){let e=t.length;for(;--e>=0;)t[e]=0}const o=new Uint8Array([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0]),s=new Uint8Array([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13]),r=new Uint8Array([0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,3,7]),l=new Uint8Array([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),h=new Array(576);n(h);const d=new Array(60);n(d);const c=new Array(512);n(c);const _=new Array(256);n(_);const f=new Array(29);n(f);const u=new Array(30);function p(t,e,i,a,n){this.static_tree=t,this.extra_bits=e,this.extra_base=i,this.elems=a,this.max_length=n,this.has_stree=t&&t.length}let g,m,w;function b(t,e){this.dyn_tree=t,this.max_code=0,this.stat_desc=e}n(u);const y=t=>t<256?c[t]:c[256+(t>>>7)],v=(t,e)=>{t.pending_buf[t.pending++]=255&e,t.pending_buf[t.pending++]=e>>>8&255},x=(t,e,i)=>{t.bi_valid>16-i?(t.bi_buf|=e<>16-t.bi_valid,t.bi_valid+=i-16):(t.bi_buf|=e<{x(t,i[2*e],i[2*e+1])},E=(t,e)=>{let i=0;do{i|=1&t,t>>>=1,i<<=1}while(--e>0);return i>>>1},C=(t,e,i)=>{const a=new Array(16);let n,o,s=0;for(n=1;n<=15;n++)a[n]=s=s+i[n-1]<<1;for(o=0;o<=e;o++){let e=t[2*o+1];0!==e&&(t[2*o]=E(a[e]++,e))}},A=t=>{let e;for(e=0;e<286;e++)t.dyn_ltree[2*e]=0;for(e=0;e<30;e++)t.dyn_dtree[2*e]=0;for(e=0;e<19;e++)t.bl_tree[2*e]=0;t.dyn_ltree[512]=1,t.opt_len=t.static_len=0,t.last_lit=t.matches=0},z=t=>{t.bi_valid>8?v(t,t.bi_buf):t.bi_valid>0&&(t.pending_buf[t.pending++]=t.bi_buf),t.bi_buf=0,t.bi_valid=0},S=(t,e,i,a)=>{const n=2*e,o=2*i;return t[n]{const a=t.heap[i];let n=i<<1;for(;n<=t.heap_len&&(n{let a,n,r,l,h=0;if(0!==t.last_lit)do{a=t.pending_buf[t.d_buf+2*h]<<8|t.pending_buf[t.d_buf+2*h+1],n=t.pending_buf[t.l_buf+h],h++,0===a?k(t,n,e):(r=_[n],k(t,r+256+1,e),l=o[r],0!==l&&(n-=f[r],x(t,n,l)),a--,r=y(a),k(t,r,i),l=s[r],0!==l&&(a-=u[r],x(t,a,l)))}while(h{const i=e.dyn_tree,a=e.stat_desc.static_tree,n=e.stat_desc.has_stree,o=e.stat_desc.elems;let s,r,l,h=-1;for(t.heap_len=0,t.heap_max=573,s=0;s>1;s>=1;s--)R(t,i,s);l=o;do{s=t.heap[1],t.heap[1]=t.heap[t.heap_len--],R(t,i,1),r=t.heap[1],t.heap[--t.heap_max]=s,t.heap[--t.heap_max]=r,i[2*l]=i[2*s]+i[2*r],t.depth[l]=(t.depth[s]>=t.depth[r]?t.depth[s]:t.depth[r])+1,i[2*s+1]=i[2*r+1]=l,t.heap[1]=l++,R(t,i,1)}while(t.heap_len>=2);t.heap[--t.heap_max]=t.heap[1],((t,e)=>{const i=e.dyn_tree,a=e.max_code,n=e.stat_desc.static_tree,o=e.stat_desc.has_stree,s=e.stat_desc.extra_bits,r=e.stat_desc.extra_base,l=e.stat_desc.max_length;let h,d,c,_,f,u,p=0;for(_=0;_<=15;_++)t.bl_count[_]=0;for(i[2*t.heap[t.heap_max]+1]=0,h=t.heap_max+1;h<573;h++)d=t.heap[h],_=i[2*i[2*d+1]+1]+1,_>l&&(_=l,p++),i[2*d+1]=_,d>a||(t.bl_count[_]++,f=0,d>=r&&(f=s[d-r]),u=i[2*d],t.opt_len+=u*(_+f),o&&(t.static_len+=u*(n[2*d+1]+f)));if(0!==p){do{for(_=l-1;0===t.bl_count[_];)_--;t.bl_count[_]--,t.bl_count[_+1]+=2,t.bl_count[l]--,p-=2}while(p>0);for(_=l;0!==_;_--)for(d=t.bl_count[_];0!==d;)c=t.heap[--h],c>a||(i[2*c+1]!==_&&(t.opt_len+=(_-i[2*c+1])*i[2*c],i[2*c+1]=_),d--)}})(t,e),C(i,h,t.bl_count)},D=(t,e,i)=>{let a,n,o=-1,s=e[1],r=0,l=7,h=4;for(0===s&&(l=138,h=3),e[2*(i+1)+1]=65535,a=0;a<=i;a++)n=s,s=e[2*(a+1)+1],++r{let a,n,o=-1,s=e[1],r=0,l=7,h=4;for(0===s&&(l=138,h=3),a=0;a<=i;a++)if(n=s,s=e[2*(a+1)+1],!(++r{x(t,0+(a?1:0),3),((t,e,i,a)=>{z(t),a&&(v(t,i),v(t,~i)),t.pending_buf.set(t.window.subarray(e,e+i),t.pending),t.pending+=i})(t,e,i,!0)};var Z=(t,e,i,a)=>{let n,o,s=0;t.level>0?(2===t.strm.data_type&&(t.strm.data_type=(t=>{let e,i=4093624447;for(e=0;e<=31;e++,i>>>=1)if(1&i&&0!==t.dyn_ltree[2*e])return 0;if(0!==t.dyn_ltree[18]||0!==t.dyn_ltree[20]||0!==t.dyn_ltree[26])return 1;for(e=32;e<256;e++)if(0!==t.dyn_ltree[2*e])return 1;return 0})(t)),I(t,t.l_desc),I(t,t.d_desc),s=(t=>{let e;for(D(t,t.dyn_ltree,t.l_desc.max_code),D(t,t.dyn_dtree,t.d_desc.max_code),I(t,t.bl_desc),e=18;e>=3&&0===t.bl_tree[2*l[e]+1];e--);return t.opt_len+=3*(e+1)+5+5+4,e})(t),n=t.opt_len+3+7>>>3,o=t.static_len+3+7>>>3,o<=n&&(n=o)):n=o=i+5,i+4<=n&&-1!==e?T(t,e,i,a):4===t.strategy||o===n?(x(t,2+(a?1:0),3),M(t,h,d)):(x(t,4+(a?1:0),3),((t,e,i,a)=>{let n;for(x(t,e-257,5),x(t,i-1,5),x(t,a-4,4),n=0;n{B||((()=>{let t,e,i,a,n;const l=new Array(16);for(i=0,a=0;a<28;a++)for(f[a]=i,t=0;t<1<>=7;a<30;a++)for(u[a]=n<<7,t=0;t<1<(t.pending_buf[t.d_buf+2*t.last_lit]=e>>>8&255,t.pending_buf[t.d_buf+2*t.last_lit+1]=255&e,t.pending_buf[t.l_buf+t.last_lit]=255&i,t.last_lit++,0===e?t.dyn_ltree[2*i]++:(t.matches++,e--,t.dyn_ltree[2*(_[i]+256+1)]++,t.dyn_dtree[2*y(e)]++),t.last_lit===t.lit_bufsize-1),_tr_align:t=>{x(t,2,3),k(t,256,h),(t=>{16===t.bi_valid?(v(t,t.bi_buf),t.bi_buf=0,t.bi_valid=0):t.bi_valid>=8&&(t.pending_buf[t.pending++]=255&t.bi_buf,t.bi_buf>>=8,t.bi_valid-=8)})(t)}};var O=(t,e,i,a)=>{let n=65535&t|0,o=t>>>16&65535|0,s=0;for(;0!==i;){s=i>2e3?2e3:i,i-=s;do{n=n+e[a++]|0,o=o+n|0}while(--s);n%=65521,o%=65521}return n|o<<16|0};const F=new Uint32Array((()=>{let t,e=[];for(var i=0;i<256;i++){t=i;for(var a=0;a<8;a++)t=1&t?3988292384^t>>>1:t>>>1;e[i]=t}return e})());var N=(t,e,i,a)=>{const n=F,o=a+i;t^=-1;for(let i=a;i>>8^n[255&(t^e[i])];return-1^t},P={2:"need dictionary",1:"stream end",0:"","-1":"file error","-2":"stream error","-3":"data error","-4":"insufficient memory","-5":"buffer error","-6":"incompatible version"},V={Z_NO_FLUSH:0,Z_PARTIAL_FLUSH:1,Z_SYNC_FLUSH:2,Z_FULL_FLUSH:3,Z_FINISH:4,Z_BLOCK:5,Z_TREES:6,Z_OK:0,Z_STREAM_END:1,Z_NEED_DICT:2,Z_ERRNO:-1,Z_STREAM_ERROR:-2,Z_DATA_ERROR:-3,Z_MEM_ERROR:-4,Z_BUF_ERROR:-5,Z_NO_COMPRESSION:0,Z_BEST_SPEED:1,Z_BEST_COMPRESSION:9,Z_DEFAULT_COMPRESSION:-1,Z_FILTERED:1,Z_HUFFMAN_ONLY:2,Z_RLE:3,Z_FIXED:4,Z_DEFAULT_STRATEGY:0,Z_BINARY:0,Z_TEXT:1,Z_UNKNOWN:2,Z_DEFLATED:8};const{_tr_init:W,_tr_stored_block:H,_tr_flush_block:Y,_tr_tally:j,_tr_align:X}=L,{Z_NO_FLUSH:$,Z_PARTIAL_FLUSH:G,Z_FULL_FLUSH:K,Z_FINISH:J,Z_BLOCK:q,Z_OK:Q,Z_STREAM_END:tt,Z_STREAM_ERROR:et,Z_DATA_ERROR:it,Z_BUF_ERROR:at,Z_DEFAULT_COMPRESSION:nt,Z_FILTERED:ot,Z_HUFFMAN_ONLY:st,Z_RLE:rt,Z_FIXED:lt,Z_DEFAULT_STRATEGY:ht,Z_UNKNOWN:dt,Z_DEFLATED:ct}=V,_t=(t,e)=>(t.msg=P[e],e),ft=t=>(t<<1)-(t>4?9:0),ut=t=>{let e=t.length;for(;--e>=0;)t[e]=0};let pt=(t,e,i)=>(e<{const e=t.state;let i=e.pending;i>t.avail_out&&(i=t.avail_out),0!==i&&(t.output.set(e.pending_buf.subarray(e.pending_out,e.pending_out+i),t.next_out),t.next_out+=i,e.pending_out+=i,t.total_out+=i,t.avail_out-=i,e.pending-=i,0===e.pending&&(e.pending_out=0))},mt=(t,e)=>{Y(t,t.block_start>=0?t.block_start:-1,t.strstart-t.block_start,e),t.block_start=t.strstart,gt(t.strm)},wt=(t,e)=>{t.pending_buf[t.pending++]=e},bt=(t,e)=>{t.pending_buf[t.pending++]=e>>>8&255,t.pending_buf[t.pending++]=255&e},yt=(t,e,i,a)=>{let n=t.avail_in;return n>a&&(n=a),0===n?0:(t.avail_in-=n,e.set(t.input.subarray(t.next_in,t.next_in+n),i),1===t.state.wrap?t.adler=O(t.adler,e,n,i):2===t.state.wrap&&(t.adler=N(t.adler,e,n,i)),t.next_in+=n,t.total_in+=n,n)},vt=(t,e)=>{let i,a,n=t.max_chain_length,o=t.strstart,s=t.prev_length,r=t.nice_match;const l=t.strstart>t.w_size-262?t.strstart-(t.w_size-262):0,h=t.window,d=t.w_mask,c=t.prev,_=t.strstart+258;let f=h[o+s-1],u=h[o+s];t.prev_length>=t.good_match&&(n>>=2),r>t.lookahead&&(r=t.lookahead);do{if(i=e,h[i+s]===u&&h[i+s-1]===f&&h[i]===h[o]&&h[++i]===h[o+1]){o+=2,i++;do{}while(h[++o]===h[++i]&&h[++o]===h[++i]&&h[++o]===h[++i]&&h[++o]===h[++i]&&h[++o]===h[++i]&&h[++o]===h[++i]&&h[++o]===h[++i]&&h[++o]===h[++i]&&o<_);if(a=258-(_-o),o=_-258,a>s){if(t.match_start=e,s=a,a>=r)break;f=h[o+s-1],u=h[o+s]}}}while((e=c[e&d])>l&&0!=--n);return s<=t.lookahead?s:t.lookahead},xt=t=>{const e=t.w_size;let i,a,n,o,s;do{if(o=t.window_size-t.lookahead-t.strstart,t.strstart>=e+(e-262)){t.window.set(t.window.subarray(e,e+e),0),t.match_start-=e,t.strstart-=e,t.block_start-=e,a=t.hash_size,i=a;do{n=t.head[--i],t.head[i]=n>=e?n-e:0}while(--a);a=e,i=a;do{n=t.prev[--i],t.prev[i]=n>=e?n-e:0}while(--a);o+=e}if(0===t.strm.avail_in)break;if(a=yt(t.strm,t.window,t.strstart+t.lookahead,o),t.lookahead+=a,t.lookahead+t.insert>=3)for(s=t.strstart-t.insert,t.ins_h=t.window[s],t.ins_h=pt(t,t.ins_h,t.window[s+1]);t.insert&&(t.ins_h=pt(t,t.ins_h,t.window[s+3-1]),t.prev[s&t.w_mask]=t.head[t.ins_h],t.head[t.ins_h]=s,s++,t.insert--,!(t.lookahead+t.insert<3)););}while(t.lookahead<262&&0!==t.strm.avail_in)},kt=(t,e)=>{let i,a;for(;;){if(t.lookahead<262){if(xt(t),t.lookahead<262&&e===$)return 1;if(0===t.lookahead)break}if(i=0,t.lookahead>=3&&(t.ins_h=pt(t,t.ins_h,t.window[t.strstart+3-1]),i=t.prev[t.strstart&t.w_mask]=t.head[t.ins_h],t.head[t.ins_h]=t.strstart),0!==i&&t.strstart-i<=t.w_size-262&&(t.match_length=vt(t,i)),t.match_length>=3)if(a=j(t,t.strstart-t.match_start,t.match_length-3),t.lookahead-=t.match_length,t.match_length<=t.max_lazy_match&&t.lookahead>=3){t.match_length--;do{t.strstart++,t.ins_h=pt(t,t.ins_h,t.window[t.strstart+3-1]),i=t.prev[t.strstart&t.w_mask]=t.head[t.ins_h],t.head[t.ins_h]=t.strstart}while(0!=--t.match_length);t.strstart++}else t.strstart+=t.match_length,t.match_length=0,t.ins_h=t.window[t.strstart],t.ins_h=pt(t,t.ins_h,t.window[t.strstart+1]);else a=j(t,0,t.window[t.strstart]),t.lookahead--,t.strstart++;if(a&&(mt(t,!1),0===t.strm.avail_out))return 1}return t.insert=t.strstart<2?t.strstart:2,e===J?(mt(t,!0),0===t.strm.avail_out?3:4):t.last_lit&&(mt(t,!1),0===t.strm.avail_out)?1:2},Et=(t,e)=>{let i,a,n;for(;;){if(t.lookahead<262){if(xt(t),t.lookahead<262&&e===$)return 1;if(0===t.lookahead)break}if(i=0,t.lookahead>=3&&(t.ins_h=pt(t,t.ins_h,t.window[t.strstart+3-1]),i=t.prev[t.strstart&t.w_mask]=t.head[t.ins_h],t.head[t.ins_h]=t.strstart),t.prev_length=t.match_length,t.prev_match=t.match_start,t.match_length=2,0!==i&&t.prev_length4096)&&(t.match_length=2)),t.prev_length>=3&&t.match_length<=t.prev_length){n=t.strstart+t.lookahead-3,a=j(t,t.strstart-1-t.prev_match,t.prev_length-3),t.lookahead-=t.prev_length-1,t.prev_length-=2;do{++t.strstart<=n&&(t.ins_h=pt(t,t.ins_h,t.window[t.strstart+3-1]),i=t.prev[t.strstart&t.w_mask]=t.head[t.ins_h],t.head[t.ins_h]=t.strstart)}while(0!=--t.prev_length);if(t.match_available=0,t.match_length=2,t.strstart++,a&&(mt(t,!1),0===t.strm.avail_out))return 1}else if(t.match_available){if(a=j(t,0,t.window[t.strstart-1]),a&&mt(t,!1),t.strstart++,t.lookahead--,0===t.strm.avail_out)return 1}else t.match_available=1,t.strstart++,t.lookahead--}return t.match_available&&(a=j(t,0,t.window[t.strstart-1]),t.match_available=0),t.insert=t.strstart<2?t.strstart:2,e===J?(mt(t,!0),0===t.strm.avail_out?3:4):t.last_lit&&(mt(t,!1),0===t.strm.avail_out)?1:2};function Ct(t,e,i,a,n){this.good_length=t,this.max_lazy=e,this.nice_length=i,this.max_chain=a,this.func=n}const At=[new Ct(0,0,0,0,((t,e)=>{let i=65535;for(i>t.pending_buf_size-5&&(i=t.pending_buf_size-5);;){if(t.lookahead<=1){if(xt(t),0===t.lookahead&&e===$)return 1;if(0===t.lookahead)break}t.strstart+=t.lookahead,t.lookahead=0;const a=t.block_start+i;if((0===t.strstart||t.strstart>=a)&&(t.lookahead=t.strstart-a,t.strstart=a,mt(t,!1),0===t.strm.avail_out))return 1;if(t.strstart-t.block_start>=t.w_size-262&&(mt(t,!1),0===t.strm.avail_out))return 1}return t.insert=0,e===J?(mt(t,!0),0===t.strm.avail_out?3:4):(t.strstart>t.block_start&&(mt(t,!1),t.strm.avail_out),1)})),new Ct(4,4,8,4,kt),new Ct(4,5,16,8,kt),new Ct(4,6,32,32,kt),new Ct(4,4,16,16,Et),new Ct(8,16,32,32,Et),new Ct(8,16,128,128,Et),new Ct(8,32,128,256,Et),new Ct(32,128,258,1024,Et),new Ct(32,258,258,4096,Et)];function zt(){this.strm=null,this.status=0,this.pending_buf=null,this.pending_buf_size=0,this.pending_out=0,this.pending=0,this.wrap=0,this.gzhead=null,this.gzindex=0,this.method=ct,this.last_flush=-1,this.w_size=0,this.w_bits=0,this.w_mask=0,this.window=null,this.window_size=0,this.prev=null,this.head=null,this.ins_h=0,this.hash_size=0,this.hash_bits=0,this.hash_mask=0,this.hash_shift=0,this.block_start=0,this.match_length=0,this.prev_match=0,this.match_available=0,this.strstart=0,this.match_start=0,this.lookahead=0,this.prev_length=0,this.max_chain_length=0,this.max_lazy_match=0,this.level=0,this.strategy=0,this.good_match=0,this.nice_match=0,this.dyn_ltree=new Uint16Array(1146),this.dyn_dtree=new Uint16Array(122),this.bl_tree=new Uint16Array(78),ut(this.dyn_ltree),ut(this.dyn_dtree),ut(this.bl_tree),this.l_desc=null,this.d_desc=null,this.bl_desc=null,this.bl_count=new Uint16Array(16),this.heap=new Uint16Array(573),ut(this.heap),this.heap_len=0,this.heap_max=0,this.depth=new Uint16Array(573),ut(this.depth),this.l_buf=0,this.lit_bufsize=0,this.last_lit=0,this.d_buf=0,this.opt_len=0,this.static_len=0,this.matches=0,this.insert=0,this.bi_buf=0,this.bi_valid=0}const St=t=>{if(!t||!t.state)return _t(t,et);t.total_in=t.total_out=0,t.data_type=dt;const e=t.state;return e.pending=0,e.pending_out=0,e.wrap<0&&(e.wrap=-e.wrap),e.status=e.wrap?42:113,t.adler=2===e.wrap?0:1,e.last_flush=$,W(e),Q},Rt=t=>{const e=St(t);var i;return e===Q&&((i=t.state).window_size=2*i.w_size,ut(i.head),i.max_lazy_match=At[i.level].max_lazy,i.good_match=At[i.level].good_length,i.nice_match=At[i.level].nice_length,i.max_chain_length=At[i.level].max_chain,i.strstart=0,i.block_start=0,i.lookahead=0,i.insert=0,i.match_length=i.prev_length=2,i.match_available=0,i.ins_h=0),e},Mt=(t,e,i,a,n,o)=>{if(!t)return et;let s=1;if(e===nt&&(e=6),a<0?(s=0,a=-a):a>15&&(s=2,a-=16),n<1||n>9||i!==ct||a<8||a>15||e<0||e>9||o<0||o>lt)return _t(t,et);8===a&&(a=9);const r=new zt;return t.state=r,r.strm=t,r.wrap=s,r.gzhead=null,r.w_bits=a,r.w_size=1<Mt(t,e,ct,15,8,ht),deflateInit2:Mt,deflateReset:Rt,deflateResetKeep:St,deflateSetHeader:(t,e)=>t&&t.state?2!==t.state.wrap?et:(t.state.gzhead=e,Q):et,deflate:(t,e)=>{let i,a;if(!t||!t.state||e>q||e<0)return t?_t(t,et):et;const n=t.state;if(!t.output||!t.input&&0!==t.avail_in||666===n.status&&e!==J)return _t(t,0===t.avail_out?at:et);n.strm=t;const o=n.last_flush;if(n.last_flush=e,42===n.status)if(2===n.wrap)t.adler=0,wt(n,31),wt(n,139),wt(n,8),n.gzhead?(wt(n,(n.gzhead.text?1:0)+(n.gzhead.hcrc?2:0)+(n.gzhead.extra?4:0)+(n.gzhead.name?8:0)+(n.gzhead.comment?16:0)),wt(n,255&n.gzhead.time),wt(n,n.gzhead.time>>8&255),wt(n,n.gzhead.time>>16&255),wt(n,n.gzhead.time>>24&255),wt(n,9===n.level?2:n.strategy>=st||n.level<2?4:0),wt(n,255&n.gzhead.os),n.gzhead.extra&&n.gzhead.extra.length&&(wt(n,255&n.gzhead.extra.length),wt(n,n.gzhead.extra.length>>8&255)),n.gzhead.hcrc&&(t.adler=N(t.adler,n.pending_buf,n.pending,0)),n.gzindex=0,n.status=69):(wt(n,0),wt(n,0),wt(n,0),wt(n,0),wt(n,0),wt(n,9===n.level?2:n.strategy>=st||n.level<2?4:0),wt(n,3),n.status=113);else{let e=ct+(n.w_bits-8<<4)<<8,i=-1;i=n.strategy>=st||n.level<2?0:n.level<6?1:6===n.level?2:3,e|=i<<6,0!==n.strstart&&(e|=32),e+=31-e%31,n.status=113,bt(n,e),0!==n.strstart&&(bt(n,t.adler>>>16),bt(n,65535&t.adler)),t.adler=1}if(69===n.status)if(n.gzhead.extra){for(i=n.pending;n.gzindex<(65535&n.gzhead.extra.length)&&(n.pending!==n.pending_buf_size||(n.gzhead.hcrc&&n.pending>i&&(t.adler=N(t.adler,n.pending_buf,n.pending-i,i)),gt(t),i=n.pending,n.pending!==n.pending_buf_size));)wt(n,255&n.gzhead.extra[n.gzindex]),n.gzindex++;n.gzhead.hcrc&&n.pending>i&&(t.adler=N(t.adler,n.pending_buf,n.pending-i,i)),n.gzindex===n.gzhead.extra.length&&(n.gzindex=0,n.status=73)}else n.status=73;if(73===n.status)if(n.gzhead.name){i=n.pending;do{if(n.pending===n.pending_buf_size&&(n.gzhead.hcrc&&n.pending>i&&(t.adler=N(t.adler,n.pending_buf,n.pending-i,i)),gt(t),i=n.pending,n.pending===n.pending_buf_size)){a=1;break}a=n.gzindexi&&(t.adler=N(t.adler,n.pending_buf,n.pending-i,i)),0===a&&(n.gzindex=0,n.status=91)}else n.status=91;if(91===n.status)if(n.gzhead.comment){i=n.pending;do{if(n.pending===n.pending_buf_size&&(n.gzhead.hcrc&&n.pending>i&&(t.adler=N(t.adler,n.pending_buf,n.pending-i,i)),gt(t),i=n.pending,n.pending===n.pending_buf_size)){a=1;break}a=n.gzindexi&&(t.adler=N(t.adler,n.pending_buf,n.pending-i,i)),0===a&&(n.status=103)}else n.status=103;if(103===n.status&&(n.gzhead.hcrc?(n.pending+2>n.pending_buf_size&>(t),n.pending+2<=n.pending_buf_size&&(wt(n,255&t.adler),wt(n,t.adler>>8&255),t.adler=0,n.status=113)):n.status=113),0!==n.pending){if(gt(t),0===t.avail_out)return n.last_flush=-1,Q}else if(0===t.avail_in&&ft(e)<=ft(o)&&e!==J)return _t(t,at);if(666===n.status&&0!==t.avail_in)return _t(t,at);if(0!==t.avail_in||0!==n.lookahead||e!==$&&666!==n.status){let i=n.strategy===st?((t,e)=>{let i;for(;;){if(0===t.lookahead&&(xt(t),0===t.lookahead)){if(e===$)return 1;break}if(t.match_length=0,i=j(t,0,t.window[t.strstart]),t.lookahead--,t.strstart++,i&&(mt(t,!1),0===t.strm.avail_out))return 1}return t.insert=0,e===J?(mt(t,!0),0===t.strm.avail_out?3:4):t.last_lit&&(mt(t,!1),0===t.strm.avail_out)?1:2})(n,e):n.strategy===rt?((t,e)=>{let i,a,n,o;const s=t.window;for(;;){if(t.lookahead<=258){if(xt(t),t.lookahead<=258&&e===$)return 1;if(0===t.lookahead)break}if(t.match_length=0,t.lookahead>=3&&t.strstart>0&&(n=t.strstart-1,a=s[n],a===s[++n]&&a===s[++n]&&a===s[++n])){o=t.strstart+258;do{}while(a===s[++n]&&a===s[++n]&&a===s[++n]&&a===s[++n]&&a===s[++n]&&a===s[++n]&&a===s[++n]&&a===s[++n]&&nt.lookahead&&(t.match_length=t.lookahead)}if(t.match_length>=3?(i=j(t,1,t.match_length-3),t.lookahead-=t.match_length,t.strstart+=t.match_length,t.match_length=0):(i=j(t,0,t.window[t.strstart]),t.lookahead--,t.strstart++),i&&(mt(t,!1),0===t.strm.avail_out))return 1}return t.insert=0,e===J?(mt(t,!0),0===t.strm.avail_out?3:4):t.last_lit&&(mt(t,!1),0===t.strm.avail_out)?1:2})(n,e):At[n.level].func(n,e);if(3!==i&&4!==i||(n.status=666),1===i||3===i)return 0===t.avail_out&&(n.last_flush=-1),Q;if(2===i&&(e===G?X(n):e!==q&&(H(n,0,0,!1),e===K&&(ut(n.head),0===n.lookahead&&(n.strstart=0,n.block_start=0,n.insert=0))),gt(t),0===t.avail_out))return n.last_flush=-1,Q}return e!==J?Q:n.wrap<=0?tt:(2===n.wrap?(wt(n,255&t.adler),wt(n,t.adler>>8&255),wt(n,t.adler>>16&255),wt(n,t.adler>>24&255),wt(n,255&t.total_in),wt(n,t.total_in>>8&255),wt(n,t.total_in>>16&255),wt(n,t.total_in>>24&255)):(bt(n,t.adler>>>16),bt(n,65535&t.adler)),gt(t),n.wrap>0&&(n.wrap=-n.wrap),0!==n.pending?Q:tt)},deflateEnd:t=>{if(!t||!t.state)return et;const e=t.state.status;return 42!==e&&69!==e&&73!==e&&91!==e&&103!==e&&113!==e&&666!==e?_t(t,et):(t.state=null,113===e?_t(t,it):Q)},deflateSetDictionary:(t,e)=>{let i=e.length;if(!t||!t.state)return et;const a=t.state,n=a.wrap;if(2===n||1===n&&42!==a.status||a.lookahead)return et;if(1===n&&(t.adler=O(t.adler,e,i,0)),a.wrap=0,i>=a.w_size){0===n&&(ut(a.head),a.strstart=0,a.block_start=0,a.insert=0);let t=new Uint8Array(a.w_size);t.set(e.subarray(i-a.w_size,i),0),e=t,i=a.w_size}const o=t.avail_in,s=t.next_in,r=t.input;for(t.avail_in=i,t.next_in=0,t.input=e,xt(a);a.lookahead>=3;){let t=a.strstart,e=a.lookahead-2;do{a.ins_h=pt(a,a.ins_h,a.window[t+3-1]),a.prev[t&a.w_mask]=a.head[a.ins_h],a.head[a.ins_h]=t,t++}while(--e);a.strstart=t,a.lookahead=2,xt(a)}return a.strstart+=a.lookahead,a.block_start=a.strstart,a.insert=a.lookahead,a.lookahead=0,a.match_length=a.prev_length=2,a.match_available=0,t.next_in=s,t.input=r,t.avail_in=o,a.wrap=n,Q},deflateInfo:"pako deflate (from Nodeca project)"};const Dt=(t,e)=>Object.prototype.hasOwnProperty.call(t,e);var Ut=function(t){const e=Array.prototype.slice.call(arguments,1);for(;e.length;){const i=e.shift();if(i){if("object"!=typeof i)throw new TypeError(i+"must be non-object");for(const e in i)Dt(i,e)&&(t[e]=i[e])}}return t},Bt=t=>{let e=0;for(let i=0,a=t.length;i=252?6:t>=248?5:t>=240?4:t>=224?3:t>=192?2:1;Zt[254]=Zt[254]=1;var Lt=t=>{if("function"==typeof TextEncoder&&TextEncoder.prototype.encode)return(new TextEncoder).encode(t);let e,i,a,n,o,s=t.length,r=0;for(n=0;n>>6,e[o++]=128|63&i):i<65536?(e[o++]=224|i>>>12,e[o++]=128|i>>>6&63,e[o++]=128|63&i):(e[o++]=240|i>>>18,e[o++]=128|i>>>12&63,e[o++]=128|i>>>6&63,e[o++]=128|63&i);return e},Ot=(t,e)=>{const i=e||t.length;if("function"==typeof TextDecoder&&TextDecoder.prototype.decode)return(new TextDecoder).decode(t.subarray(0,e));let a,n;const o=new Array(2*i);for(n=0,a=0;a4)o[n++]=65533,a+=s-1;else{for(e&=2===s?31:3===s?15:7;s>1&&a1?o[n++]=65533:e<65536?o[n++]=e:(e-=65536,o[n++]=55296|e>>10&1023,o[n++]=56320|1023&e)}}return((t,e)=>{if(e<65534&&t.subarray&&Tt)return String.fromCharCode.apply(null,t.length===e?t:t.subarray(0,e));let i="";for(let a=0;a{(e=e||t.length)>t.length&&(e=t.length);let i=e-1;for(;i>=0&&128==(192&t[i]);)i--;return i<0||0===i?e:i+Zt[t[i]]>e?i:e};var Nt=function(){this.input=null,this.next_in=0,this.avail_in=0,this.total_in=0,this.output=null,this.next_out=0,this.avail_out=0,this.total_out=0,this.msg="",this.state=null,this.data_type=2,this.adler=0};const Pt=Object.prototype.toString,{Z_NO_FLUSH:Vt,Z_SYNC_FLUSH:Wt,Z_FULL_FLUSH:Ht,Z_FINISH:Yt,Z_OK:jt,Z_STREAM_END:Xt,Z_DEFAULT_COMPRESSION:$t,Z_DEFAULT_STRATEGY:Gt,Z_DEFLATED:Kt}=V;function Jt(t){this.options=Ut({level:$t,method:Kt,chunkSize:16384,windowBits:15,memLevel:8,strategy:Gt},t||{});let e=this.options;e.raw&&e.windowBits>0?e.windowBits=-e.windowBits:e.gzip&&e.windowBits>0&&e.windowBits<16&&(e.windowBits+=16),this.err=0,this.msg="",this.ended=!1,this.chunks=[],this.strm=new Nt,this.strm.avail_out=0;let i=It.deflateInit2(this.strm,e.level,e.method,e.windowBits,e.memLevel,e.strategy);if(i!==jt)throw new Error(P[i]);if(e.header&&It.deflateSetHeader(this.strm,e.header),e.dictionary){let t;if(t="string"==typeof e.dictionary?Lt(e.dictionary):"[object ArrayBuffer]"===Pt.call(e.dictionary)?new Uint8Array(e.dictionary):e.dictionary,i=It.deflateSetDictionary(this.strm,t),i!==jt)throw new Error(P[i]);this._dict_set=!0}}Jt.prototype.push=function(t,e){const i=this.strm,a=this.options.chunkSize;let n,o;if(this.ended)return!1;for(o=e===~~e?e:!0===e?Yt:Vt,"string"==typeof t?i.input=Lt(t):"[object ArrayBuffer]"===Pt.call(t)?i.input=new Uint8Array(t):i.input=t,i.next_in=0,i.avail_in=i.input.length;;)if(0===i.avail_out&&(i.output=new Uint8Array(a),i.next_out=0,i.avail_out=a),(o===Wt||o===Ht)&&i.avail_out<=6)this.onData(i.output.subarray(0,i.next_out)),i.avail_out=0;else{if(n=It.deflate(i,o),n===Xt)return i.next_out>0&&this.onData(i.output.subarray(0,i.next_out)),n=It.deflateEnd(this.strm),this.onEnd(n),this.ended=!0,n===jt;if(0!==i.avail_out){if(o>0&&i.next_out>0)this.onData(i.output.subarray(0,i.next_out)),i.avail_out=0;else if(0===i.avail_in)break}else this.onData(i.output)}return!0},Jt.prototype.onData=function(t){this.chunks.push(t)},Jt.prototype.onEnd=function(t){t===jt&&(this.result=Bt(this.chunks)),this.chunks=[],this.err=t,this.msg=this.strm.msg};var qt=function(t,e){let i,a,n,o,s,r,l,h,d,c,_,f,u,p,g,m,w,b,y,v,x,k,E,C;const A=t.state;i=t.next_in,E=t.input,a=i+(t.avail_in-5),n=t.next_out,C=t.output,o=n-(e-t.avail_out),s=n+(t.avail_out-257),r=A.dmax,l=A.wsize,h=A.whave,d=A.wnext,c=A.window,_=A.hold,f=A.bits,u=A.lencode,p=A.distcode,g=(1<>>24,_>>>=b,f-=b,b=w>>>16&255,0===b)C[n++]=65535&w;else{if(!(16&b)){if(0==(64&b)){w=u[(65535&w)+(_&(1<>>=b,f-=b),f<15&&(_+=E[i++]<>>24,_>>>=b,f-=b,b=w>>>16&255,!(16&b)){if(0==(64&b)){w=p[(65535&w)+(_&(1<r){t.msg="invalid distance too far back",A.mode=30;break t}if(_>>>=b,f-=b,b=n-o,v>b){if(b=v-b,b>h&&A.sane){t.msg="invalid distance too far back",A.mode=30;break t}if(x=0,k=c,0===d){if(x+=l-b,b2;)C[n++]=k[x++],C[n++]=k[x++],C[n++]=k[x++],y-=3;y&&(C[n++]=k[x++],y>1&&(C[n++]=k[x++]))}else{x=n-v;do{C[n++]=C[x++],C[n++]=C[x++],C[n++]=C[x++],y-=3}while(y>2);y&&(C[n++]=C[x++],y>1&&(C[n++]=C[x++]))}break}}break}}while(i>3,i-=y,f-=y<<3,_&=(1<{const l=r.bits;let h,d,c,_,f,u,p=0,g=0,m=0,w=0,b=0,y=0,v=0,x=0,k=0,E=0,C=null,A=0;const z=new Uint16Array(16),S=new Uint16Array(16);let R,M,I,D=null,U=0;for(p=0;p<=15;p++)z[p]=0;for(g=0;g=1&&0===z[w];w--);if(b>w&&(b=w),0===w)return n[o++]=20971520,n[o++]=20971520,r.bits=1,0;for(m=1;m0&&(0===t||1!==w))return-1;for(S[1]=0,p=1;p<15;p++)S[p+1]=S[p]+z[p];for(g=0;g852||2===t&&k>592)return 1;for(;;){R=p-v,s[g]u?(M=D[U+s[g]],I=C[A+s[g]]):(M=96,I=0),h=1<>v)+d]=R<<24|M<<16|I|0}while(0!==d);for(h=1<>=1;if(0!==h?(E&=h-1,E+=h):E=0,g++,0==--z[p]){if(p===w)break;p=e[i+s[g]]}if(p>b&&(E&_)!==c){for(0===v&&(v=b),f+=m,y=p-v,x=1<852||2===t&&k>592)return 1;c=E&_,n[c]=b<<24|y<<16|f-o|0}}return 0!==E&&(n[f+E]=p-v<<24|64<<16|0),r.bits=b,0};const{Z_FINISH:ne,Z_BLOCK:oe,Z_TREES:se,Z_OK:re,Z_STREAM_END:le,Z_NEED_DICT:he,Z_STREAM_ERROR:de,Z_DATA_ERROR:ce,Z_MEM_ERROR:_e,Z_BUF_ERROR:fe,Z_DEFLATED:ue}=V,pe=t=>(t>>>24&255)+(t>>>8&65280)+((65280&t)<<8)+((255&t)<<24);function ge(){this.mode=0,this.last=!1,this.wrap=0,this.havedict=!1,this.flags=0,this.dmax=0,this.check=0,this.total=0,this.head=null,this.wbits=0,this.wsize=0,this.whave=0,this.wnext=0,this.window=null,this.hold=0,this.bits=0,this.length=0,this.offset=0,this.extra=0,this.lencode=null,this.distcode=null,this.lenbits=0,this.distbits=0,this.ncode=0,this.nlen=0,this.ndist=0,this.have=0,this.next=null,this.lens=new Uint16Array(320),this.work=new Uint16Array(288),this.lendyn=null,this.distdyn=null,this.sane=0,this.back=0,this.was=0}const me=t=>{if(!t||!t.state)return de;const e=t.state;return t.total_in=t.total_out=e.total=0,t.msg="",e.wrap&&(t.adler=1&e.wrap),e.mode=1,e.last=0,e.havedict=0,e.dmax=32768,e.head=null,e.hold=0,e.bits=0,e.lencode=e.lendyn=new Int32Array(852),e.distcode=e.distdyn=new Int32Array(592),e.sane=1,e.back=-1,re},we=t=>{if(!t||!t.state)return de;const e=t.state;return e.wsize=0,e.whave=0,e.wnext=0,me(t)},be=(t,e)=>{let i;if(!t||!t.state)return de;const a=t.state;return e<0?(i=0,e=-e):(i=1+(e>>4),e<48&&(e&=15)),e&&(e<8||e>15)?de:(null!==a.window&&a.wbits!==e&&(a.window=null),a.wrap=i,a.wbits=e,we(t))},ye=(t,e)=>{if(!t)return de;const i=new ge;t.state=i,i.window=null;const a=be(t,e);return a!==re&&(t.state=null),a};let ve,xe,ke=!0;const Ee=t=>{if(ke){ve=new Int32Array(512),xe=new Int32Array(32);let e=0;for(;e<144;)t.lens[e++]=8;for(;e<256;)t.lens[e++]=9;for(;e<280;)t.lens[e++]=7;for(;e<288;)t.lens[e++]=8;for(ae(1,t.lens,0,288,ve,0,t.work,{bits:9}),e=0;e<32;)t.lens[e++]=5;ae(2,t.lens,0,32,xe,0,t.work,{bits:5}),ke=!1}t.lencode=ve,t.lenbits=9,t.distcode=xe,t.distbits=5},Ce=(t,e,i,a)=>{let n;const o=t.state;return null===o.window&&(o.wsize=1<=o.wsize?(o.window.set(e.subarray(i-o.wsize,i),0),o.wnext=0,o.whave=o.wsize):(n=o.wsize-o.wnext,n>a&&(n=a),o.window.set(e.subarray(i-a,i-a+n),o.wnext),(a-=n)?(o.window.set(e.subarray(i-a,i),0),o.wnext=a,o.whave=o.wsize):(o.wnext+=n,o.wnext===o.wsize&&(o.wnext=0),o.whaveye(t,15),inflateInit2:ye,inflate:(t,e)=>{let i,a,n,o,s,r,l,h,d,c,_,f,u,p,g,m,w,b,y,v,x,k,E=0;const C=new Uint8Array(4);let A,z;const S=new Uint8Array([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]);if(!t||!t.state||!t.output||!t.input&&0!==t.avail_in)return de;i=t.state,12===i.mode&&(i.mode=13),s=t.next_out,n=t.output,l=t.avail_out,o=t.next_in,a=t.input,r=t.avail_in,h=i.hold,d=i.bits,c=r,_=l,k=re;t:for(;;)switch(i.mode){case 1:if(0===i.wrap){i.mode=13;break}for(;d<16;){if(0===r)break t;r--,h+=a[o++]<>>8&255,i.check=N(i.check,C,2,0),h=0,d=0,i.mode=2;break}if(i.flags=0,i.head&&(i.head.done=!1),!(1&i.wrap)||(((255&h)<<8)+(h>>8))%31){t.msg="incorrect header check",i.mode=30;break}if((15&h)!==ue){t.msg="unknown compression method",i.mode=30;break}if(h>>>=4,d-=4,x=8+(15&h),0===i.wbits)i.wbits=x;else if(x>i.wbits){t.msg="invalid window size",i.mode=30;break}i.dmax=1<>8&1),512&i.flags&&(C[0]=255&h,C[1]=h>>>8&255,i.check=N(i.check,C,2,0)),h=0,d=0,i.mode=3;case 3:for(;d<32;){if(0===r)break t;r--,h+=a[o++]<>>8&255,C[2]=h>>>16&255,C[3]=h>>>24&255,i.check=N(i.check,C,4,0)),h=0,d=0,i.mode=4;case 4:for(;d<16;){if(0===r)break t;r--,h+=a[o++]<>8),512&i.flags&&(C[0]=255&h,C[1]=h>>>8&255,i.check=N(i.check,C,2,0)),h=0,d=0,i.mode=5;case 5:if(1024&i.flags){for(;d<16;){if(0===r)break t;r--,h+=a[o++]<>>8&255,i.check=N(i.check,C,2,0)),h=0,d=0}else i.head&&(i.head.extra=null);i.mode=6;case 6:if(1024&i.flags&&(f=i.length,f>r&&(f=r),f&&(i.head&&(x=i.head.extra_len-i.length,i.head.extra||(i.head.extra=new Uint8Array(i.head.extra_len)),i.head.extra.set(a.subarray(o,o+f),x)),512&i.flags&&(i.check=N(i.check,a,f,o)),r-=f,o+=f,i.length-=f),i.length))break t;i.length=0,i.mode=7;case 7:if(2048&i.flags){if(0===r)break t;f=0;do{x=a[o+f++],i.head&&x&&i.length<65536&&(i.head.name+=String.fromCharCode(x))}while(x&&f>9&1,i.head.done=!0),t.adler=i.check=0,i.mode=12;break;case 10:for(;d<32;){if(0===r)break t;r--,h+=a[o++]<>>=7&d,d-=7&d,i.mode=27;break}for(;d<3;){if(0===r)break t;r--,h+=a[o++]<>>=1,d-=1,3&h){case 0:i.mode=14;break;case 1:if(Ee(i),i.mode=20,e===se){h>>>=2,d-=2;break t}break;case 2:i.mode=17;break;case 3:t.msg="invalid block type",i.mode=30}h>>>=2,d-=2;break;case 14:for(h>>>=7&d,d-=7&d;d<32;){if(0===r)break t;r--,h+=a[o++]<>>16^65535)){t.msg="invalid stored block lengths",i.mode=30;break}if(i.length=65535&h,h=0,d=0,i.mode=15,e===se)break t;case 15:i.mode=16;case 16:if(f=i.length,f){if(f>r&&(f=r),f>l&&(f=l),0===f)break t;n.set(a.subarray(o,o+f),s),r-=f,o+=f,l-=f,s+=f,i.length-=f;break}i.mode=12;break;case 17:for(;d<14;){if(0===r)break t;r--,h+=a[o++]<>>=5,d-=5,i.ndist=1+(31&h),h>>>=5,d-=5,i.ncode=4+(15&h),h>>>=4,d-=4,i.nlen>286||i.ndist>30){t.msg="too many length or distance symbols",i.mode=30;break}i.have=0,i.mode=18;case 18:for(;i.have>>=3,d-=3}for(;i.have<19;)i.lens[S[i.have++]]=0;if(i.lencode=i.lendyn,i.lenbits=7,A={bits:i.lenbits},k=ae(0,i.lens,0,19,i.lencode,0,i.work,A),i.lenbits=A.bits,k){t.msg="invalid code lengths set",i.mode=30;break}i.have=0,i.mode=19;case 19:for(;i.have>>24,m=E>>>16&255,w=65535&E,!(g<=d);){if(0===r)break t;r--,h+=a[o++]<>>=g,d-=g,i.lens[i.have++]=w;else{if(16===w){for(z=g+2;d>>=g,d-=g,0===i.have){t.msg="invalid bit length repeat",i.mode=30;break}x=i.lens[i.have-1],f=3+(3&h),h>>>=2,d-=2}else if(17===w){for(z=g+3;d>>=g,d-=g,x=0,f=3+(7&h),h>>>=3,d-=3}else{for(z=g+7;d>>=g,d-=g,x=0,f=11+(127&h),h>>>=7,d-=7}if(i.have+f>i.nlen+i.ndist){t.msg="invalid bit length repeat",i.mode=30;break}for(;f--;)i.lens[i.have++]=x}}if(30===i.mode)break;if(0===i.lens[256]){t.msg="invalid code -- missing end-of-block",i.mode=30;break}if(i.lenbits=9,A={bits:i.lenbits},k=ae(1,i.lens,0,i.nlen,i.lencode,0,i.work,A),i.lenbits=A.bits,k){t.msg="invalid literal/lengths set",i.mode=30;break}if(i.distbits=6,i.distcode=i.distdyn,A={bits:i.distbits},k=ae(2,i.lens,i.nlen,i.ndist,i.distcode,0,i.work,A),i.distbits=A.bits,k){t.msg="invalid distances set",i.mode=30;break}if(i.mode=20,e===se)break t;case 20:i.mode=21;case 21:if(r>=6&&l>=258){t.next_out=s,t.avail_out=l,t.next_in=o,t.avail_in=r,i.hold=h,i.bits=d,qt(t,_),s=t.next_out,n=t.output,l=t.avail_out,o=t.next_in,a=t.input,r=t.avail_in,h=i.hold,d=i.bits,12===i.mode&&(i.back=-1);break}for(i.back=0;E=i.lencode[h&(1<>>24,m=E>>>16&255,w=65535&E,!(g<=d);){if(0===r)break t;r--,h+=a[o++]<>b)],g=E>>>24,m=E>>>16&255,w=65535&E,!(b+g<=d);){if(0===r)break t;r--,h+=a[o++]<>>=b,d-=b,i.back+=b}if(h>>>=g,d-=g,i.back+=g,i.length=w,0===m){i.mode=26;break}if(32&m){i.back=-1,i.mode=12;break}if(64&m){t.msg="invalid literal/length code",i.mode=30;break}i.extra=15&m,i.mode=22;case 22:if(i.extra){for(z=i.extra;d>>=i.extra,d-=i.extra,i.back+=i.extra}i.was=i.length,i.mode=23;case 23:for(;E=i.distcode[h&(1<>>24,m=E>>>16&255,w=65535&E,!(g<=d);){if(0===r)break t;r--,h+=a[o++]<>b)],g=E>>>24,m=E>>>16&255,w=65535&E,!(b+g<=d);){if(0===r)break t;r--,h+=a[o++]<>>=b,d-=b,i.back+=b}if(h>>>=g,d-=g,i.back+=g,64&m){t.msg="invalid distance code",i.mode=30;break}i.offset=w,i.extra=15&m,i.mode=24;case 24:if(i.extra){for(z=i.extra;d>>=i.extra,d-=i.extra,i.back+=i.extra}if(i.offset>i.dmax){t.msg="invalid distance too far back",i.mode=30;break}i.mode=25;case 25:if(0===l)break t;if(f=_-l,i.offset>f){if(f=i.offset-f,f>i.whave&&i.sane){t.msg="invalid distance too far back",i.mode=30;break}f>i.wnext?(f-=i.wnext,u=i.wsize-f):u=i.wnext-f,f>i.length&&(f=i.length),p=i.window}else p=n,u=s-i.offset,f=i.length;f>l&&(f=l),l-=f,i.length-=f;do{n[s++]=p[u++]}while(--f);0===i.length&&(i.mode=21);break;case 26:if(0===l)break t;n[s++]=i.length,l--,i.mode=21;break;case 27:if(i.wrap){for(;d<32;){if(0===r)break t;r--,h|=a[o++]<{if(!t||!t.state)return de;let e=t.state;return e.window&&(e.window=null),t.state=null,re},inflateGetHeader:(t,e)=>{if(!t||!t.state)return de;const i=t.state;return 0==(2&i.wrap)?de:(i.head=e,e.done=!1,re)},inflateSetDictionary:(t,e)=>{const i=e.length;let a,n,o;return t&&t.state?(a=t.state,0!==a.wrap&&11!==a.mode?de:11===a.mode&&(n=1,n=O(n,e,i,0),n!==a.check)?ce:(o=Ce(t,e,i,i),o?(a.mode=31,_e):(a.havedict=1,re))):de},inflateInfo:"pako inflate (from Nodeca project)"};var ze=function(){this.text=0,this.time=0,this.xflags=0,this.os=0,this.extra=null,this.extra_len=0,this.name="",this.comment="",this.hcrc=0,this.done=!1};const Se=Object.prototype.toString,{Z_NO_FLUSH:Re,Z_FINISH:Me,Z_OK:Ie,Z_STREAM_END:De,Z_NEED_DICT:Ue,Z_STREAM_ERROR:Be,Z_DATA_ERROR:Te,Z_MEM_ERROR:Ze}=V;function Le(t){this.options=Ut({chunkSize:65536,windowBits:15,to:""},t||{});const e=this.options;e.raw&&e.windowBits>=0&&e.windowBits<16&&(e.windowBits=-e.windowBits,0===e.windowBits&&(e.windowBits=-15)),!(e.windowBits>=0&&e.windowBits<16)||t&&t.windowBits||(e.windowBits+=32),e.windowBits>15&&e.windowBits<48&&0==(15&e.windowBits)&&(e.windowBits|=15),this.err=0,this.msg="",this.ended=!1,this.chunks=[],this.strm=new Nt,this.strm.avail_out=0;let i=Ae.inflateInit2(this.strm,e.windowBits);if(i!==Ie)throw new Error(P[i]);if(this.header=new ze,Ae.inflateGetHeader(this.strm,this.header),e.dictionary&&("string"==typeof e.dictionary?e.dictionary=Lt(e.dictionary):"[object ArrayBuffer]"===Se.call(e.dictionary)&&(e.dictionary=new Uint8Array(e.dictionary)),e.raw&&(i=Ae.inflateSetDictionary(this.strm,e.dictionary),i!==Ie)))throw new Error(P[i])}function Oe(t,e){const i=new Le(e);if(i.push(t),i.err)throw i.msg||P[i.err];return i.result}Le.prototype.push=function(t,e){const i=this.strm,a=this.options.chunkSize,n=this.options.dictionary;let o,s,r;if(this.ended)return!1;for(s=e===~~e?e:!0===e?Me:Re,"[object ArrayBuffer]"===Se.call(t)?i.input=new Uint8Array(t):i.input=t,i.next_in=0,i.avail_in=i.input.length;;){for(0===i.avail_out&&(i.output=new Uint8Array(a),i.next_out=0,i.avail_out=a),o=Ae.inflate(i,s),o===Ue&&n&&(o=Ae.inflateSetDictionary(i,n),o===Ie?o=Ae.inflate(i,s):o===Te&&(o=Ue));i.avail_in>0&&o===De&&i.state.wrap>0&&0!==t[i.next_in];)Ae.inflateReset(i),o=Ae.inflate(i,s);switch(o){case Be:case Te:case Ue:case Ze:return this.onEnd(o),this.ended=!0,!1}if(r=i.avail_out,i.next_out&&(0===i.avail_out||o===De))if("string"===this.options.to){let t=Ft(i.output,i.next_out),e=i.next_out-t,n=Ot(i.output,t);i.next_out=e,i.avail_out=a-e,e&&i.output.set(i.output.subarray(t,t+e),0),this.onData(n)}else this.onData(i.output.length===i.next_out?i.output:i.output.subarray(0,i.next_out));if(o!==Ie||0!==r){if(o===De)return o=Ae.inflateEnd(this.strm),this.onEnd(o),this.ended=!0,!0;if(0===i.avail_in)break}}return!0},Le.prototype.onData=function(t){this.chunks.push(t)},Le.prototype.onEnd=function(t){t===Ie&&("string"===this.options.to?this.result=this.chunks.join(""):this.result=Bt(this.chunks)),this.chunks=[],this.err=t,this.msg=this.strm.msg};var Fe={Inflate:Le,inflate:Oe,inflateRaw:function(t,e){return(e=e||{}).raw=!0,Oe(t,e)},ungzip:Oe,constants:V};const{Inflate:Ne,inflate:Pe,inflateRaw:Ve,ungzip:We}=Fe;var He=Pe;const Ye=Object.freeze({title:"Vacuum",show_map:!0,show_floor:!0,show_dock:!0,show_vacuum:!0,show_walls:!0,show_currently_cleaned_zones:!0,show_no_go_areas:!0,show_no_mop_areas:!0,show_virtual_walls:!0,show_path:!0,show_currently_cleaned_zones_border:!0,show_no_go_area_border:!0,show_no_mop_area_border:!0,show_predicted_path:!0,show_goto_target:!0,show_segments:!0,show_status:!0,show_battery_level:!0,show_start_button:!0,show_pause_button:!0,show_stop_button:!0,show_home_button:!0,show_locate_button:!0,virtual_wall_width:1,path_width:1,left_padding:0,map_scale:1,icon_scale:1,floor_opacity:1,segment_opacity:.75,wall_opacity:1,currently_cleaned_zone_opacity:.5,no_go_area_opacity:.5,no_mop_area_opacity:.5,virtual_wall_opacity:1,path_opacity:1,segment_colors:["#19A1A1","#7AC037","#DF5618","#F7C841"],min_height:0}),je=Object.freeze({cleaning:3e3,paused:15e3,idle:12e4,returning:3e3,docked:12e4,error:12e4});console.info("%c Valetudo-Map-Card \n%c Version 2023.04.0 ","color: #0076FF; font-weight: bold; background: #121212","color: #52AEFF; font-weight: bold; background: #1e1e1e");class Xe extends HTMLElement{constructor(){super(),this.drawingMap=!1,this.drawingControls=!1,this.lastUpdatedControls="",this.attachShadow({mode:"open"}),this.lastMapPoll=new Date(0),this.isPollingMap=!1,this.lastRobotState="docked",this.pollInterval=je[this.lastRobotState],this.cardContainer=document.createElement("ha-card"),this.cardContainer.id="valetudoMapCard",this.cardContainerStyle=document.createElement("style"),this.shadowRoot.appendChild(this.cardContainer),this.shadowRoot.appendChild(this.cardContainerStyle),this.cardHeader=document.createElement("div"),this.cardHeader.setAttribute("class","card-header"),this.cardTitle=document.createElement("div"),this.cardTitle.setAttribute("class","name"),this.cardHeader.appendChild(this.cardTitle),this.cardContainer.appendChild(this.cardHeader),this.entityWarning1=document.createElement("hui-warning"),this.entityWarning1.id="valetudoMapCardWarning1",this.entityWarning1.style.display="none",this.cardContainer.appendChild(this.entityWarning1),this.entityWarning2=document.createElement("hui-warning"),this.entityWarning2.id="valetudoMapCardWarning2",this.entityWarning2.style.display="none",this.cardContainer.appendChild(this.entityWarning2),this.mapContainer=document.createElement("div"),this.mapContainer.id="valetudoMapCardMapContainer",this.mapContainerStyle=document.createElement("style"),this.cardContainer.appendChild(this.mapContainer),this.cardContainer.appendChild(this.mapContainerStyle),this.controlContainer=document.createElement("div"),this.controlContainer.id="valetudoMapCardControlsContainer",this.controlContainerStyle=document.createElement("style"),this.cardContainer.appendChild(this.controlContainer),this.cardContainer.appendChild(this.controlContainerStyle)}static getStubConfig(){return{vacuum:"valetudo_REPLACEME"}}getMapEntityName(t){return"camera."+t+"_map_data"}getVacuumEntityName(t){return"vacuum."+t}getMapEntity(t){return this._hass.states[this.getMapEntityName(t)]}getVacuumEntity(t){return this._hass.states[this.getVacuumEntityName(t)]}shouldDrawMap(){return!this.drawingMap}shouldDrawControls(t){return!this.drawingControls&&this.lastUpdatedControls!==t.last_updated}calculateColor(t,...e){for(let i of e)if(i){if(i.startsWith("--")){let e=getComputedStyle(t).getPropertyValue(i);if(!e)continue;return e}return i}}isOutsideBounds(t,e,i,a){return ti.width||ei.height}getLayers(t,e,i){let a=[];for(let n of t.layers)if(n.type===e&&a.push(n),a.length===i)break;return a}getEntities(t,e,i){let a=[];for(let n of t.entities)if(n.type===e&&a.push(n),i&&a.length===i)break;return a}getChargerInfo(t){let e=this.getEntities(t,"charger_location",1)[0];return void 0===e?null:[e.points[0],e.points[1]]}getRobotInfo(t){let e=this.getEntities(t,"robot_position",1)[0];return void 0===e?null:[e.points[0],e.points[1],e.metaData.angle]}getGoToInfo(t){let e=this.getEntities(t,"go_to_target",1)[0];return void 0===e?null:[e.points[0],e.points[1]]}getFloorPoints(t){let e=this.getLayers(t,"floor",1)[0];return void 0===e?null:e.pixels}getSegments(t){return this.getLayers(t,"segment")}getWallPoints(t){let e=this.getLayers(t,"wall",1)[0];return void 0===e?null:e.pixels}getVirtualWallPoints(t){return this.getEntities(t,"virtual_wall")}getPathPoints(t){return this.getEntities(t,"path")}getPredictedPathPoints(t){return this.getEntities(t,"predicted_path")}getActiveZones(t){return this.getEntities(t,"active_zone")}getNoGoAreas(t){return this.getEntities(t,"no_go_area")}getNoMopAreas(t){return this.getEntities(t,"no_mop_area")}drawMap(t,e,i,n){const o=t.pixelSize,s=o/this._config.map_scale,r=o/this._config.map_scale;let l=0,h=0;l=(n.minX-1)*this._config.map_scale,h=(n.minY-1)*this._config.map_scale;const d=document.getElementsByTagName("home-assistant")[0],c=this.calculateColor(d,this._config.floor_color,"--valetudo-map-floor-color","--secondary-background-color"),_=this.calculateColor(d,this._config.wall_color,"--valetudo-map-wall-color","--accent-color"),f=this.calculateColor(d,this._config.currently_cleaned_zone_color,"--valetudo-currently_cleaned_zone_color","--secondary-text-color"),u=this.calculateColor(d,this._config.no_go_area_color,"--valetudo-no-go-area-color","--accent-color"),p=this.calculateColor(d,this._config.no_mop_area_color,"--valetudo-no-mop-area-color","--secondary-text-color"),g=this.calculateColor(d,this._config.virtual_wall_color,"--valetudo-virtual-wall-color","--accent-color"),m=this.calculateColor(d,this._config.path_color,"--valetudo-map-path-color","--primary-text-color"),w=this.calculateColor(d,this._config.dock_color,"green"),b=this.calculateColor(d,this._config.vacuum_color,"--primary-text-color"),y=this.calculateColor(d,this._config.goto_target_color,"blue"),v=document.createElement("div");v.id="lovelaceValetudoCard";const x=document.createElement("div"),k=document.createElement("canvas");k.width=i*this._config.map_scale,k.height=e*this._config.map_scale,x.style.zIndex=1,x.appendChild(k);const E=document.createElement("div"),C=document.createElement("ha-icon");let A=this.getChargerInfo(t);this._config.show_dock&&A&&(C.style.position="absolute",C.icon=this._config.dock_icon||"mdi:flash",C.style.left=Math.floor(A[0]/s)-0-l-12*this._config.icon_scale+"px",C.style.top=Math.floor(A[1]/r)-0-h-12*this._config.icon_scale+"px",C.style.color=w,C.style.transform=`scale(${this._config.icon_scale}, ${this._config.icon_scale}) rotate(-${this._config.rotate})`),E.style.zIndex=2,E.appendChild(C);const z=document.createElement("div"),S=document.createElement("canvas");S.width=i*this._config.map_scale,S.height=e*this._config.map_scale,z.style.zIndex=3,z.appendChild(S);const R=document.createElement("div"),M=document.createElement("ha-icon");let I=this.getRobotInfo(t);I||(I=this.lastValidRobotInfo),this._config.show_vacuum&&I&&(this.lastValidRobotInfo=I,M.style.position="absolute",M.icon=this._config.vacuum_icon||"mdi:robot-vacuum",M.style.color=b,M.style.left=Math.floor(I[0]/s)-0-l-12*this._config.icon_scale+"px",M.style.top=Math.floor(I[1]/r)-0-h-12*this._config.icon_scale+"px",M.style.transform=`scale(${this._config.icon_scale}, ${this._config.icon_scale})`),R.style.zIndex=4,R.appendChild(M);const D=document.createElement("div"),U=document.createElement("ha-icon");let B=this.getGoToInfo(t);this._config.show_goto_target&&B&&(U.style.position="absolute",U.icon=this._config.goto_target_icon||"mdi:pin",U.style.left=Math.floor(B[0]/s)-0-l-12*this._config.icon_scale+"px",U.style.top=Math.floor(B[1]/r)-0-h-22*this._config.icon_scale+"px",U.style.color=y,U.style.transform=`scale(${this._config.icon_scale}, ${this._config.icon_scale})`),D.style.zIndex=5,D.appendChild(U),v.appendChild(x),v.appendChild(E),v.appendChild(z),v.appendChild(R),v.appendChild(D);const T=k.getContext("2d");if(this._config.show_floor){T.globalAlpha=this._config.floor_opacity,T.strokeStyle=c,T.lineWidth=1,T.fillStyle=c,T.beginPath();let e=this.getFloorPoints(t);if(e)for(let t=0;t0&&this._config.show_currently_cleaned_zones){T.globalAlpha=this._config.currently_cleaned_zone_opacity,T.strokeStyle=f,T.lineWidth=2,T.fillStyle=f;for(let t of L){T.globalAlpha=this._config.currently_cleaned_zone_opacity,T.beginPath();let e=t.points;for(let t=0;t0){T.globalAlpha=this._config.virtual_wall_opacity,T.strokeStyle=g,T.lineWidth=this._config.virtual_wall_width,T.beginPath();for(let t of N){let e=Math.floor(t.points[0]/s)-0-l,i=Math.floor(t.points[1]/r)-0-h,a=Math.floor(t.points[2]/s)-0-l,n=Math.floor(t.points[3]/r)-0-h;this.isOutsideBounds(e,i,k,this._config)||(this.isOutsideBounds(a,n,k,this._config)||(T.moveTo(e,i),T.lineTo(a,n),T.stroke()))}T.globalAlpha=1}const P=S.getContext("2d");P.globalAlpha=this._config.path_opacity,P.strokeStyle=m,P.lineWidth=this._config.path_width;let V=this.getPathPoints(t);if(Array.isArray(V)&&V.length>0&&this._config.show_path&&this._config.path_width>0){for(let t of V){let e=0,i=0,a=!0;P.beginPath();for(let n=0;n0&&this._config.show_predicted_path&&this._config.path_width>0){P.setLineDash([5,3]);for(let t of W){let e=0,i=0,a=!0;P.beginPath();for(let n=0;n{this._hass.callService("vacuum","start",{entity_id:this.getVacuumEntityName(this._config.vacuum)}).then()})),this.controlFlexBox.appendChild(t)}if(this._config.show_pause_button&&this.shouldDisplayButton("pause",t.state)){const t=document.createElement("paper-button"),e=document.createElement("ha-icon"),i=document.createElement("paper-ripple");e.icon="mdi:pause",t.appendChild(e),t.appendChild(i),t.addEventListener("click",(t=>{this._hass.callService("vacuum","pause",{entity_id:this.getVacuumEntityName(this._config.vacuum)}).then()})),this.controlFlexBox.appendChild(t)}if(this._config.show_stop_button&&this.shouldDisplayButton("stop",t.state)){const t=document.createElement("paper-button"),e=document.createElement("ha-icon"),i=document.createElement("paper-ripple");e.icon="mdi:stop",t.appendChild(e),t.appendChild(i),t.addEventListener("click",(t=>{this._hass.callService("vacuum","stop",{entity_id:this.getVacuumEntityName(this._config.vacuum)}).then()})),this.controlFlexBox.appendChild(t)}if(this._config.show_home_button&&this.shouldDisplayButton("home",t.state)){const t=document.createElement("paper-button"),e=document.createElement("ha-icon"),i=document.createElement("paper-ripple");e.icon="hass:home-map-marker",t.appendChild(e),t.appendChild(i),t.addEventListener("click",(t=>{this._hass.callService("vacuum","return_to_base",{entity_id:this.getVacuumEntityName(this._config.vacuum)}).then()})),this.controlFlexBox.appendChild(t)}if(this._config.show_locate_button){const t=document.createElement("paper-button"),e=document.createElement("ha-icon"),i=document.createElement("paper-ripple");e.icon="hass:map-marker",t.appendChild(e),t.appendChild(i),t.addEventListener("click",(t=>{this._hass.callService("vacuum","locate",{entity_id:this.getVacuumEntityName(this._config.vacuum)}).then()})),this.controlFlexBox.appendChild(t)}this.customControlFlexBox=document.createElement("div"),this.customControlFlexBox.classList.add("flex-box"),this._config.custom_buttons.forEach((t=>{if(t===Object(t)&&t.service){const e=document.createElement("paper-button"),i=document.createElement("ha-icon"),a=document.createElement("paper-ripple");if(i.icon=t.icon||"mdi:radiobox-blank",e.appendChild(i),t.text){const i=document.createElement("span");i.textContent=t.text,e.appendChild(i)}e.appendChild(a),e.addEventListener("click",(e=>{const i=t.service.split(".");t.service_data?this._hass.callService(i[0],i[1],t.service_data).then():this._hass.callService(i[0],i[1]).then()})),this.customControlFlexBox.appendChild(e)}})),this.clearContainer(this.controlContainer),this.controlContainer.append(this.infoBox),this.controlContainer.append(this.controlFlexBox),this.controlContainer.append(this.customControlFlexBox),this.lastUpdatedControls=t.last_updated,this.drawingControls=!1}setConfig(t){this._config=Object.assign({},Ye,t),"string"==typeof this._config.vacuum&&(this._config.vacuum=this._config.vacuum.toLowerCase()),void 0===this._config.rotate&&(this._config.rotate=0),Number(this._config.rotate)&&(this._config.rotate=`${this._config.rotate}deg`),this._config.crop!==Object(this._config.crop)&&(this._config.crop={}),void 0===this._config.crop.top&&(this._config.crop.top=0),void 0===this._config.crop.bottom&&(this._config.crop.bottom=0),void 0===this._config.crop.left&&(this._config.crop.left=0),void 0===this._config.crop.right&&(this._config.crop.right=0),this.cardHeader.style.display=this._config.title?"block":"none",this.cardTitle.textContent=this._config.title,this.cardContainer.style.background=this._config.background_color??null,Array.isArray(this._config.custom_buttons)||(this._config.custom_buttons=[])}set hass(t){if(void 0===t)return;this._hass=t;let e=this.getMapEntity(this._config.vacuum),i=this.getVacuumEntity(this._config.vacuum),a=!1,n=e?e.attributes:void 0;i&&i.state!==this.lastRobotState&&(this.pollInterval=je[i.state]||1e4,a=!0,this.lastRobotState=i.state),e&&"unavailable"!==e.state&&n?.entity_picture?((new Date).getTime()-this.pollInterval>this.lastMapPoll.getTime()||a)&&this.loadImageAndExtractMapData(n.entity_picture).then((i=>{null!==i&&this.handleDrawing(t,e,i)})).catch((i=>{this.handleDrawing(t,e,{}),console.warn(i)})).finally((()=>{this.lastMapPoll=new Date})):(this.clearContainer(this.mapContainer),this.clearContainer(this.controlContainer),this.entityWarning1.textContent=`Entity not available: ${this.getMapEntityName(this._config.vacuum)}`,this.entityWarning1.style.display="block",this.entityWarning2.style.display="none")}async loadImageAndExtractMapData(t){if(!1===this.isPollingMap){this.isPollingMap=!0;const a=await this._hass.fetchWithAuth(t);let n;if(!a.ok)throw new Error("Got error while fetching image "+a.status+" - "+a.statusText);const o=await a.arrayBuffer(),s=function(t){const e=new Uint8Array(4),i=new Uint32Array(e.buffer);if(137!==t[0])throw new Error("Invalid .png file header");if(80!==t[1])throw new Error("Invalid .png file header");if(78!==t[2])throw new Error("Invalid .png file header");if(71!==t[3])throw new Error("Invalid .png file header");if(13!==t[4])throw new Error("Invalid .png file header: possibly caused by DOS-Unix line ending conversion?");if(10!==t[5])throw new Error("Invalid .png file header: possibly caused by DOS-Unix line ending conversion?");if(26!==t[6])throw new Error("Invalid .png file header");if(10!==t[7])throw new Error("Invalid .png file header: possibly caused by DOS-Unix line ending conversion?");const a=[];let n=!1,o=8;for(;o"ValetudoMap"===t.keyword));if(s.length<1)throw new Error("No map data found in image");return n=He(s[0].data,{to:"string"}),n=JSON.parse(n),2===(null===(i=(e=n).metaData)||void 0===i?void 0:i.version)&&Array.isArray(e.layers)&&e.layers.forEach((function(t){if(0===t.pixels.length&&t.compressedPixels&&0!==t.compressedPixels.length){for(var e=0;e{t.dimensions.x.mina.maxX&&(a.maxX=t.dimensions.x.max),t.dimensions.y.max>a.maxY&&(a.maxY=t.dimensions.y.max)})),t=a.maxX-a.minX+2,e=a.maxY-a.minY+2;const n=t-this._config.crop.right,o=e-this._config.crop.bottom;let s=o*this._config.map_scale-this._config.crop.top,r=this._config.min_height;String(this._config.min_height).endsWith("w")&&(r=this._config.min_height.slice(0,-1)*this.mapContainer.offsetWidth);let l=r>s?(r-s)/2:0;this.mapContainerStyle.textContent=`\n #lovelaceValetudoMapCard {\n height: ${s}px;\n padding-top: ${l}px;\n padding-bottom: ${l}px;\n padding-left: ${this._config.left_padding}px;\n overflow: hidden;\n }\n #lovelaceValetudoCard {\n position: relative;\n margin-left: auto;\n margin-right: auto;\n width: ${n*this._config.map_scale}px;\n height: ${o*this._config.map_scale}px;\n transform: rotate(${this._config.rotate});\n top: -${this._config.crop.top}px;\n left: -${this._config.crop.left}px;\n }\n #lovelaceValetudoCard div {\n position: absolute;\n background-color: transparent;\n width: 100%;\n height: 100%;\n }\n `,this.shouldDrawMap()&&this._config.show_map&&(this.drawingMap=!0,this.drawMap(i,o,n,a),this.drawingMap=!1)}if(o){this.controlContainerStyle.textContent="\n .flex-box {\n display: flex;\n justify-content: space-evenly;\n flex-wrap: wrap;\n }\n paper-button {\n cursor: pointer;\n position: relative;\n display: inline-flex;\n align-items: center;\n padding: 8px;\n }\n ha-icon {\n width: 24px;\n height: 24px;\n }\n ";let t=this.getVacuumEntity(this._config.vacuum);this.shouldDrawControls(t)&&this.drawControls(t)}}getCardSize(){return 1}}customElements.get("valetudo-map-card")||(customElements.define("valetudo-map-card",Xe),window.customCards=window.customCards||[],window.customCards.push({type:"valetudo-map-card",name:"Valetudo Map Card",preview:!1,description:"Display the Map data of your Valetudo-enabled robot"})); 3 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Valetudo Map Card", 3 | "filename": "valetudo-map-card.js", 4 | "render_readme": true 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lovelace-valetudo-map-card", 3 | "version": "2023.04.0", 4 | "description": "Draws the map from a vacuum cleaner, that is rooted and flashed with [Valetudo](https://github.com/Hypfer/Valetudo), in a [Home Assistant](https://www.home-assistant.io/) Lovelace card.", 5 | "main": "src/valetudo-map-card.js", 6 | "scripts": { 7 | "lint": "eslint -c .automated.eslintrc.json .", 8 | "lint_fix": "eslint -c .automated.eslintrc.json . --fix", 9 | 10 | "build": "rollup -c" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/Hypfer/lovelace-valetudo-map-card.git" 15 | }, 16 | "author": "", 17 | "license": "MIT", 18 | "dependencies": { 19 | "custom-card-helpers": "1.9.0" 20 | }, 21 | "devDependencies": { 22 | "eslint": "8.16.0", 23 | "eslint-plugin-jsdoc": "39.3.0", 24 | "eslint-plugin-regexp": "1.7.0", 25 | "eslint-plugin-sort-keys-fix": "1.1.2", 26 | "eslint-plugin-sort-requires": "git+https://npm@github.com/Hypfer/eslint-plugin-sort-requires.git#2.1.1", 27 | "@typescript-eslint/eslint-plugin": "5.25.0", 28 | "@typescript-eslint/experimental-utils": "5.25.0", 29 | "@typescript-eslint/parser": "5.25.0", 30 | "@types/pako": "2.0.3", 31 | 32 | "rollup": "2.58.0", 33 | "rollup-plugin-babel": "4.4.0", 34 | "rollup-plugin-commonjs": "10.1.0", 35 | "rollup-plugin-node-resolve": "5.2.0", 36 | "rollup-plugin-terser": "7.0.2", 37 | "rollup-plugin-typescript2": "0.33.0", 38 | "typescript": "4.9.5", 39 | "@rollup/plugin-json": "4.1.0", 40 | "babel-plugin-inline-json-import": "0.3.2", 41 | 42 | "home-assistant-js-websocket": "6.1.1", 43 | "pako": "2.0.4" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from "rollup-plugin-typescript2"; 2 | import commonjs from "rollup-plugin-commonjs"; 3 | import nodeResolve from "rollup-plugin-node-resolve"; 4 | import babel from "rollup-plugin-babel"; 5 | import { terser } from "rollup-plugin-terser"; 6 | import json from "@rollup/plugin-json"; 7 | 8 | const plugins = [ 9 | nodeResolve({}), 10 | commonjs(), 11 | typescript(), 12 | json(), 13 | babel({ 14 | exclude: "node_modules/**", 15 | plugins: [ 16 | ["inline-json-import", {}] 17 | ] 18 | }), 19 | terser(), 20 | ]; 21 | 22 | export default [ 23 | { 24 | input: "src/valetudo-map-card.ts", 25 | output: { 26 | dir: "dist", 27 | format: "es", 28 | }, 29 | plugins: [...plugins], 30 | }, 31 | ]; 32 | -------------------------------------------------------------------------------- /src/lib/RawMapData.ts: -------------------------------------------------------------------------------- 1 | export interface RawMapData { 2 | __class?: string; 3 | 4 | metaData: RawMapDataMetaData; 5 | size: { 6 | x: number; 7 | y: number; 8 | }; 9 | pixelSize: number; 10 | layers: RawMapLayer[]; 11 | entities: RawMapEntity[]; 12 | } 13 | 14 | export interface RawMapEntity { 15 | metaData: RawMapEntityMetaData; 16 | points: number[]; 17 | type: RawMapEntityType; 18 | } 19 | 20 | export interface RawMapEntityMetaData { 21 | angle?: number; 22 | } 23 | 24 | export interface RawMapLayer { 25 | metaData: RawMapLayerMetaData; 26 | type: RawMapLayerType; 27 | pixels: number[]; 28 | compressedPixels?: number[]; 29 | dimensions: { 30 | x: RawMapLayerDimension; 31 | y: RawMapLayerDimension; 32 | pixelCount: number; 33 | }; 34 | } 35 | 36 | export interface RawMapLayerDimension { 37 | min: number; 38 | max: number; 39 | mid: number; 40 | avg: number; 41 | } 42 | 43 | export interface RawMapLayerMetaData { 44 | area: number; 45 | segmentId?: string; 46 | name?: string; 47 | active?: boolean; 48 | } 49 | 50 | export type RawMapLayerType = "floor" | "segment" | "wall" 51 | 52 | export type RawMapEntityType = 53 | | "charger_location" 54 | | "robot_position" 55 | | "go_to_target" 56 | | "path" 57 | | "predicted_path" 58 | | "virtual_wall" 59 | | "no_go_area" 60 | | "no_mop_area" 61 | | "active_zone"; 62 | 63 | export interface RawMapDataMetaData { 64 | version: number; 65 | nonce: string; 66 | } 67 | -------------------------------------------------------------------------------- /src/lib/colors/ColorUtils.ts: -------------------------------------------------------------------------------- 1 | export type SegmentId = string; 2 | 3 | export type PossibleSegmentId = SegmentId | undefined; 4 | 5 | export type SegmentColorId = number; 6 | 7 | export type PossibleSegmentColorId = SegmentColorId | undefined; 8 | 9 | export function create2DArray(xLength: number, yLength: number) { 10 | return [...new Array(xLength)].map(elem => { 11 | return [...new Array(yLength)]; 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/colors/FourColorTheoremSolver.ts: -------------------------------------------------------------------------------- 1 | import {MapAreaVertex} from "./MapAreaVertex"; 2 | import {MapAreaGraph} from "./MapAreaGraph"; 3 | import {create2DArray, PossibleSegmentId, SegmentColorId, SegmentId} from "./ColorUtils"; 4 | import {RawMapLayer} from "../RawMapData"; 5 | 6 | export class FourColorTheoremSolver { 7 | /* 8 | * This class determines how to color the different map segments contained in the given layers object. 9 | * The resulting color mapping will ensure that no two adjacent segments share the same color. 10 | * The map is evaluated row-by-row and column-by-column in order to find every pair of segments that are in "line of sight" of each other. 11 | * Each pair of segments is then represented as an edge in a graph where the vertices represent the segments themselves. 12 | * We then use a simple greedy algorithm to color all vertices so that none of its edges connect it to a vertex with the same color. 13 | * 14 | * @param {Array} layers - the data containing the map image (array of pixel offsets) 15 | * @param {number} pixelSize - Used to calculate the resolution of the theorem. Assumes a robot diameter of 30cm and calculates the minimum size of a room. 16 | */ 17 | private readonly stepFunction: (c: number) => number; 18 | private readonly areaGraph: MapAreaGraph | undefined; 19 | 20 | constructor(layers: Array, pixelSize: number) { 21 | /** 22 | * @param {number} resolution - Minimal resolution of the map scanner in pixels. Any number higher than one will lead to this many pixels being skipped when finding segment boundaries. 23 | * For example: If the robot measures 30cm in length/width, this should be set to 6, as no room can be smaller than 6 pixels. This of course implies that a pixel represents 5cm in the real world. 24 | */ 25 | const resolution = Math.floor(30 / pixelSize); 26 | this.stepFunction = function (c) { 27 | return c + resolution; 28 | }; 29 | 30 | const preparedLayers = this.preprocessLayers(layers); 31 | if (preparedLayers !== undefined) { 32 | const mapData = this.createPixelToSegmentMapping(preparedLayers); 33 | this.areaGraph = this.buildGraph(mapData); 34 | this.areaGraph.colorAllVertices(); 35 | } 36 | } 37 | 38 | /* 39 | * @param {string} segmentId - ID of the segment you want to get the color for. 40 | * The segment ID is extracted from the layer metadata in the first constructor parameter of this class. 41 | * @returns {number} The segment color, represented as an integer. Starts at 0 and goes up the minimal number of colors required to color the map without collisions. 42 | */ 43 | getColor(segmentId?: string) : SegmentColorId { 44 | if (!segmentId || this.areaGraph === undefined) { 45 | // Layer preprocessing seems to have failed. Just return a default value for any input. 46 | return 0; 47 | } 48 | 49 | const segmentFromGraph = this.areaGraph.getById(segmentId); 50 | 51 | if (segmentFromGraph && segmentFromGraph.color !== undefined) { 52 | return segmentFromGraph.color; 53 | } else { 54 | return 0; 55 | } 56 | } 57 | 58 | private preprocessLayers(layers: Array): PreparedLayers | undefined { 59 | const internalSegments : Array<{ 60 | segmentId: SegmentId, 61 | name: string | undefined, 62 | pixels: Array 63 | }>= []; 64 | 65 | const boundaries: Boundaries = { 66 | minX: Infinity, 67 | maxX: -Infinity, 68 | minY: Infinity, 69 | maxY: -Infinity, 70 | }; 71 | 72 | 73 | const filteredLayers = layers.filter((layer) => { 74 | return layer.type === "segment"; 75 | }); 76 | 77 | if (filteredLayers.length <= 0) { 78 | return undefined; 79 | } 80 | 81 | filteredLayers.forEach((layer) => { 82 | const allPixels: { x: number, y: number }[] = []; 83 | for (let index = 0; index < layer.pixels.length - 1; index += 2) { 84 | const p = { 85 | x: layer.pixels[index], 86 | y: layer.pixels[index + 1], 87 | }; 88 | FourColorTheoremSolver.setBoundaries(boundaries, p); 89 | allPixels.push(p); 90 | } 91 | 92 | if (layer.metaData.segmentId !== undefined) { 93 | internalSegments.push({ 94 | segmentId: layer.metaData.segmentId, 95 | name: layer.metaData.name, 96 | pixels: allPixels, 97 | }); 98 | } 99 | 100 | }); 101 | 102 | return { 103 | boundaries: boundaries, 104 | segments: internalSegments, 105 | }; 106 | } 107 | 108 | private static setBoundaries(res: Boundaries, pixel: Pixel) { 109 | if (pixel.x < res.minX) { 110 | res.minX = pixel.x; 111 | } 112 | if (pixel.y < res.minY) { 113 | res.minY = pixel.y; 114 | } 115 | if (pixel.x > res.maxX) { 116 | res.maxX = pixel.x; 117 | } 118 | if (pixel.y > res.maxY) { 119 | res.maxY = pixel.y; 120 | } 121 | } 122 | 123 | private createPixelToSegmentMapping(preparedLayers: PreparedLayers) { 124 | const pixelData = create2DArray( 125 | preparedLayers.boundaries.maxX + 1, 126 | preparedLayers.boundaries.maxY + 1 127 | ); 128 | const segmentIds: Array = []; 129 | 130 | preparedLayers.segments.forEach((seg) => { 131 | segmentIds.push(seg.segmentId); 132 | 133 | seg.pixels.forEach((p) => { 134 | pixelData[p.x][p.y] = seg.segmentId; 135 | }); 136 | }); 137 | 138 | return { 139 | map: pixelData as Array>, 140 | segmentIds: segmentIds, 141 | boundaries: preparedLayers.boundaries, 142 | }; 143 | } 144 | 145 | private buildGraph(mapData: {map: Array>, segmentIds: Array, boundaries: Boundaries}) { 146 | const vertices = mapData.segmentIds.map((i) => { 147 | return new MapAreaVertex(i); 148 | }); 149 | 150 | const graph = new MapAreaGraph(vertices); 151 | 152 | this.traverseMap( 153 | mapData.boundaries, 154 | mapData.map, 155 | (x: number, y: number, currentSegmentId: PossibleSegmentId, pixelData: Array>) => { 156 | const newSegmentId = pixelData[x][y]; 157 | 158 | graph.connectVertices(currentSegmentId, newSegmentId); 159 | return newSegmentId !== undefined ? newSegmentId : currentSegmentId; 160 | } 161 | ); 162 | 163 | return graph; 164 | } 165 | 166 | private traverseMap(boundaries: Boundaries, pixelData: Array>, func : TraverseFunction) { 167 | // row-first traversal 168 | for ( 169 | let y = boundaries.minY; 170 | y <= boundaries.maxY; 171 | y = this.stepFunction(y) 172 | ) { 173 | let rowFirstSegmentId: PossibleSegmentId = undefined; 174 | for ( 175 | let x = boundaries.minX; 176 | x <= boundaries.maxX; 177 | x = this.stepFunction(x) 178 | ) { 179 | rowFirstSegmentId = func(x, y, rowFirstSegmentId, pixelData); 180 | } 181 | } 182 | // column-first traversal 183 | for ( 184 | let x = boundaries.minX; 185 | x <= boundaries.maxX; 186 | x = this.stepFunction(x) 187 | ) { 188 | let colFirstSegmentId: PossibleSegmentId = undefined; 189 | for ( 190 | let y = boundaries.minY; 191 | y <= boundaries.maxY; 192 | y = this.stepFunction(y) 193 | ) { 194 | colFirstSegmentId = func(x, y, colFirstSegmentId, pixelData); 195 | } 196 | } 197 | } 198 | } 199 | 200 | type Pixel = { 201 | x: number, 202 | y: number 203 | } 204 | 205 | type Boundaries = { 206 | minX: number, 207 | maxX: number, 208 | minY: number, 209 | maxY: number 210 | } 211 | 212 | type PreparedLayers = { 213 | boundaries: Boundaries 214 | segments: Array<{ 215 | segmentId: SegmentId, 216 | name: string | undefined, 217 | pixels: Array 218 | }> 219 | } 220 | 221 | type TraverseFunction = ( 222 | x: number, 223 | y: number, 224 | currentSegmentId: PossibleSegmentId, 225 | pixelData: Array> 226 | ) => PossibleSegmentId 227 | -------------------------------------------------------------------------------- /src/lib/colors/MapAreaGraph.ts: -------------------------------------------------------------------------------- 1 | import {MapAreaVertex} from "./MapAreaVertex"; 2 | import {PossibleSegmentId, SegmentColorId, SegmentId} from "./ColorUtils"; 3 | 4 | export class MapAreaGraph { 5 | vertices: Array; 6 | vertexLookup: Map; 7 | 8 | 9 | constructor(vertices: Array) { 10 | this.vertices = vertices; 11 | this.vertexLookup = new Map(); 12 | 13 | this.vertices.forEach((v) => { 14 | this.vertexLookup.set(v.id, v); 15 | }); 16 | } 17 | 18 | connectVertices(id1 : PossibleSegmentId, id2: PossibleSegmentId) { 19 | if (id1 !== undefined && id2 !== undefined && id1 !== id2) { 20 | if (this.vertexLookup.has(id1)) { 21 | this.vertexLookup.get(id1)!.appendVertex(id2); 22 | } 23 | if (this.vertexLookup.has(id2)) { 24 | this.vertexLookup.get(id2)!.appendVertex(id1); 25 | } 26 | } 27 | } 28 | 29 | /** 30 | * Color the graphs vertices using a greedy algorithm. Any vertices that have already been assigned a color will not be changed. 31 | * Color assignment will start with the vertex that is connected with the highest number of edges. In most cases, this will 32 | * naturally lead to a distribution where only four colors are required for the whole graph. This is relevant for maps with a high 33 | * number of segments, as the naive, greedy algorithm tends to require a fifth color when starting coloring in a segment far from the map's center. 34 | * 35 | */ 36 | colorAllVertices() { 37 | this.vertices.sort((l, r) => { 38 | return r.adjacentVertexIds.size - l.adjacentVertexIds.size; 39 | }); 40 | 41 | this.vertices.forEach((v) => { 42 | if (v.adjacentVertexIds.size <= 0) { 43 | v.color = 0; 44 | } else { 45 | const adjacentVertices = this.getAdjacentVertices(v); 46 | 47 | const existingColors = adjacentVertices 48 | .filter((vert) => { 49 | return vert.color !== undefined; 50 | }) 51 | .map((vert) => { 52 | return vert.color; 53 | }) as Array; 54 | 55 | v.color = this.lowestColor(existingColors); 56 | } 57 | }); 58 | } 59 | 60 | getAdjacentVertices(vertex: MapAreaVertex): Array { 61 | return Array.from(vertex.adjacentVertexIds).map((id) => { 62 | return this.getById(id); 63 | }).filter(adjacentVertex => { 64 | return adjacentVertex !== undefined; 65 | }) as Array; 66 | } 67 | 68 | getById(id: string): MapAreaVertex | undefined { 69 | return this.vertices.find((v) => { 70 | return v.id === id; 71 | }); 72 | } 73 | 74 | lowestColor(colors: Array) { 75 | if (colors.length <= 0) { 76 | return 0; 77 | } 78 | 79 | for (let index = 0; index < colors.length + 1; index++) { 80 | if (!colors.includes(index)) { 81 | return index; 82 | } 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/lib/colors/MapAreaVertex.ts: -------------------------------------------------------------------------------- 1 | import {PossibleSegmentColorId, SegmentId} from "./ColorUtils"; 2 | 3 | export class MapAreaVertex { 4 | id: SegmentId; 5 | adjacentVertexIds: Set; 6 | color: PossibleSegmentColorId; 7 | 8 | constructor(id: SegmentId) { 9 | this.id = id; 10 | this.adjacentVertexIds = new Set(); 11 | 12 | this.color = undefined; 13 | } 14 | 15 | appendVertex(vertexId: string) { 16 | if (vertexId !== undefined) { 17 | this.adjacentVertexIds.add(vertexId); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/lib/mapUtils.ts: -------------------------------------------------------------------------------- 1 | import {RawMapData} from "./RawMapData"; 2 | 3 | export function preprocessMap(data : RawMapData) : RawMapData { 4 | if (data.metaData?.version === 2 && Array.isArray(data.layers)) { 5 | data.layers.forEach(layer => { 6 | if (layer.pixels.length === 0 && layer.compressedPixels && layer.compressedPixels.length !== 0) { 7 | for (let i = 0; i < layer.compressedPixels.length; i = i + 3) { 8 | const xStart = layer.compressedPixels[i]; 9 | const y = layer.compressedPixels[i+1]; 10 | const count = layer.compressedPixels[i+2]; 11 | 12 | for (let j = 0; j < count; j++) { 13 | layer.pixels.push( 14 | xStart + j, 15 | y 16 | ); 17 | } 18 | } 19 | 20 | delete(layer.compressedPixels); 21 | } 22 | }); 23 | } 24 | 25 | return data; 26 | } 27 | -------------------------------------------------------------------------------- /src/lib/pngUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This has been adapted for this use-case from https://github.com/hughsk/png-chunks-extract/blob/d098d583f3ab3877c1e4613ec9353716f86e2eec/index.js 3 | * 4 | * See https://github.com/hughsk/png-chunks-extract/blob/d098d583f3ab3877c1e4613ec9353716f86e2eec/LICENSE.md for more information. 5 | */ 6 | 7 | export function extractZtxtPngChunks (data: Uint8Array | Buffer) { 8 | // Used for fast-ish conversion between uint8s and uint32s/int32s. 9 | // Also required in order to remain agnostic for both Node Buffers and 10 | // Uint8Arrays. 11 | const uint8 = new Uint8Array(4); 12 | const uint32 = new Uint32Array(uint8.buffer); 13 | 14 | 15 | if (data[0] !== 0x89) { 16 | throw new Error("Invalid .png file header"); 17 | } 18 | if (data[1] !== 0x50) { 19 | throw new Error("Invalid .png file header"); 20 | } 21 | if (data[2] !== 0x4E) { 22 | throw new Error("Invalid .png file header"); 23 | } 24 | if (data[3] !== 0x47) { 25 | throw new Error("Invalid .png file header"); 26 | } 27 | if (data[4] !== 0x0D) { 28 | throw new Error("Invalid .png file header: possibly caused by DOS-Unix line ending conversion?"); 29 | } 30 | if (data[5] !== 0x0A) { 31 | throw new Error("Invalid .png file header: possibly caused by DOS-Unix line ending conversion?"); 32 | } 33 | if (data[6] !== 0x1A) { 34 | throw new Error("Invalid .png file header"); 35 | } 36 | if (data[7] !== 0x0A) { 37 | throw new Error("Invalid .png file header: possibly caused by DOS-Unix line ending conversion?"); 38 | } 39 | 40 | const chunks: { keyword: string, data: Uint8Array }[] = []; 41 | let ended = false; 42 | let idx = 8; 43 | 44 | while (idx < data.length) { 45 | // Read the length of the current chunk, 46 | // which is stored as a Uint32. 47 | uint8[3] = data[idx++]; 48 | uint8[2] = data[idx++]; 49 | uint8[1] = data[idx++]; 50 | uint8[0] = data[idx++]; 51 | 52 | // Chunk includes name/type for CRC check (see below). 53 | const length = uint32[0] + 4; 54 | const chunk = new Uint8Array(length); 55 | chunk[0] = data[idx++]; 56 | chunk[1] = data[idx++]; 57 | chunk[2] = data[idx++]; 58 | chunk[3] = data[idx++]; 59 | 60 | // Get the name in ASCII for identification. 61 | const name = ( 62 | String.fromCharCode(chunk[0]) + 63 | String.fromCharCode(chunk[1]) + 64 | String.fromCharCode(chunk[2]) + 65 | String.fromCharCode(chunk[3]) 66 | ); 67 | 68 | // The IEND header marks the end of the file, 69 | // so on discovering it break out of the loop. 70 | if (name === "IEND") { 71 | ended = true; 72 | 73 | break; 74 | } 75 | 76 | // Read the contents of the chunk out of the main buffer. 77 | for (let i = 4; i < length; i++) { 78 | chunk[i] = data[idx++]; 79 | } 80 | 81 | //Skip the CRC32 82 | idx += 4; 83 | 84 | // The chunk data is now copied to remove the 4 preceding 85 | // bytes used for the chunk name/type. 86 | const chunkData = new Uint8Array(chunk.buffer.slice(4)); 87 | 88 | if (name === "zTXt") { 89 | let i = 0; 90 | let keyword = ""; 91 | 92 | while (chunkData[i] !== 0 && i < 79 ) { 93 | keyword += String.fromCharCode(chunkData[i]); 94 | 95 | i++; 96 | } 97 | 98 | chunks.push({ 99 | keyword: keyword, 100 | data: new Uint8Array(chunkData.slice(i + 2)) 101 | }); 102 | } 103 | } 104 | 105 | if (!ended) { 106 | throw new Error(".png file ended prematurely: no IEND header was found"); 107 | } 108 | 109 | return chunks; 110 | } 111 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | export type HaIconElement = HTMLElement & { icon?: string }; 2 | 3 | export type RobotInfo = [x: number, y: number, angle?: number]; 4 | 5 | export type BoundingBox = { 6 | minX: number; 7 | minY: number; 8 | maxX: number; 9 | maxY: number; 10 | }; 11 | 12 | export interface CropConfig { 13 | left: number; 14 | top: number; 15 | right: number; 16 | bottom: number; 17 | } 18 | 19 | export interface CustomButtonConfig { 20 | service: string; 21 | service_data?: unknown; 22 | icon?: string; 23 | text?: string; 24 | } 25 | 26 | export interface Configuration { 27 | vacuum: string; 28 | 29 | // Title settings 30 | title: string; 31 | 32 | // Core show settings 33 | show_map: boolean; 34 | 35 | // Map show settings 36 | show_floor: boolean; 37 | show_dock: boolean; 38 | show_vacuum: boolean; 39 | show_walls: boolean; 40 | show_currently_cleaned_zones: boolean; 41 | show_no_go_areas: boolean; 42 | show_no_mop_areas: boolean; 43 | show_virtual_walls: boolean; 44 | show_path: boolean; 45 | show_currently_cleaned_zones_border: boolean; 46 | show_no_go_area_border: boolean; 47 | show_no_mop_area_border: boolean; 48 | show_predicted_path: boolean; 49 | show_goto_target: boolean; 50 | show_segments: boolean; 51 | 52 | // Info show settings 53 | show_status: boolean; 54 | show_battery_level: boolean; 55 | 56 | // Show button settings 57 | show_start_button: boolean; 58 | show_pause_button: boolean; 59 | show_stop_button: boolean; 60 | show_home_button: boolean; 61 | show_locate_button: boolean; 62 | 63 | // Width settings 64 | virtual_wall_width: number; 65 | path_width: number; 66 | 67 | // Padding settings 68 | left_padding: number; 69 | 70 | // Scale settings 71 | map_scale: number; 72 | icon_scale: number; 73 | rotate: number | string; 74 | 75 | // Opacity settings 76 | floor_opacity: number; 77 | segment_opacity: number; 78 | wall_opacity: number; 79 | currently_cleaned_zone_opacity: number; 80 | no_go_area_opacity: number; 81 | no_mop_area_opacity: number; 82 | virtual_wall_opacity: number; 83 | path_opacity: number; 84 | 85 | // Color settings 86 | background_color: string; 87 | floor_color: string; 88 | wall_color: string; 89 | currently_cleaned_zone_color: string; 90 | no_go_area_color: string; 91 | no_mop_area_color: string; 92 | virtual_wall_color: string; 93 | path_color: string; 94 | dock_color: string; 95 | vacuum_color: string; 96 | goto_target_color: string; 97 | 98 | // Color segment settings 99 | segment_colors: string[]; 100 | 101 | // Icon settings 102 | dock_icon: string; 103 | vacuum_icon: string; 104 | goto_target_icon: string; 105 | 106 | // Crop settings 107 | min_height: number | string; 108 | crop: CropConfig; 109 | 110 | custom_buttons: CustomButtonConfig[]; 111 | } 112 | -------------------------------------------------------------------------------- /src/res/consts.ts: -------------------------------------------------------------------------------- 1 | import { Configuration } from "../lib/types"; 2 | 3 | export const DEFAULT_CARD_CONFIG = Object.freeze({ 4 | // Title settings 5 | title: "Vacuum", 6 | 7 | // Core show settings 8 | show_map: true, 9 | 10 | // Map show settings 11 | show_floor: true, 12 | show_dock: true, 13 | show_vacuum: true, 14 | show_walls: true, 15 | show_currently_cleaned_zones: true, 16 | show_no_go_areas: true, 17 | show_no_mop_areas: true, 18 | show_virtual_walls: true, 19 | show_path: true, 20 | show_currently_cleaned_zones_border: true, 21 | show_no_go_area_border: true, 22 | show_no_mop_area_border: true, 23 | show_predicted_path: true, 24 | show_goto_target: true, 25 | show_segments: true, 26 | 27 | // Info show settings 28 | show_status: true, 29 | show_battery_level: true, 30 | 31 | // Show button settings 32 | show_start_button: true, 33 | show_pause_button: true, 34 | show_stop_button: true, 35 | show_home_button: true, 36 | show_locate_button: true, 37 | 38 | // Width settings 39 | virtual_wall_width: 1, 40 | path_width: 1, 41 | 42 | // Padding settings 43 | left_padding: 0, 44 | 45 | // Scale settings 46 | map_scale: 1, 47 | icon_scale: 1, 48 | 49 | // Opacity settings 50 | floor_opacity: 1, 51 | segment_opacity: 0.75, 52 | wall_opacity: 1, 53 | currently_cleaned_zone_opacity: 0.5, 54 | no_go_area_opacity: 0.5, 55 | no_mop_area_opacity: 0.5, 56 | virtual_wall_opacity: 1, 57 | path_opacity: 1, 58 | 59 | // Color segment settings 60 | segment_colors: [ 61 | "#19A1A1", 62 | "#7AC037", 63 | "#DF5618", 64 | "#F7C841", 65 | ], 66 | 67 | // Crop settings 68 | min_height: 0 69 | } satisfies Partial); 70 | 71 | export const POLL_INTERVAL_STATE_MAP: Record = Object.freeze({ 72 | "cleaning": 3*1000, 73 | "paused": 15*1000, 74 | "idle": 2*60*1000, 75 | "returning": 3*1000, 76 | "docked": 2*60*1000, 77 | "error": 2*60*1000 78 | }); 79 | -------------------------------------------------------------------------------- /src/valetudo-map-card.ts: -------------------------------------------------------------------------------- 1 | import * as pako from "pako"; 2 | import { HomeAssistant } from "custom-card-helpers"; 3 | import { HassEntity } from "home-assistant-js-websocket"; 4 | 5 | import packageJson from "../package.json"; 6 | import { FourColorTheoremSolver } from "./lib/colors/FourColorTheoremSolver"; 7 | import { preprocessMap } from "./lib/mapUtils"; 8 | import { extractZtxtPngChunks } from "./lib/pngUtils"; 9 | import { RawMapData, RawMapEntity, RawMapEntityType, RawMapLayer, RawMapLayerType } from "./lib/RawMapData"; 10 | import { BoundingBox, Configuration, CropConfig, HaIconElement, RobotInfo } from "./lib/types"; 11 | import { DEFAULT_CARD_CONFIG, POLL_INTERVAL_STATE_MAP } from "./res/consts"; 12 | 13 | console.info( 14 | `%c Valetudo-Map-Card \n%c Version ${packageJson.version} `, 15 | "color: #0076FF; font-weight: bold; background: #121212", 16 | "color: #52AEFF; font-weight: bold; background: #1e1e1e" 17 | ); 18 | 19 | class ValetudoMapCard extends HTMLElement { 20 | _hass: HomeAssistant; 21 | _config: Configuration; 22 | 23 | drawingMap: boolean; 24 | drawingControls: boolean; 25 | lastUpdatedControls: string; 26 | lastMapPoll: Date; 27 | isPollingMap: boolean; 28 | lastRobotState: string; 29 | pollInterval: number; 30 | lastValidRobotInfo: RobotInfo | null; 31 | 32 | cardContainer: HTMLElement; 33 | cardContainerStyle: HTMLStyleElement; 34 | cardHeader: HTMLDivElement; 35 | cardTitle: HTMLDivElement; 36 | entityWarning1: HTMLElement; 37 | entityWarning2: HTMLElement; 38 | mapContainer: HTMLDivElement; 39 | mapContainerStyle: HTMLStyleElement; 40 | controlContainer: HTMLDivElement; 41 | controlContainerStyle: HTMLStyleElement; 42 | infoBox: HTMLDivElement; 43 | controlFlexBox: HTMLDivElement; 44 | customControlFlexBox: HTMLDivElement; 45 | 46 | constructor() { 47 | super(); 48 | 49 | this.drawingMap = false; 50 | this.drawingControls = false; 51 | this.lastUpdatedControls = ""; 52 | this.attachShadow({ mode: "open" }); 53 | this.lastMapPoll = new Date(0); 54 | this.isPollingMap = false; 55 | this.lastRobotState = "docked"; 56 | this.pollInterval = POLL_INTERVAL_STATE_MAP[this.lastRobotState]; 57 | 58 | this.cardContainer = document.createElement("ha-card"); 59 | this.cardContainer.id = "valetudoMapCard"; 60 | this.cardContainerStyle = document.createElement("style"); 61 | this.shadowRoot?.appendChild(this.cardContainer); 62 | this.shadowRoot?.appendChild(this.cardContainerStyle); 63 | 64 | this.cardHeader = document.createElement("div"); 65 | this.cardHeader.setAttribute("class", "card-header"); 66 | this.cardTitle = document.createElement("div"); 67 | this.cardTitle.setAttribute("class", "name"); 68 | this.cardHeader.appendChild(this.cardTitle); 69 | this.cardContainer.appendChild(this.cardHeader); 70 | 71 | this.entityWarning1 = document.createElement("hui-warning"); 72 | this.entityWarning1.id = "valetudoMapCardWarning1"; 73 | this.entityWarning1.style.display = "none"; 74 | this.cardContainer.appendChild(this.entityWarning1); 75 | 76 | this.entityWarning2 = document.createElement("hui-warning"); 77 | this.entityWarning2.id = "valetudoMapCardWarning2"; 78 | this.entityWarning2.style.display = "none"; 79 | this.cardContainer.appendChild(this.entityWarning2); 80 | 81 | this.mapContainer = document.createElement("div"); 82 | this.mapContainer.id = "valetudoMapCardMapContainer"; 83 | this.mapContainerStyle = document.createElement("style"); 84 | this.cardContainer.appendChild(this.mapContainer); 85 | this.cardContainer.appendChild(this.mapContainerStyle); 86 | 87 | this.controlContainer = document.createElement("div"); 88 | this.controlContainer.id = "valetudoMapCardControlsContainer"; 89 | this.controlContainerStyle = document.createElement("style"); 90 | this.cardContainer.appendChild(this.controlContainer); 91 | this.cardContainer.appendChild(this.controlContainerStyle); 92 | } 93 | 94 | static getStubConfig() { 95 | return { vacuum: "valetudo_REPLACEME" }; 96 | } 97 | 98 | getMapEntityName(vacuum_name: string) { 99 | return "camera." + vacuum_name + "_map_data"; 100 | } 101 | 102 | getVacuumEntityName(vacuum_name: string) { 103 | return "vacuum." + vacuum_name; 104 | } 105 | 106 | getMapEntity(vacuum_name: string) { 107 | return this._hass.states[this.getMapEntityName(vacuum_name)]; 108 | } 109 | 110 | getVacuumEntity(vacuum_name: string) { 111 | return this._hass.states[this.getVacuumEntityName(vacuum_name)]; 112 | } 113 | 114 | shouldDrawMap() { 115 | return !this.drawingMap; 116 | } 117 | 118 | shouldDrawControls(state: HassEntity) { 119 | return !this.drawingControls && this.lastUpdatedControls !== state.last_updated; 120 | } 121 | 122 | calculateColor(container: Element, ...colors: (string | undefined)[]) { 123 | for (let color of colors) { 124 | if (!color) { 125 | continue; 126 | } 127 | if (color.startsWith("--")) { 128 | let possibleColor = getComputedStyle(container).getPropertyValue(color); 129 | if (!possibleColor) { 130 | continue; 131 | } 132 | return possibleColor; 133 | } 134 | return color; 135 | } 136 | 137 | return ''; 138 | } 139 | 140 | isOutsideBounds(x: number, y: number, drawnMapCanvas: HTMLCanvasElement, config: Configuration) { 141 | return (x < config.crop.left) || (x > drawnMapCanvas.width) || (y < config.crop.top) || (y > drawnMapCanvas.height); 142 | } 143 | 144 | getLayers(attributes: RawMapData, type: RawMapLayerType, maxCount?: number) { 145 | let layers: RawMapLayer[] = []; 146 | for (let layer of attributes.layers) { 147 | if (layer.type === type) { 148 | layers.push(layer); 149 | } 150 | 151 | if (layers.length === maxCount) { 152 | break; 153 | } 154 | } 155 | 156 | return layers; 157 | } 158 | 159 | getEntities(attributes: RawMapData, type: RawMapEntityType, maxCount?: number) { 160 | let entities: RawMapEntity[] = []; 161 | for (let entity of attributes.entities) { 162 | if (entity.type === type) { 163 | entities.push(entity); 164 | } 165 | 166 | if (maxCount && entities.length === maxCount) { 167 | break; 168 | } 169 | } 170 | 171 | return entities; 172 | } 173 | 174 | getChargerInfo(attributes: RawMapData) { 175 | let layer = this.getEntities(attributes, "charger_location", 1)[0]; 176 | if (layer === undefined) { 177 | return null; 178 | } 179 | 180 | return [layer.points[0], layer.points[1]]; 181 | } 182 | 183 | getRobotInfo(attributes: RawMapData): RobotInfo | null { 184 | let layer = this.getEntities(attributes, "robot_position", 1)[0]; 185 | if (layer === undefined) { 186 | return null; 187 | } 188 | 189 | return [layer.points[0], layer.points[1], layer.metaData.angle]; 190 | } 191 | 192 | getGoToInfo(attributes: RawMapData) { 193 | 194 | let layer = this.getEntities(attributes, "go_to_target", 1)[0]; 195 | if (layer === undefined) { 196 | return null; 197 | } 198 | 199 | return [layer.points[0], layer.points[1]]; 200 | 201 | } 202 | 203 | getFloorPoints(attributes: RawMapData) { 204 | 205 | let layer = this.getLayers(attributes, "floor", 1)[0]; 206 | if (layer === undefined) { 207 | return null; 208 | } 209 | 210 | return layer.pixels; 211 | 212 | } 213 | 214 | getSegments(attributes: RawMapData) { 215 | 216 | return this.getLayers(attributes, "segment"); 217 | 218 | } 219 | 220 | getWallPoints(attributes: RawMapData) { 221 | let layer = this.getLayers(attributes, "wall", 1)[0]; 222 | if (layer === undefined) { 223 | return null; 224 | } 225 | 226 | return layer.pixels; 227 | } 228 | 229 | getVirtualWallPoints(attributes: RawMapData) { 230 | return this.getEntities(attributes, "virtual_wall"); 231 | } 232 | 233 | getPathPoints(attributes: RawMapData) { 234 | return this.getEntities(attributes, "path"); 235 | } 236 | 237 | getPredictedPathPoints(attributes: RawMapData) { 238 | return this.getEntities(attributes, "predicted_path"); 239 | } 240 | 241 | getActiveZones(attributes: RawMapData) { 242 | return this.getEntities(attributes, "active_zone"); 243 | } 244 | 245 | getNoGoAreas(attributes: RawMapData) { 246 | return this.getEntities(attributes, "no_go_area"); 247 | } 248 | 249 | getNoMopAreas(attributes: RawMapData) { 250 | return this.getEntities(attributes, "no_mop_area"); 251 | } 252 | 253 | drawMap(attributes: RawMapData, mapHeight: number, mapWidth: number, boundingBox: BoundingBox) { 254 | const pixelSize = attributes.pixelSize; 255 | 256 | const widthScale = pixelSize / this._config.map_scale; 257 | const heightScale = pixelSize / this._config.map_scale; 258 | 259 | let objectLeftOffset = 0; 260 | let objectTopOffset = 0; 261 | let mapLeftOffset = 0; 262 | let mapTopOffset = 0; 263 | 264 | mapLeftOffset = ((boundingBox.minX) - 1) * this._config.map_scale; 265 | mapTopOffset = ((boundingBox.minY) - 1) * this._config.map_scale; 266 | 267 | // Calculate colours 268 | const homeAssistant = document.getElementsByTagName("home-assistant")[0]; 269 | const floorColor = this.calculateColor(homeAssistant, this._config.floor_color, "--valetudo-map-floor-color", "--secondary-background-color"); 270 | const wallColor = this.calculateColor(homeAssistant, this._config.wall_color, "--valetudo-map-wall-color", "--accent-color"); 271 | const currentlyCleanedZoneColor = this.calculateColor(homeAssistant, this._config.currently_cleaned_zone_color, "--valetudo-currently_cleaned_zone_color", "--secondary-text-color"); 272 | const noGoAreaColor = this.calculateColor(homeAssistant, this._config.no_go_area_color, "--valetudo-no-go-area-color", "--accent-color"); 273 | const noMopAreaColor = this.calculateColor(homeAssistant, this._config.no_mop_area_color, "--valetudo-no-mop-area-color", "--secondary-text-color"); 274 | const virtualWallColor = this.calculateColor(homeAssistant, this._config.virtual_wall_color, "--valetudo-virtual-wall-color", "--accent-color"); 275 | const pathColor = this.calculateColor(homeAssistant, this._config.path_color, "--valetudo-map-path-color", "--primary-text-color"); 276 | const chargerColor = this.calculateColor(homeAssistant, this._config.dock_color, "green"); 277 | const vacuumColor = this.calculateColor(homeAssistant, this._config.vacuum_color, "--primary-text-color"); 278 | const gotoTargetColor = this.calculateColor(homeAssistant, this._config.goto_target_color, "blue"); 279 | 280 | // Create all objects 281 | const containerContainer = document.createElement("div"); 282 | containerContainer.id = "lovelaceValetudoCard"; 283 | 284 | const drawnMapContainer = document.createElement("div"); 285 | const drawnMapCanvas = document.createElement("canvas"); 286 | drawnMapCanvas.width = mapWidth * this._config.map_scale; 287 | drawnMapCanvas.height = mapHeight * this._config.map_scale; 288 | drawnMapContainer.style.zIndex = "1"; 289 | drawnMapContainer.appendChild(drawnMapCanvas); 290 | 291 | const chargerContainer = document.createElement("div"); 292 | const chargerHTML = document.createElement("ha-icon"); 293 | let chargerInfo = this.getChargerInfo(attributes); 294 | if (this._config.show_dock && chargerInfo) { 295 | chargerHTML.style.position = "absolute"; // Needed in Home Assistant 0.110.0 and up 296 | chargerHTML.icon = this._config.dock_icon || "mdi:flash"; 297 | chargerHTML.style.left = `${Math.floor(chargerInfo[0] / widthScale) - objectLeftOffset - mapLeftOffset - (12 * this._config.icon_scale)}px`; 298 | chargerHTML.style.top = `${Math.floor(chargerInfo[1] / heightScale) - objectTopOffset - mapTopOffset - (12 * this._config.icon_scale)}px`; 299 | chargerHTML.style.color = chargerColor; 300 | chargerHTML.style.transform = `scale(${this._config.icon_scale}, ${this._config.icon_scale}) rotate(-${this._config.rotate})`; 301 | } 302 | chargerContainer.style.zIndex = "2"; 303 | chargerContainer.appendChild(chargerHTML); 304 | 305 | const pathContainer = document.createElement("div"); 306 | const pathCanvas = document.createElement("canvas"); 307 | pathCanvas.width = mapWidth * this._config.map_scale; 308 | pathCanvas.height = mapHeight * this._config.map_scale; 309 | pathContainer.style.zIndex = "3"; 310 | pathContainer.appendChild(pathCanvas); 311 | 312 | const vacuumContainer = document.createElement("div"); 313 | const vacuumHTML = document.createElement("ha-icon"); 314 | 315 | let robotInfo = this.getRobotInfo(attributes); 316 | if (!robotInfo) { 317 | robotInfo = this.lastValidRobotInfo; 318 | } 319 | 320 | if (this._config.show_vacuum && robotInfo) { 321 | this.lastValidRobotInfo = robotInfo; 322 | vacuumHTML.style.position = "absolute"; // Needed in Home Assistant 0.110.0 and up 323 | vacuumHTML.icon = this._config.vacuum_icon || "mdi:robot-vacuum"; 324 | vacuumHTML.style.color = vacuumColor; 325 | vacuumHTML.style.left = `${Math.floor(robotInfo[0] / widthScale) - objectLeftOffset - mapLeftOffset - (12 * this._config.icon_scale)}px`; 326 | vacuumHTML.style.top = `${Math.floor(robotInfo[1] / heightScale) - objectTopOffset - mapTopOffset - (12 * this._config.icon_scale)}px`; 327 | vacuumHTML.style.transform = `scale(${this._config.icon_scale}, ${this._config.icon_scale})`; 328 | } 329 | vacuumContainer.style.zIndex = "4"; 330 | vacuumContainer.appendChild(vacuumHTML); 331 | 332 | const goToTargetContainer = document.createElement("div"); 333 | const goToTargetHTML = document.createElement("ha-icon"); 334 | let goToInfo = this.getGoToInfo(attributes); 335 | if (this._config.show_goto_target && goToInfo) { 336 | goToTargetHTML.style.position = "absolute"; // Needed in Home Assistant 0.110.0 and up 337 | goToTargetHTML.icon = this._config.goto_target_icon || "mdi:pin"; 338 | goToTargetHTML.style.left = `${Math.floor(goToInfo[0] / widthScale) - objectLeftOffset - mapLeftOffset - (12 * this._config.icon_scale)}px`; 339 | goToTargetHTML.style.top = `${Math.floor(goToInfo[1] / heightScale) - objectTopOffset - mapTopOffset - (22 * this._config.icon_scale)}px`; 340 | goToTargetHTML.style.color = gotoTargetColor; 341 | goToTargetHTML.style.transform = `scale(${this._config.icon_scale}, ${this._config.icon_scale})`; 342 | } 343 | goToTargetContainer.style.zIndex = "5"; 344 | goToTargetContainer.appendChild(goToTargetHTML); 345 | 346 | // Put objects in container 347 | containerContainer.appendChild(drawnMapContainer); 348 | containerContainer.appendChild(chargerContainer); 349 | containerContainer.appendChild(pathContainer); 350 | containerContainer.appendChild(vacuumContainer); 351 | containerContainer.appendChild(goToTargetContainer); 352 | 353 | const mapCtx = drawnMapCanvas.getContext("2d")!; 354 | if (this._config.show_floor) { 355 | mapCtx.globalAlpha = this._config.floor_opacity; 356 | 357 | mapCtx.strokeStyle = floorColor; 358 | mapCtx.lineWidth = 1; 359 | mapCtx.fillStyle = floorColor; 360 | mapCtx.beginPath(); 361 | let floorPoints = this.getFloorPoints(attributes); 362 | if (floorPoints) { 363 | for (let i = 0; i < floorPoints.length; i+=2) { 364 | let x = (floorPoints[i] * this._config.map_scale) - mapLeftOffset; 365 | let y = (floorPoints[i + 1] * this._config.map_scale) - mapTopOffset; 366 | if (this.isOutsideBounds(x, y, drawnMapCanvas, this._config)) { 367 | continue; 368 | } 369 | mapCtx.fillRect(x, y, this._config.map_scale, this._config.map_scale); 370 | } 371 | } 372 | 373 | mapCtx.globalAlpha = 1; 374 | } 375 | 376 | let segmentAreas = this.getSegments(attributes); 377 | if (segmentAreas && this._config.show_segments) { 378 | const colorFinder = new FourColorTheoremSolver(segmentAreas, 6); 379 | mapCtx.globalAlpha = this._config.segment_opacity; 380 | 381 | for (let item of segmentAreas) { 382 | mapCtx.strokeStyle = this._config.segment_colors[colorFinder.getColor(item.metaData.segmentId)]; 383 | mapCtx.lineWidth = 1; 384 | mapCtx.fillStyle = this._config.segment_colors[colorFinder.getColor(item.metaData.segmentId)]; 385 | mapCtx.beginPath(); 386 | let segmentPoints = item["pixels"]; 387 | if (segmentPoints) { 388 | for (let i = 0; i < segmentPoints.length; i+=2) { 389 | let x = (segmentPoints[i] * this._config.map_scale) - mapLeftOffset; 390 | let y = (segmentPoints[i + 1] * this._config.map_scale) - mapTopOffset; 391 | if (this.isOutsideBounds(x, y, drawnMapCanvas, this._config)) { 392 | continue; 393 | } 394 | mapCtx.fillRect(x, y, this._config.map_scale, this._config.map_scale); 395 | } 396 | } 397 | } 398 | 399 | mapCtx.globalAlpha = 1; 400 | } 401 | 402 | if (this._config.show_walls) { 403 | mapCtx.globalAlpha = this._config.wall_opacity; 404 | 405 | mapCtx.strokeStyle = wallColor; 406 | mapCtx.lineWidth = 1; 407 | mapCtx.fillStyle = wallColor; 408 | mapCtx.beginPath(); 409 | let wallPoints = this.getWallPoints(attributes); 410 | if (wallPoints) { 411 | for (let i = 0; i < wallPoints.length; i+=2) { 412 | let x = (wallPoints[i] * this._config.map_scale) - mapLeftOffset; 413 | let y = (wallPoints[i + 1] * this._config.map_scale) - mapTopOffset; 414 | if (this.isOutsideBounds(x, y, drawnMapCanvas, this._config)) { 415 | continue; 416 | } 417 | mapCtx.fillRect(x, y, this._config.map_scale, this._config.map_scale); 418 | } 419 | } 420 | 421 | mapCtx.globalAlpha = 1; 422 | } 423 | 424 | let activeZones = this.getActiveZones(attributes); 425 | if (Array.isArray(activeZones) && activeZones.length > 0 && this._config.show_currently_cleaned_zones) { 426 | mapCtx.globalAlpha = this._config.currently_cleaned_zone_opacity; 427 | 428 | mapCtx.strokeStyle = currentlyCleanedZoneColor; 429 | mapCtx.lineWidth = 2; 430 | mapCtx.fillStyle = currentlyCleanedZoneColor; 431 | for (let item of activeZones) { 432 | mapCtx.globalAlpha = this._config.currently_cleaned_zone_opacity; 433 | mapCtx.beginPath(); 434 | let points = item["points"]; 435 | for (let i = 0; i < points.length; i+=2) { 436 | let x = Math.floor(points[i] / widthScale) - objectLeftOffset - mapLeftOffset; 437 | let y = Math.floor(points[i + 1] / heightScale) - objectTopOffset - mapTopOffset; 438 | if (i === 0) { 439 | mapCtx.moveTo(x, y); 440 | } else { 441 | mapCtx.lineTo(x, y); 442 | } 443 | if (this.isOutsideBounds(x, y, drawnMapCanvas, this._config)) { 444 | // noinspection UnnecessaryContinueJS 445 | continue; 446 | } 447 | } 448 | mapCtx.fill(); 449 | 450 | if (this._config.show_currently_cleaned_zones_border) { 451 | mapCtx.closePath(); 452 | mapCtx.globalAlpha = 1.0; 453 | mapCtx.stroke(); 454 | } 455 | } 456 | mapCtx.globalAlpha = 1.0; 457 | } 458 | 459 | let noGoAreas = this.getNoGoAreas(attributes); 460 | if (noGoAreas && this._config.show_no_go_areas) { 461 | mapCtx.strokeStyle = noGoAreaColor; 462 | mapCtx.lineWidth = 2; 463 | mapCtx.fillStyle = noGoAreaColor; 464 | for (let item of noGoAreas) { 465 | mapCtx.globalAlpha = this._config.no_go_area_opacity; 466 | mapCtx.beginPath(); 467 | let points = item["points"]; 468 | for (let i = 0; i < points.length; i+=2) { 469 | let x = Math.floor(points[i] / widthScale) - objectLeftOffset - mapLeftOffset; 470 | let y = Math.floor(points[i + 1] / heightScale) - objectTopOffset - mapTopOffset; 471 | if (i === 0) { 472 | mapCtx.moveTo(x, y); 473 | } else { 474 | mapCtx.lineTo(x, y); 475 | } 476 | if (this.isOutsideBounds(x, y, drawnMapCanvas, this._config)) { 477 | // noinspection UnnecessaryContinueJS 478 | continue; 479 | } 480 | } 481 | mapCtx.fill(); 482 | 483 | if (this._config.show_no_go_area_border) { 484 | mapCtx.closePath(); 485 | mapCtx.globalAlpha = 1.0; 486 | mapCtx.stroke(); 487 | } 488 | } 489 | mapCtx.globalAlpha = 1.0; 490 | } 491 | 492 | let noMopAreas = this.getNoMopAreas(attributes); 493 | if (noMopAreas && this._config.show_no_mop_areas) { 494 | mapCtx.strokeStyle = noMopAreaColor; 495 | mapCtx.lineWidth = 2; 496 | mapCtx.fillStyle = noMopAreaColor; 497 | for (let item of noMopAreas) { 498 | mapCtx.globalAlpha = this._config.no_mop_area_opacity; 499 | mapCtx.beginPath(); 500 | let points = item["points"]; 501 | for (let i = 0; i < points.length; i+=2) { 502 | let x = Math.floor(points[i] / widthScale) - objectLeftOffset - mapLeftOffset; 503 | let y = Math.floor(points[i + 1] / heightScale) - objectTopOffset - mapTopOffset; 504 | if (i === 0) { 505 | mapCtx.moveTo(x, y); 506 | } else { 507 | mapCtx.lineTo(x, y); 508 | } 509 | if (this.isOutsideBounds(x, y, drawnMapCanvas, this._config)) { 510 | // noinspection UnnecessaryContinueJS 511 | continue; 512 | } 513 | } 514 | mapCtx.fill(); 515 | 516 | if (this._config.show_no_mop_area_border) { 517 | mapCtx.closePath(); 518 | mapCtx.globalAlpha = 1.0; 519 | mapCtx.stroke(); 520 | } 521 | } 522 | mapCtx.globalAlpha = 1.0; 523 | } 524 | 525 | let virtualWallPoints = this.getVirtualWallPoints(attributes); 526 | if (virtualWallPoints && this._config.show_virtual_walls && this._config.virtual_wall_width > 0) { 527 | mapCtx.globalAlpha = this._config.virtual_wall_opacity; 528 | 529 | mapCtx.strokeStyle = virtualWallColor; 530 | mapCtx.lineWidth = this._config.virtual_wall_width; 531 | mapCtx.beginPath(); 532 | for (let item of virtualWallPoints) { 533 | let fromX = Math.floor(item["points"][0] / widthScale) - objectLeftOffset - mapLeftOffset; 534 | let fromY = Math.floor(item["points"][1] / heightScale) - objectTopOffset - mapTopOffset; 535 | let toX = Math.floor(item["points"][2] / widthScale) - objectLeftOffset - mapLeftOffset; 536 | let toY = Math.floor(item["points"][3] / heightScale) - objectTopOffset - mapTopOffset; 537 | if (this.isOutsideBounds(fromX, fromY, drawnMapCanvas, this._config)) { 538 | continue; 539 | } 540 | if (this.isOutsideBounds(toX, toY, drawnMapCanvas, this._config)) { 541 | continue; 542 | } 543 | mapCtx.moveTo(fromX, fromY); 544 | mapCtx.lineTo(toX, toY); 545 | mapCtx.stroke(); 546 | } 547 | 548 | mapCtx.globalAlpha = 1; 549 | } 550 | 551 | const pathCtx = pathCanvas.getContext("2d")!; 552 | pathCtx.globalAlpha = this._config.path_opacity; 553 | pathCtx.strokeStyle = pathColor; 554 | pathCtx.lineWidth = this._config.path_width; 555 | 556 | let pathPoints = this.getPathPoints(attributes); 557 | if (Array.isArray(pathPoints) && pathPoints.length > 0 && (this._config.show_path && this._config.path_width > 0)) { 558 | for (let item of pathPoints) { 559 | let x = 0; 560 | let y = 0; 561 | let first = true; 562 | pathCtx.beginPath(); 563 | for (let i = 0; i < item.points.length; i+=2) { 564 | x = Math.floor((item.points[i]) / widthScale) - objectLeftOffset - mapLeftOffset; 565 | y = Math.floor((item.points[i + 1]) / heightScale) - objectTopOffset - mapTopOffset; 566 | if (this.isOutsideBounds(x, y, drawnMapCanvas, this._config)) { 567 | continue; 568 | } 569 | if (first) { 570 | pathCtx.moveTo(x, y); 571 | first = false; 572 | } else { 573 | pathCtx.lineTo(x, y); 574 | } 575 | } 576 | pathCtx.stroke(); 577 | } 578 | 579 | if (robotInfo) { 580 | // Update vacuum angle 581 | vacuumHTML.style.transform = `scale(${this._config.icon_scale}, ${this._config.icon_scale}) rotate(${robotInfo[2]}deg)`; 582 | } 583 | 584 | pathCtx.globalAlpha = 1; 585 | } 586 | 587 | let predictedPathPoints = this.getPredictedPathPoints(attributes); 588 | if (Array.isArray(predictedPathPoints) && predictedPathPoints.length > 0 && (this._config.show_predicted_path && this._config.path_width > 0)) { 589 | pathCtx.setLineDash([5,3]); 590 | for (let item of predictedPathPoints) { 591 | let x = 0; 592 | let y = 0; 593 | let first = true; 594 | pathCtx.beginPath(); 595 | for (let i = 0; i < item.points.length; i+=2) { 596 | x = Math.floor((item.points[i]) / widthScale) - objectLeftOffset - mapLeftOffset; 597 | y = Math.floor((item.points[i + 1]) / heightScale) - objectTopOffset - mapTopOffset; 598 | if (this.isOutsideBounds(x, y, drawnMapCanvas, this._config)) { 599 | continue; 600 | } 601 | if (first) { 602 | pathCtx.moveTo(x, y); 603 | first = false; 604 | } else { 605 | pathCtx.lineTo(x, y); 606 | } 607 | } 608 | pathCtx.stroke(); 609 | } 610 | 611 | pathCtx.globalAlpha = 1; 612 | } 613 | 614 | // Put our newly generated map in there 615 | this.clearContainer(this.mapContainer); 616 | this.mapContainer.appendChild(containerContainer); 617 | } 618 | 619 | clearContainer(container: Element) { 620 | while (container.firstChild) { 621 | container.firstChild.remove(); 622 | } 623 | } 624 | 625 | drawControls(infoEntity: HassEntity) { 626 | // Start drawing controls 627 | this.drawingControls = true; 628 | 629 | this.infoBox = document.createElement("div"); 630 | this.infoBox.classList.add("flex-box"); 631 | 632 | if (infoEntity && infoEntity.state && this._config.show_status) { 633 | const statusInfo = document.createElement("p"); 634 | statusInfo.innerHTML = infoEntity.state[0].toUpperCase() + infoEntity.state.substring(1); 635 | this.infoBox.appendChild(statusInfo); 636 | } 637 | 638 | if (infoEntity && infoEntity.attributes && infoEntity.attributes.battery_icon && infoEntity.attributes.battery_level && this._config.show_battery_level) { 639 | const batteryData = document.createElement("div"); 640 | batteryData.style.display = "flex"; 641 | batteryData.style.alignItems = "center"; 642 | const batteryIcon = document.createElement("ha-icon"); 643 | const batteryText = document.createElement("span"); 644 | batteryIcon.icon = infoEntity.attributes.battery_icon; 645 | batteryText.innerHTML = " " + infoEntity.attributes.battery_level + " %"; 646 | batteryData.appendChild(batteryIcon); 647 | batteryData.appendChild(batteryText); 648 | this.infoBox.appendChild(batteryData); 649 | } 650 | 651 | this.controlFlexBox = document.createElement("div"); 652 | this.controlFlexBox.classList.add("flex-box"); 653 | 654 | // Create controls 655 | if (this._config.show_start_button && this.shouldDisplayButton("start", infoEntity.state)) { 656 | const startButton = document.createElement("paper-button"); 657 | const startIcon = document.createElement("ha-icon"); 658 | const startRipple = document.createElement("paper-ripple"); 659 | startIcon.icon = "mdi:play"; 660 | startButton.appendChild(startIcon); 661 | startButton.appendChild(startRipple); 662 | startButton.addEventListener("click", (event) => { 663 | this._hass.callService("vacuum", "start", { entity_id: this.getVacuumEntityName(this._config.vacuum) }).then(); 664 | }); 665 | this.controlFlexBox.appendChild(startButton); 666 | } 667 | 668 | if (this._config.show_pause_button && this.shouldDisplayButton("pause", infoEntity.state)) { 669 | const pauseButton = document.createElement("paper-button"); 670 | const pauseIcon = document.createElement("ha-icon"); 671 | const pauseRipple = document.createElement("paper-ripple"); 672 | pauseIcon.icon = "mdi:pause"; 673 | pauseButton.appendChild(pauseIcon); 674 | pauseButton.appendChild(pauseRipple); 675 | pauseButton.addEventListener("click", (event) => { 676 | this._hass.callService("vacuum", "pause", { entity_id: this.getVacuumEntityName(this._config.vacuum) }).then(); 677 | }); 678 | this.controlFlexBox.appendChild(pauseButton); 679 | } 680 | 681 | if (this._config.show_stop_button && this.shouldDisplayButton("stop", infoEntity.state)) { 682 | const stopButton = document.createElement("paper-button"); 683 | const stopIcon = document.createElement("ha-icon"); 684 | const stopRipple = document.createElement("paper-ripple"); 685 | stopIcon.icon = "mdi:stop"; 686 | stopButton.appendChild(stopIcon); 687 | stopButton.appendChild(stopRipple); 688 | stopButton.addEventListener("click", (event) => { 689 | this._hass.callService("vacuum", "stop", { entity_id: this.getVacuumEntityName(this._config.vacuum) }).then(); 690 | }); 691 | this.controlFlexBox.appendChild(stopButton); 692 | } 693 | 694 | if (this._config.show_home_button && this.shouldDisplayButton("home", infoEntity.state)) { 695 | const homeButton = document.createElement("paper-button"); 696 | const homeIcon = document.createElement("ha-icon"); 697 | const homeRipple = document.createElement("paper-ripple"); 698 | homeIcon.icon = "hass:home-map-marker"; 699 | homeButton.appendChild(homeIcon); 700 | homeButton.appendChild(homeRipple); 701 | homeButton.addEventListener("click", (event) => { 702 | this._hass.callService("vacuum", "return_to_base", { entity_id: this.getVacuumEntityName(this._config.vacuum) }).then(); 703 | }); 704 | this.controlFlexBox.appendChild(homeButton); 705 | } 706 | 707 | if (this._config.show_locate_button) { 708 | const locateButton = document.createElement("paper-button"); 709 | const locateIcon = document.createElement("ha-icon"); 710 | const locateRipple = document.createElement("paper-ripple"); 711 | locateIcon.icon = "hass:map-marker"; 712 | locateButton.appendChild(locateIcon); 713 | locateButton.appendChild(locateRipple); 714 | locateButton.addEventListener("click", (event) => { 715 | this._hass.callService("vacuum", "locate", { entity_id: this.getVacuumEntityName(this._config.vacuum) }).then(); 716 | }); 717 | this.controlFlexBox.appendChild(locateButton); 718 | } 719 | 720 | this.customControlFlexBox = document.createElement("div"); 721 | this.customControlFlexBox.classList.add("flex-box"); 722 | 723 | 724 | this._config.custom_buttons.forEach(buttonConfig => { 725 | if (buttonConfig === Object(buttonConfig) && buttonConfig.service) { 726 | const customButton = document.createElement("paper-button"); 727 | const customButtonIcon = document.createElement("ha-icon"); 728 | const customButtonRipple = document.createElement("paper-ripple"); 729 | 730 | customButtonIcon.icon = buttonConfig["icon"] || "mdi:radiobox-blank"; 731 | customButton.appendChild(customButtonIcon); 732 | 733 | if (buttonConfig.text) { 734 | const customButtonText = document.createElement("span"); 735 | customButtonText.textContent = buttonConfig.text; 736 | customButton.appendChild(customButtonText); 737 | } 738 | 739 | customButton.appendChild(customButtonRipple); 740 | 741 | customButton.addEventListener("click", (event) => { 742 | const args = buttonConfig["service"].split("."); 743 | if (buttonConfig.service_data) { 744 | this._hass.callService(args[0], args[1], buttonConfig.service_data).then(); 745 | } else { 746 | this._hass.callService(args[0], args[1]).then(); 747 | } 748 | }); 749 | 750 | this.customControlFlexBox.appendChild(customButton); 751 | } 752 | }); 753 | 754 | // Replace existing controls 755 | this.clearContainer(this.controlContainer); 756 | this.controlContainer.append(this.infoBox); 757 | this.controlContainer.append(this.controlFlexBox); 758 | this.controlContainer.append(this.customControlFlexBox); 759 | 760 | // Done drawing controls 761 | this.lastUpdatedControls = infoEntity.last_updated; 762 | this.drawingControls = false; 763 | } 764 | 765 | // noinspection JSUnusedGlobalSymbols 766 | setConfig(config: Configuration) { 767 | this._config = Object.assign( 768 | {}, 769 | DEFAULT_CARD_CONFIG, 770 | config 771 | ); 772 | 773 | if (typeof this._config.vacuum === "string") { 774 | this._config.vacuum = this._config.vacuum.toLowerCase(); 775 | } 776 | 777 | 778 | /* More default stuff */ 779 | 780 | // Rotation settings 781 | if (this._config.rotate === undefined) { 782 | this._config.rotate = 0; 783 | } 784 | if (Number(this._config.rotate)) { 785 | this._config.rotate = `${this._config.rotate}deg`; 786 | } 787 | 788 | // Crop settings 789 | if (this._config.crop !== Object(this._config.crop)) { 790 | this._config.crop = {} as CropConfig; 791 | } 792 | if (this._config.crop.top === undefined) { 793 | this._config.crop.top = 0; 794 | } 795 | if (this._config.crop.bottom === undefined) { 796 | this._config.crop.bottom = 0; 797 | } 798 | if (this._config.crop.left === undefined) { 799 | this._config.crop.left = 0; 800 | } 801 | if (this._config.crop.right === undefined) { 802 | this._config.crop.right = 0; 803 | } 804 | 805 | /* End more default stuff */ 806 | 807 | 808 | // Set card title and hide the header completely if the title is set to an empty value 809 | this.cardHeader.style.display = !this._config.title ? "none" : "block"; 810 | this.cardTitle.textContent = this._config.title; 811 | 812 | // Set container card background color 813 | this.cardContainer.style.background = this._config.background_color ?? null; 814 | 815 | if (!Array.isArray(this._config.custom_buttons)) { 816 | this._config.custom_buttons = []; 817 | } 818 | } 819 | 820 | // noinspection JSUnusedGlobalSymbols 821 | set hass(hass: HomeAssistant) { 822 | if (hass === undefined) { 823 | // Home Assistant 0.110.0 may call this function with undefined sometimes if inside another card 824 | return; 825 | } 826 | 827 | this._hass = hass; 828 | 829 | let mapEntity = this.getMapEntity(this._config.vacuum); 830 | let vacuumEntity = this.getVacuumEntity(this._config.vacuum); 831 | let shouldForcePoll = false; 832 | 833 | let attributes = mapEntity ? mapEntity.attributes : undefined; 834 | 835 | if (vacuumEntity && vacuumEntity.state !== this.lastRobotState) { 836 | this.pollInterval = POLL_INTERVAL_STATE_MAP[vacuumEntity.state] || 10000; 837 | 838 | shouldForcePoll = true; 839 | this.lastRobotState = vacuumEntity.state; 840 | } 841 | 842 | if (mapEntity && mapEntity["state"] !== "unavailable" && attributes?.["entity_picture"]) { 843 | if (new Date().getTime() - this.pollInterval > this.lastMapPoll.getTime() || shouldForcePoll) { 844 | this.loadImageAndExtractMapData(attributes["entity_picture"]).then(mapData => { 845 | if (mapData !== null) { 846 | this.handleDrawing(hass, mapEntity, mapData); 847 | } 848 | }).catch(e => { 849 | this.handleDrawing(hass, mapEntity,{} as RawMapData); 850 | 851 | console.warn(e); 852 | }).finally(() => { 853 | this.lastMapPoll = new Date(); 854 | }); 855 | } 856 | } else { 857 | this.clearContainer(this.mapContainer); 858 | this.clearContainer(this.controlContainer); 859 | 860 | this.entityWarning1.textContent = `Entity not available: ${this.getMapEntityName(this._config.vacuum)}`; 861 | this.entityWarning1.style.display = "block"; 862 | this.entityWarning2.style.display = "none"; 863 | } 864 | } 865 | 866 | async loadImageAndExtractMapData(url: string): Promise { 867 | if (this.isPollingMap === false ) { 868 | this.isPollingMap = true; 869 | 870 | const response = await this._hass.fetchWithAuth(url); 871 | let mapData; 872 | 873 | if (!response.ok) { 874 | throw new Error("Got error while fetching image " + response.status + " - " + response.statusText); 875 | } 876 | const responseData = await response.arrayBuffer(); 877 | 878 | const chunks = extractZtxtPngChunks(new Uint8Array(responseData)).filter(c => { 879 | return c.keyword === "ValetudoMap"; 880 | }); 881 | 882 | if (chunks.length < 1) { 883 | throw new Error("No map data found in image"); 884 | } 885 | 886 | 887 | mapData = pako.inflate(chunks[0].data, { to: "string" }); 888 | mapData = JSON.parse(mapData); 889 | 890 | mapData = preprocessMap(mapData); 891 | 892 | this.isPollingMap = false; 893 | return mapData; 894 | } else { 895 | return null; 896 | } 897 | } 898 | 899 | shouldDisplayButton(buttonName: string, vacuumState: unknown) { 900 | switch (vacuumState) { 901 | case "on": 902 | case "auto": 903 | case "spot": 904 | case "edge": 905 | case "single_room": 906 | case "cleaning": { 907 | return buttonName === "pause" || buttonName === "stop" || buttonName === "home"; 908 | } 909 | 910 | case "returning": { 911 | return buttonName === "start" || buttonName === "pause"; 912 | } 913 | 914 | case "docked": { 915 | return buttonName === "start"; 916 | } 917 | 918 | case "idle": 919 | case "paused": 920 | default: { 921 | return buttonName === "start" || buttonName === "home"; 922 | } 923 | } 924 | } 925 | 926 | 927 | handleDrawing(hass: HomeAssistant, mapEntity: HassEntity, attributes: RawMapData) { 928 | let infoEntity = this.getVacuumEntity(this._config.vacuum); 929 | 930 | let canDrawMap = false; 931 | let canDrawControls = true; 932 | 933 | if (attributes.__class === "ValetudoMap") { 934 | canDrawMap = true; 935 | } 936 | 937 | if (!infoEntity || infoEntity["state"] === "unavailable" || !infoEntity.attributes) { 938 | canDrawControls = false; 939 | // Reset last-updated to redraw as soon as element becomes available 940 | this.lastUpdatedControls = ""; 941 | } 942 | 943 | // Remove the map 944 | this.mapContainer.style.display = (!canDrawMap || !this._config.show_map) ? "none" : "block"; 945 | 946 | if (!canDrawMap && this._config.show_map) { 947 | // Show the warning 948 | this.entityWarning1.textContent = `Entity not available: ${this.getMapEntityName(this._config.vacuum)}`; 949 | this.entityWarning1.style.display = "block"; 950 | } else { 951 | this.entityWarning1.style.display = "none"; 952 | } 953 | 954 | if (!canDrawControls) { 955 | // Remove the controls 956 | this.controlContainer.style.display = "none"; 957 | 958 | // Show the warning 959 | this.entityWarning2.textContent = `Entity not available: ${this.getVacuumEntityName(this._config.vacuum)}`; 960 | this.entityWarning2.style.display = "block"; 961 | } else { 962 | this.entityWarning2.style.display = "none"; 963 | this.controlContainer.style.display = "block"; 964 | } 965 | 966 | if (canDrawMap) { 967 | // Calculate map height and width 968 | let width: number; 969 | let height: number; 970 | 971 | let boundingBox: BoundingBox = { 972 | minX: attributes.size.x / attributes.pixelSize, 973 | minY: attributes.size.y / attributes.pixelSize, 974 | maxX: 0, 975 | maxY: 0 976 | }; 977 | 978 | attributes.layers.forEach(l => { 979 | if (l.dimensions.x.min < boundingBox.minX) { 980 | boundingBox.minX = l.dimensions.x.min; 981 | } 982 | if (l.dimensions.y.min < boundingBox.minY) { 983 | boundingBox.minY = l.dimensions.y.min; 984 | } 985 | if (l.dimensions.x.max > boundingBox.maxX) { 986 | boundingBox.maxX = l.dimensions.x.max; 987 | } 988 | if (l.dimensions.y.max > boundingBox.maxY) { 989 | boundingBox.maxY = l.dimensions.y.max; 990 | } 991 | }); 992 | 993 | width = (boundingBox.maxX - boundingBox.minX) + 2; 994 | height = (boundingBox.maxY - boundingBox.minY) + 2; 995 | 996 | const mapWidth = width - this._config.crop.right; 997 | const mapHeight = height - this._config.crop.bottom; 998 | 999 | // Calculate desired container height 1000 | let containerHeight = (mapHeight * this._config.map_scale) - this._config.crop.top; 1001 | let minHeight = Number(this._config.min_height); 1002 | 1003 | // Want height based on container width 1004 | if (typeof this._config.min_height === 'string' && this._config.min_height.endsWith("w")) { 1005 | minHeight = Number(this._config.min_height.slice(0, -1)) * this.mapContainer.offsetWidth; 1006 | } 1007 | 1008 | let containerMinHeightPadding = minHeight > containerHeight ? (minHeight - containerHeight) / 2 : 0; 1009 | 1010 | // Set container CSS 1011 | this.mapContainerStyle.textContent = ` 1012 | #lovelaceValetudoMapCard { 1013 | height: ${containerHeight}px; 1014 | padding-top: ${containerMinHeightPadding}px; 1015 | padding-bottom: ${containerMinHeightPadding}px; 1016 | padding-left: ${this._config.left_padding}px; 1017 | overflow: hidden; 1018 | } 1019 | #lovelaceValetudoCard { 1020 | position: relative; 1021 | margin-left: auto; 1022 | margin-right: auto; 1023 | width: ${mapWidth * this._config.map_scale}px; 1024 | height: ${mapHeight * this._config.map_scale}px; 1025 | transform: rotate(${this._config.rotate}); 1026 | top: -${this._config.crop.top}px; 1027 | left: -${this._config.crop.left}px; 1028 | } 1029 | #lovelaceValetudoCard div { 1030 | position: absolute; 1031 | background-color: transparent; 1032 | width: 100%; 1033 | height: 100%; 1034 | } 1035 | `; 1036 | 1037 | if (this.shouldDrawMap() && this._config.show_map) { 1038 | // Start drawing map 1039 | this.drawingMap = true; 1040 | 1041 | this.drawMap( 1042 | attributes, 1043 | mapHeight, 1044 | mapWidth, 1045 | boundingBox 1046 | ); 1047 | 1048 | this.drawingMap = false; 1049 | } 1050 | } 1051 | 1052 | // Draw status and controls 1053 | if (canDrawControls) { 1054 | // Set control container CSS 1055 | this.controlContainerStyle.textContent = ` 1056 | .flex-box { 1057 | display: flex; 1058 | justify-content: space-evenly; 1059 | flex-wrap: wrap; 1060 | } 1061 | paper-button { 1062 | cursor: pointer; 1063 | position: relative; 1064 | display: inline-flex; 1065 | align-items: center; 1066 | padding: 8px; 1067 | } 1068 | ha-icon { 1069 | width: 24px; 1070 | height: 24px; 1071 | } 1072 | `; 1073 | 1074 | let infoEntity = this.getVacuumEntity(this._config.vacuum); 1075 | if (this.shouldDrawControls(infoEntity)) { 1076 | this.drawControls(infoEntity); 1077 | } 1078 | } 1079 | } 1080 | 1081 | // noinspection JSUnusedGlobalSymbols 1082 | getCardSize() { 1083 | return 1; 1084 | } 1085 | } 1086 | 1087 | declare global { 1088 | // eslint-disable-next-line no-unused-vars 1089 | interface Window { 1090 | customCards: { type: string, name: string, preview?: boolean, description?: string }[]; 1091 | } 1092 | 1093 | // eslint-disable-next-line no-unused-vars 1094 | interface HTMLElementTagNameMap { 1095 | "ha-icon": HaIconElement; 1096 | } 1097 | } 1098 | 1099 | let componentName = "valetudo-map-card"; 1100 | if (!customElements.get(componentName)) { 1101 | customElements.define(componentName, ValetudoMapCard); 1102 | 1103 | window.customCards = window.customCards || []; 1104 | window.customCards.push({ 1105 | type: componentName, 1106 | name: "Valetudo Map Card", 1107 | preview: false, 1108 | description: "Display the Map data of your Valetudo-enabled robot", 1109 | }); 1110 | } 1111 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "es2020", 4 | "moduleResolution": "node", 5 | "target": "es5", 6 | "resolveJsonModule": true, 7 | "allowSyntheticDefaultImports": true 8 | } 9 | } --------------------------------------------------------------------------------