├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── build-binaries.yml │ └── build-docker-images.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── cmd └── valetudopng │ └── main.go ├── config.example.yml ├── embed.go ├── go.mod ├── go.sum ├── pkg ├── config │ └── config.go ├── mqtt │ ├── consumer.go │ ├── decoder │ │ └── decoder.go │ ├── mqtt.go │ ├── producer.go │ └── tls.go ├── renderer │ ├── calibration.go │ ├── drawer.go │ ├── drawer_entities.go │ ├── drawer_layers.go │ ├── fourcolortheorem.go │ ├── json.go │ ├── renderer.go │ └── result.go └── server │ ├── http.go │ └── server.go ├── res ├── charger.png └── robot.png └── web ├── static └── js │ ├── App.js │ └── JQuery.js └── templates └── index.html.tmpl /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: erkexzcx 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots/Outputs** 24 | If applicable, add screenshots or outputs to help explain your problem. 25 | 26 | **JSON** 27 | If you believe the issue is related to JSON parsing (map data from Vacuum) - you can speed up this issue and provide us a contents (or relevant part) of `http:///api/v2/robot/state` here. **Note** that this data contains your house map. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/build-binaries.yml: -------------------------------------------------------------------------------- 1 | name: Build binaries 2 | run-name: Build binaries ${{ github.event.release.tag_name }} 3 | 4 | on: 5 | release: 6 | types: [published] 7 | 8 | jobs: 9 | build: 10 | name: Build binaries 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | platform: 15 | - linux/amd64 16 | - linux/arm64 17 | - linux/arm/v7 18 | - linux/arm/v6 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Set up Go 23 | uses: actions/setup-go@v4 24 | with: 25 | go-version: "1.21" 26 | 27 | - name: Build binary 28 | run: | 29 | export GOOS=$(echo ${{ matrix.platform }} | awk -F/ '{print $1}') 30 | export GOARCH=$(echo ${{ matrix.platform }} | awk -F/ '{print $2}') 31 | export GOARM=$(echo ${{ matrix.platform }} | awk -F/ '{print $3}' | sed 's/v//') 32 | export CGO_ENABLED=0 33 | 34 | reponame="${{ github.event.repository.name }}" 35 | version="${{ github.event.release.tag_name }}" 36 | fullarch="${GOARCH}$(echo ${{ matrix.platform }} | awk -F/ '{print $3}')" 37 | filename="${reponame}_${version}_${GOOS}_${fullarch}" 38 | 39 | go build -ldflags "-w -s -X main.version=$version -extldflags '-static'" -o "$filename" cmd/$reponame/main.go 40 | 41 | echo "FILENAME=$filename" >> $GITHUB_ENV 42 | 43 | - name: Compress binary 44 | run: | 45 | tar -czvf "${{ env.FILENAME }}.tar.gz" "${{ env.FILENAME }}" 46 | 47 | - name: Upload binary 48 | uses: svenstaro/upload-release-action@v2 49 | with: 50 | repo_token: ${{ secrets.GITHUB_TOKEN }} 51 | file: ${{ env.FILENAME }}.tar.gz 52 | tag: ${{ github.event.release.tag_name }} 53 | overwrite: false 54 | -------------------------------------------------------------------------------- /.github/workflows/build-docker-images.yml: -------------------------------------------------------------------------------- 1 | name: Build Docker images 2 | run-name: Build Docker images ${{ github.event.release.tag_name }} 3 | 4 | on: 5 | release: 6 | types: [published] 7 | 8 | jobs: 9 | build: 10 | name: Build images 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | platform: 15 | - linux/amd64 16 | - linux/arm64 17 | - linux/arm/v7 18 | - linux/arm/v6 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Set up Docker Buildx 23 | uses: docker/setup-buildx-action@v3 24 | 25 | - name: Docker meta 26 | id: meta 27 | uses: docker/metadata-action@v5 28 | with: 29 | images: ghcr.io/${{ github.repository }} 30 | 31 | - name: Login to GitHub Container Registry 32 | uses: docker/login-action@v3 33 | with: 34 | registry: ghcr.io 35 | username: ${{ github.actor }} 36 | password: ${{ secrets.GITHUB_TOKEN }} 37 | 38 | - name: Go Build Cache for Docker 39 | uses: actions/cache@v3 40 | with: 41 | path: go-build-cache 42 | key: ${{ matrix.platform }}-go-build-cache-${{ hashFiles('**/go.sum') }} 43 | 44 | - name: inject go-build-cache into docker 45 | uses: reproducible-containers/buildkit-cache-dance@v2.1.2 46 | with: 47 | cache-source: go-build-cache 48 | 49 | - name: Build and push Docker images 50 | id: build 51 | uses: docker/build-push-action@v5 52 | with: 53 | context: . 54 | platforms: ${{ matrix.platform }} 55 | labels: ${{ steps.meta.outputs.labels }} 56 | outputs: type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true 57 | cache-from: type=gha 58 | cache-to: type=gha,mode=max 59 | build-args: | 60 | version=${{ github.event.release.tag_name }} 61 | 62 | - name: Export digest 63 | run: | 64 | mkdir -p /tmp/digests 65 | digest="${{ steps.build.outputs.digest }}" 66 | touch "/tmp/digests/${digest#sha256:}" 67 | 68 | - name: Upload digest 69 | uses: actions/upload-artifact@v3 70 | with: 71 | name: digests 72 | path: /tmp/digests/* 73 | if-no-files-found: error 74 | retention-days: 1 75 | 76 | merge: 77 | name: Merge images 78 | runs-on: ubuntu-latest 79 | needs: build 80 | steps: 81 | - name: Download digests 82 | uses: actions/download-artifact@v3 83 | with: 84 | name: digests 85 | path: /tmp/digests 86 | 87 | - name: Set up Docker Buildx 88 | uses: docker/setup-buildx-action@v3 89 | 90 | - name: Docker meta 91 | id: meta 92 | uses: docker/metadata-action@v5 93 | with: 94 | images: ghcr.io/${{ github.repository }} 95 | 96 | - name: Login to GitHub Container Registry 97 | uses: docker/login-action@v3 98 | with: 99 | registry: ghcr.io 100 | username: ${{ github.actor }} 101 | password: ${{ secrets.GITHUB_TOKEN }} 102 | 103 | - name: Create manifest list and push 104 | working-directory: /tmp/digests 105 | run: | 106 | docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ 107 | $(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *) 108 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /config.yml 2 | /config.yaml 3 | 4 | /image.png 5 | 6 | /valetudopng 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM golang:1.21-alpine as builder 2 | WORKDIR /app 3 | COPY go.mod go.sum ./ 4 | RUN go mod download 5 | COPY . . 6 | ARG TARGETOS 7 | ARG TARGETARCH 8 | ARG TARGETVARIANT 9 | ARG version 10 | RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH GOARM=${TARGETVARIANT#v} go build -a -ldflags "-w -s -X main.version=$version -extldflags '-static'" -o valetudopng ./cmd/valetudopng/main.go 11 | 12 | FROM scratch 13 | COPY --from=builder /etc/ssl/cert.pem /etc/ssl/ 14 | COPY --from=builder /app/valetudopng /valetudopng 15 | ENTRYPOINT ["/valetudopng"] 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DISCLAIMER 2 | 3 | I've purchased Roborock S8 MaxV Ultra model, which has no Valetudo support (and which I will not flash/root any time soon). This means I will not have a way to test or verify if this application still works... 4 | 5 | # ValetudoPNG 6 | 7 | ValetudoPNG is a service designed to render map from Valetudo-enabled vacuum robot into a more accessible PNG format. This PNG map is sent to Home Assistant via MQTT, where it can be viewed as a real-time camera feed. ValetudoPNG was specifically developed to integrate with third-party frontend cards, such as the [PiotrMachowski/lovelace-xiaomi-vacuum-map-card](https://github.com/PiotrMachowski/lovelace-xiaomi-vacuum-map-card). 8 | 9 | Alternative projects: 10 | * [sca075/valetudo_vacuum_camera](https://github.com/sca075/valetudo_vacuum_camera) - deploys as HACS addon, written in Python. 11 | * [alexkn/node-red-contrib-valetudo](https://github.com/alexkn/node-red-contrib-valetudo) - nodered map renderer. 12 | 13 | Broken or dead projects: 14 | * [Hypfer/ICantBelieveItsNotValetudo](https://github.com/Hypfer/ICantBelieveItsNotValetudo) - original project, written in javascript for NodeJS. 15 | * [rand256/valetudo-mapper](https://github.com/rand256/valetudo-mapper) - fork of original project to be used with [rand256/valetudo](https://github.com/rand256/valetudo). Does not work with [Hypfer/Valetudo](https://github.com/Hypfer/Valetudo). 16 | 17 | # Features 18 | 19 | * Written in Go. 20 | * Single binary 21 | * No dependencies 22 | * Fast & multithreaded rendering 23 | * Pre-built Docker images. 24 | * Automatic map calibration data for [PiotrMachowski/lovelace-xiaomi-vacuum-map-card](https://github.com/PiotrMachowski/lovelace-xiaomi-vacuum-map-card). 25 | * Easy configuration using `yaml` config file. 26 | * Map modification: 27 | * Rotation 28 | * Scaling 29 | * "croping" by binding map to coordinates in robot's coordinates system 30 | * HTTP endpoint: 31 | * Access image `http://ip:port/api/map/image`. 32 | * Debug image and it's coordinates/pixels in robot's coordinates system `http://ip:port/api/map/image/debug`. 33 | * Designed to work with HomeAssistant in mind. 34 | 35 | Supported architectures: 36 | 37 | - **linux/amd64**: x86_64, Intel 64, AMD64, 64-bit PC architecture 38 | - **linux/arm64**: aarch64, armv8, ARM 64-bit 39 | - **linux/arm/v7**: armv7l, armv7-a, ARM 32-bit version 7 40 | - **linux/arm/v6**: armv6l, armv6-a, ARM 32-bit version 6 41 | 42 | # Get started 43 | 44 | ## Configure Valetudo 45 | 46 | It is assumed that Valetudo is connected to Home Assistant via MQTT and is working. 47 | 48 | Go to Valetudo URL -> Connectivity -> MQTT Connectivity -> Customizations. Make sure `Provide map data` is enabled. 49 | 50 | ## Configuration file 51 | 52 | Create `config.yml` file out of `config.example.yml` file and update according. 53 | 54 | For starters, assuming that you don't have TLS and username/password set in your MQTT server, you can update only these for now: 55 | ```yaml 56 | host: 192.168.0.123 57 | port: 1883 58 | ``` 59 | and these: 60 | ```yaml 61 | # Should match "Topic prefix" in Valetudo MQTT settings 62 | valetudo_prefix: valetudo 63 | 64 | # Should match "Identifier" in Valetudo MQTT settings 65 | valetudo_identifier: rockrobo 66 | ``` 67 | 68 | Now move to installation and usage sections, where you will be able to easily "experiment" with your config. 69 | 70 | ## Installation 71 | 72 | ### Binaries 73 | 74 | See [Releases](https://github.com/erkexzcx/valetudopng/releases). 75 | 76 | ```bash 77 | $ tar -xvzf valetudopng_v1.0.0_linux_amd64.tar.gz 78 | valetudopng_v1.0.0_linux_amd64 79 | $ ./valetudopng_v1.0.0_linux_amd64 --help 80 | Usage of ./valetudopng_v1.0.0_linux_amd64: 81 | -config string 82 | Path to configuration file (default "config.yml") 83 | -version 84 | prints version of the application 85 | ``` 86 | 87 | You can technically install it on robot itself: 88 | ``` 89 | [root@rockrobo ~]# grep -e scale config.yml 90 | scale: 2 91 | [root@rockrobo ~]# ./valetudopng 92 | 2023/10/02 07:18:10 [MQTT producer] Connected 93 | 2023/10/02 07:18:10 [MQTT consumer] Connected 94 | 2023/10/02 07:18:10 [MQTT consumer] Subscribed to map data topic 95 | 2023/10/02 07:18:10 Image rendered! drawing:39ms, encoding:61ms, size:9.1kB 96 | 2023/10/02 07:18:16 Image rendered! drawing:37ms, encoding:72ms, size:9.1kB 97 | 2023/10/02 07:18:16 Image rendered! drawing:35ms, encoding:66ms, size:9.1kB 98 | 2023/10/02 07:18:17 Image rendered! drawing:44ms, encoding:50ms, size:7.4kB 99 | 2023/10/02 07:18:18 Image rendered! drawing:33ms, encoding:54ms, size:7.4kB 100 | 2023/10/02 07:18:20 Image rendered! drawing:34ms, encoding:52ms, size:7.4kB 101 | 2023/10/02 07:18:22 Image rendered! drawing:34ms, encoding:61ms, size:7.4kB 102 | 2023/10/02 07:18:24 Image rendered! drawing:32ms, encoding:56ms, size:7.7kB 103 | 2023/10/02 07:18:26 Image rendered! drawing:45ms, encoding:62ms, size:7.8kB 104 | 2023/10/02 07:18:28 Image rendered! drawing:33ms, encoding:64ms, size:7.8kB 105 | 2023/10/02 07:18:30 Image rendered! drawing:44ms, encoding:59ms, size:8.0kB 106 | 2023/10/02 07:18:32 Image rendered! drawing:38ms, encoding:62ms, size:8.2kB 107 | 2023/10/02 07:18:35 Image rendered! drawing:88ms, encoding:54ms, size:8.3kB 108 | 2023/10/02 07:18:36 Image rendered! drawing:35ms, encoding:72ms, size:8.4kB 109 | ``` 110 | Download binary appropriate for your robot's CPU and follow the service installation guidelines of another project: https://github.com/porech/roborock-oucher 111 | 112 | Note that this service is still resources-intensive and drains more battery when robot is not charging. Generally it is not recommended to host it on robot. 113 | 114 | ### Docker compose 115 | 116 | ```yaml 117 | valetudopng: 118 | image: ghcr.io/erkexzcx/valetudopng:latest 119 | container_name: valetudopng 120 | restart: always 121 | volumes: 122 | - ./valetudopng/config.yml:/config.yml 123 | ports: 124 | - "3000:3000" 125 | ``` 126 | 127 | ### Docker CLI 128 | 129 | ``` 130 | docker run -d \ 131 | --restart=always \ 132 | --name=valetudopng \ 133 | -v $(pwd)/valetudopng/config.yml:/config.yml \ 134 | -p 3000:3000 \ 135 | ghcr.io/erkexzcx/valetudopng:latest 136 | ``` 137 | 138 | ## Usage 139 | 140 | When hosted, go to `http://ip:port/api/map/image/debug` and start selecting rectangles. Below the picture there will be information that you will want to copy/paste. 141 | 142 | For example, this is how my [PiotrMachowski/lovelace-xiaomi-vacuum-map-card](https://github.com/PiotrMachowski/lovelace-xiaomi-vacuum-map-card) card looks like with `valetudo_prefix: valetudo` and `valetudo_identifier: rockrobo` and RockRobo S5 vacuum: 143 | 144 | ```yaml 145 | type: custom:xiaomi-vacuum-map-card 146 | map_source: 147 | camera: camera.rockrobo_rendered_map 148 | calibration_source: 149 | entity: sensor.rockrobo_calibration_data 150 | entity: vacuum.valetudo_rockrobo 151 | vacuum_platform: Hypfer/Valetudo 152 | internal_variables: 153 | topic: valetudo/rockrobo 154 | map_modes: 155 | - template: vacuum_clean_zone_predefined 156 | selection_type: PREDEFINED_RECTANGLE 157 | predefined_selections: 158 | - zones: [[2185,2975,2310,3090]] 159 | label: 160 | text: Entrance 161 | x: 2247.5 162 | y: 3032.5 163 | offset_y: 28 164 | icon: 165 | name: mdi:door 166 | x: 2247.5 167 | y: 3032.5 168 | - template: vacuum_goto 169 | - template: vacuum_clean_zone 170 | map_locked: true 171 | two_finger_pan: false 172 | ``` 173 | -------------------------------------------------------------------------------- /cmd/valetudopng/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/erkexzcx/valetudopng/pkg/config" 9 | "github.com/erkexzcx/valetudopng/pkg/server" 10 | ) 11 | 12 | var ( 13 | version string 14 | 15 | flagConfigFile = flag.String("config", "config.yml", "Path to configuration file") 16 | flagVersion = flag.Bool("version", false, "prints version of the application") 17 | ) 18 | 19 | func main() { 20 | flag.Parse() 21 | 22 | if *flagVersion { 23 | fmt.Println("Version:", version) 24 | return 25 | } 26 | 27 | c, err := config.NewConfig(*flagConfigFile) 28 | if err != nil { 29 | log.Fatalln("Failed to read configuration file:", err) 30 | } 31 | 32 | server.Start(c) 33 | } 34 | -------------------------------------------------------------------------------- /config.example.yml: -------------------------------------------------------------------------------- 1 | mqtt: 2 | connection: 3 | # Configure connection to your MQTT server 4 | host: 192.168.0.123 5 | port: 1883 6 | 7 | # Will become valetudopng_consumer and valetudopng_producer 8 | client_id_prefix: valetudopng 9 | 10 | # Leave empty or delete these fields if authorization is not used 11 | username: 12 | password: 13 | 14 | # Leave empty or delete these fields if TLS is not used 15 | tls_enabled: false 16 | tls_min_version: # Available values are 1.0, 1.1, 1.2 and 1.3. Defaults to Go's default (1.2) if not set. 17 | tls_ca_path: 18 | tls_insecure: false 19 | 20 | topics: 21 | # Should match "Topic prefix" in Valetudo MQTT settings 22 | valetudo_prefix: valetudo 23 | 24 | # Should match "Identifier" in Valetudo MQTT settings 25 | valetudo_identifier: rockrobo 26 | 27 | # Home assistant autoconf topic prefix 28 | # Do not change unless you know what you are doing 29 | ha_autoconf_prefix: homeassistant 30 | 31 | # Leave this set to false... 32 | # No idea about the use of this, but it sends image to MQTT 33 | # encoded in base64. 34 | image_as_base64: false 35 | 36 | # Access image via HTTP: /api/map/image 37 | # Also needed to access /api/map/image/debug 38 | http: 39 | enabled: true 40 | bind: 0.0.0.0:3000 41 | 42 | map: 43 | # Do not render map more than once within below specified interval 44 | min_refresh_int: 5000ms 45 | 46 | # Specify compression level for Golang's PNG library: 47 | # 0 - Best speed 48 | # 1 - Best compression 49 | # 2 - Default compression 50 | # 3 - No compression 51 | png_compression: 0 52 | 53 | # 4 is default 54 | scale: 4 55 | 56 | # Rotate clockwise this amount of times. 57 | # 0 - no rotation 58 | # 1 - 90 clockwise 59 | # 2 - 180 clockwise 60 | # 3 - 270 clockwise 61 | rotate: 0 62 | 63 | # Set map size within robot's coordinates system, or leave 64 | # empty to make map fully dynamic. This is useful if vacuum 65 | # has seen outside through your entrance door, or just seen a 66 | # mirror and draws non-existent areas. Crop it once and for 67 | # good this way. 68 | # 69 | # For below coordinates in robot's coordinate system, visit 70 | # http://:/api/map/image/debug 71 | # 72 | custom_limits: 73 | start_x: 74 | start_y: 75 | end_x: 76 | end_y: 77 | 78 | # You can customize map colors with these 79 | colors: 80 | floor: "#0076ff" 81 | obstacle: "#5d5d5d" 82 | path: "#ffffff" 83 | no_go_area: "#ff00004a" 84 | virtual_wall: "#ff0000bf" 85 | segments: 86 | - "#19a1a1" 87 | - "#7ac037" 88 | - "#ff9b57" 89 | - "#f7c841" -------------------------------------------------------------------------------- /embed.go: -------------------------------------------------------------------------------- 1 | package valetudopng 2 | 3 | import "embed" 4 | 5 | //go:embed res 6 | var ResFS embed.FS 7 | 8 | //go:embed web 9 | var WebFS embed.FS 10 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/erkexzcx/valetudopng 2 | 3 | go 1.21.1 4 | 5 | require github.com/eclipse/paho.mqtt.golang v1.4.3 6 | 7 | require github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect 8 | 9 | require ( 10 | github.com/bitly/go-simplejson v0.5.1 11 | github.com/fogleman/gg v1.3.0 12 | github.com/gorilla/websocket v1.5.0 // indirect 13 | golang.org/x/image v0.12.0 14 | golang.org/x/net v0.15.0 // indirect 15 | golang.org/x/sync v0.3.0 // indirect 16 | gopkg.in/yaml.v2 v2.4.0 17 | ) 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow= 2 | github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q= 3 | github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik= 4 | github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE= 5 | github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= 6 | github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= 7 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= 8 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= 9 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 10 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 11 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 12 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 13 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 14 | golang.org/x/image v0.12.0 h1:w13vZbU4o5rKOFFR8y7M+c4A5jXDC0uXTdHYRP8X2DQ= 15 | golang.org/x/image v0.12.0/go.mod h1:Lu90jvHG7GfemOIcldsh9A2hS01ocl6oNO7ype5mEnk= 16 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 17 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 18 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 19 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 20 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 21 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 22 | golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8= 23 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 24 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 25 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 26 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 27 | golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= 28 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 29 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 30 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 31 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 32 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 33 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 34 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 35 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 36 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 37 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 38 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 39 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 40 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 41 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 42 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 43 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 44 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 45 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 46 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 47 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 48 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 49 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 50 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 51 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 52 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "time" 7 | 8 | "gopkg.in/yaml.v2" 9 | ) 10 | 11 | type MQTTConfig struct { 12 | Connection *ConnectionConfig `yaml:"connection"` 13 | Topics *TopicsConfig `yaml:"topics"` 14 | ImageAsBase64 bool `yaml:"image_as_base64"` 15 | } 16 | 17 | type HTTPConfig struct { 18 | Enabled bool `yaml:"enabled"` 19 | Bind string `yaml:"bind"` 20 | } 21 | 22 | type ConnectionConfig struct { 23 | Host string `yaml:"host"` 24 | Port string `yaml:"port"` 25 | Username string `yaml:"username"` 26 | Password string `yaml:"password"` 27 | ClientIDPrefix string `yaml:"client_id_prefix"` 28 | TLSEnabled bool `yaml:"tls_enabled"` 29 | TLSMinVersion string `yaml:"tls_min_version"` 30 | TLSCaPath string `yaml:"tls_ca_path"` 31 | TLSInsecure bool `yaml:"tls_insecure"` 32 | } 33 | 34 | type TopicsConfig struct { 35 | ValetudoPrefix string `yaml:"valetudo_prefix"` 36 | ValetudoIdentifier string `yaml:"valetudo_identifier"` 37 | HaAutoconfPrefix string `yaml:"ha_autoconf_prefix"` 38 | } 39 | 40 | type MapConfig struct { 41 | MinRefreshInt time.Duration `yaml:"min_refresh_int"` 42 | PNGCompression int `yaml:"png_compression"` 43 | Scale float64 `yaml:"scale"` 44 | RotationTimes int `yaml:"rotate"` 45 | CustomLimits struct { 46 | StartX int `yaml:"start_x"` 47 | StartY int `yaml:"start_y"` 48 | EndX int `yaml:"end_x"` 49 | EndY int `yaml:"end_y"` 50 | } `yaml:"custom_limits"` 51 | Colors struct { 52 | Floor string `yaml:"floor"` 53 | Obstacle string `yaml:"obstacle"` 54 | Path string `yaml:"path"` 55 | NoGoArea string `yaml:"no_go_area"` 56 | VirtualWall string `yaml:"virtual_wall"` 57 | Segments []string `yaml:"segments"` 58 | } `yaml:"colors"` 59 | } 60 | 61 | type Config struct { 62 | Mqtt *MQTTConfig `yaml:"mqtt"` 63 | HTTP *HTTPConfig `yaml:"http"` 64 | Map *MapConfig `yaml:"map"` 65 | } 66 | 67 | func NewConfig(configFile string) (*Config, error) { 68 | c := &Config{} 69 | 70 | yamlFile, err := os.ReadFile(configFile) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | err = yaml.Unmarshal(yamlFile, c) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | c, err = validate(c) 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | return setDefaultColors(c) 86 | } 87 | 88 | func setDefaultColors(c *Config) (*Config, error) { 89 | if c.Map.Colors.Floor == "" { 90 | c.Map.Colors.Floor = "#0076ffff" 91 | } 92 | 93 | if c.Map.Colors.Obstacle == "" { 94 | c.Map.Colors.Obstacle = "#5d5d5d" 95 | } 96 | 97 | if c.Map.Colors.Path == "" { 98 | c.Map.Colors.Path = "#ffffffff" 99 | } 100 | 101 | if c.Map.Colors.NoGoArea == "" { 102 | c.Map.Colors.NoGoArea = "#ff00004a" 103 | } 104 | 105 | if c.Map.Colors.VirtualWall == "" { 106 | c.Map.Colors.VirtualWall = "#ff0000bf" 107 | } 108 | 109 | if len(c.Map.Colors.Segments) < 4 { 110 | c.Map.Colors.Segments = []string{"#19a1a1ff", "#7ac037ff", "#ff9b57ff", "#f7c841ff"} 111 | } 112 | 113 | return c, nil 114 | } 115 | 116 | func validate(c *Config) (*Config, error) { 117 | // Check if any section is nil (missing) 118 | if c.Mqtt == nil { 119 | return nil, errors.New("missing mqtt section") 120 | } 121 | if c.HTTP == nil { 122 | return nil, errors.New("missing http section") 123 | } 124 | if c.Map == nil { 125 | return nil, errors.New("missing map section") 126 | } 127 | if c.Mqtt.Connection == nil { 128 | return nil, errors.New("missing mqtt.connection section") 129 | } 130 | if c.Mqtt.Topics == nil { 131 | return nil, errors.New("missing mqtt.topics section") 132 | } 133 | 134 | // Check MQTT topics section 135 | if c.Mqtt.Topics.ValetudoIdentifier == "" { 136 | return nil, errors.New("missing mqtt.topics.valetudo_identifier value") 137 | } 138 | if c.Mqtt.Topics.ValetudoPrefix == "" { 139 | return nil, errors.New("missing mqtt.topics.valetudo_prefix value") 140 | } 141 | if c.Mqtt.Topics.HaAutoconfPrefix == "" { 142 | return nil, errors.New("missing mqtt.topics.ha_autoconf_prefix value") 143 | } 144 | 145 | // Check map section 146 | if c.Map.Scale < 1 { 147 | return nil, errors.New("missing map.scale cannot be lower than 1") 148 | } 149 | if c.Map.PNGCompression < 0 || c.Map.PNGCompression > 3 { 150 | return nil, errors.New("invalid map.png_compression value") 151 | } 152 | 153 | // Everything else should fail when used (e.g. wrong IP/port will cause 154 | // fatal error when starting http server) 155 | 156 | return c, nil 157 | } 158 | -------------------------------------------------------------------------------- /pkg/mqtt/consumer.go: -------------------------------------------------------------------------------- 1 | package mqtt 2 | 3 | import ( 4 | "log" 5 | "time" 6 | 7 | mqttgo "github.com/eclipse/paho.mqtt.golang" 8 | "github.com/erkexzcx/valetudopng/pkg/config" 9 | "github.com/erkexzcx/valetudopng/pkg/mqtt/decoder" 10 | ) 11 | 12 | func startConsumer(c *config.MQTTConfig, mapJSONChan chan []byte) { 13 | opts := mqttgo.NewClientOptions() 14 | 15 | if c.Connection.TLSEnabled { 16 | opts.AddBroker("ssl://" + c.Connection.Host + ":" + c.Connection.Port) 17 | tlsConfig, err := newTLSConfig(c.Connection.TLSCaPath, c.Connection.TLSInsecure, c.Connection.TLSMinVersion) 18 | if err != nil { 19 | log.Fatalln(err) 20 | } 21 | opts.SetTLSConfig(tlsConfig) 22 | } else { 23 | opts.AddBroker("tcp://" + c.Connection.Host + ":" + c.Connection.Port) 24 | } 25 | 26 | opts.SetClientID(c.Connection.ClientIDPrefix + "_consumer") 27 | opts.SetUsername(c.Connection.Username) 28 | opts.SetPassword(c.Connection.Password) 29 | opts.SetAutoReconnect(true) 30 | 31 | // On received message 32 | var handler mqttgo.MessageHandler = func(client mqttgo.Client, msg mqttgo.Message) { 33 | consumerMapDataReceiveHandler(client, msg, mapJSONChan) 34 | } 35 | opts.SetDefaultPublishHandler(handler) 36 | 37 | // On connection 38 | opts.OnConnect = func(client mqttgo.Client) { 39 | log.Println("[MQTT consumer] Connected") 40 | token := client.Subscribe(c.Topics.ValetudoPrefix+"/"+c.Topics.ValetudoIdentifier+"/MapData/map-data", 1, nil) 41 | token.Wait() 42 | log.Println("[MQTT consumer] Subscribed to map data topic") 43 | } 44 | 45 | // On disconnection 46 | opts.OnConnectionLost = func(client mqttgo.Client, err error) { 47 | log.Printf("[MQTT consumer] Connection lost: %v", err) 48 | } 49 | 50 | // Initial connection 51 | client := mqttgo.NewClient(opts) 52 | for { 53 | if token := client.Connect(); token.Wait() && token.Error() != nil { 54 | log.Printf("[MQTT consumer] Failed to connect: %v. Retrying in 5 seconds...\n", token.Error()) 55 | time.Sleep(5 * time.Second) 56 | } else { 57 | break 58 | } 59 | } 60 | } 61 | 62 | func consumerMapDataReceiveHandler(client mqttgo.Client, msg mqttgo.Message, mapJSONChan chan []byte) { 63 | payload, err := decoder.Decode(msg.Payload()) 64 | if err != nil { 65 | log.Println("[MQTT consumer] Failed to process raw data:", err) 66 | return 67 | } 68 | mapJSONChan <- payload 69 | } 70 | -------------------------------------------------------------------------------- /pkg/mqtt/decoder/decoder.go: -------------------------------------------------------------------------------- 1 | package decoder 2 | 3 | import ( 4 | "bytes" 5 | "compress/zlib" 6 | "encoding/binary" 7 | "errors" 8 | "io" 9 | ) 10 | 11 | func Decode(payload []byte) ([]byte, error) { 12 | var err error 13 | if isPNG(payload) { 14 | payload, err = extractZtxtValetudoMapPngChunk(payload) 15 | if err != nil { 16 | err = errors.New("failed to extract Ztxt: " + err.Error()) 17 | return nil, err 18 | } 19 | } 20 | 21 | if isCompressed(payload) { 22 | payload, err = inflateSync(payload) 23 | if err != nil { 24 | err = errors.New("failed to decompress: " + err.Error()) 25 | return nil, err 26 | } 27 | } 28 | 29 | return payload, nil 30 | } 31 | 32 | func isPNG(data []byte) bool { 33 | return len(data) >= 8 && 34 | data[0] == 0x89 && 35 | data[1] == 0x50 && 36 | data[2] == 0x4E && 37 | data[3] == 0x47 && 38 | data[4] == 0x0D && 39 | data[5] == 0x0A && 40 | data[6] == 0x1A && 41 | data[7] == 0x0A 42 | } 43 | 44 | func extractZtxtValetudoMapPngChunk(data []byte) ([]byte, error) { 45 | ended := false 46 | idx := 8 47 | 48 | for idx < len(data) { 49 | // Read the length of the current chunk, 50 | // which is stored as a Uint32. 51 | length := binary.BigEndian.Uint32(data[idx : idx+4]) 52 | idx += 4 53 | 54 | // Chunk includes name/type for CRC check (see below). 55 | chunk := make([]byte, length+4) 56 | copy(chunk, data[idx:idx+4]) 57 | idx += 4 58 | 59 | // Get the name in ASCII for identification. 60 | name := string(chunk[:4]) 61 | 62 | // The IEND header marks the end of the file, 63 | // so on discovering it break out of the loop. 64 | if name == "IEND" { 65 | ended = true 66 | break 67 | } 68 | 69 | // Read the contents of the chunk out of the main buffer. 70 | copy(chunk[4:], data[idx:idx+int(length)]) 71 | idx += int(length) 72 | 73 | // Skip the CRC32. 74 | idx += 4 75 | 76 | // The chunk data is now copied to remove the 4 preceding 77 | // bytes used for the chunk name/type. 78 | chunkData := chunk[4:] 79 | 80 | if name == "zTXt" { 81 | i := 0 82 | keyword := "" 83 | 84 | for chunkData[i] != 0 && i < 79 { 85 | keyword += string(chunkData[i]) 86 | i++ 87 | } 88 | 89 | if keyword != "ValetudoMap" { 90 | continue 91 | } 92 | 93 | return chunkData[i+2:], nil 94 | } 95 | } 96 | 97 | if !ended { 98 | return nil, errors.New(".png file ended prematurely: no IEND header was found") 99 | } 100 | 101 | return nil, errors.New("no ValetudoMap chunk found in the PNG") 102 | } 103 | 104 | func isCompressed(data []byte) bool { 105 | return data[0x00] == 0x78 106 | } 107 | 108 | func inflateSync(data []byte) ([]byte, error) { 109 | b := bytes.NewReader(data) 110 | r, err := zlib.NewReader(b) 111 | if err != nil { 112 | return nil, err 113 | } 114 | defer r.Close() 115 | 116 | out, err := io.ReadAll(r) 117 | if err != nil { 118 | return nil, err 119 | } 120 | 121 | return out, nil 122 | } 123 | -------------------------------------------------------------------------------- /pkg/mqtt/mqtt.go: -------------------------------------------------------------------------------- 1 | package mqtt 2 | 3 | import ( 4 | "github.com/erkexzcx/valetudopng/pkg/config" 5 | ) 6 | 7 | func Start(c *config.MQTTConfig, mapJSONChan, renderedMapChan, calibrationDataChan chan []byte) { 8 | go startConsumer(c, mapJSONChan) 9 | go startProducer(c, renderedMapChan, calibrationDataChan) 10 | } 11 | -------------------------------------------------------------------------------- /pkg/mqtt/producer.go: -------------------------------------------------------------------------------- 1 | package mqtt 2 | 3 | import ( 4 | "log" 5 | "time" 6 | 7 | "github.com/bitly/go-simplejson" 8 | mqttgo "github.com/eclipse/paho.mqtt.golang" 9 | "github.com/erkexzcx/valetudopng/pkg/config" 10 | ) 11 | 12 | type Device struct { 13 | Name string `json:"name"` 14 | Identifiers []string `json:"identifiers"` 15 | } 16 | 17 | type Map struct { 18 | Name string `json:"name"` 19 | UniqueID string `json:"unique_id"` 20 | Device Device `json:"device"` 21 | Topic string `json:"topic"` 22 | } 23 | 24 | func startProducer(c *config.MQTTConfig, renderedMapChan, calibrationDataChan chan []byte) { 25 | opts := mqttgo.NewClientOptions() 26 | 27 | if c.Connection.TLSEnabled { 28 | opts.AddBroker("ssl://" + c.Connection.Host + ":" + c.Connection.Port) 29 | tlsConfig, err := newTLSConfig(c.Connection.TLSCaPath, c.Connection.TLSInsecure, c.Connection.TLSMinVersion) 30 | if err != nil { 31 | log.Fatalln(err) 32 | } 33 | opts.SetTLSConfig(tlsConfig) 34 | } else { 35 | opts.AddBroker("tcp://" + c.Connection.Host + ":" + c.Connection.Port) 36 | } 37 | 38 | opts.SetClientID(c.Connection.ClientIDPrefix + "_producer") 39 | opts.SetUsername(c.Connection.Username) 40 | opts.SetPassword(c.Connection.Password) 41 | opts.SetAutoReconnect(true) 42 | 43 | // On connection 44 | opts.OnConnect = func(client mqttgo.Client) { 45 | log.Println("[MQTT producer] Connected") 46 | } 47 | 48 | // On disconnection 49 | opts.OnConnectionLost = func(client mqttgo.Client, err error) { 50 | log.Printf("[MQTT producer] Connection lost: %v", err) 51 | } 52 | 53 | // Initial connection 54 | client := mqttgo.NewClient(opts) 55 | for { 56 | if token := client.Connect(); token.Wait() && token.Error() != nil { 57 | log.Printf("[MQTT producer] Failed to connect: %v. Retrying in 5 seconds...\n", token.Error()) 58 | time.Sleep(5 * time.Second) 59 | } else { 60 | break 61 | } 62 | } 63 | 64 | renderedMapTopic := c.Topics.ValetudoPrefix + "/" + c.Topics.ValetudoIdentifier + "/MapData/map" 65 | go produceAnnounceMapTopic(client, renderedMapTopic, c) 66 | go producerMapUpdatesHandler(client, renderedMapChan, renderedMapTopic) 67 | 68 | calibrationTopic := c.Topics.ValetudoPrefix + "/" + c.Topics.ValetudoIdentifier + "/MapData/calibration" 69 | go producerAnnounceCalibrationTopic(client, calibrationTopic, c) 70 | go producerCalibrationDataHandler(client, calibrationDataChan, calibrationTopic) 71 | } 72 | 73 | func producerMapUpdatesHandler(client mqttgo.Client, renderedMapChan chan []byte, topic string) { 74 | for img := range renderedMapChan { 75 | token := client.Publish(topic, 1, true, img) 76 | token.Wait() 77 | if token.Error() != nil { 78 | log.Printf("[MQTT producer] Failed to publish: %v\n", token.Error()) 79 | } 80 | } 81 | } 82 | 83 | func produceAnnounceMapTopic(client mqttgo.Client, rmt string, c *config.MQTTConfig) { 84 | announceTopic := c.Topics.HaAutoconfPrefix + "/camera/" + c.Topics.ValetudoIdentifier + "/" + c.Topics.ValetudoPrefix + "_" + c.Topics.ValetudoIdentifier + "_map/config" 85 | 86 | js := simplejson.New() 87 | js.Set("name", "Map") 88 | js.Set("unique_id", c.Topics.ValetudoIdentifier+"_rendered_map") 89 | js.Set("topic", rmt) 90 | 91 | device := simplejson.New() 92 | device.Set("name", c.Topics.ValetudoIdentifier) 93 | device.Set("identifiers", []string{c.Topics.ValetudoIdentifier}) 94 | 95 | js.Set("device", device) 96 | 97 | announcementData, err := js.MarshalJSON() 98 | if err != nil { 99 | panic(err) 100 | } 101 | 102 | token := client.Publish(announceTopic, 1, true, announcementData) 103 | token.Wait() 104 | if token.Error() != nil { 105 | log.Printf("[MQTT producer] Failed to publish: %v\n", token.Error()) 106 | } 107 | } 108 | 109 | func producerCalibrationDataHandler(client mqttgo.Client, calibrationDataChan chan []byte, topic string) { 110 | for img := range calibrationDataChan { 111 | token := client.Publish(topic, 1, true, img) 112 | token.Wait() 113 | if token.Error() != nil { 114 | log.Printf("[MQTT producer] Failed to publish: %v\n", token.Error()) 115 | } 116 | } 117 | } 118 | 119 | func producerAnnounceCalibrationTopic(client mqttgo.Client, cdt string, c *config.MQTTConfig) { 120 | announceTopic := c.Topics.HaAutoconfPrefix + "/sensor/" + c.Topics.ValetudoIdentifier + "/" + c.Topics.ValetudoPrefix + "_" + c.Topics.ValetudoIdentifier + "_calibration/config" 121 | 122 | js := simplejson.New() 123 | js.Set("name", "Calibration") 124 | js.Set("unique_id", c.Topics.ValetudoIdentifier+"_calibration") 125 | js.Set("state_topic", cdt) 126 | 127 | device := simplejson.New() 128 | device.Set("name", c.Topics.ValetudoIdentifier) 129 | device.Set("identifiers", []string{c.Topics.ValetudoIdentifier}) 130 | 131 | js.Set("device", device) 132 | 133 | announcementData, err := js.MarshalJSON() 134 | if err != nil { 135 | panic(err) 136 | } 137 | 138 | token := client.Publish(announceTopic, 1, true, announcementData) 139 | token.Wait() 140 | if token.Error() != nil { 141 | log.Printf("[MQTT producer] Failed to publish: %v\n", token.Error()) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /pkg/mqtt/tls.go: -------------------------------------------------------------------------------- 1 | package mqtt 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "errors" 7 | "os" 8 | ) 9 | 10 | func newTLSConfig(caFile string, insecureSkipVerify bool, minTLSVersion string) (tlsConfig *tls.Config, err error) { 11 | config := &tls.Config{} 12 | 13 | // Add CA file if provided to CA store 14 | if caFile != "" { 15 | certpool := x509.NewCertPool() 16 | pemCerts, err := os.ReadFile(caFile) 17 | if err != nil { 18 | return nil, err 19 | } 20 | certpool.AppendCertsFromPEM(pemCerts) 21 | config.RootCAs = certpool 22 | } 23 | 24 | // if 'true', then TLS verification is skipped 25 | config.InsecureSkipVerify = insecureSkipVerify 26 | 27 | // Set min TLS version 28 | switch minTLSVersion { 29 | case "": // Do nothing - defaults to Go's default 30 | case "1.0": 31 | config.MinVersion = tls.VersionTLS10 32 | case "1.1": 33 | config.MinVersion = tls.VersionTLS11 34 | case "1.2": 35 | config.MinVersion = tls.VersionTLS12 36 | case "1.3": 37 | config.MinVersion = tls.VersionTLS13 38 | default: 39 | return nil, errors.New("unrecognized TLS version " + minTLSVersion) 40 | } 41 | 42 | return config, nil 43 | } 44 | -------------------------------------------------------------------------------- /pkg/renderer/calibration.go: -------------------------------------------------------------------------------- 1 | package renderer 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | type CalibrationPoint struct { 8 | Vacuum *CalibrationCoords `json:"vacuum"` 9 | Map *CalibrationCoords `json:"map"` 10 | } 11 | 12 | type CalibrationCoords struct { 13 | X int `json:"x"` 14 | Y int `json:"y"` 15 | } 16 | 17 | func (vi *valetudoImage) getCalibrationPointsJSON() []byte { 18 | calImgx1, calImgy1 := vi.RotateLayer(0, 0) 19 | calImgx2, calImgy2 := vi.RotateLayer(vi.robotCoords.maxX-vi.robotCoords.minX, 0) 20 | calImgx3, calImgy3 := vi.RotateLayer(vi.robotCoords.maxX-vi.robotCoords.minX, vi.robotCoords.maxY-vi.robotCoords.minY) 21 | scale := int(vi.renderer.settings.Scale) 22 | 23 | data := []*CalibrationPoint{ 24 | { 25 | Vacuum: &CalibrationCoords{vi.robotCoords.minX * vi.valetudoJSON.PixelSize, vi.robotCoords.minY * vi.valetudoJSON.PixelSize}, 26 | Map: &CalibrationCoords{calImgx1 * scale, calImgy1 * scale}, 27 | }, 28 | { 29 | Vacuum: &CalibrationCoords{vi.robotCoords.maxX * vi.valetudoJSON.PixelSize, vi.robotCoords.minY * vi.valetudoJSON.PixelSize}, 30 | Map: &CalibrationCoords{calImgx2 * scale, calImgy2 * scale}, 31 | }, 32 | { 33 | Vacuum: &CalibrationCoords{vi.robotCoords.maxX * vi.valetudoJSON.PixelSize, vi.robotCoords.maxY * vi.valetudoJSON.PixelSize}, 34 | Map: &CalibrationCoords{calImgx3 * scale, calImgy3 * scale}, 35 | }, 36 | } 37 | 38 | jsonData, _ := json.Marshal(data) 39 | return jsonData 40 | } 41 | -------------------------------------------------------------------------------- /pkg/renderer/drawer.go: -------------------------------------------------------------------------------- 1 | package renderer 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | "math" 7 | "runtime" 8 | "sync" 9 | 10 | "github.com/fogleman/gg" 11 | ) 12 | 13 | type valetudoImage struct { 14 | img *image.RGBA // This is used until image is upscaled 15 | ggContext *gg.Context // This is used after upscale is done 16 | 17 | // Store img width and height 18 | unscaledImgWidth int 19 | unscaledImgHeight int 20 | scaledImgWidth int 21 | scaledImgHeight int 22 | 23 | // JSON data 24 | valetudoJSON *ValetudoJSON 25 | 26 | // Renderer reference, for easy access 27 | renderer *Renderer 28 | 29 | // Store details about the image within the robots coordinates system 30 | robotCoords struct { 31 | minX int 32 | minY int 33 | maxX int 34 | maxY int 35 | } 36 | 37 | // For faster acess, store them here 38 | layers map[string][]*Layer 39 | entities map[string][]*Entity 40 | 41 | // Segment ID to segment (room) color 42 | segmentColor map[string]color.RGBA 43 | 44 | // Rotation functions 45 | RotateLayer rotationFunc 46 | RotateEntity rotationFunc 47 | } 48 | 49 | func newValetudoImage(valetudoJSON *ValetudoJSON, r *Renderer) *valetudoImage { 50 | // Create new object 51 | vi := &valetudoImage{ 52 | valetudoJSON: valetudoJSON, 53 | renderer: r, 54 | } 55 | 56 | // Prepare layers and entities (to speed up iterations) 57 | vi.layers = make(map[string][]*Layer) 58 | vi.entities = make(map[string][]*Entity) 59 | for _, layer := range vi.valetudoJSON.Layers { 60 | _, found := vi.layers[layer.Type] 61 | if !found { 62 | vi.layers[layer.Type] = []*Layer{layer} 63 | } else { 64 | vi.layers[layer.Type] = append(vi.layers[layer.Type], layer) 65 | } 66 | } 67 | for _, entity := range vi.valetudoJSON.Entities { 68 | _, found := vi.entities[entity.Type] 69 | if !found { 70 | vi.entities[entity.Type] = []*Entity{entity} 71 | } else { 72 | vi.entities[entity.Type] = append(vi.entities[entity.Type], entity) 73 | } 74 | } 75 | 76 | // Load colors for each segment 77 | vi.segmentColor = make(map[string]color.RGBA) 78 | vi.findFourColors(r.settings.SegmentColors) 79 | 80 | // Find map bounds within robot's coordinates system (from given layers) 81 | vi.robotCoords.minX = math.MaxInt32 82 | vi.robotCoords.minY = math.MaxInt32 83 | vi.robotCoords.maxX = 0 84 | vi.robotCoords.maxY = 0 85 | 86 | // Either use user's static robot's coordinates, or find them dynamically 87 | if vi.renderer.settings.StaticStartX == 0 && vi.renderer.settings.StaticStartY == 0 && 88 | vi.renderer.settings.StaticEndX == 0 && vi.renderer.settings.StaticEndY == 0 { 89 | 90 | for _, layer := range valetudoJSON.Layers { 91 | if layer.Dimensions.X.Min < vi.robotCoords.minX { 92 | vi.robotCoords.minX = layer.Dimensions.X.Min 93 | } 94 | if layer.Dimensions.Y.Min < vi.robotCoords.minY { 95 | vi.robotCoords.minY = layer.Dimensions.Y.Min 96 | } 97 | if layer.Dimensions.X.Max > vi.robotCoords.maxX { 98 | vi.robotCoords.maxX = layer.Dimensions.X.Max 99 | } 100 | if layer.Dimensions.Y.Max > vi.robotCoords.maxY { 101 | vi.robotCoords.maxY = layer.Dimensions.Y.Max 102 | } 103 | } 104 | } else { 105 | 106 | vi.robotCoords.minX = vi.renderer.settings.StaticStartX / 5 107 | vi.robotCoords.minY = vi.renderer.settings.StaticStartY / 5 108 | vi.robotCoords.maxX = vi.renderer.settings.StaticEndX / 5 109 | vi.robotCoords.maxY = vi.renderer.settings.StaticEndY / 5 110 | } 111 | 112 | // +1 because width is count of pixels, not difference 113 | // "123456", so if you perform 5-3, you get 2, but actually it's 345, so+1 and it's 3 114 | vi.unscaledImgWidth = vi.robotCoords.maxX - vi.robotCoords.minX + 1 115 | vi.unscaledImgHeight = vi.robotCoords.maxY - vi.robotCoords.minY + 1 116 | 117 | // Switch width and height if needed according to rotation 118 | if vi.renderer.settings.RotationTimes%2 != 0 { 119 | vi.unscaledImgWidth, vi.unscaledImgHeight = vi.unscaledImgHeight, vi.unscaledImgWidth 120 | } 121 | 122 | // Create a new image 123 | vi.img = image.NewRGBA(image.Rect(0, 0, vi.unscaledImgWidth, vi.unscaledImgHeight)) 124 | 125 | // Explanation about image.Rect (documentation is lying): 126 | // 127 | // img := image.NewRGBA(image.Rect(0, 0, 100, 100)) 128 | // would result in an image that has X from 0 to 99, Y from 0 to 99 129 | // width 100 and height 100 130 | 131 | // Create rotation funcs 132 | vi.RotateLayer = vi.getRotationFunc(true) 133 | vi.RotateEntity = vi.getRotationFunc(false) 134 | 135 | return vi 136 | } 137 | 138 | func (vi *valetudoImage) DrawAll() { 139 | vi.drawLayers() 140 | vi.upscaleToGGContext() 141 | 142 | // Draw path entity 143 | col := vi.renderer.settings.PathColor 144 | vi.ggContext.SetRGBA255(int(col.R), int(col.G), int(col.B), int(col.A)) 145 | vi.ggContext.SetLineWidth(float64(vi.renderer.settings.Scale) * 0.75) 146 | for _, e := range vi.entities["path"] { 147 | vi.drawEntityPath(e) 148 | } 149 | vi.ggContext.Stroke() 150 | 151 | // Draw virtual_wall entities 152 | col = vi.renderer.settings.VirtualWallColor 153 | vi.ggContext.SetRGBA255(int(col.R), int(col.G), int(col.B), int(col.A)) 154 | vi.ggContext.SetLineWidth(float64(vi.renderer.settings.Scale) * 1.5) 155 | vi.ggContext.SetLineCapButt() 156 | for _, e := range vi.entities["virtual_wall"] { 157 | vi.drawEntityVirtualWall(e) 158 | } 159 | vi.ggContext.Stroke() 160 | // Draw no_go_area entities 161 | lineWidth := float64(vi.renderer.settings.Scale * 0.5) 162 | noGoAreas := vi.entities["no_go_area"] 163 | col = vi.renderer.settings.NoGoAreaColor 164 | vi.ggContext.SetRGBA255(int(col.R), int(col.G), int(col.B), int(col.A)) 165 | vi.ggContext.SetLineWidth(0) 166 | for _, e := range noGoAreas { 167 | vi.drawEntityNoGoArea(e) 168 | } 169 | vi.ggContext.Fill() 170 | col = vi.renderer.settings.VirtualWallColor 171 | vi.ggContext.SetRGBA255(int(col.R), int(col.G), int(col.B), int(col.A)) 172 | vi.ggContext.SetLineWidth(lineWidth) 173 | for _, e := range noGoAreas { 174 | vi.drawEntityNoGoArea(e) 175 | } 176 | vi.ggContext.Stroke() 177 | 178 | // Draw charger_location entity 179 | for _, e := range vi.entities["charger_location"] { 180 | vi.drawEntityCharger(e, 0, 0) 181 | } 182 | 183 | // Draw robot_position entity 184 | for _, e := range vi.entities["robot_position"] { 185 | vi.drawEntityRobot(e, int(vi.renderer.settings.Scale)/2, -1) 186 | } 187 | } 188 | 189 | func (vi *valetudoImage) upscaleToGGContext() { 190 | scale := int(vi.renderer.settings.Scale) 191 | scaledImgWidth := vi.unscaledImgWidth * scale 192 | scaledImgHeight := vi.unscaledImgHeight * scale 193 | scaledImg := image.NewRGBA(image.Rect(0, 0, scaledImgWidth, scaledImgHeight)) 194 | 195 | numCPUs := runtime.NumCPU() 196 | var wg sync.WaitGroup 197 | jobs := make(chan int, vi.unscaledImgHeight) 198 | 199 | // Start workers 200 | for w := 0; w < numCPUs; w++ { 201 | go func() { 202 | for y := range jobs { 203 | yScale := y * scale 204 | yUnscaledImgWidth := y * vi.unscaledImgWidth 205 | for x := 0; x < vi.unscaledImgWidth; x++ { 206 | xScale := x * scale 207 | for scaleIndex := 0; scaleIndex < scale; scaleIndex++ { 208 | copy(scaledImg.Pix[(yScale*scaledImgWidth+xScale+scaleIndex)*4:(yScale*scaledImgWidth+xScale+scaleIndex+1)*4], vi.img.Pix[(yUnscaledImgWidth+x)*4:(yUnscaledImgWidth+x+1)*4]) 209 | } 210 | } 211 | for scaleIndex := 1; scaleIndex < scale; scaleIndex++ { 212 | copy(scaledImg.Pix[((yScale+scaleIndex)*scaledImgWidth)*4:((yScale+scaleIndex+1)*scaledImgWidth)*4], scaledImg.Pix[(yScale*scaledImgWidth)*4:(yScale+1)*scaledImgWidth*4]) 213 | } 214 | wg.Done() 215 | } 216 | }() 217 | } 218 | 219 | // Distribute work 220 | for y := 0; y < vi.unscaledImgHeight; y++ { 221 | wg.Add(1) 222 | jobs <- y 223 | } 224 | close(jobs) 225 | 226 | // Wait for all workers to finish 227 | wg.Wait() 228 | 229 | vi.ggContext = gg.NewContextForRGBA(scaledImg) 230 | vi.scaledImgWidth = scaledImgWidth 231 | vi.scaledImgHeight = scaledImgHeight 232 | } 233 | 234 | type rotationFunc func(x, y int) (int, int) 235 | 236 | // For layers, "subtractOne" should be true 237 | // For entities, "subtractOne" should be false 238 | func (vi *valetudoImage) getRotationFunc(subtractOne bool) rotationFunc { 239 | switch vi.renderer.settings.RotationTimes { 240 | case 0: 241 | // No rotation 242 | return func(x, y int) (int, int) { return x, y } 243 | case 1: 244 | // 90 degrees clockwise 245 | if subtractOne { 246 | return func(x, y int) (int, int) { return vi.unscaledImgWidth - 1 - y, x } 247 | } 248 | return func(x, y int) (int, int) { return vi.unscaledImgWidth - y, x } 249 | case 2: 250 | // 180 degrees clockwise 251 | if subtractOne { 252 | return func(x, y int) (int, int) { return vi.unscaledImgWidth - 1 - x, vi.unscaledImgHeight - 1 - y } 253 | } 254 | return func(x, y int) (int, int) { return vi.unscaledImgWidth - x, vi.unscaledImgHeight - y } 255 | case 3: 256 | // 270 degrees clockwise 257 | if subtractOne { 258 | return func(x, y int) (int, int) { return y, vi.unscaledImgHeight - 1 - x } 259 | } 260 | return func(x, y int) (int, int) { return y, vi.unscaledImgHeight - x } 261 | } 262 | return func(x, y int) (int, int) { return x, y } 263 | } 264 | -------------------------------------------------------------------------------- /pkg/renderer/drawer_entities.go: -------------------------------------------------------------------------------- 1 | package renderer 2 | 3 | // Entities coordinates are basically same as layers coordinates, just multiplied by 4 | // vi.valetudoJSON.PixelSize value, so simply divide by it and we get their coords at 5 | // 1x scale. Then we can upscale to our scale integer. 6 | func (vi *valetudoImage) entityToImageCoords(vacuumX, vacuumY int) (float64, float64) { 7 | imgX := (vacuumX/vi.valetudoJSON.PixelSize - vi.robotCoords.minX) 8 | imgY := (vacuumY/vi.valetudoJSON.PixelSize - vi.robotCoords.minY) 9 | rotatedX, rotatedY := vi.RotateEntity(imgX, imgY) 10 | return float64(rotatedX) * vi.renderer.settings.Scale, float64(rotatedY) * vi.renderer.settings.Scale 11 | } 12 | 13 | func (vi *valetudoImage) drawEntityVirtualWall(e *Entity) { 14 | sx, sy := vi.entityToImageCoords(e.Points[0], e.Points[1]) 15 | ex, ey := vi.entityToImageCoords(e.Points[2], e.Points[3]) 16 | vi.ggContext.DrawLine(sx, sy, ex, ey) 17 | } 18 | 19 | func (vi *valetudoImage) drawEntityNoGoArea(e *Entity) { 20 | sx, sy := vi.entityToImageCoords(e.Points[0], e.Points[1]) 21 | ex, ey := vi.entityToImageCoords(e.Points[4], e.Points[5]) 22 | 23 | width := ex - sx 24 | height := ey - sy 25 | vi.ggContext.DrawRectangle(sx, sy, width, height) 26 | } 27 | 28 | func (vi *valetudoImage) drawEntityPath(e *Entity) { 29 | sx, sy := vi.entityToImageCoords(e.Points[0], e.Points[1]) 30 | vi.ggContext.MoveTo(sx, sy) 31 | for i := 2; i < len(e.Points); i += 2 { 32 | currX, currY := vi.entityToImageCoords(e.Points[i], e.Points[i+1]) 33 | vi.ggContext.LineTo(currX, currY) 34 | } 35 | } 36 | 37 | func (vi *valetudoImage) drawEntityRobot(e *Entity, xOffset, yOffset int) { 38 | coordX, coordY := vi.entityToImageCoords(e.Points[0], e.Points[1]) 39 | angle := (int(e.MetaData.Angle) + (vi.renderer.settings.RotationTimes * 90)) % 360 40 | vi.ggContext.DrawImageAnchored(vi.renderer.assetRobot[angle], int(coordX)+xOffset, int(coordY)+yOffset, 0.5, 0.5) 41 | } 42 | 43 | func (vi *valetudoImage) drawEntityCharger(e *Entity, xOffset, yOffset int) { 44 | coordX, coordY := vi.entityToImageCoords(e.Points[0], e.Points[1]) 45 | vi.ggContext.DrawImageAnchored(vi.renderer.assetCharger, int(coordX)+xOffset, int(coordY)+yOffset, 0.5, 0.5) 46 | } 47 | -------------------------------------------------------------------------------- /pkg/renderer/drawer_layers.go: -------------------------------------------------------------------------------- 1 | package renderer 2 | 3 | import ( 4 | "image/color" 5 | "runtime" 6 | "sync" 7 | ) 8 | 9 | type layerColor struct { 10 | layer *Layer 11 | color color.RGBA 12 | } 13 | 14 | func (vi *valetudoImage) drawLayers() { 15 | numWorkers := runtime.NumCPU() 16 | layerCh := make(chan layerColor, numWorkers) 17 | wg := &sync.WaitGroup{} 18 | 19 | // Start the workers 20 | for i := 0; i < numWorkers; i++ { 21 | wg.Add(1) 22 | go func() { 23 | defer wg.Done() 24 | for lc := range layerCh { 25 | vi.drawLayer(lc.layer, lc.color) 26 | } 27 | }() 28 | } 29 | 30 | // Send layers to the channel 31 | col := vi.renderer.settings.FloorColor 32 | for _, l := range vi.layers["floor"] { 33 | layerCh <- layerColor{l, col} 34 | } 35 | 36 | col = vi.renderer.settings.ObstacleColor 37 | for _, l := range vi.layers["wall"] { 38 | layerCh <- layerColor{l, col} 39 | } 40 | 41 | for _, l := range vi.layers["segment"] { 42 | col = vi.segmentColor[l.MetaData.SegmentId] 43 | layerCh <- layerColor{l, col} 44 | } 45 | 46 | // Close the channel to signal the workers to stop 47 | close(layerCh) 48 | 49 | // Wait for all workers to finish 50 | wg.Wait() 51 | } 52 | 53 | func (vi *valetudoImage) drawLayer(l *Layer, col color.RGBA) { 54 | for i := 0; i < len(l.CompressedPixels); i += 3 { 55 | drawX := l.CompressedPixels[i] - vi.robotCoords.minX 56 | drawY := l.CompressedPixels[i+1] - vi.robotCoords.minY 57 | count := l.CompressedPixels[i+2] 58 | 59 | for c := 0; c < count; c++ { 60 | x, y := vi.RotateLayer(drawX+c, drawY) 61 | vi.img.SetRGBA(x, y, col) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /pkg/renderer/fourcolortheorem.go: -------------------------------------------------------------------------------- 1 | package renderer 2 | 3 | import ( 4 | "image/color" 5 | "sort" 6 | ) 7 | 8 | type vertex struct { 9 | id string 10 | adjacent map[string]struct{} 11 | color int 12 | } 13 | 14 | type graph struct { 15 | vertices map[string]*vertex 16 | } 17 | 18 | func newVertex(id string) *vertex { 19 | return &vertex{id: id, adjacent: make(map[string]struct{}), color: -1} 20 | } 21 | 22 | func (g *graph) addEdge(id1, id2 string) { 23 | if g.vertices == nil { 24 | g.vertices = make(map[string]*vertex) 25 | } 26 | 27 | if _, ok := g.vertices[id1]; !ok { 28 | g.vertices[id1] = newVertex(id1) 29 | } 30 | 31 | if _, ok := g.vertices[id2]; !ok { 32 | g.vertices[id2] = newVertex(id2) 33 | } 34 | 35 | g.vertices[id1].adjacent[id2] = struct{}{} 36 | g.vertices[id2].adjacent[id1] = struct{}{} 37 | } 38 | 39 | func nextAvailableColor(colors map[int]struct{}) int { 40 | for color := 0; ; color++ { 41 | if _, exists := colors[color]; !exists { 42 | return color 43 | } 44 | } 45 | } 46 | 47 | func (g *graph) colorVertices() { 48 | vertices := make([]*vertex, 0, len(g.vertices)) 49 | for _, v := range g.vertices { 50 | vertices = append(vertices, v) 51 | } 52 | 53 | sort.Slice(vertices, func(i, j int) bool { 54 | if len(vertices[i].adjacent) == len(vertices[j].adjacent) { 55 | return vertices[i].id < vertices[j].id 56 | } 57 | return len(vertices[i].adjacent) > len(vertices[j].adjacent) 58 | }) 59 | 60 | for _, v := range vertices { 61 | if len(v.adjacent) == 0 { 62 | v.color = 0 63 | } else { 64 | colors := make(map[int]struct{}) 65 | for id := range v.adjacent { 66 | vertex := g.vertices[id] 67 | if vertex.color != -1 { 68 | colors[vertex.color] = struct{}{} 69 | } 70 | } 71 | v.color = nextAvailableColor(colors) 72 | } 73 | } 74 | } 75 | 76 | func (vi *valetudoImage) findFourColors(fourColors []color.RGBA) { 77 | g := graph{} 78 | 79 | for _, layer := range vi.layers["segment"] { 80 | for _, otherLayer := range vi.layers["segment"] { 81 | if layer != otherLayer && areAdjacent(layer, otherLayer, vi.valetudoJSON.PixelSize) { 82 | g.addEdge(layer.MetaData.SegmentId, otherLayer.MetaData.SegmentId) 83 | } 84 | } 85 | } 86 | 87 | g.colorVertices() 88 | 89 | for _, v := range g.vertices { 90 | vi.segmentColor[v.id] = fourColors[v.color] 91 | } 92 | } 93 | 94 | func areAdjacent(layer1, layer2 *Layer, threshold int) bool { 95 | return (layer1.Dimensions.X.Max >= layer2.Dimensions.X.Min-threshold) && 96 | (layer1.Dimensions.X.Min <= layer2.Dimensions.X.Max+threshold) && 97 | (layer1.Dimensions.Y.Max >= layer2.Dimensions.Y.Min-threshold) && 98 | (layer1.Dimensions.Y.Min <= layer2.Dimensions.Y.Max+threshold) 99 | } 100 | -------------------------------------------------------------------------------- /pkg/renderer/json.go: -------------------------------------------------------------------------------- 1 | package renderer 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | type ValetudoJSON struct { 8 | Class string `json:"__class"` 9 | MetaData *MetaData `json:"metaData"` 10 | Size *Size `json:"size"` 11 | PixelSize int `json:"pixelSize"` 12 | Layers []*Layer `json:"layers"` 13 | Entities []*Entity `json:"entities"` 14 | } 15 | 16 | type MetaData struct { 17 | VendorMapId int `json:"vendorMapId"` 18 | Version int `json:"version"` 19 | Nonce string `json:"nonce"` 20 | TotalLayerArea int `json:"totalLayerArea"` 21 | Area int `json:"area,omitempty"` 22 | SegmentId string `json:"segmentId,omitempty"` 23 | Active bool `json:"active,omitempty"` 24 | Name string `json:"name,omitempty"` 25 | } 26 | 27 | type Size struct { 28 | X int `json:"x"` 29 | Y int `json:"y"` 30 | } 31 | 32 | type Layer struct { 33 | Class string `json:"__class"` 34 | MetaData MetaData `json:"metaData"` 35 | Type string `json:"type"` 36 | Pixels []int `json:"pixels"` 37 | Dimensions Dimensions `json:"dimensions"` 38 | CompressedPixels []int `json:"compressedPixels"` 39 | } 40 | 41 | type Dimensions struct { 42 | X Dimension `json:"x"` 43 | Y Dimension `json:"y"` 44 | PixelCount int `json:"pixelCount"` 45 | } 46 | 47 | type Dimension struct { 48 | Min int `json:"min"` 49 | Max int `json:"max"` 50 | Mid int `json:"mid"` 51 | Avg int `json:"avg"` 52 | } 53 | 54 | type Entity struct { 55 | Class string `json:"__class"` 56 | MetaData MetaDataEntity `json:"metaData"` 57 | Points []int `json:"points"` 58 | Type string `json:"type"` 59 | } 60 | 61 | type MetaDataEntity struct { 62 | Angle float64 `json:"angle,omitempty"` 63 | } 64 | 65 | func toJSON(payload []byte) (*ValetudoJSON, error) { 66 | var JSON *ValetudoJSON 67 | err := json.Unmarshal(payload, &JSON) 68 | if err != nil { 69 | return nil, err 70 | } 71 | return JSON, nil 72 | } 73 | -------------------------------------------------------------------------------- /pkg/renderer/renderer.go: -------------------------------------------------------------------------------- 1 | package renderer 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | "image/png" 7 | "math" 8 | 9 | "github.com/erkexzcx/valetudopng" 10 | "github.com/erkexzcx/valetudopng/pkg/config" 11 | "golang.org/x/image/draw" 12 | "golang.org/x/image/math/f64" 13 | ) 14 | 15 | type Renderer struct { 16 | assetRobot map[int]image.Image 17 | assetCharger image.Image 18 | settings *Settings 19 | } 20 | 21 | type Settings struct { 22 | Scale float64 23 | PNGCompression int 24 | RotationTimes int 25 | 26 | // Hardcoded limits for a map within robot's coordinates system 27 | StaticStartX, StaticStartY int 28 | StaticEndX, StaticEndY int 29 | 30 | FloorColor color.RGBA 31 | ObstacleColor color.RGBA 32 | PathColor color.RGBA 33 | NoGoAreaColor color.RGBA 34 | VirtualWallColor color.RGBA 35 | SegmentColors []color.RGBA 36 | } 37 | 38 | func New(s *Settings) *Renderer { 39 | switch s.PNGCompression { 40 | case 0: 41 | pngEncoder.CompressionLevel = png.BestSpeed 42 | case 1: 43 | pngEncoder.CompressionLevel = png.BestCompression 44 | case 2: 45 | pngEncoder.CompressionLevel = png.DefaultCompression 46 | case 3: 47 | pngEncoder.CompressionLevel = png.NoCompression 48 | } 49 | 50 | r := &Renderer{ 51 | settings: s, 52 | } 53 | loadAssetRobot(r) 54 | loadAssetCharger(r) 55 | return r 56 | } 57 | 58 | func (r *Renderer) Render(data []byte, mc *config.MapConfig) (*Result, error) { 59 | // Parse data to JSON object 60 | JSON, err := toJSON(data) 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | // Render image 66 | vi := newValetudoImage(JSON, r) 67 | vi.DrawAll() 68 | 69 | img := vi.ggContext.Image() 70 | return &Result{ 71 | Image: &img, 72 | ImageSize: &ImgSize{ 73 | Width: vi.scaledImgWidth, 74 | Height: vi.scaledImgHeight, 75 | }, 76 | RobotCoords: &RbtCoords{ 77 | MinX: vi.robotCoords.minX, 78 | MinY: vi.robotCoords.minY, 79 | MaxX: vi.robotCoords.maxX, 80 | MaxY: vi.robotCoords.maxY, 81 | }, 82 | Settings: vi.renderer.settings, 83 | Calibration: vi.getCalibrationPointsJSON(), 84 | PixelSize: vi.valetudoJSON.PixelSize, 85 | }, nil 86 | } 87 | 88 | func loadAssetRobot(r *Renderer) { 89 | file, err := valetudopng.ResFS.Open("res/robot.png") 90 | if err != nil { 91 | panic(err) 92 | } 93 | defer file.Close() 94 | 95 | img, err := png.Decode(file) 96 | if err != nil { 97 | panic(err) 98 | } 99 | 100 | r.assetRobot = make(map[int]image.Image, 360) 101 | for degree := 0; degree < 360; degree++ { 102 | // Create a new image with the same dimensions as the original image 103 | rotatedImg := image.NewRGBA(img.Bounds()) 104 | 105 | // Create a rotation matrix 106 | rotationMatrix := f64.Aff3{} 107 | sin, cos := math.Sincos(math.Pi * float64(degree) / 180.0) 108 | rotationMatrix[0], rotationMatrix[1] = cos, -sin 109 | rotationMatrix[3], rotationMatrix[4] = sin, cos 110 | 111 | // Adjust the rotation matrix to rotate around the center of the image 112 | rotationMatrix[2], rotationMatrix[5] = float64(img.Bounds().Dx())/2*(1-cos)+float64(img.Bounds().Dy())/2*sin, float64(img.Bounds().Dy())/2*(1-cos)-float64(img.Bounds().Dx())/2*sin 113 | 114 | // Use the rotation matrix to rotate the image 115 | draw.BiLinear.Transform(rotatedImg, rotationMatrix, img, img.Bounds(), draw.Over, nil) 116 | 117 | // Create a new image with the scaled dimensions 118 | newWidth := rotatedImg.Bounds().Dx() * int(r.settings.Scale) / 4 119 | newHeight := rotatedImg.Bounds().Dy() * int(r.settings.Scale) / 4 120 | scaledImg := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight)) 121 | 122 | // Draw the rotated image onto the scaled image 123 | draw.BiLinear.Scale(scaledImg, scaledImg.Bounds(), rotatedImg, rotatedImg.Bounds(), draw.Over, nil) 124 | 125 | r.assetRobot[degree] = scaledImg 126 | } 127 | } 128 | 129 | func loadAssetCharger(r *Renderer) { 130 | file, err := valetudopng.ResFS.Open("res/charger.png") 131 | if err != nil { 132 | panic(err) 133 | } 134 | defer file.Close() 135 | 136 | img, err := png.Decode(file) 137 | if err != nil { 138 | panic(err) 139 | } 140 | 141 | newWidth := img.Bounds().Dx() * int(r.settings.Scale) / 4 142 | newHeight := img.Bounds().Dy() * int(r.settings.Scale) / 4 143 | scaledImg := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight)) 144 | 145 | draw.BiLinear.Scale(scaledImg, scaledImg.Bounds(), img, img.Bounds(), draw.Over, nil) 146 | r.assetCharger = scaledImg 147 | } 148 | -------------------------------------------------------------------------------- /pkg/renderer/result.go: -------------------------------------------------------------------------------- 1 | package renderer 2 | 3 | import ( 4 | "bytes" 5 | "image" 6 | "image/png" 7 | ) 8 | 9 | // Set compression value from 'New' function. 10 | var pngEncoder = png.Encoder{} 11 | 12 | type Result struct { 13 | Image *image.Image 14 | ImageSize *ImgSize 15 | RobotCoords *RbtCoords 16 | Settings *Settings 17 | Calibration []byte 18 | PixelSize int // taken from JSON, for traslating image coords to robot's coords system coordinates 19 | } 20 | 21 | type ImgSize struct { 22 | Width int 23 | Height int 24 | } 25 | 26 | type RbtCoords struct { 27 | MinX int 28 | MinY int 29 | MaxX int 30 | MaxY int 31 | } 32 | 33 | func (r *Result) RenderPNG() ([]byte, error) { 34 | var b bytes.Buffer 35 | err := pngEncoder.Encode(&b, *r.Image) 36 | if err != nil { 37 | return nil, err 38 | } 39 | return b.Bytes(), nil 40 | } 41 | -------------------------------------------------------------------------------- /pkg/server/http.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "net/http" 7 | "strconv" 8 | "strings" 9 | "text/template" 10 | 11 | "github.com/erkexzcx/valetudopng" 12 | ) 13 | 14 | func runWebServer(bind string) { 15 | http.HandleFunc("/api/map/image", requestHandlerImage) 16 | http.HandleFunc("/api/map/image/debug", requestHandlerDebug) 17 | http.HandleFunc("/api/map/image/debug/static/", requestHandlerDebugStatic) 18 | panic(http.ListenAndServe(bind, nil)) 19 | } 20 | 21 | func isResultNotReady() bool { 22 | renderedPNGMux.RLock() 23 | defer renderedPNGMux.RUnlock() 24 | return result == nil 25 | } 26 | 27 | func requestHandlerImage(w http.ResponseWriter, r *http.Request) { 28 | if isResultNotReady() { 29 | http.Error(w, "image not yet loaded", http.StatusAccepted) 30 | return 31 | } 32 | 33 | renderedPNGMux.RLock() 34 | imageCopy := make([]byte, len(renderedPNG)) 35 | copy(imageCopy, renderedPNG) 36 | renderedPNGMux.RUnlock() 37 | 38 | w.Header().Set("Content-Length", strconv.Itoa(len(imageCopy))) 39 | w.Header().Set("Content-Type", "image/png") 40 | w.WriteHeader(200) 41 | w.Write(imageCopy) 42 | } 43 | 44 | type TemplateData struct { 45 | RobotMinX int 46 | RobotMinY int 47 | RobotMaxX int 48 | RobotMaxY int 49 | RotatedTimes int 50 | Scale int 51 | PixelSize int 52 | } 53 | 54 | func requestHandlerDebug(w http.ResponseWriter, r *http.Request) { 55 | if isResultNotReady() { 56 | http.Error(w, "image not yet loaded", http.StatusAccepted) 57 | return 58 | } 59 | 60 | // Parse the template file 61 | tmpl, err := template.ParseFS(valetudopng.WebFS, "web/templates/index.html.tmpl") 62 | if err != nil { 63 | http.Error(w, err.Error(), 500) 64 | return 65 | } 66 | 67 | // Create a data structure to hold the template values 68 | renderedPNGMux.RLock() 69 | data := TemplateData{ 70 | RobotMinX: result.RobotCoords.MinX, 71 | RobotMinY: result.RobotCoords.MinY, 72 | RobotMaxX: result.RobotCoords.MaxX, 73 | RobotMaxY: result.RobotCoords.MaxY, 74 | RotatedTimes: result.Settings.RotationTimes, 75 | Scale: int(result.Settings.Scale), 76 | PixelSize: result.PixelSize, 77 | } 78 | renderedPNGMux.RUnlock() 79 | 80 | // Render the template with the data 81 | err = tmpl.Execute(w, data) 82 | if err != nil { 83 | http.Error(w, err.Error(), 500) 84 | return 85 | } 86 | } 87 | 88 | func requestHandlerDebugStatic(w http.ResponseWriter, r *http.Request) { 89 | staticPath := "web/static/" + strings.TrimPrefix(r.URL.Path, "/api/map/image/debug/static/") 90 | file, err := valetudopng.WebFS.Open(staticPath) 91 | if err != nil { 92 | http.Error(w, "File not found", http.StatusNotFound) 93 | return 94 | } 95 | defer file.Close() 96 | 97 | info, err := file.Stat() 98 | if err != nil { 99 | http.Error(w, "Internal server error", http.StatusInternalServerError) 100 | return 101 | } 102 | 103 | if info.IsDir() { 104 | http.Error(w, "Forbidden", http.StatusForbidden) 105 | return 106 | } 107 | 108 | // Read the entire file into memory 109 | data, err := io.ReadAll(file) 110 | if err != nil { 111 | http.Error(w, "Internal server error", http.StatusInternalServerError) 112 | return 113 | } 114 | 115 | // Create an io.ReadSeeker from the byte slice 116 | reader := bytes.NewReader(data) 117 | 118 | http.ServeContent(w, r, info.Name(), info.ModTime(), reader) 119 | } 120 | -------------------------------------------------------------------------------- /pkg/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "image/color" 7 | "log" 8 | "os" 9 | "os/signal" 10 | "strconv" 11 | "sync" 12 | "syscall" 13 | "time" 14 | 15 | "github.com/erkexzcx/valetudopng/pkg/config" 16 | "github.com/erkexzcx/valetudopng/pkg/mqtt" 17 | "github.com/erkexzcx/valetudopng/pkg/renderer" 18 | ) 19 | 20 | var ( 21 | renderedPNG = make([]byte, 0) 22 | renderedPNGMux = &sync.RWMutex{} 23 | result *renderer.Result 24 | ) 25 | 26 | func Start(c *config.Config) { 27 | r := renderer.New(&renderer.Settings{ 28 | Scale: c.Map.Scale, 29 | PNGCompression: c.Map.PNGCompression, 30 | RotationTimes: c.Map.RotationTimes, 31 | 32 | StaticStartX: c.Map.CustomLimits.StartX, 33 | StaticStartY: c.Map.CustomLimits.StartY, 34 | StaticEndX: c.Map.CustomLimits.EndX, 35 | StaticEndY: c.Map.CustomLimits.EndY, 36 | 37 | FloorColor: HexColor(c.Map.Colors.Floor), 38 | ObstacleColor: HexColor(c.Map.Colors.Obstacle), 39 | PathColor: HexColor(c.Map.Colors.Path), 40 | NoGoAreaColor: HexColor(c.Map.Colors.NoGoArea), 41 | VirtualWallColor: HexColor(c.Map.Colors.VirtualWall), 42 | SegmentColors: []color.RGBA{ 43 | HexColor(c.Map.Colors.Segments[0]), 44 | HexColor(c.Map.Colors.Segments[1]), 45 | HexColor(c.Map.Colors.Segments[2]), 46 | HexColor(c.Map.Colors.Segments[3]), 47 | }, 48 | }) 49 | 50 | if c.HTTP.Enabled { 51 | go runWebServer(c.HTTP.Bind) 52 | } 53 | 54 | mapJSONChan := make(chan []byte) 55 | renderedMapChan := make(chan []byte) 56 | calibrationDataChan := make(chan []byte) 57 | go mqtt.Start(c.Mqtt, mapJSONChan, renderedMapChan, calibrationDataChan) 58 | 59 | renderedAt := time.Now().Add(-c.Map.MinRefreshInt) 60 | for payload := range mapJSONChan { 61 | if time.Now().Before(renderedAt) { 62 | log.Println("Skipping image render due to min_refresh_int") 63 | continue 64 | } 65 | renderedAt = time.Now().Add(c.Map.MinRefreshInt) 66 | 67 | tsStart := time.Now() 68 | res, err := r.Render(payload, c.Map) 69 | if err != nil { 70 | log.Fatalln("Error occurred while rendering map:", err) 71 | } 72 | drawnInMS := time.Since(tsStart).Milliseconds() 73 | 74 | img, err := res.RenderPNG() 75 | if err != nil { 76 | log.Fatalln("Error occurred while rendering PNG image:", err) 77 | } 78 | renderedIn := time.Since(tsStart).Milliseconds() - drawnInMS 79 | 80 | log.Printf("Image rendered! drawing:%dms, encoding:%dms, size:%s\n", drawnInMS, renderedIn, ByteCountSI(int64(len(img)))) 81 | 82 | if !(c.Mqtt.ImageAsBase64 && !c.HTTP.Enabled) { 83 | renderedPNGMux.Lock() 84 | renderedPNG = img 85 | result = res 86 | renderedPNGMux.Unlock() 87 | } 88 | 89 | if c.Mqtt.ImageAsBase64 { 90 | img = []byte(base64.StdEncoding.EncodeToString(img)) 91 | } 92 | 93 | // Send data to MQTT 94 | renderedMapChan <- img 95 | calibrationDataChan <- res.Calibration 96 | } 97 | 98 | // Create a channel to wait for OS interrupt signal 99 | interrupt := make(chan os.Signal, 1) 100 | signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM) 101 | 102 | // Block main function here until an interrupt is received 103 | <-interrupt 104 | fmt.Println("Program interrupted") 105 | } 106 | 107 | func ByteCountSI(b int64) string { 108 | const unit = 1000 109 | if b < unit { 110 | return fmt.Sprintf("%d B", b) 111 | } 112 | div, exp := int64(unit), 0 113 | for n := b / unit; n >= unit; n /= unit { 114 | div *= unit 115 | exp++ 116 | } 117 | return fmt.Sprintf("%.1f%cB", float64(b)/float64(div), "kMGTPE"[exp]) 118 | } 119 | 120 | func HexColor(hex string) color.RGBA { 121 | red, _ := strconv.ParseUint(hex[1:3], 16, 8) 122 | green, _ := strconv.ParseUint(hex[3:5], 16, 8) 123 | blue, _ := strconv.ParseUint(hex[5:7], 16, 8) 124 | alpha := uint64(255) 125 | 126 | if len(hex) > 8 { 127 | alpha, _ = strconv.ParseUint(hex[7:9], 16, 8) 128 | } 129 | 130 | return color.RGBA{R: uint8(red), G: uint8(green), B: uint8(blue), A: uint8(alpha)} 131 | } 132 | -------------------------------------------------------------------------------- /res/charger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erkexzcx/valetudopng/6326235f3557de96d17a0af7998bc7a2a8916d90/res/charger.png -------------------------------------------------------------------------------- /res/robot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erkexzcx/valetudopng/6326235f3557de96d17a0af7998bc7a2a8916d90/res/robot.png -------------------------------------------------------------------------------- /web/static/js/App.js: -------------------------------------------------------------------------------- 1 | function calculateMapPosition(imageX, imageY) { 2 | // Calculate the robot's coordinates 3 | var robotX = Math.floor(imageX / scale); 4 | var robotY = Math.floor(imageY / scale); 5 | 6 | // Adjust the map's coordinates based on the rotation 7 | var rotatedRobotX, rotatedRobotY; 8 | switch (rotatedTimes) { 9 | case 0: 10 | // No rotation 11 | rotatedRobotX = robotMinX + robotX; 12 | rotatedRobotY = robotMinY + robotY; 13 | break; 14 | case 1: 15 | // 90 degrees clockwise 16 | rotatedRobotX = robotMinX + robotY; 17 | rotatedRobotY = robotMaxY - robotX; 18 | break; 19 | case 2: 20 | // 180 degrees clockwise 21 | rotatedRobotX = robotMaxX - robotX; 22 | rotatedRobotY = robotMaxY - robotY; 23 | break; 24 | case 3: 25 | // 270 degrees clockwise 26 | rotatedRobotX = robotMaxX - robotY; 27 | rotatedRobotY = robotMinY + robotX; 28 | break; 29 | } 30 | 31 | imageX = imageX - (imageX%scale) 32 | imageY = imageY - (imageY%scale) 33 | 34 | return { 35 | mapStartX: imageX, mapStartY: imageY, 36 | mapEndX: imageX+scale, mapEndY: imageY+scale, 37 | robotX: rotatedRobotX*pixelSize, robotY: rotatedRobotY*pixelSize 38 | }; 39 | } 40 | 41 | // Create "Canvas drawing object" (just the way I call it lol) 42 | let cdo = {}; 43 | cdo.rectCoords = []; 44 | 45 | cdo.clear = function() { 46 | this.ctx.clearRect(0, 0, this.canvas.width(), this.canvas.height()); 47 | } 48 | 49 | cdo.drawCrosshair = function (rawImgX, rawImgY) { 50 | var pos = calculateMapPosition(rawImgX, rawImgY); 51 | 52 | // Draw highlight for pixel block 53 | this.ctx.fillStyle = 'rgba(255, 0, 0, 1)'; 54 | this.ctx.fillRect(pos.mapStartX, pos.mapStartY, pos.mapEndX - pos.mapStartX, pos.mapEndY - pos.mapStartY); 55 | 56 | // Draw highlights for horizontal and vertical axis rows 57 | this.ctx.fillStyle = 'rgba(255, 0, 0, 0.25)'; 58 | this.ctx.fillRect(pos.mapStartX, 0, pos.mapEndX - pos.mapStartX, this.canvas.height()); 59 | this.ctx.fillRect(0, pos.mapStartY, this.canvas.width(), pos.mapEndY - pos.mapStartY); 60 | } 61 | 62 | cdo.drawRectangle = function (rawImgX, rawImgY) { 63 | if (cdo.rectCoords.length == 0 || cdo.rectCoords.length > 2) { 64 | return 65 | } 66 | 67 | sx = cdo.rectCoords[0][0]; 68 | sy = cdo.rectCoords[0][1]; 69 | 70 | if (cdo.rectCoords.length == 1) { 71 | var position = calculateMapPosition(rawImgX, rawImgY); 72 | ex = position.mapStartX+scale; 73 | ey = position.mapStartY+scale; 74 | } else { 75 | ex = cdo.rectCoords[1][0]+scale; 76 | ey = cdo.rectCoords[1][1]+scale; 77 | } 78 | 79 | if (sx > ex || sy > ey) { 80 | let temp = sx; 81 | sx = ex; 82 | ex = temp; 83 | 84 | temp = sy; 85 | sy = ey; 86 | ey = temp; 87 | } 88 | 89 | width = ex-sx; 90 | height = ey-sy 91 | 92 | this.ctx.fillStyle = 'rgba(158, 221, 255, 0.60)'; 93 | this.ctx.fillRect(sx, sy, width, height); 94 | 95 | resStart = calculateMapPosition(sx, sy); 96 | resConfig = calculateMapPosition(ex-scale, ey-scale); 97 | resCard = calculateMapPosition(ex, ey); 98 | 99 | cdo.setCofigData(resStart.robotX, resStart.robotY, resConfig.robotX, resConfig.robotY); 100 | cdo.setCardData(resStart.robotX, resStart.robotY, resCard.robotX, resCard.robotY); 101 | } 102 | 103 | cdo.addRectangleCoordinates = function (rawImgX, rawImgY) { 104 | if (cdo.rectCoords.length < 2) { 105 | var position = calculateMapPosition(rawImgX, rawImgY); 106 | cdo.rectCoords.push([position.mapStartX, position.mapStartY]); 107 | return; 108 | }else{ 109 | cdo.rectCoords = []; 110 | return; 111 | } 112 | } 113 | 114 | cdo.showPopup = function (rawImgX, rawImgY, pageX, pageY) { 115 | var position = calculateMapPosition(rawImgX, rawImgY); 116 | this.popup.text('Map: (' + position.mapStartX + ', ' + position.mapStartY + '), Robot: (' + position.robotX + ', ' + position.robotY + ')') 117 | .css({left: pageX + 10, top: pageY + 10}) 118 | .show(); 119 | } 120 | 121 | cdo.hidePopup = function () { 122 | this.popup.hide(); 123 | } 124 | 125 | // Set robot coordinates, not map coordinates 126 | // for xiaomi-vacuum-map-card 127 | cdo.setCardData = function(x1, y1, x2, y2){ 128 | middleX = (x2+x1)/2 129 | middleY = (y2+y1)/2 130 | str = `map_modes: 131 | - template: vacuum_clean_zone_predefined 132 | # See https://github.com/PiotrMachowski/lovelace-xiaomi-vacuum-map-card/issues/662 133 | selection_type: PREDEFINED_RECTANGLE 134 | predefined_selections: 135 | - zones: [[${x1},${y1},${x2},${y2}]] 136 | label: 137 | text: Entrance 138 | x: ${middleX} 139 | y: ${middleY} 140 | offset_y: 28 141 | icon: 142 | name: mdi:door 143 | x: ${middleX} 144 | y: ${middleY}`; 145 | cdo.carddata.html(str); 146 | } 147 | 148 | // Set robot coordinates, not map coordinates 149 | // for config file (YAML) 150 | cdo.setCofigData = function(x1, y1, x2, y2){ 151 | str = ` 152 | custom_limits: 153 | start_x: ${x1} 154 | start_y: ${y1} 155 | end_x: ${x2} 156 | end_y: ${y2}`; 157 | cdo.cofigdata.html(str); 158 | } 159 | -------------------------------------------------------------------------------- /web/static/js/JQuery.js: -------------------------------------------------------------------------------- 1 | /*! jQuery v3.7.1 | (c) OpenJS Foundation and other contributors | jquery.org/license */ 2 | !function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(ie,e){"use strict";var oe=[],r=Object.getPrototypeOf,ae=oe.slice,g=oe.flat?function(e){return oe.flat.call(e)}:function(e){return oe.concat.apply([],e)},s=oe.push,se=oe.indexOf,n={},i=n.toString,ue=n.hasOwnProperty,o=ue.toString,a=o.call(Object),le={},v=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},y=function(e){return null!=e&&e===e.window},C=ie.document,u={type:!0,src:!0,nonce:!0,noModule:!0};function m(e,t,n){var r,i,o=(n=n||C).createElement("script");if(o.text=e,t)for(r in u)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function x(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[i.call(e)]||"object":typeof e}var t="3.7.1",l=/HTML$/i,ce=function(e,t){return new ce.fn.init(e,t)};function c(e){var t=!!e&&"length"in e&&e.length,n=x(e);return!v(e)&&!y(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+ge+")"+ge+"*"),x=new RegExp(ge+"|>"),j=new RegExp(g),A=new RegExp("^"+t+"$"),D={ID:new RegExp("^#("+t+")"),CLASS:new RegExp("^\\.("+t+")"),TAG:new RegExp("^("+t+"|[*])"),ATTR:new RegExp("^"+p),PSEUDO:new RegExp("^"+g),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+ge+"*(even|odd|(([+-]|)(\\d*)n|)"+ge+"*(?:([+-]|)"+ge+"*(\\d+)|))"+ge+"*\\)|)","i"),bool:new RegExp("^(?:"+f+")$","i"),needsContext:new RegExp("^"+ge+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+ge+"*((?:-\\d)?\\d*)"+ge+"*\\)|)(?=[^-]|$)","i")},N=/^(?:input|select|textarea|button)$/i,q=/^h\d$/i,L=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,H=/[+~]/,O=new RegExp("\\\\[\\da-fA-F]{1,6}"+ge+"?|\\\\([^\\r\\n\\f])","g"),P=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},M=function(){V()},R=J(function(e){return!0===e.disabled&&fe(e,"fieldset")},{dir:"parentNode",next:"legend"});try{k.apply(oe=ae.call(ye.childNodes),ye.childNodes),oe[ye.childNodes.length].nodeType}catch(e){k={apply:function(e,t){me.apply(e,ae.call(t))},call:function(e){me.apply(e,ae.call(arguments,1))}}}function I(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(V(e),e=e||T,C)){if(11!==p&&(u=L.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return k.call(n,a),n}else if(f&&(a=f.getElementById(i))&&I.contains(e,a)&&a.id===i)return k.call(n,a),n}else{if(u[2])return k.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&e.getElementsByClassName)return k.apply(n,e.getElementsByClassName(i)),n}if(!(h[t+" "]||d&&d.test(t))){if(c=t,f=e,1===p&&(x.test(t)||m.test(t))){(f=H.test(t)&&U(e.parentNode)||e)==e&&le.scope||((s=e.getAttribute("id"))?s=ce.escapeSelector(s):e.setAttribute("id",s=S)),o=(l=Y(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+Q(l[o]);c=l.join(",")}try{return k.apply(n,f.querySelectorAll(c)),n}catch(e){h(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return re(t.replace(ve,"$1"),e,n,r)}function W(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function F(e){return e[S]=!0,e}function $(e){var t=T.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function B(t){return function(e){return fe(e,"input")&&e.type===t}}function _(t){return function(e){return(fe(e,"input")||fe(e,"button"))&&e.type===t}}function z(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&R(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function X(a){return F(function(o){return o=+o,F(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function U(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}function V(e){var t,n=e?e.ownerDocument||e:ye;return n!=T&&9===n.nodeType&&n.documentElement&&(r=(T=n).documentElement,C=!ce.isXMLDoc(T),i=r.matches||r.webkitMatchesSelector||r.msMatchesSelector,r.msMatchesSelector&&ye!=T&&(t=T.defaultView)&&t.top!==t&&t.addEventListener("unload",M),le.getById=$(function(e){return r.appendChild(e).id=ce.expando,!T.getElementsByName||!T.getElementsByName(ce.expando).length}),le.disconnectedMatch=$(function(e){return i.call(e,"*")}),le.scope=$(function(){return T.querySelectorAll(":scope")}),le.cssHas=$(function(){try{return T.querySelector(":has(*,:jqfake)"),!1}catch(e){return!0}}),le.getById?(b.filter.ID=function(e){var t=e.replace(O,P);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&C){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(O,P);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&C){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):t.querySelectorAll(e)},b.find.CLASS=function(e,t){if("undefined"!=typeof t.getElementsByClassName&&C)return t.getElementsByClassName(e)},d=[],$(function(e){var t;r.appendChild(e).innerHTML="",e.querySelectorAll("[selected]").length||d.push("\\["+ge+"*(?:value|"+f+")"),e.querySelectorAll("[id~="+S+"-]").length||d.push("~="),e.querySelectorAll("a#"+S+"+*").length||d.push(".#.+[+~]"),e.querySelectorAll(":checked").length||d.push(":checked"),(t=T.createElement("input")).setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),r.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&d.push(":enabled",":disabled"),(t=T.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||d.push("\\["+ge+"*name"+ge+"*="+ge+"*(?:''|\"\")")}),le.cssHas||d.push(":has"),d=d.length&&new RegExp(d.join("|")),l=function(e,t){if(e===t)return a=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!le.sortDetached&&t.compareDocumentPosition(e)===n?e===T||e.ownerDocument==ye&&I.contains(ye,e)?-1:t===T||t.ownerDocument==ye&&I.contains(ye,t)?1:o?se.call(o,e)-se.call(o,t):0:4&n?-1:1)}),T}for(e in I.matches=function(e,t){return I(e,null,null,t)},I.matchesSelector=function(e,t){if(V(e),C&&!h[t+" "]&&(!d||!d.test(t)))try{var n=i.call(e,t);if(n||le.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){h(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(O,P),e[3]=(e[3]||e[4]||e[5]||"").replace(O,P),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||I.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&I.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return D.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&j.test(n)&&(t=Y(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(O,P).toLowerCase();return"*"===e?function(){return!0}:function(e){return fe(e,t)}},CLASS:function(e){var t=s[e+" "];return t||(t=new RegExp("(^|"+ge+")"+e+"("+ge+"|$)"))&&s(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=I.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function T(e,n,r){return v(n)?ce.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?ce.grep(e,function(e){return e===n!==r}):"string"!=typeof n?ce.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(ce.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||k,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:S.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof ce?t[0]:t,ce.merge(this,ce.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:C,!0)),w.test(r[1])&&ce.isPlainObject(t))for(r in t)v(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=C.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):v(e)?void 0!==n.ready?n.ready(e):e(ce):ce.makeArray(e,this)}).prototype=ce.fn,k=ce(C);var E=/^(?:parents|prev(?:Until|All))/,j={children:!0,contents:!0,next:!0,prev:!0};function A(e,t){while((e=e[t])&&1!==e.nodeType);return e}ce.fn.extend({has:function(e){var t=ce(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,Ce=/^$|^module$|\/(?:java|ecma)script/i;xe=C.createDocumentFragment().appendChild(C.createElement("div")),(be=C.createElement("input")).setAttribute("type","radio"),be.setAttribute("checked","checked"),be.setAttribute("name","t"),xe.appendChild(be),le.checkClone=xe.cloneNode(!0).cloneNode(!0).lastChild.checked,xe.innerHTML="",le.noCloneChecked=!!xe.cloneNode(!0).lastChild.defaultValue,xe.innerHTML="",le.option=!!xe.lastChild;var ke={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function Se(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&fe(e,t)?ce.merge([e],n):n}function Ee(e,t){for(var n=0,r=e.length;n",""]);var je=/<|&#?\w+;/;function Ae(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function Re(e,t){return fe(e,"table")&&fe(11!==t.nodeType?t:t.firstChild,"tr")&&ce(e).children("tbody")[0]||e}function Ie(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function We(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Fe(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(_.hasData(e)&&(s=_.get(e).events))for(i in _.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),C.head.appendChild(r[0])},abort:function(){i&&i()}}});var Jt,Kt=[],Zt=/(=)\?(?=&|$)|\?\?/;ce.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Kt.pop()||ce.expando+"_"+jt.guid++;return this[e]=!0,e}}),ce.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Zt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Zt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=v(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Zt,"$1"+r):!1!==e.jsonp&&(e.url+=(At.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||ce.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=ie[r],ie[r]=function(){o=arguments},n.always(function(){void 0===i?ce(ie).removeProp(r):ie[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Kt.push(r)),o&&v(i)&&i(o[0]),o=i=void 0}),"script"}),le.createHTMLDocument=((Jt=C.implementation.createHTMLDocument("").body).innerHTML="
",2===Jt.childNodes.length),ce.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(le.createHTMLDocument?((r=(t=C.implementation.createHTMLDocument("")).createElement("base")).href=C.location.href,t.head.appendChild(r)):t=C),o=!n&&[],(i=w.exec(e))?[t.createElement(i[1])]:(i=Ae([e],t,o),o&&o.length&&ce(o).remove(),ce.merge([],i.childNodes)));var r,i,o},ce.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(ce.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},ce.expr.pseudos.animated=function(t){return ce.grep(ce.timers,function(e){return t===e.elem}).length},ce.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=ce.css(e,"position"),c=ce(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=ce.css(e,"top"),u=ce.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),v(t)&&(t=t.call(e,n,ce.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},ce.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){ce.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===ce.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===ce.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=ce(e).offset()).top+=ce.css(e,"borderTopWidth",!0),i.left+=ce.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-ce.css(r,"marginTop",!0),left:t.left-i.left-ce.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===ce.css(e,"position"))e=e.offsetParent;return e||J})}}),ce.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;ce.fn[t]=function(e){return M(this,function(e,t,n){var r;if(y(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),ce.each(["top","left"],function(e,n){ce.cssHooks[n]=Ye(le.pixelPosition,function(e,t){if(t)return t=Ge(e,n),_e.test(t)?ce(e).position()[n]+"px":t})}),ce.each({Height:"height",Width:"width"},function(a,s){ce.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){ce.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return M(this,function(e,t,n){var r;return y(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?ce.css(e,t,i):ce.style(e,t,n,i)},s,n?e:void 0,n)}})}),ce.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){ce.fn[t]=function(e){return this.on(t,e)}}),ce.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.on("mouseenter",e).on("mouseleave",t||e)}}),ce.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){ce.fn[n]=function(e,t){return 0 2 | 3 | 4 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |

You can copy/paste this for custom:xiaomi-vacuum-map-card card:

18 |

(click twice on map to generate)

19 | 20 |

Use this in valetudopng config to bound your map to these static coordinates:

21 |

(click twice on map to generate)

22 | 23 |

Note that 1 block (pixel) in robot's vacuum system is {{ .PixelSize }}. Want to extend rectangle by 1 block? Just add 5 to it's X or Y axis.

24 |
25 | 26 | 36 | 37 | 38 | 39 | 40 | 89 | 90 | 91 | 92 | --------------------------------------------------------------------------------