From 9c259c7686058564bda97ea441913e14cad79208 Mon Sep 17 00:00:00 2001 From: Piotr Machowski Date: Wed, 16 Aug 2023 03:21:44 +0200 Subject: [PATCH] Initial commit --- .github/FUNDING.yml | 2 + .github/dependabot.yml | 10 + .github/workflows/automerge.yaml | 29 ++ .github/workflows/code_quality.yaml | 42 +++ .github/workflows/codeql.yml | 38 +++ .github/workflows/release.yml | 43 +++ .github/workflows/requirements.txt | 2 + .gitignore | 160 +++++++++ LICENSE | 202 ++++++++++++ README.md | 144 ++++++++ pyproject.toml | 73 +++++ src/vacuum_map_parser_dreame/__init__.py | 0 src/vacuum_map_parser_dreame/header.py | 20 ++ src/vacuum_map_parser_dreame/image_parser.py | 105 ++++++ .../map_data_parser.py | 307 ++++++++++++++++++ src/vacuum_map_parser_dreame/map_data_type.py | 10 + src/vacuum_map_parser_dreame/py.typed | 0 17 files changed, 1187 insertions(+) create mode 100644 .github/FUNDING.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/automerge.yaml create mode 100644 .github/workflows/code_quality.yaml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/requirements.txt create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/vacuum_map_parser_dreame/__init__.py create mode 100644 src/vacuum_map_parser_dreame/header.py create mode 100644 src/vacuum_map_parser_dreame/image_parser.py create mode 100644 src/vacuum_map_parser_dreame/map_data_parser.py create mode 100644 src/vacuum_map_parser_dreame/map_data_type.py create mode 100644 src/vacuum_map_parser_dreame/py.typed diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..3e1a96b --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +ko_fi: piotrmachowski +custom: ["buycoffee.to/piotrmachowski", "paypal.me/PiMachowski", "revolut.me/314ma"] diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..645c171 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/automerge.yaml b/.github/workflows/automerge.yaml new file mode 100644 index 0000000..23515ed --- /dev/null +++ b/.github/workflows/automerge.yaml @@ -0,0 +1,29 @@ +--- +name: 'Automatically merge master -> dev' + +on: + push: + branches: + - master + +jobs: + build: + name: Automatically merge master to dev + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + name: Git checkout + with: + fetch-depth: 0 + - name: Merge master -> dev + run: | + git config user.name "GitHub Actions" + git config user.email "PiotrMachowski@users.noreply.github.com" + if (git checkout dev) + then + git merge --ff-only master || git merge --no-commit master + git commit -m "Automatically merge master -> dev" || echo "No commit needed" + git push origin dev + else + echo "No dev branch" + fi diff --git a/.github/workflows/code_quality.yaml b/.github/workflows/code_quality.yaml new file mode 100644 index 0000000..4b442bf --- /dev/null +++ b/.github/workflows/code_quality.yaml @@ -0,0 +1,42 @@ +name: Code Quality + +on: + pull_request: + branches: + - master + push: + +jobs: + code_quality: + name: ${{ matrix.name }} + runs-on: ubuntu-latest + strategy: + matrix: + include: + - id: black + name: Check code with black + - id: isort + name: Check code with isort + - id: pylint + name: Check code with pylint + - id: mypy + name: Check code with mypy + steps: + - name: Checkout the repository + uses: actions/checkout@v3 + + - name: Set up Python 3 + uses: actions/setup-python@v4 + id: python + with: + python-version: "3.11" + + - name: Install workflow dependencies + run: | + pip install -r .github/workflows/requirements.txt + + - name: Install Python dependencies + run: poetry install --no-interaction + + - name: Run ${{ matrix.id }} checks + run: poetry run ${{ matrix.id }} src \ No newline at end of file diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..82ff256 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,38 @@ +name: "CodeQL" + +on: + push: + branches: [master] + pull_request: + branches: [master] + schedule: + - cron: "21 5 * * 0" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: ["python"] + + steps: + - name: Checkout repository + uses: actions/checkout@v3.5.3 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..00140ef --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,43 @@ +name: Publish release + +on: + release: + types: [published] + +jobs: + build-and-publish-pypi: + name: Builds and publishes release to PyPI + runs-on: ubuntu-latest + outputs: + version: ${{ steps.vars.outputs.tag }} + steps: + - uses: actions/checkout@v3.5.3 + + - name: Set up Python 3.11 + uses: actions/setup-python@v4.7.0 + with: + python-version: "3.11" + + - name: Install workflow dependencies + run: | + pip install -r .github/workflows/requirements.txt + + - name: Install dependencies + run: poetry install --no-interaction + + - name: Set package version + run: | + version="${{ github.event.release.tag_name }}" + version="${version,,}" + version="${version#v}" + poetry version --no-interaction "${version}" + + - name: Build package + run: poetry build --no-interaction + + - name: Publish to PyPi + env: + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} + run: | + poetry config pypi-token.pypi "${PYPI_TOKEN}" + poetry publish --no-interaction diff --git a/.github/workflows/requirements.txt b/.github/workflows/requirements.txt new file mode 100644 index 0000000..3883e27 --- /dev/null +++ b/.github/workflows/requirements.txt @@ -0,0 +1,2 @@ +pip==23.2.1 +poetry==1.5.1 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6769e21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,160 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..52aa601 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 Piotr Machowski + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0c73e35 --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ +[![GitHub Latest Release][releases_shield]][latest_release] +[![PyPI][pypi_releases_shield]][pypi_latest_release] +[![PyPI - Downloads][pypi_downloads_shield]][pypi_downloads] +[![Ko-Fi][ko_fi_shield]][ko_fi] +[![buycoffee.to][buycoffee_to_shield]][buycoffee_to] +[![PayPal.Me][paypal_me_shield]][paypal_me] +[![Revolut.Me][revolut_me_shield]][revolut_me] + + +[latest_release]: https://github.com/PiotrMachowski/Python-package-vacuum-map-parser-dreame/releases/latest +[releases_shield]: https://img.shields.io/github/release/PiotrMachowski/Python-package-vacuum-map-parser-dreame.svg?style=popout + +[pypi_latest_release]: https://pypi.org/project/vacuum-map-parser-dreame/ +[pypi_releases_shield]: https://img.shields.io/pypi/v/vacuum-map-parser-dreame + +[pypi_downloads]: https://pepy.tech/project/vacuum-map-parser-dreame +[pypi_downloads_shield]: https://static.pepy.tech/badge/vacuum-map-parser-dreame/month + +# Vacuum map parser - Dreame + +Map data parser that can be used to parse maps generated by Dreame vacuums. + +## Installation + +```shell +pip install vacuum-map-parser-dreame +``` + +## Usage + +```python +from vacuum_map_parser_base.config.color import ColorsPalette +from vacuum_map_parser_base.config.drawable import Drawable +from vacuum_map_parser_base.config.image_config import ImageConfig +from vacuum_map_parser_base.config.size import Sizes +from vacuum_map_parser_base.config.text import Text +from vacuum_map_parser_dreame.map_data_parser import DreameMapDataParser + +palette: ColorsPalette = ColorsPalette() +sizes: Sizes = Sizes() +drawables: list[Drawable] = [Drawable.PATH, Drawable.CHARGER] +image_config: ImageConfig = ImageConfig() +texts: list[Text] = [] +model: str = "dreame.vacuum.p2008" +enc_key: str | None = "secret_enc_key" # required just for vacuums with AES-encoded map data +raw_map: bytes = b'' + +parser = DreameMapDataParser(palette, sizes, drawables, image_config, texts, model) + +unpacked_map = parser.unpack_map(raw_map, enc_key) +parsed_map = parser.parse(unpacked_map) +``` + +## Special thanks + +The code of this library was initially created by [@Neonox31](https://github.com/Neonox31) as a part of [Xiaomi Cloud Map Extractor](https://github.com/PiotrMachowski/Home-Assistant-custom-components-Xiaomi-Cloud-Map-Extractor). + + + + +## Support + +If you want to support my work with a donation you can use one of the following platforms: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PlatformPayment methodsLinkComment
Ko-fi +
  • PayPal
  • +
  • Credit card
  • +
    + Buy Me a Coffee at ko-fi.com + +
  • No fees
  • +
  • Single or monthly payment
  • +
    buycoffee.to +
  • BLIK
  • +
  • Bank transfer
  • +
    + Postaw mi kawÄ™ na buycoffee.to +
    PayPal +
  • PayPal
  • +
    + PayPal Logo + +
  • No fees
  • +
    Revolut +
  • Revolut
  • +
  • Credit Card
  • +
    + Revolut + +
  • No fees
  • +
    + + +[ko_fi_shield]: https://img.shields.io/static/v1.svg?label=%20&message=Ko-Fi&color=F16061&logo=ko-fi&logoColor=white + +[ko_fi]: https://ko-fi.com/piotrmachowski + +[buycoffee_to_shield]: https://shields.io/badge/buycoffee.to-white?style=flat&labelColor=white&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABhmlDQ1BJQ0MgcHJvZmlsZQAAKJF9kT1Iw1AUhU9TpaIVh1YQcchQnayIijhKFYtgobQVWnUweemP0KQhSXFxFFwLDv4sVh1cnHV1cBUEwR8QVxcnRRcp8b6k0CLGC4/3cd49h/fuA4R6malmxzigapaRisfEbG5FDLzChxB6MIZ+iZl6Ir2QgWd93VM31V2UZ3n3/Vm9St5kgE8knmW6YRGvE09vWjrnfeIwK0kK8TnxqEEXJH7kuuzyG+eiwwLPDBuZ1BxxmFgstrHcxqxkqMRTxBFF1ShfyLqscN7irJarrHlP/sJgXltOc53WEOJYRAJJiJBRxQbKsBClXSPFRIrOYx7+QcefJJdMrg0wcsyjAhWS4wf/g9+zNQuTE25SMAZ0vtj2xzAQ2AUaNdv+PrbtxgngfwautJa/UgdmPkmvtbTIEdC3DVxctzR5D7jcAQaedMmQHMlPSygUgPcz+qYcELoFulfduTXPcfoAZGhWSzfAwSEwUqTsNY93d7XP7d+e5vx+AIahcq//o+yoAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH5wETCy4vFNqLzwAAAVpJREFUOMvd0rFLVXEYxvHPOedKJnKJhrDLuUFREULE7YDCMYj+AydpsCWiaKu29hZxiP4Al4aWwC1EdFI4Q3hqEmkIBI8ZChWXKNLLvS0/Qcza84V3enm/7/s878t/HxGkeTaIGziP+EB918nawu7Dq1d0e1+2J2bepnk2jFEUVVF+qKV51o9neBCaugfge70keoxxUbSWjrQ+4SUyzKZ5NlnDZdzGG7w4DIh+dtZEFntDA98l8S0MYwctNGrYz9WqKJePFLq80g5Sr+EHlnATp+NA+4qLaZ7FfzMrzbMBjGEdq8GrJMZnvAvFC/8wfAwjWMQ8XmMzaW9sdevNRgd3MFhvNpbaG1u/Dk2/hOc4gadVUa7Um425qii/7Z+xH9O4jwW8Cqv24Tru4hyeVEU588cfBMgpPMI9nMFe0BkFzVOYrYqycyQgQJLwTC2cDZCPeF8V5Y7jGb8BUpRicy7OU5MAAAAASUVORK5CYII= + +[buycoffee_to]: https://buycoffee.to/piotrmachowski + +[buy_me_a_coffee_shield]: https://img.shields.io/static/v1.svg?label=%20&message=Buy%20me%20a%20coffee&color=6f4e37&logo=buy%20me%20a%20coffee&logoColor=white + +[buy_me_a_coffee]: https://www.buymeacoffee.com/PiotrMachowski + +[paypal_me_shield]: https://img.shields.io/static/v1.svg?label=%20&message=PayPal.Me&logo=paypal + +[paypal_me]: https://paypal.me/PiMachowski + +[revolut_me_shield]: https://img.shields.io/static/v1.svg?label=%20&message=Revolut&logo=revolut + +[revolut_me]: https://revolut.me/314ma + \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..eec11ec --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,73 @@ +[tool.poetry] +name = "vacuum-map-parser-dreame" +# The version is set by GH action on release +version = "0.0.0" +license = "Apache-2.0" +description = "Functionalities for Dreame vacuum map parsing" +readme = "README.md" +authors = ["Piotr Machowski "] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Environment :: Console", + "Programming Language :: Python :: 3.11", + "Topic :: Home Automation", +] +packages = [ + { include = "vacuum_map_parser_dreame", from = "src" }, +] + +[tool.poetry.urls] +"Homepage" = "https://github.com/PiotrMachowski/Python-package-vacuum-map-parser-dreame" +"Repository" = "https://github.com/PiotrMachowski/Python-package-vacuum-map-parser-dreame" +"Bug Tracker" = "https://github.com/PiotrMachowski/Python-package-vacuum-map-parser-dreame/issues" +"Changelog" = "https://github.com/PiotrMachowski/Python-package-vacuum-map-parser-dreame/releases" + +[tool.poetry.dependencies] +python = "^3.11" +Pillow = "*" +pycryptodome = "*" +vacuum-map-parser-base = "0.1.1" + +[tool.poetry.dev-dependencies] +black = "*" +mypy = "*" +ruff = "*" +isort = "*" +pylint = "*" +types-Pillow = "*" + +[tool.black] +line-length = 120 + +[tool.isort] +profile = "black" +line_length = 120 + +[tool.mypy] +platform = "linux" + +check_untyped_defs = true +disallow_any_generics = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +no_implicit_reexport = true +strict_optional = true +warn_incomplete_stub = true +warn_no_return = true +warn_redundant_casts = true +warn_return_any = true +warn_unused_configs = true +warn_unused_ignores = true + +[tool.pylint] +disable = ["C0103", "C0116", "R0902", "R0903", "R0912", "R0913", "R0914", "R0915", "W0640"] +max-line-length = 120 + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/src/vacuum_map_parser_dreame/__init__.py b/src/vacuum_map_parser_dreame/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/vacuum_map_parser_dreame/header.py b/src/vacuum_map_parser_dreame/header.py new file mode 100644 index 0000000..37fb18e --- /dev/null +++ b/src/vacuum_map_parser_dreame/header.py @@ -0,0 +1,20 @@ +"""Dreame map header.""" + +from dataclasses import dataclass + +from vacuum_map_parser_base.map_data import Point + + +@dataclass +class MapDataHeader: + """Dreame map header.""" + + map_index: int + frame_type: int + vacuum_position: Point + charger_position: Point + image_pixel_size: int + image_width: int + image_height: int + image_left: int + image_top: int diff --git a/src/vacuum_map_parser_dreame/image_parser.py b/src/vacuum_map_parser_dreame/image_parser.py new file mode 100644 index 0000000..4b24dbd --- /dev/null +++ b/src/vacuum_map_parser_dreame/image_parser.py @@ -0,0 +1,105 @@ +"""Dreame map image parser.""" + +import logging +from enum import IntEnum +from typing import Any + +from PIL import Image +from PIL.Image import Image as ImageType +from PIL.Image import Resampling +from vacuum_map_parser_base.config.color import ColorsPalette, SupportedColor +from vacuum_map_parser_base.config.image_config import ImageConfig +from vacuum_map_parser_base.map_data import Room + +from .header import MapDataHeader +from .map_data_type import MapDataType + +_LOGGER = logging.getLogger(__name__) + + +class PixelTypes(IntEnum): + """Dreame map pixel type.""" + + NONE = 0 + FLOOR = 1 + WALL = 2 + + +class DreameImageParser: + """Dreame map image parser.""" + + def __init__(self, palette: ColorsPalette, image_config: ImageConfig): + self._palette = palette + self._image_config = image_config + + def parse( + self, raw_data: bytes, header: MapDataHeader, map_data_type: MapDataType + ) -> tuple[ImageType | None, dict[int, Room]]: + if ( + header.image_width is None + or header.image_width == 0 + or header.image_height is None + or header.image_height == 0 + ): + return None, {} + scale = self._image_config.scale + trim_left = int(self._image_config.trim.left * header.image_width / 100) + trim_right = int(self._image_config.trim.right * header.image_width / 100) + trim_top = int(self._image_config.trim.top * header.image_height / 100) + trim_bottom = int(self._image_config.trim.bottom * header.image_height / 100) + trimmed_height = header.image_height - trim_top - trim_bottom + trimmed_width = header.image_width - trim_left - trim_right + image = Image.new("RGBA", (trimmed_width, trimmed_height)) + pixels = image.load() + rooms: dict[int, Room] = {} + + for img_y in range(trimmed_height): + for img_x in range(trimmed_width): + x = img_x + y = trimmed_height - img_y - 1 + room_x = img_x + trim_left + room_y = img_y + trim_bottom + + if map_data_type == MapDataType.REGULAR: + px = raw_data[img_x + trim_left + header.image_width * (img_y + trim_bottom)] + segment_id = px >> 2 + if 0 < segment_id < 62: + self._create_or_update_room(pixels, room_x, room_y, rooms, segment_id, x, y) + else: + masked_px = px & 0b00000011 + + if masked_px == PixelTypes.NONE.value: + pixels[x, y] = self._palette.get_color(SupportedColor.MAP_OUTSIDE) + elif masked_px == PixelTypes.FLOOR.value: + pixels[x, y] = self._palette.get_color(SupportedColor.MAP_INSIDE) + elif masked_px == PixelTypes.WALL.value: + pixels[x, y] = self._palette.get_color(SupportedColor.MAP_WALL) + else: + _LOGGER.warning("unhandled pixel type: %d", px) + elif map_data_type == MapDataType.RISM: + px = raw_data[img_x + trim_left + header.image_width * (img_y + trim_bottom)] + segment_id = px & 0b01111111 + wall_flag = px >> 7 + + if wall_flag: + pixels[x, y] = self._palette.get_color(SupportedColor.MAP_WALL) + elif segment_id > 0: + self._create_or_update_room(pixels, room_x, room_y, rooms, segment_id, x, y) + + if self._image_config.scale != 1 and header.image_width != 0 and header.image_height != 0: + image = image.resize((int(trimmed_width * scale), int(trimmed_height * scale)), resample=Resampling.NEAREST) + return image, rooms + + def _create_or_update_room( + self, pixels: Any, room_x: int, room_y: int, rooms: dict[int, Room], segment_id: int, x: int, y: int + ) -> None: + if segment_id not in rooms: + rooms[segment_id] = Room(room_x, room_y, room_x, room_y, segment_id) + rooms[segment_id] = Room( + min(rooms[segment_id].x0, room_x), + min(rooms[segment_id].y0, room_y), + max(rooms[segment_id].x1, room_x), + max(rooms[segment_id].y1, room_y), + segment_id, + ) + pixels[x, y] = self._palette.get_room_color(segment_id) diff --git a/src/vacuum_map_parser_dreame/map_data_parser.py b/src/vacuum_map_parser_dreame/map_data_parser.py new file mode 100644 index 0000000..9ffb0ce --- /dev/null +++ b/src/vacuum_map_parser_dreame/map_data_parser.py @@ -0,0 +1,307 @@ +"""Dreame map parser.""" + +import base64 +import hashlib +import json +import logging +import re +import zlib +from enum import IntEnum, StrEnum +from typing import Any + +from Crypto.Cipher import AES +from vacuum_map_parser_base.config.color import ColorsPalette +from vacuum_map_parser_base.config.drawable import Drawable +from vacuum_map_parser_base.config.image_config import ImageConfig +from vacuum_map_parser_base.config.size import Sizes +from vacuum_map_parser_base.config.text import Text +from vacuum_map_parser_base.map_data import Area, ImageData, MapData, Path, Point, Room, Wall +from vacuum_map_parser_base.map_data_parser import MapDataParser + +from .header import MapDataHeader +from .image_parser import DreameImageParser +from .map_data_type import MapDataType + +_LOGGER = logging.getLogger(__name__) + + +class PathOperator(StrEnum): + """Dreame map path operator.""" + + START = "S" + RELATIVE_LINE = "L" + + +class FrameType(IntEnum): + """Dreame map frame type.""" + + I_FRAME = 73 + P_FRAME = 80 + + +class DreameMapDataParser(MapDataParser): + """Dreame map parser.""" + + HEADER_SIZE = 27 + PATH_REGEX = r"(?P[SL])(?P-?\d+),(?P-?\d+)" + IVs = { + "dreame.vacuum.p2114a": "6PFiLPYMHLylp7RR", + "dreame.vacuum.p2114o": "6PFiLPYMHLylp7RR", + "dreame.vacuum.p2140o": "8qnS9dqgT3CppGe1", + "dreame.vacuum.p2140p": "8qnS9dqgT3CppGe1", + "dreame.vacuum.p2149o": "RNO4p35b2QKaovHC", + "dreame.vacuum.r2209": "qFKhvoAqRFTPfKN6", + "dreame.vacuum.r2211o": "dndRQ3z8ACjDdDMo", + "dreame.vacuum.r2216o": "4sCv3Q2BtbWVBIB2", + "dreame.vacuum.r2235": "NRwnBj5FsNPgBNbT", + "dreame.vacuum.r2254": "wRy05fYLQJMRH6Mj", + } + + def __init__( + self, + palette: ColorsPalette, + sizes: Sizes, + drawables: list[Drawable], + image_config: ImageConfig, + texts: list[Text], + model: str, + ): + super().__init__(palette, sizes, drawables, image_config, texts) + self._image_parser = DreameImageParser(palette, image_config) + self._iv: bytes | None = None + iv = DreameMapDataParser.IVs.get(model) + if iv is not None: + self._iv = iv.encode("utf8") + + def unpack_map( + self, + raw_encoded: bytes, + *args: Any, + enckey: str | None = None, + **kwargs: Any, + ) -> bytes: + raw_map_str = raw_encoded.decode().replace("_", "/").replace("-", "+") + raw_map = base64.decodebytes(raw_map_str.encode("utf8")) + if enckey is not None and self._iv is not None: + _LOGGER.debug("Enc Key: %s", enckey) + key = hashlib.sha256(enckey.encode()).hexdigest()[0:32].encode("utf8") + raw_map_dec = AES.new(key, AES.MODE_CBC, iv=self._iv).decrypt(raw_map) + unzipped = zlib.decompress(raw_map_dec) + else: + unzipped = zlib.decompress(raw_map) + + return unzipped + + def parse(self, raw: bytes, *args: Any, **kwargs: Any) -> MapData: + parsed = self._parse_internal(raw, MapDataType.REGULAR) + if parsed is None: + raise NotImplementedError("Unsupported frame type") + return parsed + + def _parse_internal(self, raw: bytes, map_data_type: MapDataType) -> MapData | None: + map_data = MapData(0, 1000) + + header = DreameMapDataParser._parse_header(raw) + + if header is None or header.frame_type != FrameType.I_FRAME: + _LOGGER.error("unsupported map frame type") + return None + + if len(raw) >= DreameMapDataParser.HEADER_SIZE + header.image_width * header.image_height: + image_raw = raw[ + DreameMapDataParser.HEADER_SIZE : DreameMapDataParser.HEADER_SIZE + + header.image_width * header.image_height + ] + additional_data_raw = raw[DreameMapDataParser.HEADER_SIZE + header.image_width * header.image_height :] + additional_data_json = json.loads(additional_data_raw.decode("utf8")) + _LOGGER.debug("map additional_data: %s", str(additional_data_json)) + + map_data.charger = header.charger_position + map_data.vacuum_position = header.vacuum_position + + map_data.image, map_data.rooms = self._parse_image(image_raw, header, additional_data_json, map_data_type) + if ( + additional_data_json.get("rism") + and additional_data_json.get("ris") + and additional_data_json["ris"] == 2 + ): + decoded_rism_map_data = self.unpack_map(additional_data_json["rism"].encode("utf-8")) + rism_map_data = self._parse_internal(decoded_rism_map_data, MapDataType.RISM) + if rism_map_data is not None: + map_data.no_go_areas = rism_map_data.no_go_areas + map_data.no_mopping_areas = rism_map_data.no_mopping_areas + map_data.walls = rism_map_data.walls + map_data.rooms = rism_map_data.rooms + _LOGGER.debug("rooms: %s", str(map_data.rooms)) + + if rism_map_data.image is not None and not rism_map_data.image.is_empty: + map_data.image = rism_map_data.image + + if additional_data_json.get("tr"): + map_data.path = DreameMapDataParser._parse_path(additional_data_json["tr"]) + + if additional_data_json.get("vw"): + if additional_data_json["vw"].get("rect"): + map_data.no_go_areas = DreameMapDataParser._parse_areas(additional_data_json["vw"]["rect"]) + if additional_data_json["vw"].get("mop"): + map_data.no_mopping_areas = DreameMapDataParser._parse_areas(additional_data_json["vw"]["mop"]) + if additional_data_json["vw"].get("line"): + map_data.walls = DreameMapDataParser._parse_virtual_walls(additional_data_json["vw"]["line"]) + + if additional_data_json.get("sa") and isinstance(additional_data_json["sa"], list): + map_data.additional_parameters["active_segment_ids"] = [sa[0] for sa in additional_data_json["sa"]] + + if map_data.image is not None and not map_data.image.is_empty: + if map_data_type == MapDataType.REGULAR: + self._image_generator.draw_map(map_data) + + return map_data + + @staticmethod + def _parse_header(raw: bytes) -> MapDataHeader | None: + if not raw or len(raw) < DreameMapDataParser.HEADER_SIZE: + _LOGGER.error("wrong header size for map") + return None + + map_index = DreameMapDataParser._read_int_16_le(raw) + frame_type = DreameMapDataParser._read_int_8(raw, 4) + vacuum_position = Point( + DreameMapDataParser._read_int_16_le(raw, 5), + DreameMapDataParser._read_int_16_le(raw, 7), + DreameMapDataParser._read_int_16_le(raw, 9), + ) + charger_position = Point( + DreameMapDataParser._read_int_16_le(raw, 11), + DreameMapDataParser._read_int_16_le(raw, 13), + DreameMapDataParser._read_int_16_le(raw, 15), + ) + image_pixel_size = DreameMapDataParser._read_int_16_le(raw, 17) + image_width = DreameMapDataParser._read_int_16_le(raw, 19) + image_height = DreameMapDataParser._read_int_16_le(raw, 21) + image_left = round(DreameMapDataParser._read_int_16_le(raw, 23) / image_pixel_size) + image_top = round(DreameMapDataParser._read_int_16_le(raw, 25) / image_pixel_size) + + header = MapDataHeader( + map_index, + frame_type, + vacuum_position, + charger_position, + image_pixel_size, + image_width, + image_height, + image_left, + image_top, + ) + + _LOGGER.debug("decoded map header: %s", str(header)) + + return header + + def _parse_image( + self, image_raw: bytes, header: MapDataHeader, additional_data_json: dict[str, Any], map_data_type: MapDataType + ) -> tuple[ImageData, dict[int, Room]]: + _LOGGER.debug("parse image for map %s", map_data_type) + image, image_rooms = self._image_parser.parse(image_raw, header, map_data_type) + if image is None: + image = self._image_generator.create_empty_map_image() + + room_names = {} + if additional_data_json.get("seg_inf"): + room_names = { + int(k): base64.b64decode(v.get("name")).decode("utf-8") + for (k, v) in additional_data_json["seg_inf"].items() + if v.get("name") + } + + rooms = { + k: Room( + (v.x0 + header.image_left) * header.image_pixel_size, + (v.y0 + header.image_top) * header.image_pixel_size, + (v.x1 + header.image_left) * header.image_pixel_size, + (v.y1 + header.image_top) * header.image_pixel_size, + k, + room_names[k] if room_names.get(k) else str(k), + ) + for (k, v) in image_rooms.items() + } + + return ( + ImageData( + header.image_width * header.image_height, + header.image_top, + header.image_left, + header.image_height, + header.image_width, + self._image_config, + image, + lambda p: DreameMapDataParser._map_to_image(p, header.image_pixel_size), + ), + rooms, + ) + + @staticmethod + def _map_to_image(p: Point, image_pixel_size: int) -> Point: + return Point(p.x / image_pixel_size, p.y / image_pixel_size) + + @staticmethod + def _parse_path(path_string: str) -> Path: + r = re.compile(DreameMapDataParser.PATH_REGEX) + matches = [m.groupdict() for m in r.finditer(path_string)] + + current_path: list[Point] = [] + path_points = [] + current_position = Point(0, 0) + for match in matches: + if match["operator"] == PathOperator.START: + current_path = [] + path_points.append(current_path) + current_position = Point(int(match["x"]), int(match["y"])) + elif match["operator"] == PathOperator.RELATIVE_LINE: + current_position = Point(current_position.x + int(match["x"]), current_position.y + int(match["y"])) + else: + _LOGGER.error("invalid path operator %s", match["operator"]) + current_path.append(current_position) + + return Path(None, None, None, path_points) + + @staticmethod + def _parse_areas(areas: list[tuple[int, int, int, int]]) -> list[Area]: + parsed_areas = [] + for area in areas: + x_coords = sorted([area[0], area[2]]) + y_coords = sorted([area[1], area[3]]) + parsed_areas.append( + Area( + x_coords[0], + y_coords[0], + x_coords[1], + y_coords[0], + x_coords[1], + y_coords[1], + x_coords[0], + y_coords[1], + ) + ) + return parsed_areas + + @staticmethod + def _parse_virtual_walls(virtual_walls: list[tuple[int, int, int, int]]) -> list[Wall]: + return [ + Wall(virtual_wall[0], virtual_wall[1], virtual_wall[2], virtual_wall[3]) for virtual_wall in virtual_walls + ] + + @staticmethod + def _read_int_8(data: bytes, offset: int = 0) -> int: + return int.from_bytes(data[offset : offset + 1], byteorder="big", signed=True) + + @staticmethod + def _read_int_8_le(data: bytes, offset: int = 0) -> int: + return int.from_bytes(data[offset : offset + 1], byteorder="little", signed=True) + + @staticmethod + def _read_int_16(data: bytes, offset: int = 0) -> int: + return int.from_bytes(data[offset : offset + 2], byteorder="big", signed=True) + + @staticmethod + def _read_int_16_le(data: bytes, offset: int = 0) -> int: + return int.from_bytes(data[offset : offset + 2], byteorder="little", signed=True) diff --git a/src/vacuum_map_parser_dreame/map_data_type.py b/src/vacuum_map_parser_dreame/map_data_type.py new file mode 100644 index 0000000..77dbe15 --- /dev/null +++ b/src/vacuum_map_parser_dreame/map_data_type.py @@ -0,0 +1,10 @@ +"""Dreame map type.""" + +from enum import StrEnum + + +class MapDataType(StrEnum): + """Dreame map type.""" + + REGULAR = "regular" + RISM = "rism" # Room - information diff --git a/src/vacuum_map_parser_dreame/py.typed b/src/vacuum_map_parser_dreame/py.typed new file mode 100644 index 0000000..e69de29